commit e661ffc5cefcb00ff0d4dbe5c53d7adf7b97aeb4
parent 839129e2387492fd1bba94ad1be66bd1c3a87745
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:39:39 +0000
ffi: remove superseded v1 command surface
- remove unversioned open generate import observe and shutdown APIs
- migrate Kotlin to compatibility-gated v2 bindings
- adopt native staged recovery and ordered change consumers
- guard generated contracts against legacy API reintroduction
Diffstat:
3 files changed, 43 insertions(+), 135 deletions(-)
diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs
@@ -122,13 +122,6 @@ impl StudioError {
}
}
-#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
-pub struct GeneratedAccountDto {
- pub account: AccountDto,
- pub snapshot: AppSnapshotDto,
- pub nsec: String,
-}
-
#[derive(uniffi::Object)]
pub struct GeneratedRecoveryRequest {
handle: GeneratedKeyRecoveryHandle,
@@ -201,19 +194,6 @@ impl StudioAppCore {
Self::open_path_compatible(&path, &expectation, development_mode)
}
- /// 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
@@ -233,24 +213,6 @@ impl StudioAppCore {
(&self.inner.actor.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> {
- self.inner
- .actor
- .generate_account()
- .await
- .map(|receipt| GeneratedAccountDto {
- account: receipt.account().into(),
- snapshot: (&self.inner.actor.snapshot()).into(),
- nsec: receipt.generated_nsec().with_exposed_secret(str::to_owned),
- })
- .map_err(StudioError::from)
- }
-
/// Begins the exclusive generated-account recovery flow without persistence.
///
/// # Errors
@@ -311,24 +273,6 @@ impl StudioAppCore {
.map_err(StudioError::from)
}
- /// 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: Vec<u8>,
- ) -> Result<AppSnapshotDto, StudioError> {
- let input = SecretKeyInput::parse_bytes(secret_key).map_err(StudioError::from)?;
- self.inner
- .actor
- .import_secret_key(input)
- .await
- .map(|_| (&self.inner.actor.snapshot()).into())
- .map_err(StudioError::from)
- }
-
/// Imports or repairs an account using a caller-owned idempotency key.
///
/// # Errors
@@ -786,4 +730,20 @@ mod tests {
Some("canonical-lowercase-public-key-hex")
);
}
+
+ #[test]
+ fn superseded_v1_ffi_commands_are_absent() {
+ let commands = include_str!("commands.rs");
+ let observer = include_str!("observer.rs");
+ for forbidden in [
+ format!("pub async fn {}_account(", "generate"),
+ format!("pub async fn {}_secret_key(", "import"),
+ format!("pub fn {}(development_mode", "open"),
+ format!("pub async fn {}(", "subscribe"),
+ format!("pub fn {}(&self)", "shutdown"),
+ ] {
+ assert!(!commands.contains(&forbidden));
+ assert!(!observer.contains(&forbidden));
+ }
+ }
}
diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs
@@ -5,8 +5,8 @@ mod dto;
mod observer;
pub use commands::{
- AccountCommandReceiptDto, GeneratedAccountDto, GeneratedRecoveryRequest, RemovalRequest,
- RequestContextDto, StudioAppCore, StudioError,
+ AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto,
+ StudioAppCore, StudioError,
};
pub use dto::{
AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
@@ -15,7 +15,6 @@ pub use dto::{
};
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
@@ -12,11 +12,6 @@ const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
None => unreachable!(),
};
-#[uniffi::export(callback_interface)]
-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,
@@ -115,62 +110,6 @@ impl StudioAppCore {
}))
}
- /// Subscribes to revisioned snapshots and immediately delivers the current value.
- ///
- /// # Errors
- ///
- /// Returns a safe observer or lifecycle error.
- pub async fn subscribe(
- &self,
- observer: Box<dyn StudioObserver>,
- ) -> 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 StudioObserver> = Arc::from(observer);
- let task = crate::commands::runtime().spawn(async move {
- while let Some(change) = subscription.receive().await {
- observer.on_snapshot_changed(change.snapshot().into());
- }
- });
- 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)),
- }))
- }
-
- pub fn shutdown(&self) {
- if self.inner.closed.swap(true, Ordering::AcqRel) {
- return;
- }
- let handles = std::mem::take(
- &mut *self
- .inner
- .observers
- .lock()
- .unwrap_or_else(std::sync::PoisonError::into_inner),
- );
- for (_, task) in handles {
- task.abort();
- }
- let actor = self.inner.actor.clone();
- crate::commands::runtime().spawn(async move {
- let _ = actor.close().await;
- });
- }
-
/// Stops observer delivery and waits for actor-owned shutdown.
///
/// # Errors
@@ -223,7 +162,9 @@ mod tests {
use radroots_studio_storage::RuntimeActorHandle;
use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime};
- use crate::{AppSnapshotDto, ProfileLoadStateDto, StudioAppCore, StudioObserver};
+ use crate::{
+ AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver,
+ };
const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
@@ -233,8 +174,9 @@ mod tests {
core: Mutex<Option<Arc<StudioAppCore>>>,
}
- impl StudioObserver for RecordingObserver {
- fn on_snapshot_changed(&self, snapshot: AppSnapshotDto) {
+ impl StudioChangeObserver for RecordingObserver {
+ fn on_change(&self, change: SnapshotChangeDto) {
+ let snapshot = change.snapshot;
if let Some(core) = self.core.lock().expect("core").as_ref() {
assert_eq!(core.snapshot().revision, snapshot.revision);
}
@@ -272,7 +214,7 @@ mod tests {
let observer = Arc::new(RecordingObserver::default());
*observer.core.lock().expect("core") = Some(Arc::clone(&core));
let subscription = core
- .subscribe(Box::new(ArcObserver(observer.clone())))
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
.await
.expect("subscribe");
@@ -294,15 +236,14 @@ mod tests {
let core = core();
let observer = Arc::new(RecordingObserver::default());
let _subscription = runtime()
- .block_on(core.subscribe(Box::new(ArcObserver(observer.clone()))))
+ .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))))
.expect("subscribe");
- core.shutdown();
- core.shutdown();
+ runtime().block_on(core.shutdown_v2()).expect("shutdown");
assert!(
runtime()
- .block_on(core.subscribe(Box::new(ArcObserver(observer))))
+ .block_on(core.subscribe_changes_v2(Box::new(ArcObserver(observer))))
.is_err()
);
assert!(core.inner.observers.lock().expect("observers").is_empty());
@@ -333,13 +274,21 @@ mod tests {
let observer = Arc::new(RecordingObserver::default());
*observer.core.lock().expect("core") = Some(Arc::clone(&core));
let subscription = core
- .subscribe(Box::new(ArcObserver(observer.clone())))
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
.await
.expect("subscribe");
let imported = core
- .import_secret_key(SECRET_HEX.as_bytes().to_vec())
+ .import_account_v2(
+ crate::RequestContextDto {
+ request_id: "observer-import".to_owned(),
+ expected_revision: core.snapshot().revision,
+ deadline_millis: 5_000,
+ },
+ SECRET_HEX.as_bytes().to_vec(),
+ )
.await
- .expect("import");
+ .expect("import")
+ .snapshot;
let public_key = imported.selected_public_key_hex.expect("selection");
core.activate_account(public_key).await.expect("activate");
core.refresh_active_profile().await.expect("refresh");
@@ -361,16 +310,16 @@ mod tests {
core.sign_out().await.expect("sign out");
assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count);
- core.shutdown();
+ core.shutdown_v2().await.expect("shutdown");
publisher.shutdown().await;
local_relay.shutdown();
}
struct ArcObserver(Arc<RecordingObserver>);
- impl StudioObserver for ArcObserver {
- fn on_snapshot_changed(&self, snapshot: AppSnapshotDto) {
- self.0.on_snapshot_changed(snapshot);
+ impl StudioChangeObserver for ArcObserver {
+ fn on_change(&self, change: SnapshotChangeDto) {
+ self.0.on_change(change);
}
}