lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 7c29f8fd1e594ffabad61b5a3003d296e9b98630
parent 331fbb99cfdfd13cd34e6d843e9f92ce5380e697
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 08:10:06 +0000

mobile: isolate native ffi ownership

- remove UniFFI annotations and dependencies from the ordinary Rust mobile core
- own remote DTO converters, errors, exports, logging, and delegation in mobileffi
- require full canonical lib and consumer revisions in runtime provenance
- qualify isolated features, generated bindings, lifecycle ordering, and coverage

Diffstat:
MCargo.lock | 3++-
Mcrates/mobile_core/Cargo.toml | 1-
Mcrates/mobile_core/build.rs | 29+++++++++++++++++++++--------
Mcrates/mobile_core/src/error.rs | 4++--
Mcrates/mobile_core/src/lib.rs | 9++++-----
Acrates/mobile_core/src/provenance.rs | 26++++++++++++++++++++++++++
Mcrates/mobile_core/src/runtime/app_info.rs | 2+-
Mcrates/mobile_core/src/runtime/info.rs | 12+++++++-----
Mcrates/mobile_core/src/runtime/key_management.rs | 7+++----
Mcrates/mobile_core/src/runtime/mod.rs | 3---
Mcrates/mobile_core/src/runtime/nostr.rs | 13++++++-------
Mcrates/mobile_core/src/runtime/product_surface.rs | 77++++++++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/mobile_core/src/runtime/sdk.rs | 7+++----
Acrates/mobile_core/tests/package_boundary.rs | 35+++++++++++++++++++++++++++++++++++
Dcrates/mobile_core/tests/uniffi_contract.rs | 33---------------------------------
Mcrates/mobile_ffi/Cargo.toml | 2++
Acrates/mobile_ffi/src/error.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_ffi/src/lib.rs | 10++++++++--
Mcrates/mobile_ffi/src/logging.rs | 15+++++++--------
Acrates/mobile_ffi/src/remote.rs | 664+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_ffi/src/runtime.rs | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_ffi/tests/logging_error.rs | 2+-
Acrates/mobile_ffi/tests/runtime_delegation.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_ffi/tests/runtime_lifecycle.rs | 40++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_ffi/tests/uniffi_contract.rs | 33+++++++++++++++++++++++++++++++++
25 files changed, 1427 insertions(+), 124 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3046,7 +3046,6 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", - "uniffi", ] [[package]] @@ -3056,6 +3055,8 @@ dependencies = [ "radroots_mobile_core", "rustix 1.1.4", "tempfile", + "thiserror 1.0.69", + "tokio", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -32,7 +32,6 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } -uniffi = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mobile_core/build.rs b/crates/mobile_core/build.rs @@ -1,10 +1,14 @@ use std::{env, process::Command}; +#[path = "src/provenance.rs"] +mod provenance; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=RUSTC"); println!("cargo:rerun-if-env-changed=PROFILE"); - println!("cargo:rerun-if-env-changed=RADROOTS_SOURCE_SHA"); + println!("cargo:rerun-if-env-changed=RADROOTS_LIB_REVISION"); + println!("cargo:rerun-if-env-changed=RADROOTS_CONSUMER_REVISION"); println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); let rustc = env::var("RUSTC").expect("missing required env var RUSTC"); @@ -15,8 +19,17 @@ fn main() { println!("cargo:rustc-env=RUSTC_VERSION={}", version.trim()); } - if let Some(source_sha) = optional_source_sha() { - println!("cargo:rustc-env=GIT_HASH={source_sha}"); + let lib_revision = optional_full_revision("RADROOTS_LIB_REVISION"); + let consumer_revision = optional_full_revision("RADROOTS_CONSUMER_REVISION"); + assert!( + consumer_revision.is_none() || lib_revision.is_some(), + "RADROOTS_CONSUMER_REVISION requires RADROOTS_LIB_REVISION" + ); + if let Some(revision) = lib_revision { + println!("cargo:rustc-env=RADROOTS_LIB_REVISION={revision}"); + } + if let Some(revision) = consumer_revision { + println!("cargo:rustc-env=RADROOTS_CONSUMER_REVISION={revision}"); } let profile = env::var("PROFILE").expect("missing required env var PROFILE"); @@ -27,13 +40,13 @@ fn main() { } } -fn optional_source_sha() -> Option<String> { - let value = env::var("RADROOTS_SOURCE_SHA").ok()?; +fn optional_full_revision(name: &str) -> Option<String> { + let value = env::var(name).ok()?; assert!( - (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit()), - "RADROOTS_SOURCE_SHA must contain 7 to 64 hexadecimal characters" + provenance::is_full_revision(&value), + "{name} must contain exactly 40 lowercase hexadecimal characters" ); - Some(value.to_ascii_lowercase()) + Some(value) } fn optional_source_date_epoch() -> Option<u64> { diff --git a/crates/mobile_core/src/error.rs b/crates/mobile_core/src/error.rs @@ -1,7 +1,7 @@ use thiserror::Error; /// Versioned, secret-safe SDK failure exposed to mobile hosts. -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkErrorRecord { pub schema_version: u16, pub code: String, @@ -13,7 +13,7 @@ pub struct SdkErrorRecord { pub message: String, } -#[derive(Debug, Error, uniffi::Error)] +#[derive(Debug, Error)] pub enum RadrootsAppError { #[error("initialization: {0}")] Initialization(String), diff --git a/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs @@ -1,12 +1,11 @@ -// UniFFI errors are serialized value contracts. Keeping the complete stable -// SDK report on the error is more important than optimizing the Rust enum's -// in-process size; mobile calls cross this boundary by value in all cases. +// Mobile errors retain the complete stable SDK report. Preserving that typed +// value is more important than optimizing the Rust enum's in-process size. #![allow(clippy::result_large_err)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -uniffi::setup_scaffolding!("radroots_mobile_core"); - pub mod error; +#[cfg(test)] +mod provenance; pub mod runtime; pub use error::{RadrootsAppError, SdkErrorRecord}; diff --git a/crates/mobile_core/src/provenance.rs b/crates/mobile_core/src/provenance.rs @@ -0,0 +1,26 @@ +pub(crate) fn is_full_revision(value: &str) -> bool { + value.len() == 40 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::is_full_revision; + + #[test] + fn only_full_lowercase_git_revisions_are_accepted() { + assert!(is_full_revision("0123456789abcdef0123456789abcdef01234567")); + assert!(!is_full_revision("0123456")); + assert!(!is_full_revision( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )); + assert!(!is_full_revision( + "0123456789ABCDEF0123456789abcdef01234567" + )); + assert!(!is_full_revision( + "g123456789abcdef0123456789abcdef01234567" + )); + } +} diff --git a/crates/mobile_core/src/runtime/app_info.rs b/crates/mobile_core/src/runtime/app_info.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone, Default, serde::Serialize, uniffi::Record)] +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct AppInfoPlatform { pub platform: Option<String>, pub bundle_id: Option<String>, diff --git a/crates/mobile_core/src/runtime/info.rs b/crates/mobile_core/src/runtime/info.rs @@ -2,17 +2,18 @@ use super::RadrootsRuntime; use chrono::Utc; use serde::Serialize; -#[derive(Debug, Clone, Serialize, Default, uniffi::Record)] +#[derive(Debug, Clone, Serialize, Default)] pub struct RuntimeBuildInfo { pub crate_name: String, pub crate_version: String, pub rustc: Option<String>, pub profile: Option<String>, - pub git_sha: Option<String>, + pub lib_revision: Option<String>, + pub consumer_revision: Option<String>, pub build_time_unix: Option<u64>, } -#[derive(Debug, Clone, Serialize, uniffi::Record)] +#[derive(Debug, Clone, Serialize)] pub struct AppInfo { pub build: RuntimeBuildInfo, pub started_unix_ms: i64, @@ -21,7 +22,7 @@ pub struct AppInfo { pub platform: Option<super::app_info::AppInfoPlatform>, } -#[derive(Debug, Clone, Serialize, uniffi::Record)] +#[derive(Debug, Clone, Serialize)] pub struct RuntimeInfo { pub app: AppInfo, pub sdk: RuntimeBuildInfo, @@ -59,7 +60,8 @@ pub fn app_build_info() -> RuntimeBuildInfo { crate_version: env!("CARGO_PKG_VERSION").to_owned(), rustc: option_env!("RUSTC_VERSION").map(str::to_owned), profile: option_env!("PROFILE").map(str::to_owned), - git_sha: option_env!("GIT_HASH").map(str::to_owned), + lib_revision: option_env!("RADROOTS_LIB_REVISION").map(str::to_owned), + consumer_revision: option_env!("RADROOTS_CONSUMER_REVISION").map(str::to_owned), build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|value| value.parse().ok()), } } diff --git a/crates/mobile_core/src/runtime/key_management.rs b/crates/mobile_core/src/runtime/key_management.rs @@ -3,7 +3,7 @@ use super::RadrootsRuntime; use crate::RadrootsAppError; -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrIdentityRecord { pub id: String, pub public_key_hex: String, @@ -12,7 +12,7 @@ pub struct NostrIdentityRecord { pub is_selected: bool, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrIdentitySnapshot { pub has_selected_signing_identity: bool, pub selected_identity_id: Option<String>, @@ -20,7 +20,7 @@ pub struct NostrIdentitySnapshot { pub identities: Vec<NostrIdentityRecord>, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrHostCustodyIdentity { pub id: String, pub public_key_hex: String, @@ -50,7 +50,6 @@ fn identity_record( } } -#[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() diff --git a/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs @@ -21,7 +21,6 @@ use self::{ }; use crate::RadrootsAppError; -#[derive(uniffi::Object)] pub struct RadrootsRuntime { pub(crate) client: Client, #[cfg(feature = "mobile-social")] @@ -35,9 +34,7 @@ pub struct RadrootsRuntime { pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, } -#[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { - #[cfg_attr(not(coverage_nightly), uniffi::constructor)] pub fn new() -> Result<Self, RadrootsAppError> { #[cfg(feature = "mobile-social")] let signing_slot = radroots_sdk::signing::Slot::new(); diff --git a/crates/mobile_core/src/runtime/nostr.rs b/crates/mobile_core/src/runtime/nostr.rs @@ -3,14 +3,14 @@ use super::RadrootsRuntime; use crate::RadrootsAppError; -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NostrLight { Red, Yellow, Green, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrConnectionStatus { pub light: NostrLight, pub configured: bool, @@ -19,7 +19,7 @@ pub struct NostrConnectionStatus { pub last_error: Option<String>, } -#[derive(uniffi::Record, Debug, Clone, Default, Eq, PartialEq)] +#[derive(Debug, Clone, Default, Eq, PartialEq)] pub struct NostrProfile { pub name: Option<String>, pub display_name: Option<String>, @@ -33,7 +33,7 @@ pub struct NostrProfile { pub bot: Option<String>, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrProfileEventMetadata { pub id: String, pub author: String, @@ -41,12 +41,12 @@ pub struct NostrProfileEventMetadata { pub profile: NostrProfile, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrPost { pub content: String, } -#[derive(uniffi::Record, Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct NostrPostEventMetadata { pub id: String, pub author: String, @@ -85,7 +85,6 @@ fn map_post(event: radroots_sdk::client::PostEvent) -> NostrPostEventMetadata { } } -#[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { self.nostr_slot diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -16,7 +16,7 @@ pub const WORKFLOW_ACTOR_COMPATIBILITY_NOTE: &str = "Phase 1 workflow actors are roles such as Farmer, Buyer, Seller, and Service are compatibility roles \ and are not sufficient authority."; -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ContextType { Regional, @@ -31,7 +31,7 @@ pub enum ContextType { NetworkSteward, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum WorkflowActor { NetworkMember, @@ -47,7 +47,7 @@ pub enum WorkflowActor { NetworkSteward, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum VisibilityClass { LocalDraft, @@ -61,7 +61,7 @@ pub enum VisibilityClass { SecretNeverShared, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthorityDomain { #[serde(rename = "Relay/group access")] RelayGroupAccess, @@ -83,7 +83,7 @@ pub enum AuthorityDomain { NetworkStewardship, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum AuthorityAction { Submit, @@ -98,7 +98,7 @@ pub enum AuthorityAction { NavigateRelatedObject, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ObjectKind { Region, @@ -127,7 +127,7 @@ pub enum ObjectKind { Correction, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ObjectPageFamily { Network, @@ -145,7 +145,7 @@ pub enum ObjectPageFamily { Exception, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum TodayCardType { Route, @@ -162,7 +162,7 @@ pub enum TodayCardType { SyncOutbox, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum AddActionType { Photo, @@ -185,7 +185,7 @@ pub enum AddActionType { Correction, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum AddFlowState { NotStarted, @@ -206,7 +206,7 @@ pub enum AddFlowState { Discarded, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum OutboxBehavior { LocalOnly, @@ -216,7 +216,7 @@ pub enum OutboxBehavior { ShareWhenAuthorized, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum PrototypePathKind { ProducerFoodToRoute, @@ -224,7 +224,7 @@ pub enum PrototypePathKind { RouteCoordinatorAssignment, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum RouteExecutionFlowKind { RoutePartnerAssignedStops, @@ -232,7 +232,7 @@ pub enum RouteExecutionFlowKind { ExceptionRecovery, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum RouteExecutionStepKind { AssignedRoute, @@ -243,7 +243,7 @@ pub enum RouteExecutionStepKind { RecoveryAction, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ProofProvenanceArtifactKind { ProofCompleteness, @@ -252,7 +252,7 @@ pub enum ProofProvenanceArtifactKind { PublicProvenancePreview, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum ProofProvenanceReviewState { MissingProof, @@ -265,7 +265,7 @@ pub enum ProofProvenanceReviewState { Published, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum StewardshipAccessItemKind { AccessRequestReview, @@ -279,7 +279,7 @@ pub enum StewardshipAccessItemKind { GroupManagementDeferred, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum OutboxState { NotQueued, @@ -294,7 +294,7 @@ pub enum OutboxState { Discarded, } -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub enum SyncState { Unknown, @@ -306,7 +306,7 @@ pub enum SyncState { Failed, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ObjectRef { pub object_type: ObjectKind, @@ -314,7 +314,7 @@ pub struct ObjectRef { pub display_label: String, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EventRef { pub event_id: String, @@ -322,7 +322,7 @@ pub struct EventRef { pub kind: Option<u32>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActiveContext { pub context_type: ContextType, @@ -332,7 +332,7 @@ pub struct ActiveContext { pub visibility_scope: VisibilityClass, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthorityGate { pub domain: AuthorityDomain, @@ -344,7 +344,7 @@ pub struct AuthorityGate { pub reason: Option<String>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ObjectPageSummary { pub object_ref: ObjectRef, @@ -358,7 +358,7 @@ pub struct ObjectPageSummary { pub sync_state: SyncState, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TodayCardAction { pub id: String, @@ -367,7 +367,7 @@ pub struct TodayCardAction { pub target_object: Option<ObjectRef>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TodayCard { pub id: String, @@ -392,7 +392,7 @@ pub struct TodayCard { pub is_offline: bool, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RelatedObjectRequirement { pub object_type: ObjectKind, @@ -400,7 +400,7 @@ pub struct RelatedObjectRequirement { pub is_required: bool, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ValidationRequirement { pub id: String, @@ -408,7 +408,7 @@ pub struct ValidationRequirement { pub is_blocking: bool, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddAction { pub action_type: AddActionType, @@ -427,7 +427,7 @@ pub struct AddAction { pub completion_state: AddFlowState, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OutboxItem { pub id: String, @@ -446,7 +446,7 @@ pub struct OutboxItem { pub last_error: Option<String>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OutboxRetryDecision { pub item_id: String, @@ -455,7 +455,7 @@ pub struct OutboxRetryDecision { pub reason: Option<String>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchResultSummary { pub id: String, @@ -469,7 +469,7 @@ pub struct SearchResultSummary { pub sync_state: SyncState, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PrototypePathStep { pub id: String, @@ -483,7 +483,7 @@ pub struct PrototypePathStep { pub sync_state: SyncState, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PrototypePath { pub id: String, @@ -494,7 +494,7 @@ pub struct PrototypePath { pub steps: Vec<PrototypePathStep>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RouteExecutionStep { pub id: String, @@ -514,7 +514,7 @@ pub struct RouteExecutionStep { pub detail_lines: Vec<String>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RouteExecutionFlow { pub id: String, @@ -526,7 +526,7 @@ pub struct RouteExecutionFlow { pub steps: Vec<RouteExecutionStep>, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProofProvenanceArtifact { pub id: String, @@ -548,7 +548,7 @@ pub struct ProofProvenanceArtifact { pub sync_state: SyncState, } -#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StewardshipAccessItem { pub id: String, @@ -2559,7 +2559,6 @@ pub fn fixture_outbox_retry_decision(item: OutboxItem) -> OutboxRetryDecision { } } -#[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn phase1_active_contexts(&self) -> Vec<ActiveContext> { let _ = self; diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -3,7 +3,7 @@ use radroots_sdk::capability::{Availability, Maturity}; use super::RadrootsRuntime; use crate::RadrootsAppError; -#[derive(Clone, Debug, uniffi::Record)] +#[derive(Clone, Debug)] pub struct SdkCapabilityRecord { pub id: String, pub compiled: bool, @@ -12,7 +12,7 @@ pub struct SdkCapabilityRecord { pub maturity: String, } -#[derive(Clone, Debug, uniffi::Record)] +#[derive(Clone, Debug)] pub struct SdkStorageStatusRecord { pub backend: String, pub open_mode: String, @@ -20,13 +20,12 @@ pub struct SdkStorageStatusRecord { pub integrity: String, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct SdkShutdownRecord { pub state: String, pub already_closed: bool, } -#[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { self.client diff --git a/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_core/tests/package_boundary.rs @@ -0,0 +1,35 @@ +const MANIFEST: &str = include_str!("../Cargo.toml"); +const LIB: &str = include_str!("../src/lib.rs"); +const ERROR: &str = include_str!("../src/error.rs"); +const RUNTIME: &str = include_str!("../src/runtime/mod.rs"); +const APP_INFO: &str = include_str!("../src/runtime/app_info.rs"); +const INFO: &str = include_str!("../src/runtime/info.rs"); +const KEY_MANAGEMENT: &str = include_str!("../src/runtime/key_management.rs"); +const NOSTR: &str = include_str!("../src/runtime/nostr.rs"); +const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs"); +const SDK: &str = include_str!("../src/runtime/sdk.rs"); + +#[test] +fn core_owns_no_uniffi_or_process_global_logging_policy() { + for (name, source) in [ + ("Cargo.toml", MANIFEST), + ("src/lib.rs", LIB), + ("src/error.rs", ERROR), + ("src/runtime/mod.rs", RUNTIME), + ("src/runtime/app_info.rs", APP_INFO), + ("src/runtime/info.rs", INFO), + ("src/runtime/key_management.rs", KEY_MANAGEMENT), + ("src/runtime/nostr.rs", NOSTR), + ("src/runtime/product_surface.rs", PRODUCT_SURFACE), + ("src/runtime/sdk.rs", SDK), + ] { + assert!( + !source.to_ascii_lowercase().contains("uniffi"), + "{name} contains UniFFI boundary policy" + ); + assert!( + !source.contains("tracing_subscriber") && !source.contains("set_global_default"), + "{name} contains process-global logging policy" + ); + } +} diff --git a/crates/mobile_core/tests/uniffi_contract.rs b/crates/mobile_core/tests/uniffi_contract.rs @@ -1,33 +0,0 @@ -use radroots_mobile_core::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord}; - -#[tokio::test] -async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let storage = runtime.sdk_storage_status().await.expect("storage status"); - assert_eq!(storage.backend, "memory"); - - runtime.shutdown().await.expect("shutdown"); - let error = runtime - .sdk_storage_status() - .await - .expect_err("closed client must reject operations"); - let RadrootsAppError::Sdk { - report: - SdkErrorRecord { - schema_version, - code, - class, - retryable, - message, - .. - }, - } = error - else { - panic!("expected versioned SDK error record"); - }; - assert_eq!(schema_version, 1); - assert_eq!(code, "client_closed"); - assert_eq!(class, "runtime"); - assert!(!retryable); - assert_eq!(message, "SDK client is closed"); -} diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml @@ -23,6 +23,7 @@ radroots_mobile_core = { workspace = true, features = ["mobile-social"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +thiserror = { workspace = true } uniffi = { workspace = true } [target.'cfg(unix)'.dependencies] @@ -30,3 +31,4 @@ rustix = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_ffi/src/error.rs @@ -0,0 +1,70 @@ +use thiserror::Error; + +pub use radroots_mobile_core::SdkErrorRecord; + +/// Versioned, secret-safe failure exposed across the native language boundary. +#[derive(Debug, Error, uniffi::Error)] +pub enum RadrootsAppError { + #[error("initialization: {0}")] + Initialization(String), + #[error("sdk: {report:?}")] + Sdk { report: SdkErrorRecord }, + #[error("runtime: {0}")] + Runtime(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("internal: {0}")] + Internal(String), +} + +impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError { + fn from(error: radroots_mobile_core::RadrootsAppError) -> Self { + match error { + radroots_mobile_core::RadrootsAppError::Initialization(message) => { + Self::Initialization(message) + } + radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Sdk { report }, + radroots_mobile_core::RadrootsAppError::Runtime(message) => Self::Runtime(message), + radroots_mobile_core::RadrootsAppError::Unsupported(message) => { + Self::Unsupported(message) + } + radroots_mobile_core::RadrootsAppError::Internal(message) => Self::Internal(message), + } + } +} + +impl RadrootsAppError { + pub(crate) fn initialization(message: impl Into<String>) -> Self { + Self::Initialization(message.into()) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::RadrootsAppError; + + #[test] + fn every_core_error_variant_maps_without_string_erasure() { + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::initialization( + "initialization" + )), + RadrootsAppError::Initialization(message) if message == "initialization" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::runtime("runtime")), + RadrootsAppError::Runtime(message) if message == "runtime" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::unsupported( + "unsupported" + )), + RadrootsAppError::Unsupported(message) if message == "unsupported" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal("internal")), + RadrootsAppError::Internal(message) if message == "internal" + )); + } +} diff --git a/crates/mobile_ffi/src/lib.rs b/crates/mobile_ffi/src/lib.rs @@ -3,10 +3,16 @@ #![allow(clippy::result_large_err)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -uniffi::setup_scaffolding!("radroots_mobile_ffi"); -radroots_mobile_core::uniffi_reexport_scaffolding!(); +uniffi::setup_scaffolding!("radroots_mobile_core"); pub mod logging; +mod remote; +mod runtime; + +pub use error::{RadrootsAppError, SdkErrorRecord}; +pub use runtime::RadrootsRuntime; + +mod error; #[allow( clippy::if_same_then_else, diff --git a/crates/mobile_ffi/src/logging.rs b/crates/mobile_ffi/src/logging.rs @@ -40,9 +40,9 @@ pub fn init_logging( dir: Option<String>, file_name: Option<String>, is_stdout: Option<bool>, -) -> Result<(), radroots_mobile_core::RadrootsAppError> { +) -> Result<(), crate::RadrootsAppError> { let opts = logging_options(dir, file_name, is_stdout); - initialize(opts).map_err(radroots_mobile_core::RadrootsAppError::initialization) + initialize(opts).map_err(crate::RadrootsAppError::initialization) } fn logging_options( @@ -142,25 +142,24 @@ fn build_file_writer( } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging_stdout() -> Result<(), radroots_mobile_core::RadrootsAppError> { - initialize(LoggingOptions::default()) - .map_err(radroots_mobile_core::RadrootsAppError::initialization) +pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> { + initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_info(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::info!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_error(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::error!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_debug(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::debug!("{msg}"); Ok(()) } diff --git a/crates/mobile_ffi/src/remote.rs b/crates/mobile_ffi/src/remote.rs @@ -0,0 +1,664 @@ +//! 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::*; + +#[uniffi::remote(Record)] +pub struct SdkErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub operation_id: Option<String>, + pub capability_id: Option<String>, + pub message: String, +} + +#[uniffi::remote(Record)] +pub struct AppInfoPlatform { + pub platform: Option<String>, + pub bundle_id: Option<String>, + pub version: Option<String>, + pub build_number: Option<String>, + pub build_sha: Option<String>, +} +#[uniffi::remote(Record)] +pub struct RuntimeBuildInfo { + pub crate_name: String, + pub crate_version: String, + pub rustc: Option<String>, + pub profile: Option<String>, + pub lib_revision: Option<String>, + pub consumer_revision: Option<String>, + pub build_time_unix: Option<u64>, +} + +#[uniffi::remote(Record)] +pub struct AppInfo { + pub build: RuntimeBuildInfo, + pub started_unix_ms: i64, + pub uptime_millis: i64, + pub shutting_down: bool, + pub platform: Option<AppInfoPlatform>, +} + +#[uniffi::remote(Record)] +pub struct RuntimeInfo { + pub app: AppInfo, + pub sdk: RuntimeBuildInfo, + pub sdk_closed: bool, +} +#[uniffi::remote(Record)] +pub struct SdkCapabilityRecord { + pub id: String, + pub compiled: bool, + pub configured: bool, + pub availability: String, + pub maturity: String, +} + +#[uniffi::remote(Record)] +pub struct SdkStorageStatusRecord { + pub backend: String, + pub open_mode: String, + pub shutdown: String, + pub integrity: String, +} + +#[uniffi::remote(Record)] +pub struct SdkShutdownRecord { + pub state: String, + pub already_closed: bool, +} +#[uniffi::remote(Record)] +pub struct NostrIdentityRecord { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, + pub label: Option<String>, + pub is_selected: bool, +} + +#[uniffi::remote(Record)] +pub struct NostrIdentitySnapshot { + pub has_selected_signing_identity: bool, + pub selected_identity_id: Option<String>, + pub selected_npub: Option<String>, + pub identities: Vec<NostrIdentityRecord>, +} + +#[uniffi::remote(Record)] +pub struct NostrHostCustodyIdentity { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, +} +#[uniffi::remote(Enum)] +pub enum NostrLight { + Red, + Yellow, + Green, +} + +#[uniffi::remote(Record)] +pub struct NostrConnectionStatus { + pub light: NostrLight, + pub configured: bool, + pub source_available: bool, + pub sink_available: bool, + pub last_error: Option<String>, +} + +#[uniffi::remote(Record)] +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>, +} + +#[uniffi::remote(Record)] +pub struct NostrProfileEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub profile: NostrProfile, +} + +#[uniffi::remote(Record)] +pub struct NostrPost { + pub content: String, +} + +#[uniffi::remote(Record)] +pub struct NostrPostEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub post: NostrPost, +} +#[uniffi::remote(Enum)] +pub enum ContextType { + Regional, + Network, + Farm, + Buyer, + Route, + RoutePartner, + PickupPoint, + TraceRecords, + Hub, + NetworkSteward, +} + +#[uniffi::remote(Enum)] +pub enum WorkflowActor { + NetworkMember, + ProducerAdmin, + FarmTeamMember, + HubOperator, + TraceLead, + BuyerSourcingLead, + BuyerReceiver, + PickupPointCoordinator, + RouteCoordinator, + RoutePartner, + NetworkSteward, +} + +#[uniffi::remote(Enum)] +pub enum VisibilityClass { + LocalDraft, + FarmPrivate, + WorkspacePrivate, + NetworkVisible, + RouteScoped, + BuyerScoped, + PublicCommunity, + PublicProvenance, + SecretNeverShared, +} + +#[uniffi::remote(Enum)] +pub enum AuthorityDomain { + #[serde(rename = "Relay/group access")] + RelayGroupAccess, + #[serde(rename = "Farm/workspace operations authority")] + FarmWorkspaceOperations, + #[serde(rename = "Buyer workspace authority")] + BuyerWorkspace, + #[serde(rename = "Route coordination authority")] + RouteCoordination, + #[serde(rename = "Route execution authority")] + RouteExecution, + #[serde(rename = "Receipt authority")] + Receipt, + #[serde(rename = "Trace/proof authority")] + TraceProof, + #[serde(rename = "Public publishing authority")] + PublicPublishing, + #[serde(rename = "Network stewardship authority")] + NetworkStewardship, +} + +#[uniffi::remote(Enum)] +pub enum AuthorityAction { + Submit, + Publish, + Share, + Assign, + Approve, + Correct, + Close, + Retry, + Search, + NavigateRelatedObject, +} + +#[uniffi::remote(Enum)] +pub enum ObjectKind { + Region, + Network, + Farm, + BuyerWorkspace, + Route, + RoutePartner, + PickupPoint, + Hub, + Food, + Ask, + Event, + Place, + Task, + Proof, + Exception, + Provenance, + Update, + AccessMembership, + BuyerPacket, + RouteStop, + Draft, + OutboxItem, + MemberInvite, + Correction, +} + +#[uniffi::remote(Enum)] +pub enum ObjectPageFamily { + Network, + NetworkRoute, + FarmWorkspace, + FarmPublicProfile, + BuyerWorkspace, + PickupPointPlace, + Food, + Event, + RouteStop, + Proof, + BuyerPacket, + PublicProvenance, + Exception, +} + +#[uniffi::remote(Enum)] +pub enum TodayCardType { + Route, + Food, + Ask, + Event, + Place, + Task, + Proof, + Exception, + Provenance, + Update, + AccessMembership, + SyncOutbox, +} + +#[uniffi::remote(Enum)] +pub enum AddActionType { + Photo, + Note, + Ask, + Scan, + Food, + Harvest, + BuyerRequest, + BuyerCommitment, + RouteNeed, + RouteStop, + PickupEvent, + Place, + Proof, + Exception, + PublicUpdate, + Provenance, + MemberInvite, + Correction, +} + +#[uniffi::remote(Enum)] +pub enum AddFlowState { + NotStarted, + Draft, + Editing, + ValidationFailed, + ReadyToSubmit, + Submitted, + Queued, + Syncing, + NeedsApproval, + Approved, + Published, + Shared, + Confirmed, + Failed, + Conflict, + Discarded, +} + +#[uniffi::remote(Enum)] +pub enum OutboxBehavior { + LocalOnly, + QueueWhenOffline, + RequireOnline, + PublishWhenAuthorized, + ShareWhenAuthorized, +} + +#[uniffi::remote(Enum)] +pub enum PrototypePathKind { + ProducerFoodToRoute, + BuyerCommitmentToRoute, + RouteCoordinatorAssignment, +} + +#[uniffi::remote(Enum)] +pub enum RouteExecutionFlowKind { + RoutePartnerAssignedStops, + BuyerReceiptConfirmation, + ExceptionRecovery, +} + +#[uniffi::remote(Enum)] +pub enum RouteExecutionStepKind { + AssignedRoute, + PickupConfirmation, + DropoffConfirmation, + ReceiptConfirmation, + ExceptionReport, + RecoveryAction, +} + +#[uniffi::remote(Enum)] +pub enum ProofProvenanceArtifactKind { + ProofCompleteness, + BuyerPacketDraft, + BuyerPacketShared, + PublicProvenancePreview, +} + +#[uniffi::remote(Enum)] +pub enum ProofProvenanceReviewState { + MissingProof, + Complete, + Draft, + ReadyToShare, + Shared, + RedactionRequired, + ReadyToPublish, + Published, +} + +#[uniffi::remote(Enum)] +pub enum StewardshipAccessItemKind { + AccessRequestReview, + RoleApproval, + RoutePartnerInvite, + RoutePoolMetadata, + PublicModeration, + InviteAcceptance, + RequestAccess, + AccessDenied, + GroupManagementDeferred, +} + +#[uniffi::remote(Enum)] +pub enum OutboxState { + NotQueued, + Draft, + Queued, + Syncing, + AwaitingAuthority, + Published, + Shared, + Failed, + Conflict, + Discarded, +} + +#[uniffi::remote(Enum)] +pub enum SyncState { + Unknown, + Online, + Offline, + Syncing, + Synced, + Stale, + Failed, +} + +#[uniffi::remote(Record)] +pub struct ObjectRef { + pub object_type: ObjectKind, + pub object_id: String, + pub display_label: String, +} + +#[uniffi::remote(Record)] +pub struct EventRef { + pub event_id: String, + pub relay_url: Option<String>, + pub kind: Option<u32>, +} + +#[uniffi::remote(Record)] +pub struct ActiveContext { + pub context_type: ContextType, + pub context_ref: ObjectRef, + pub actor: WorkflowActor, + pub display_label: String, + pub visibility_scope: VisibilityClass, +} + +#[uniffi::remote(Record)] +pub struct AuthorityGate { + pub domain: AuthorityDomain, + pub action: AuthorityAction, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub is_required: bool, + pub is_allowed: bool, + pub reason: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct ObjectPageSummary { + pub object_ref: ObjectRef, + pub family: ObjectPageFamily, + pub primary_context: ActiveContext, + pub title: String, + pub subtitle: Option<String>, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub required_authority: AuthorityGate, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct TodayCardAction { + pub id: String, + pub label: String, + pub action_type: Option<AddActionType>, + pub target_object: Option<ObjectRef>, +} + +#[uniffi::remote(Record)] +pub struct TodayCard { + pub id: String, + pub card_type: TodayCardType, + pub source_object_refs: Vec<ObjectRef>, + pub source_event_refs: Vec<EventRef>, + pub primary_context: ActiveContext, + pub actor: WorkflowActor, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub title: String, + pub status_line: String, + pub detail_lines: Vec<String>, + pub pills: Vec<String>, + pub primary_action: TodayCardAction, + pub secondary_action: Option<TodayCardAction>, + pub ranking_reason: String, + pub ranking_features: Vec<String>, + pub sync_state: SyncState, + pub outbox_state: OutboxState, + pub is_stale: bool, + pub is_offline: bool, +} + +#[uniffi::remote(Record)] +pub struct RelatedObjectRequirement { + pub object_type: ObjectKind, + pub relationship_label: String, + pub is_required: bool, +} + +#[uniffi::remote(Record)] +pub struct ValidationRequirement { + pub id: String, + pub label: String, + pub is_blocking: bool, +} + +#[uniffi::remote(Record)] +pub struct AddAction { + pub action_type: AddActionType, + pub display_label: String, + pub allowed_context_types: Vec<ContextType>, + pub required_authority: AuthorityGate, + pub default_visibility: VisibilityClass, + pub allowed_visibility_options: Vec<VisibilityClass>, + pub created_or_updated_object_type: ObjectKind, + pub related_object_requirements: Vec<RelatedObjectRequirement>, + pub validation_requirements: Vec<ValidationRequirement>, + pub supports_offline: bool, + pub supports_draft: bool, + pub outbox_behavior: OutboxBehavior, + pub primary_submit_label: String, + pub completion_state: AddFlowState, +} + +#[uniffi::remote(Record)] +pub struct OutboxItem { + pub id: String, + pub action_type: AddActionType, + pub context: ActiveContext, + pub object_refs: Vec<ObjectRef>, + pub event_refs: Vec<EventRef>, + pub visibility: VisibilityClass, + pub authority_gate: AuthorityGate, + pub flow_state: AddFlowState, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub queued_at_unix: Option<u64>, + pub last_attempt_at_unix: Option<u64>, + pub retry_count: u32, + pub last_error: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct OutboxRetryDecision { + pub item_id: String, + pub is_retryable: bool, + pub authority_gate: AuthorityGate, + pub reason: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct SearchResultSummary { + pub id: String, + pub object_ref: ObjectRef, + pub primary_context: ActiveContext, + pub title: String, + pub subtitle: Option<String>, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub required_authority: AuthorityGate, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct PrototypePathStep { + pub id: String, + pub label: String, + pub context: ActiveContext, + pub action_type: Option<AddActionType>, + pub object_ref: Option<ObjectRef>, + pub authority_gate: AuthorityGate, + pub visibility: VisibilityClass, + pub outbox_state: OutboxState, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct PrototypePath { + pub id: String, + pub kind: PrototypePathKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub steps: Vec<PrototypePathStep>, +} + +#[uniffi::remote(Record)] +pub struct RouteExecutionStep { + pub id: String, + pub kind: RouteExecutionStepKind, + pub label: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub object_ref: Option<ObjectRef>, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub supports_offline: bool, + pub supports_partial_receipt: bool, + pub uses_receipt_token: bool, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub detail_lines: Vec<String>, +} + +#[uniffi::remote(Record)] +pub struct RouteExecutionFlow { + pub id: String, + pub kind: RouteExecutionFlowKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub steps: Vec<RouteExecutionStep>, +} + +#[uniffi::remote(Record)] +pub struct ProofProvenanceArtifact { + pub id: String, + pub kind: ProofProvenanceArtifactKind, + pub review_state: ProofProvenanceReviewState, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub object_ref: ObjectRef, + pub source_object_refs: Vec<ObjectRef>, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub is_public_preview: bool, + pub requires_redaction_review: bool, + pub can_publish: bool, + pub public_summary_lines: Vec<String>, + pub redacted_field_labels: Vec<String>, + pub outbox_state: OutboxState, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct StewardshipAccessItem { + pub id: String, + pub kind: StewardshipAccessItemKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub target_ref: ObjectRef, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub is_admin_lite: bool, + pub is_phase_2_deferred: bool, + pub grants_private_access: bool, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub detail_lines: Vec<String>, +} diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -0,0 +1,277 @@ +use radroots_mobile_core::runtime::{ + info::RuntimeInfo, + key_management::{NostrHostCustodyIdentity, NostrIdentityRecord, NostrIdentitySnapshot}, + nostr::{NostrConnectionStatus, NostrPostEventMetadata, NostrProfileEventMetadata}, + product_surface::{ + ActiveContext, AddAction, AuthorityAction, AuthorityDomain, AuthorityGate, + ObjectPageSummary, OutboxItem, OutboxRetryDecision, ProofProvenanceArtifact, PrototypePath, + RouteExecutionFlow, SearchResultSummary, StewardshipAccessItem, TodayCard, WorkflowActor, + }, + sdk::{SdkCapabilityRecord, SdkShutdownRecord, SdkStorageStatusRecord}, +}; + +use crate::RadrootsAppError; + +/// Native boundary object delegating all behavior to the ordinary Rust core. +#[derive(uniffi::Object)] +pub struct RadrootsRuntime { + inner: radroots_mobile_core::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() + .map(|inner| Self { inner }) + .map_err(Into::into) + } + + pub async fn shutdown(&self) -> Result<SdkShutdownRecord, RadrootsAppError> { + self.inner.shutdown().await.map_err(Into::into) + } + + pub fn uptime_millis(&self) -> i64 { + self.inner.uptime_millis() + } + + pub fn info(&self) -> RuntimeInfo { + self.inner.info() + } + + pub fn info_json(&self) -> String { + self.inner.info_json() + } + + pub fn set_app_info_platform( + &self, + platform: Option<String>, + bundle_id: Option<String>, + version: Option<String>, + build_number: Option<String>, + build_sha: Option<String>, + ) { + self.inner + .set_app_info_platform(platform, bundle_id, version, build_number, build_sha); + } + + pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { + self.inner.sdk_capabilities() + } + + pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { + self.inner.sdk_storage_status().await.map_err(Into::into) + } + + pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { + self.inner.nostr_identity_has_selected_signing_identity() + } + + pub fn nostr_identity_selected_npub(&self) -> Option<String> { + self.inner.nostr_identity_selected_npub() + } + + pub fn nostr_identity_list(&self) -> Result<Vec<NostrIdentityRecord>, RadrootsAppError> { + self.inner.nostr_identity_list().map_err(Into::into) + } + + pub fn nostr_identity_list_ids(&self) -> Result<Vec<String>, RadrootsAppError> { + self.inner.nostr_identity_list_ids().map_err(Into::into) + } + + pub fn nostr_identity_snapshot(&self) -> Result<NostrIdentitySnapshot, RadrootsAppError> { + self.inner.nostr_identity_snapshot().map_err(Into::into) + } + + pub fn nostr_identity_validate_host_custody_secret( + &self, + secret_key: String, + ) -> Result<NostrHostCustodyIdentity, RadrootsAppError> { + self.inner + .nostr_identity_validate_host_custody_secret(secret_key) + .map_err(Into::into) + } + + pub fn nostr_identity_restore_host_custody_secret( + &self, + secret_key: String, + label: Option<String>, + make_selected: bool, + ) -> Result<NostrIdentityRecord, RadrootsAppError> { + self.inner + .nostr_identity_restore_host_custody_secret(secret_key, label, make_selected) + .map_err(Into::into) + } + + pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_select(identity_id) + .map_err(Into::into) + } + + pub fn nostr_identity_remove(&self, identity_id: String) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_remove(identity_id) + .map_err(Into::into) + } + + pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_lock_host_custody_runtime() + .map_err(Into::into) + } + + pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_reset_host_custody_runtime() + .map_err(Into::into) + } + + pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { + self.inner + .nostr_set_default_relays(relays) + .map_err(Into::into) + } + + pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_connect_if_key_present() + .map_err(Into::into) + } + + pub async fn nostr_connection_status(&self) -> Result<NostrConnectionStatus, RadrootsAppError> { + self.inner + .nostr_connection_status() + .await + .map_err(Into::into) + } + + pub async fn nostr_profile_for_self( + &self, + ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { + self.inner + .nostr_profile_for_self() + .await + .map_err(Into::into) + } + + pub async fn nostr_post_profile( + &self, + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + ) -> Result<String, RadrootsAppError> { + self.inner + .nostr_post_profile(name, display_name, nip05, about) + .await + .map_err(Into::into) + } + + pub async fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { + self.inner + .nostr_post_text_note(content) + .await + .map_err(Into::into) + } + + pub async fn nostr_fetch_text_notes( + &self, + limit: u16, + since_unix: Option<u64>, + ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { + self.inner + .nostr_fetch_text_notes(limit, since_unix) + .await + .map_err(Into::into) + } + + 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> { + self.inner + .nostr_post_reply( + parent_event_id_hex, + parent_author_hex, + content, + root_event_id_hex, + ) + .await + .map_err(Into::into) + } + + pub fn phase1_active_contexts(&self) -> Vec<ActiveContext> { + self.inner.phase1_active_contexts() + } + + pub fn phase1_today_cards(&self, context_id: Option<String>) -> Vec<TodayCard> { + self.inner.phase1_today_cards(context_id) + } + + pub fn phase1_add_actions(&self, context_id: Option<String>) -> Vec<AddAction> { + self.inner.phase1_add_actions(context_id) + } + + pub fn phase1_object_page_summaries( + &self, + context_id: Option<String>, + ) -> Vec<ObjectPageSummary> { + self.inner.phase1_object_page_summaries(context_id) + } + + pub fn phase1_outbox_snapshot(&self) -> Vec<OutboxItem> { + self.inner.phase1_outbox_snapshot() + } + + pub fn phase1_search_results( + &self, + query: Option<String>, + context_id: Option<String>, + ) -> Vec<SearchResultSummary> { + self.inner.phase1_search_results(query, context_id) + } + + pub fn phase1_prototype_paths(&self) -> Vec<PrototypePath> { + self.inner.phase1_prototype_paths() + } + + pub fn phase1_route_execution_flows( + &self, + context_id: Option<String>, + ) -> Vec<RouteExecutionFlow> { + self.inner.phase1_route_execution_flows(context_id) + } + + pub fn phase1_proof_provenance_artifacts( + &self, + context_id: Option<String>, + ) -> Vec<ProofProvenanceArtifact> { + self.inner.phase1_proof_provenance_artifacts(context_id) + } + + pub fn phase1_stewardship_access_items( + &self, + context_id: Option<String>, + ) -> Vec<StewardshipAccessItem> { + self.inner.phase1_stewardship_access_items(context_id) + } + + pub fn phase1_outbox_retry_decision(&self, item: OutboxItem) -> OutboxRetryDecision { + self.inner.phase1_outbox_retry_decision(item) + } + + pub fn phase1_check_authority( + &self, + actor: WorkflowActor, + context: ActiveContext, + domain: AuthorityDomain, + action: AuthorityAction, + ) -> AuthorityGate { + self.inner + .phase1_check_authority(actor, context, domain, action) + } +} diff --git a/crates/mobile_ffi/tests/logging_error.rs b/crates/mobile_ffi/tests/logging_error.rs @@ -1,4 +1,4 @@ -use radroots_mobile_core::RadrootsAppError; +use radroots_mobile_ffi::RadrootsAppError; use radroots_mobile_ffi::logging; #[test] diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -0,0 +1,177 @@ +use radroots_mobile_core::runtime::product_surface::{ + AuthorityAction, AuthorityDomain, OutboxState, +}; +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; + +const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + +#[tokio::test] +async fn native_boundary_delegates_the_complete_core_surface() { + let runtime = RadrootsRuntime::new().expect("runtime"); + assert!(runtime.uptime_millis() >= 0); + assert!(runtime.info_json().contains("sdk")); + runtime.set_app_info_platform( + Some("ios".to_owned()), + Some("org.radroots.app".to_owned()), + Some("0.1.0-alpha".to_owned()), + Some("1".to_owned()), + Some("0123456789abcdef0123456789abcdef01234567".to_owned()), + ); + assert_eq!( + runtime.info().app.platform.expect("platform").platform, + Some("ios".to_owned()) + ); + assert!(!runtime.sdk_capabilities().is_empty()); + assert_eq!( + runtime.sdk_storage_status().await.expect("storage").backend, + "memory" + ); + + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + assert!(runtime.nostr_identity_selected_npub().is_none()); + assert!( + runtime + .nostr_identity_list() + .expect("empty list") + .is_empty() + ); + assert!( + runtime + .nostr_identity_list_ids() + .expect("empty identifiers") + .is_empty() + ); + assert!( + runtime + .nostr_identity_snapshot() + .expect("empty snapshot") + .identities + .is_empty() + ); + let validated = runtime + .nostr_identity_validate_host_custody_secret(SECRET.to_owned()) + .expect("valid secret"); + let staged = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("staged".to_owned()), + false, + ) + .expect("staged identity"); + assert_eq!(validated.id, staged.id); + let selected = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("selected".to_owned()), + true, + ) + .expect("selected identity"); + assert_eq!( + runtime.nostr_identity_selected_npub(), + Some(selected.public_key_npub.clone()) + ); + runtime + .nostr_identity_select(selected.id.clone()) + .expect("select installed"); + assert!(runtime.nostr_identity_select("missing".to_owned()).is_err()); + runtime + .nostr_identity_remove("missing".to_owned()) + .expect("removing missing identity is idempotent"); + runtime + .nostr_identity_lock_host_custody_runtime() + .expect("lock identity"); + runtime + .nostr_identity_reset_host_custody_runtime() + .expect("reset identity"); + + assert!(runtime.nostr_set_default_relays(Vec::new()).is_err()); + assert!(runtime.nostr_connect_if_key_present().is_err()); + assert!( + runtime + .nostr_connection_status() + .await + .expect("unconfigured status") + .last_error + .is_none() + ); + assert!(runtime.nostr_profile_for_self().await.is_err()); + assert!( + runtime + .nostr_post_profile(None, None, None, None) + .await + .is_err() + ); + assert!( + runtime + .nostr_post_text_note("post".to_owned()) + .await + .is_err() + ); + assert!(runtime.nostr_fetch_text_notes(1, None).await.is_err()); + assert!(matches!( + runtime + .nostr_post_reply( + "parent".to_owned(), + "author".to_owned(), + "reply".to_owned(), + Some("different-root".to_owned()), + ) + .await, + Err(RadrootsAppError::Unsupported(_)) + )); + + let contexts = runtime.phase1_active_contexts(); + let context = contexts.first().expect("context").clone(); + let context_id = Some(context.context_ref.object_id.clone()); + assert!(!runtime.phase1_today_cards(context_id.clone()).is_empty()); + assert!(!runtime.phase1_add_actions(context_id.clone()).is_empty()); + assert!( + !runtime + .phase1_object_page_summaries(context_id.clone()) + .is_empty() + ); + let outbox = runtime.phase1_outbox_snapshot(); + let failed = outbox + .into_iter() + .find(|item| item.outbox_state == OutboxState::Failed) + .expect("failed outbox item"); + assert!(runtime.phase1_outbox_retry_decision(failed).is_retryable); + assert!( + !runtime + .phase1_search_results(Some("farm".to_owned()), context_id.clone()) + .is_empty() + ); + assert!(!runtime.phase1_prototype_paths().is_empty()); + assert!( + !runtime + .phase1_route_execution_flows(context_id.clone()) + .is_empty() + ); + assert!( + !runtime + .phase1_proof_provenance_artifacts(context_id.clone()) + .is_empty() + ); + assert!( + !runtime + .phase1_stewardship_access_items(context_id) + .is_empty() + ); + assert_eq!( + runtime + .phase1_check_authority( + context.actor, + context.clone(), + AuthorityDomain::RelayGroupAccess, + AuthorityAction::Search, + ) + .context, + context + ); + + runtime.shutdown().await.expect("shutdown"); + assert!(matches!( + runtime.sdk_storage_status().await, + Err(RadrootsAppError::Sdk { .. }) + )); +} diff --git a/crates/mobile_ffi/tests/runtime_lifecycle.rs b/crates/mobile_ffi/tests/runtime_lifecycle.rs @@ -0,0 +1,40 @@ +use std::{sync::Arc, time::Duration}; + +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; + +#[tokio::test] +async fn host_release_ordering_retains_close_and_finishes_within_deadline() { + let host = Arc::new(RadrootsRuntime::new().expect("runtime")); + let closing_owner = Arc::clone(&host); + let close = tokio::spawn(async move { closing_owner.shutdown().await }); + drop(host); + + let result = tokio::time::timeout(Duration::from_secs(1), close) + .await + .expect("mobile shutdown exceeded its host deadline") + .expect("shutdown task panicked") + .expect("shutdown failed"); + assert_eq!(result.state, "closed"); + assert!(!result.already_closed); +} + +#[tokio::test] +async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() { + let runtime = Arc::new(RadrootsRuntime::new().expect("runtime")); + let first = Arc::clone(&runtime); + let second = Arc::clone(&runtime); + let (first, second) = tokio::join!(first.shutdown(), second.shutdown()); + + for outcome in [&first, &second] { + assert!( + outcome.is_ok() + || matches!( + outcome, + Err(RadrootsAppError::Sdk { report }) + if report.code == "close_in_progress" + ) + ); + } + let repeated = runtime.shutdown().await.expect("repeated close"); + assert!(repeated.already_closed); +} diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs @@ -0,0 +1,33 @@ +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord}; + +#[tokio::test] +async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let storage = runtime.sdk_storage_status().await.expect("storage status"); + assert_eq!(storage.backend, "memory"); + + runtime.shutdown().await.expect("shutdown"); + let error = runtime + .sdk_storage_status() + .await + .expect_err("closed client must reject operations"); + let RadrootsAppError::Sdk { + report: + SdkErrorRecord { + schema_version, + code, + class, + retryable, + message, + .. + }, + } = error + else { + panic!("expected versioned SDK error record"); + }; + assert_eq!(schema_version, 1); + assert_eq!(code, "client_closed"); + assert_eq!(class, "runtime"); + assert!(!retryable); + assert_eq!(message, "SDK client is closed"); +}