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