lib

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

commit 839129e2387492fd1bba94ad1be66bd1c3a87745
parent 277eeba147443ea8eb1e37fd60065bc6bf24d0e4
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:35:18 +0000

ffi: bridge ordered changes and recovery handles

- expose predecessor revisions for change-gap recovery
- return generated recovery material through a one-use object
- gate generated account persistence on native acknowledgement
- add awaited native shutdown receipts and bounded import transport

Diffstat:
Mcrates/studio_ffi/src/commands.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/studio_ffi/src/lib.rs | 9++++++---
Mcrates/studio_ffi/src/observer.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 207 insertions(+), 6 deletions(-)

diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -2,14 +2,14 @@ use std::collections::BTreeMap; use std::fmt::{self, Display, Formatter}; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use radroots_studio_application::{ - Clock, DurableRequestId, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, - relay_configuration_from_environment, + Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayRuntimeMode, + RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle}; @@ -130,6 +130,35 @@ pub struct GeneratedAccountDto { } #[derive(uniffi::Object)] +pub struct GeneratedRecoveryRequest { + handle: GeneratedKeyRecoveryHandle, + resolved: AtomicBool, +} + +#[uniffi::export] +impl GeneratedRecoveryRequest { + pub fn account(&self) -> AccountDto { + self.handle.view().account().into() + } + + pub fn expires_at_seconds(&self) -> i64 { + self.handle.view().expires_at().as_seconds() + } + + /// Returns the recovery secret exactly once. + /// + /// # Errors + /// + /// Returns a safe unavailable error after the first read. + pub fn take_recovery_nsec(&self) -> Result<String, StudioError> { + self.handle + .take_recovery_nsec() + .map(|nsec| nsec.with_exposed_secret(str::to_owned)) + .map_err(StudioError::from) + } +} + +#[derive(uniffi::Object)] pub struct RemovalRequest { public_key_hex: String, token: Mutex<Option<RemovalConfirmationToken>>, @@ -222,6 +251,66 @@ impl StudioAppCore { .map_err(StudioError::from) } + /// Begins the exclusive generated-account recovery flow without persistence. + /// + /// # Errors + /// + /// Returns a safe key-generation, conflict, timeout, or lifecycle error. + pub async fn begin_generated_account_v2( + &self, + ) -> Result<Arc<GeneratedRecoveryRequest>, StudioError> { + self.inner + .actor + .begin_generated_key_stage() + .await + .map(|handle| { + Arc::new(GeneratedRecoveryRequest { + handle, + resolved: AtomicBool::new(false), + }) + }) + .map_err(StudioError::from) + } + + /// Acknowledges recovery and commits the generated account once. + /// + /// # Errors + /// + /// Returns a safe recovery, credential, persistence, timeout, or lifecycle error. + pub async fn acknowledge_generated_account_v2( + &self, + request: Arc<GeneratedRecoveryRequest>, + ) -> Result<AppSnapshotDto, StudioError> { + if request.resolved.swap(true, Ordering::AcqRel) { + return Err(confirmation_expired()); + } + self.inner + .actor + .acknowledge_generated_key_stage(request.handle.id()) + .await + .map(|snapshot| (&snapshot).into()) + .map_err(StudioError::from) + } + + /// Cancels the exclusive generated-account recovery flow. + /// + /// # Errors + /// + /// Returns a safe timeout or lifecycle error. + pub async fn cancel_generated_account_v2( + &self, + request: Arc<GeneratedRecoveryRequest>, + ) -> Result<bool, StudioError> { + if request.resolved.swap(true, Ordering::AcqRel) { + return Ok(false); + } + self.inner + .actor + .cancel_generated_key_stage() + .await + .map_err(StudioError::from) + } + /// Imports one nsec or canonical secret-key hex value. /// /// # Errors @@ -595,6 +684,31 @@ mod tests { assert_eq!(first.request_id, "ffi-test-import-1"); } + #[tokio::test] + async fn generated_recovery_handle_is_one_use_and_acknowledgement_gated() { + let core = in_memory_core(); + let initial = core.snapshot(); + let recovery = core + .begin_generated_account_v2() + .await + .expect("begin recovery"); + + assert_eq!(core.snapshot(), initial); + let nsec = recovery.take_recovery_nsec().expect("one-use nsec"); + assert!(nsec.starts_with("nsec1")); + assert!(recovery.take_recovery_nsec().is_err()); + let committed = core + .acknowledge_generated_account_v2(Arc::clone(&recovery)) + .await + .expect("acknowledge"); + assert_eq!(committed.accounts.len(), 1); + assert!( + core.acknowledge_generated_account_v2(recovery) + .await + .is_err() + ); + } + #[test] fn compatibility_matrix_rejects_before_storage_mutation() { let actual = compatibility_descriptor(); diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs @@ -5,15 +5,18 @@ mod dto; mod observer; pub use commands::{ - AccountCommandReceiptDto, GeneratedAccountDto, RemovalRequest, RequestContextDto, - StudioAppCore, StudioError, + AccountCommandReceiptDto, GeneratedAccountDto, GeneratedRecoveryRequest, RemovalRequest, + RequestContextDto, StudioAppCore, StudioError, }; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, }; -pub use observer::{ObserverSubscription, StudioObserver}; +pub use observer::{ + ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver, + StudioObserver, +}; uniffi::setup_scaffolding!(); diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -17,6 +17,23 @@ pub trait StudioObserver: Send + Sync { fn on_snapshot_changed(&self, snapshot: AppSnapshotDto); } +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SnapshotChangeDto { + pub snapshot: AppSnapshotDto, + pub previous_revision: Option<u64>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct ShutdownReceiptDto { + pub final_revision: u64, + pub closed: bool, +} + +#[uniffi::export(callback_interface)] +pub trait StudioChangeObserver: Send + Sync { + fn on_change(&self, change: SnapshotChangeDto); +} + #[derive(uniffi::Object)] pub struct ObserverSubscription { core: Weak<RuntimeCore>, @@ -57,6 +74,47 @@ impl Drop for ObserverSubscription { #[uniffi::export] impl StudioAppCore { + /// Subscribes to ordered revision changes including predecessor metadata. + /// + /// # Errors + /// + /// Returns a safe observer or lifecycle error. + pub async fn subscribe_changes_v2( + &self, + observer: Box<dyn StudioChangeObserver>, + ) -> Result<Arc<ObserverSubscription>, StudioError> { + if self.inner.closed.load(Ordering::Acquire) { + return Err(closed_error()); + } + let mut subscription = self + .inner + .actor + .subscribe_changes(OBSERVER_CHANGE_CAPACITY) + .await + .map_err(StudioError::from)?; + let id = subscription.id(); + let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer); + let task = crate::commands::runtime().spawn(async move { + while let Some(change) = subscription.receive().await { + observer.on_change(SnapshotChangeDto { + snapshot: change.snapshot().into(), + previous_revision: change + .previous_revision() + .map(radroots_studio_application::SnapshotRevision::value), + }); + } + }); + self.inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, task); + Ok(Arc::new(ObserverSubscription { + core: Arc::downgrade(&self.inner), + id: Mutex::new(Some(id)), + })) + } + /// Subscribes to revisioned snapshots and immediately delivers the current value. /// /// # Errors @@ -112,6 +170,32 @@ impl StudioAppCore { let _ = actor.close().await; }); } + + /// Stops observer delivery and waits for actor-owned shutdown. + /// + /// # Errors + /// + /// Returns a safe closed or timeout error when shutdown cannot complete. + pub async fn shutdown_v2(&self) -> Result<ShutdownReceiptDto, StudioError> { + if self.inner.closed.swap(true, Ordering::AcqRel) { + return Err(closed_error()); + } + let handles = std::mem::take( + &mut *self + .inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, task) in handles { + task.abort(); + } + self.inner.actor.close().await.map_err(StudioError::from)?; + Ok(ShutdownReceiptDto { + final_revision: self.inner.actor.snapshot().revision().value(), + closed: true, + }) + } } fn closed_error() -> StudioError {