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:
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.