app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 2626ab342494a993f277cb3ebf8bdec7536b8a31
parent 59551e922e0b10a1e23f3bd9682fd4835bb00466
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:36:34 +0000

ffi: add closeable snapshot observers

- bridge revisioned Rust snapshots to foreign callbacks
- deliver callbacks outside core locks to permit safe re-entry
- make observer subscriptions idempotently closeable
- deregister all callbacks when the application core closes

Diffstat:
Mcore/crates/ffi/src/commands.rs | 22+++++++++++++++-------
Mcore/crates/ffi/src/lib.rs | 2++
Acore/crates/ffi/src/observer.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 217 insertions(+), 8 deletions(-)

diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeSet; use std::fmt::{self, Display, Formatter}; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -57,16 +59,18 @@ impl RemovalRequest { } } -struct RuntimeCore { - adapter: PersistentAppCore, - secrets: OsKeyringSecretStore, - clock: SystemClock, - nostr: SdkNostrClient, +pub(crate) struct RuntimeCore { + pub(crate) adapter: PersistentAppCore, + pub(crate) secrets: OsKeyringSecretStore, + pub(crate) clock: SystemClock, + pub(crate) nostr: SdkNostrClient, + pub(crate) observers: Mutex<BTreeSet<radroots_studio_application::ObserverHandle>>, + pub(crate) closed: AtomicBool, } #[derive(uniffi::Object)] pub struct StudioAppCore { - inner: Arc<RuntimeCore>, + pub(crate) inner: Arc<RuntimeCore>, } #[uniffi::export] @@ -278,13 +282,15 @@ impl StudioAppCore { secrets: OsKeyringSecretStore, clock: SystemClock, nostr: SdkNostrClient::new(Duration::from_secs(5)), + observers: Mutex::new(BTreeSet::new()), + closed: AtomicBool::new(false), }), })) } } #[derive(Clone, Copy)] -struct SystemClock; +pub(crate) struct SystemClock; impl Clock for SystemClock { fn now(&self) -> UnixTimestamp { @@ -370,6 +376,8 @@ mod tests { nostr: radroots_studio_application::SdkNostrClient::new( std::time::Duration::from_millis(10), ), + observers: std::sync::Mutex::new(std::collections::BTreeSet::new()), + closed: std::sync::atomic::AtomicBool::new(false), }), }) } diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs @@ -2,11 +2,13 @@ mod commands; mod dto; +mod observer; pub use commands::{GeneratedAccountDto, RemovalRequest, StudioAppCore, StudioError}; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, }; +pub use observer::{ObserverSubscription, StudioObserver}; uniffi::setup_scaffolding!(); diff --git a/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs @@ -0,0 +1,199 @@ +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex, Weak}; + +use radroots_studio_application::{AppObserver, AppSnapshot, ObserverHandle}; + +use crate::commands::RuntimeCore; +use crate::{AppSnapshotDto, StudioAppCore, StudioError}; + +#[uniffi::export(callback_interface)] +pub trait StudioObserver: Send + Sync { + fn on_snapshot_changed(&self, snapshot: AppSnapshotDto); +} + +struct ObserverBridge { + observer: Arc<dyn StudioObserver>, +} + +impl AppObserver for ObserverBridge { + fn on_snapshot_changed(&self, snapshot: AppSnapshot) { + self.observer.on_snapshot_changed((&snapshot).into()); + } +} + +#[derive(uniffi::Object)] +pub struct ObserverSubscription { + core: Weak<RuntimeCore>, + handle: Mutex<Option<ObserverHandle>>, +} + +#[uniffi::export] +impl ObserverSubscription { + pub fn close(&self) { + let handle = self + .handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let (Some(core), Some(handle)) = (self.core.upgrade(), handle) else { + return; + }; + let _ = core.adapter.core().unsubscribe(handle); + core.observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&handle); + } +} + +impl Drop for ObserverSubscription { + fn drop(&mut self) { + self.close(); + } +} + +#[uniffi::export] +impl StudioAppCore { + /// Subscribes to revisioned snapshots and immediately delivers the current value. + /// + /// # Errors + /// + /// Returns a safe observer or lifecycle error. + pub fn subscribe( + &self, + observer: Box<dyn StudioObserver>, + ) -> Result<Arc<ObserverSubscription>, StudioError> { + if self.inner.closed.load(Ordering::Acquire) { + return Err(closed_error()); + } + let bridge = Arc::new(ObserverBridge { + observer: Arc::from(observer), + }); + let handle = self + .inner + .adapter + .core() + .subscribe(bridge) + .map_err(StudioError::from)?; + self.inner + .observers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(handle); + Ok(Arc::new(ObserverSubscription { + core: Arc::downgrade(&self.inner), + handle: Mutex::new(Some(handle)), + })) + } + + pub fn close(&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 handle in handles { + let _ = self.inner.adapter.core().unsubscribe(handle); + } + let _ = self.inner.adapter.sign_out(); + } +} + +fn closed_error() -> StudioError { + StudioError::Failure { + code: "InvalidApplicationState".to_owned(), + message: "The application runtime is closed.".to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use radroots_studio_application::RelayConfiguration; + use radroots_studio_storage::PersistentAppCore; + + use crate::commands::{RuntimeCore, SystemClock}; + use crate::{AppSnapshotDto, StudioAppCore, StudioObserver}; + + #[derive(Default)] + struct RecordingObserver { + snapshots: Mutex<Vec<AppSnapshotDto>>, + core: Mutex<Option<Arc<StudioAppCore>>>, + } + + impl StudioObserver for RecordingObserver { + fn on_snapshot_changed(&self, snapshot: AppSnapshotDto) { + if let Some(core) = self.core.lock().expect("core").as_ref() { + assert_eq!(core.snapshot().revision, snapshot.revision); + } + self.snapshots.lock().expect("snapshots").push(snapshot); + } + } + + fn core() -> Arc<StudioAppCore> { + Arc::new(StudioAppCore { + inner: Arc::new(RuntimeCore { + adapter: PersistentAppCore::in_memory(RelayConfiguration::default()).expect("core"), + secrets: radroots_studio_storage::OsKeyringSecretStore, + clock: SystemClock, + nostr: radroots_studio_application::SdkNostrClient::new( + std::time::Duration::from_millis(10), + ), + observers: Mutex::new(std::collections::BTreeSet::new()), + closed: std::sync::atomic::AtomicBool::new(false), + }), + }) + } + + #[test] + fn callbacks_allow_reentry_and_stop_after_subscription_close() { + let core = core(); + core.inner.adapter.core().bootstrap().expect("bootstrap"); + let observer = Arc::new(RecordingObserver::default()); + *observer.core.lock().expect("core") = Some(Arc::clone(&core)); + let subscription = core + .subscribe(Box::new(ArcObserver(observer.clone()))) + .expect("subscribe"); + + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); + core.inner + .adapter + .core() + .bootstrap() + .expect("idempotent bootstrap"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); + subscription.close(); + core.inner.adapter.core().sign_out().expect("sign out"); + assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); + } + + #[test] + fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() { + let core = core(); + core.inner.adapter.core().bootstrap().expect("bootstrap"); + let observer = Arc::new(RecordingObserver::default()); + let _subscription = core + .subscribe(Box::new(ArcObserver(observer.clone()))) + .expect("subscribe"); + + core.close(); + core.close(); + + assert!(core.subscribe(Box::new(ArcObserver(observer))).is_err()); + assert!(core.inner.observers.lock().expect("observers").is_empty()); + } + + struct ArcObserver(Arc<RecordingObserver>); + + impl StudioObserver for ArcObserver { + fn on_snapshot_changed(&self, snapshot: AppSnapshotDto) { + self.0.on_snapshot_changed(snapshot); + } + } +} diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -523,7 +523,7 @@ handoff commit sequence. - [x] 41. Create UniFFI scaffolding and DTO mapping. - [x] 42. Expose approved AppCore commands through UniFFI. -- [ ] 43. Expose observer callback and deregistration through UniFFI. +- [x] 43. Expose observer callback and deregistration through UniFFI. - [ ] 44. Build native library artifacts from Cargo. - [ ] 45. Add Gradle task for Cargo build. - [ ] 46. Add Gradle UniFFI binding generation and generated source set.