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