lib

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

commit d826735b5a86100a435d3a261acea8ed8dfa01ff
parent 37323ca235197d6c2a142e5c8d1cfe2086d92ff8
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:
Mcrates/studio_ffi/Cargo.toml | 6++++++
Acrates/studio_ffi/src/commands.rs | 386+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_ffi/src/lib.rs | 2++
Mimports/studio_workspace/Cargo.toml | 3++-
4 files changed, 396 insertions(+), 1 deletion(-)

diff --git a/crates/studio_ffi/Cargo.toml b/crates/studio_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/crates/studio_ffi/src/commands.rs b/crates/studio_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/crates/studio_ffi/src/lib.rs b/crates/studio_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/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/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]