commit 235a396133ca0c5adde648396c8ed983b0d1185f
parent 439ad010eb3aec5f9193f5ab58c4eab2dc5f6261
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 13:36:36 +0000
ffi: wrap shared sdk engine
- Add a private UniFFI crate that directly owns the shared SDK client.
- Expose versioned capability, error, and explicit lifecycle DTOs for mobile hosts.
- Keep keychain, background execution, scheduling, and presentation host-owned.
- Verify locked metadata, all targets, lifecycle tests, Clippy, and architecture policy.
Diffstat:
3 files changed, 209 insertions(+), 0 deletions(-)
diff --git a/crates/sdk_ffi/Cargo.toml b/crates/sdk_ffi/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "radroots_sdk_ffi"
+publish = false
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+license.workspace = true
+description = "Private UniFFI wrapper over the shared Radroots SDK engine"
+repository.workspace = true
+homepage.workspace = true
+readme = "README.md"
+
+[lib]
+crate-type = ["lib", "staticlib", "cdylib"]
+
+[dependencies]
+radroots_sdk = { workspace = true, features = ["memory"] }
+thiserror = { workspace = true }
+uniffi = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["macros", "rt"] }
+
+[lints]
+workspace = true
diff --git a/crates/sdk_ffi/README.md b/crates/sdk_ffi/README.md
@@ -0,0 +1,10 @@
+# radroots_sdk_ffi
+
+Private UniFFI bindings over the host-neutral `radroots_sdk` engine. The V1
+surface exposes deterministic memory-client construction, versioned capability
+DTOs, secret-safe errors, and explicit asynchronous close. It does not own
+keychain prompts, background execution, application scheduling, or presentation
+DTOs; those remain mobile-host responsibilities.
+
+This Rust build crate is never published. Generated Swift and Kotlin artifacts
+are qualified independently from the public 19-crate Rust inventory.
diff --git a/crates/sdk_ffi/src/lib.rs b/crates/sdk_ffi/src/lib.rs
@@ -0,0 +1,173 @@
+//! Private mobile FFI over the shared SDK engine.
+
+#![forbid(unsafe_code)]
+#![doc = include_str!("../README.md")]
+
+use std::sync::Arc;
+
+use radroots_sdk::{Client, ClientBuilder};
+
+uniffi::setup_scaffolding!("radroots_sdk");
+
+/// Generation-1 mobile DTOs.
+pub mod v1 {
+ use super::*;
+
+ /// Stable capability maturity independent of runtime availability.
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+ pub enum CapabilityMaturity {
+ Stable,
+ Preview,
+ Experimental,
+ }
+
+ /// Current side-effect-free capability availability.
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+ pub enum CapabilityAvailability {
+ Available,
+ Degraded,
+ Unavailable,
+ Unsupported,
+ }
+
+ /// Versioned, presentation-independent capability observation.
+ #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+ pub struct CapabilityStatus {
+ pub id: String,
+ pub compiled: bool,
+ pub configured: bool,
+ pub availability: CapabilityAvailability,
+ pub maturity: CapabilityMaturity,
+ }
+
+ /// Secret-safe SDK failure at the language boundary.
+ #[derive(Debug, thiserror::Error, uniffi::Error)]
+ pub enum Error {
+ #[error("SDK {code}: {message}")]
+ Sdk {
+ code: String,
+ message: String,
+ retryable: bool,
+ },
+ }
+
+ impl From<radroots_sdk::Error> for Error {
+ fn from(error: radroots_sdk::Error) -> Self {
+ let descriptor = error.descriptor();
+ Self::Sdk {
+ code: descriptor.code().as_str().to_owned(),
+ message: descriptor.message().to_owned(),
+ retryable: descriptor.retryable(),
+ }
+ }
+ }
+
+ /// Thread-safe mobile handle delegating lifecycle to [`radroots_sdk::Client`].
+ #[derive(uniffi::Object)]
+ pub struct MobileClient {
+ client: Client,
+ }
+
+ #[uniffi::export]
+ impl MobileClient {
+ /// Creates a deterministic, local-only memory client without I/O.
+ #[uniffi::constructor]
+ pub fn memory() -> Result<Arc<Self>, Error> {
+ let client = ClientBuilder::memory_default().build()?;
+ Ok(Arc::new(Self { client }))
+ }
+
+ /// Returns stable capability DTOs without probing host resources.
+ pub fn capabilities(&self) -> Vec<CapabilityStatus> {
+ self.client
+ .capabilities()
+ .iter()
+ .copied()
+ .map(CapabilityStatus::from)
+ .collect()
+ }
+
+ /// Returns whether explicit SDK close completed across all clones.
+ pub fn is_closed(&self) -> bool {
+ self.client.is_closed()
+ }
+
+ /// Explicitly closes SDK-owned resources without installing a runtime.
+ pub async fn close(&self) -> Result<(), Error> {
+ self.client.close().await.map_err(Error::from)
+ }
+ }
+
+ impl From<radroots_sdk::capability::CapabilityStatus> for CapabilityStatus {
+ fn from(status: radroots_sdk::capability::CapabilityStatus) -> Self {
+ Self {
+ id: status.id().as_str().to_owned(),
+ compiled: status.is_compiled(),
+ configured: status.is_configured(),
+ availability: match status.availability() {
+ radroots_sdk::capability::Availability::Available => {
+ CapabilityAvailability::Available
+ }
+ radroots_sdk::capability::Availability::Degraded => {
+ CapabilityAvailability::Degraded
+ }
+ radroots_sdk::capability::Availability::Unavailable => {
+ CapabilityAvailability::Unavailable
+ }
+ radroots_sdk::capability::Availability::Unsupported => {
+ CapabilityAvailability::Unsupported
+ }
+ },
+ maturity: match status.maturity() {
+ radroots_sdk::capability::Maturity::Stable => CapabilityMaturity::Stable,
+ radroots_sdk::capability::Maturity::Preview => CapabilityMaturity::Preview,
+ radroots_sdk::capability::Maturity::Experimental => {
+ CapabilityMaturity::Experimental
+ }
+ },
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::v1::{CapabilityAvailability, Error, MobileClient};
+
+ fn assert_send_sync<T: Send + Sync>() {}
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn memory_client_delegates_capabilities_and_lifecycle() {
+ assert_send_sync::<MobileClient>();
+ let client = MobileClient::memory().expect("memory client");
+ let capabilities = client.capabilities();
+ let storage = capabilities
+ .iter()
+ .find(|status| status.id == "storage.canonical")
+ .expect("canonical storage");
+ assert!(storage.compiled);
+ assert!(storage.configured);
+ assert_eq!(storage.availability, CapabilityAvailability::Available);
+ assert!(!client.is_closed());
+
+ client.close().await.expect("close");
+ assert!(client.is_closed());
+ client.close().await.expect("idempotent close");
+ }
+
+ #[test]
+ fn sdk_error_mapping_is_versioned_and_secret_safe() {
+ let native = radroots_sdk::ClientBuilder::new()
+ .build()
+ .expect_err("missing storage");
+ let error = Error::from(native);
+ let Error::Sdk {
+ code,
+ message,
+ retryable,
+ } = error;
+ assert_eq!(code, "missing_storage");
+ assert_eq!(message, "SDK storage capability is not configured");
+ assert!(!retryable);
+ }
+}