app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 59551e922e0b10a1e23f3bd9682fd4835bb00466
parent 3af620fde90c4e4a357a81692cb1555fb4095e90
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:34:35 +0000

ffi: expose explicit application commands

- open the canonical database with platform runtime services
- run blocking persistence and credential operations off the caller
- expose account session profile and confirmed removal commands
- confine generated nsec text to the one-time backup receipt

Diffstat:
Mcore/Cargo.lock | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/Cargo.toml | 3++-
Mcore/crates/ffi/Cargo.toml | 6++++++
Acore/crates/ffi/src/commands.rs | 386+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/ffi/src/lib.rs | 2++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
6 files changed, 448 insertions(+), 2 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -616,6 +616,27 @@ dependencies = [ ] [[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] name = "displaydoc" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1189,6 +1210,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] name = "libsqlite3-sys" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1473,6 +1503,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] name = "ordered-stream" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1651,8 +1687,12 @@ dependencies = [ name = "radroots-studio-ffi" version = "0.1.0-alpha.0" dependencies = [ + "directories", "radroots-studio-application", "radroots-studio-domain", + "radroots-studio-storage", + "tempfile", + "tokio", "uniffi", ] @@ -1740,6 +1780,17 @@ dependencies = [ ] [[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] name = "refinery" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/core/Cargo.toml b/core/Cargo.toml @@ -25,6 +25,7 @@ pedantic = "deny" [workspace.dependencies] keyring = "=4.1.6" +directories = "=6.0.0" nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } nostr-sdk = "=0.44.1" nostr-relay-builder = "=0.44.1" @@ -33,7 +34,7 @@ rusqlite = { version = "=0.39.0", features = ["bundled"] } secrecy = "=0.10.3" url = "=2.5.8" zeroize = "=1.9.0" -tokio = "=1.47.1" +tokio = { version = "=1.47.1", features = ["rt-multi-thread", "sync"] } uniffi = "=0.32.0" [patch.crates-io] diff --git a/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml @@ -10,9 +10,15 @@ repository.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] +directories.workspace = true radroots-studio-application = { path = "../application" } radroots-studio-domain = { path = "../domain" } +radroots-studio-storage = { path = "../storage" } +tokio.workspace = true uniffi.workspace = true +[dev-dependencies] +tempfile = "=3.23.0" + [lints] workspace = true diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -0,0 +1,386 @@ +use std::fmt::{self, Display, Formatter}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use directories::ProjectDirs; +use radroots_studio_application::{ + Clock, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, + relay_configuration_from_environment, +}; +use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; +use radroots_studio_storage::{OsKeyringSecretStore, PersistentAppCore}; + +use crate::{AccountDto, AppSnapshotDto}; + +#[derive(Debug, uniffi::Error)] +pub enum StudioError { + Failure { code: String, message: String }, +} + +impl Display for StudioError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Failure { message, .. } => formatter.write_str(message), + } + } +} + +impl std::error::Error for StudioError {} + +impl From<SafeError> for StudioError { + fn from(error: SafeError) -> Self { + Self::Failure { + code: format!("{:?}", error.code()), + message: error.message().as_str().to_owned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct GeneratedAccountDto { + pub account: AccountDto, + pub snapshot: AppSnapshotDto, + pub nsec: String, +} + +#[derive(uniffi::Object)] +pub struct RemovalRequest { + public_key_hex: String, + token: Mutex<Option<RemovalConfirmationToken>>, +} + +#[uniffi::export] +impl RemovalRequest { + pub fn public_key_hex(&self) -> String { + self.public_key_hex.clone() + } +} + +struct RuntimeCore { + adapter: PersistentAppCore, + secrets: OsKeyringSecretStore, + clock: SystemClock, + nostr: SdkNostrClient, +} + +#[derive(uniffi::Object)] +pub struct StudioAppCore { + inner: Arc<RuntimeCore>, +} + +#[uniffi::export] +impl StudioAppCore { + /// Opens the canonical application database and runtime services. + /// + /// # Errors + /// + /// Returns a safe configuration or storage error. + #[uniffi::constructor] + pub fn open(development_mode: bool) -> Result<Arc<Self>, StudioError> { + let path = canonical_database_path()?; + std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?) + .map_err(|_| path_unavailable())?; + Self::open_path(&path, development_mode) + } + + /// Restores durable public application state. + /// + /// # Errors + /// + /// Returns a safe storage, recovery, or application-state error. + pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> { + let inner = Arc::clone(&self.inner); + blocking(move || { + inner + .adapter + .bootstrap(&inner.secrets, &inner.clock) + .map(|snapshot| (&snapshot).into()) + }) + .await + } + + #[must_use] + pub fn snapshot(&self) -> AppSnapshotDto { + (&self.inner.adapter.core().snapshot()).into() + } + + /// Generates and stores one local account with a one-time backup receipt. + /// + /// # Errors + /// + /// Returns a safe keyring, storage, or account error. + pub async fn generate_account(&self) -> Result<GeneratedAccountDto, StudioError> { + let inner = Arc::clone(&self.inner); + blocking(move || { + let receipt = inner + .adapter + .generate_account(&inner.secrets, &inner.clock)?; + Ok(GeneratedAccountDto { + account: receipt.account().into(), + snapshot: (&inner.adapter.core().snapshot()).into(), + nsec: receipt.generated_nsec().with_exposed_secret(str::to_owned), + }) + }) + .await + } + + /// Imports one nsec or canonical secret-key hex value. + /// + /// # Errors + /// + /// Returns a safe validation, keyring, storage, or account error. + pub async fn import_secret_key( + &self, + secret_key: String, + ) -> Result<AppSnapshotDto, StudioError> { + let input = SecretKeyInput::parse(secret_key).map_err(StudioError::from)?; + let inner = Arc::clone(&self.inner); + blocking(move || { + inner + .adapter + .import_secret_key(input, &inner.secrets, &inner.clock)?; + Ok((&inner.adapter.core().snapshot()).into()) + }) + .await + } + + /// Selects one saved account without activating it. + /// + /// # Errors + /// + /// Returns a safe public-key, account, or storage error. + pub async fn select_account( + &self, + public_key_hex: String, + ) -> Result<AppSnapshotDto, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + let inner = Arc::clone(&self.inner); + blocking(move || { + inner + .adapter + .select_account(public_key) + .map(|snapshot| (&snapshot).into()) + }) + .await + } + + /// Activates one saved account after validating its credential. + /// + /// # Errors + /// + /// Returns a safe public-key, credential, account, or storage error. + pub async fn activate_account( + &self, + public_key_hex: String, + ) -> Result<AppSnapshotDto, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + let inner = Arc::clone(&self.inner); + blocking(move || { + inner + .adapter + .activate_account(public_key, &inner.secrets, &inner.clock) + .map(|snapshot| (&snapshot).into()) + }) + .await + } + + /// Signs out while retaining accounts and credentials. + /// + /// # Errors + /// + /// Returns a safe application-state error. + pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> { + let inner = Arc::clone(&self.inner); + blocking(move || inner.adapter.sign_out().map(|snapshot| (&snapshot).into())).await + } + + /// Refreshes the active Nostr profile from configured relays. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. + pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> { + let inner = Arc::clone(&self.inner); + runtime() + .spawn(async move { + inner + .adapter + .core() + .refresh_active_profile(inner.adapter.database(), &inner.nostr, &inner.clock) + .await + .map(|snapshot| (&snapshot).into()) + .map_err(StudioError::from) + }) + .await + .map_err(|_| runtime_unavailable())? + } + + /// Issues a revision-bound removal confirmation object. + /// + /// # Errors + /// + /// Returns a safe public-key or account error. + pub async fn request_account_removal( + &self, + public_key_hex: String, + ) -> Result<Arc<RemovalRequest>, StudioError> { + let public_key = parse_public_key(&public_key_hex)?; + let inner = Arc::clone(&self.inner); + blocking(move || { + let token = inner.adapter.request_account_removal(public_key)?; + Ok(Arc::new(RemovalRequest { + public_key_hex, + token: Mutex::new(Some(token)), + })) + }) + .await + } + + /// Permanently removes the account represented by a one-time request. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, recovery, or storage error. + pub async fn confirm_account_removal( + &self, + request: Arc<RemovalRequest>, + ) -> Result<AppSnapshotDto, StudioError> { + let token = request + .token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(confirmation_expired)?; + let inner = Arc::clone(&self.inner); + blocking(move || { + inner + .adapter + .confirm_account_removal(token, &inner.secrets, &inner.clock) + .map(|snapshot| (&snapshot).into()) + }) + .await + } +} + +impl StudioAppCore { + fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> { + let mode = if development_mode { + RelayRuntimeMode::Development + } else { + RelayRuntimeMode::Packaged + }; + let relays = relay_configuration_from_environment(mode)?; + let adapter = PersistentAppCore::open(path, relays)?; + Ok(Arc::new(Self { + inner: Arc::new(RuntimeCore { + adapter, + secrets: OsKeyringSecretStore, + clock: SystemClock, + nostr: SdkNostrClient::new(Duration::from_secs(5)), + }), + })) + } +} + +#[derive(Clone, Copy)] +struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> UnixTimestamp { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + UnixTimestamp::from_seconds(seconds).expect("system time is nonnegative") + } +} + +fn canonical_database_path() -> Result<PathBuf, StudioError> { + ProjectDirs::from("org", "radroots", "studio") + .map(|project| project.data_dir().join("studio.sqlite3")) + .ok_or_else(path_unavailable) +} + +fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> { + PublicKey::from_hex(value).map_err(StudioError::from) +} + +async fn blocking<T, F>(operation: F) -> Result<T, StudioError> +where + T: Send + 'static, + F: FnOnce() -> Result<T, SafeError> + Send + 'static, +{ + runtime() + .spawn_blocking(operation) + .await + .map_err(|_| runtime_unavailable())? + .map_err(StudioError::from) +} + +fn runtime() -> &'static tokio::runtime::Runtime { + static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("radroots-studio-core") + .build() + .expect("Tokio runtime construction") + }) +} + +fn path_unavailable() -> StudioError { + StudioError::Failure { + code: "StorageUnavailable".to_owned(), + message: "The application data directory is unavailable.".to_owned(), + } +} + +fn runtime_unavailable() -> StudioError { + StudioError::Failure { + code: "InvalidApplicationState".to_owned(), + message: "The application runtime is unavailable.".to_owned(), + } +} + +fn confirmation_expired() -> StudioError { + StudioError::Failure { + code: "InvalidApplicationState".to_owned(), + message: "The account removal confirmation is no longer valid.".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use radroots_studio_application::RelayConfiguration; + use radroots_studio_storage::PersistentAppCore; + + use super::{RuntimeCore, StudioAppCore, SystemClock}; + + fn in_memory_core() -> Arc<StudioAppCore> { + Arc::new(StudioAppCore { + inner: Arc::new(RuntimeCore { + adapter: PersistentAppCore::in_memory(RelayConfiguration::default()) + .expect("in-memory core"), + secrets: radroots_studio_storage::OsKeyringSecretStore, + clock: SystemClock, + nostr: radroots_studio_application::SdkNostrClient::new( + std::time::Duration::from_millis(10), + ), + }), + }) + } + + #[tokio::test] + async fn exported_bootstrap_and_snapshot_are_revisioned() { + let core = in_memory_core(); + let bootstrapped = core.bootstrap().await.expect("bootstrap"); + let current = core.snapshot(); + + assert_eq!(bootstrapped, current); + assert_eq!(current.revision, 1); + } +} diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs @@ -1,7 +1,9 @@ #![doc = "Radroots Studio `UniFFI` boundary."] +mod commands; mod dto; +pub use commands::{GeneratedAccountDto, RemovalRequest, StudioAppCore, StudioError}; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -522,7 +522,7 @@ handoff commit sequence. ### RCLD-09 - [x] 41. Create UniFFI scaffolding and DTO mapping. -- [ ] 42. Expose approved AppCore commands through UniFFI. +- [x] 42. Expose approved AppCore commands through UniFFI. - [ ] 43. Expose observer callback and deregistration through UniFFI. - [ ] 44. Build native library artifacts from Cargo. - [ ] 45. Add Gradle task for Cargo build.