app

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

commit 71a3695085b34ec97d41f2e461425bc2a3f36bba
parent ebe9f6734942ee399838a876409c065bb6261d34
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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 15++++++++++++++-
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 27+++++++++++++++++++++------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 62++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 3+--
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 33+++++++++++++++++++++++++++------
Mcore/crates/ffi/src/commands.rs | 72++++++++++++++++--------------------------------------------------------
Mcore/crates/ffi/src/lib.rs | 5++---
Mcore/crates/ffi/src/observer.rs | 101++++++++++++++++++++-----------------------------------------------------------
8 files changed, 158 insertions(+), 160 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -11,6 +11,8 @@ import org.radroots.studio.accounts.ui.StartupFailureScreen import org.radroots.studio.accounts.ui.toUiModel import org.radroots.studio.ffi.StudioAppCore import org.radroots.studio.ffi.StudioException +import org.radroots.studio.ffi.CompatibilityExpectation +import org.radroots.studio.ffi.compatibilityDescriptor internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore @@ -60,6 +62,17 @@ fun RadrootsApplication( internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore { val developmentMode = java.lang.Boolean.getBoolean("radroots.studio.development") - val core = StudioAppCore.open(developmentMode = developmentMode) + val descriptor = compatibilityDescriptor() + val core = StudioAppCore.openCompatible( + expectation = CompatibilityExpectation( + contractMajor = 2.toUShort(), + minimumContractMinor = 0.toUShort(), + contractHash = "radroots-studio-native-v2-2026-08-03", + minimumSchemaVersion = 5U, + maximumSchemaVersion = 9U, + ), + developmentMode = developmentMode, + ) + check(descriptor.contractMajor == 2.toUShort()) return StudioAppStore(NativeStudioCoreGateway(core), scope) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -29,6 +29,7 @@ class StudioAppStore( private var closed = false private var subscription: AutoCloseable? = null private var pendingRemoval: RemovalTicket? = null + private var pendingGeneratedRecovery: GeneratedRecoveryTicket? = null private var command: Job? = null val state: State<StudioStoreState> @@ -58,18 +59,31 @@ class StudioAppStore( } fun generateAccount() { - runCommand { - val receipt = gateway.generateAccount() + launchCommand { + val recovery = gateway.beginGeneratedAccount() + pendingGeneratedRecovery = recovery mutableState.value = mutableState.value.copy( - generatedKeyBackup = generatedRecovery.begin(receipt.account.npub, receipt.nsec), + generatedKeyBackup = generatedRecovery.begin( + recovery.account.npub, + recovery.takeRecoveryNsec(), + ), ) - receipt.snapshot } } fun acknowledgeGeneratedKeyBackup() { - generatedRecovery.acknowledge() - mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + val recovery = pendingGeneratedRecovery ?: return + pendingGeneratedRecovery = null + runCommand { + try { + recovery.acknowledge().also { + generatedRecovery.acknowledge() + mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + } + } finally { + recovery.close() + } + } } fun importSecretKey() { @@ -137,6 +151,7 @@ class StudioAppStore( fun cancelAccountRemoval() { pendingRemoval?.close() + pendingGeneratedRecovery?.close() pendingRemoval = null mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt @@ -1,14 +1,29 @@ package org.radroots.studio.application +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.runBlocking import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.GeneratedAccountDto +import org.radroots.studio.ffi.AccountDto +import org.radroots.studio.ffi.GeneratedRecoveryRequest import org.radroots.studio.ffi.ObserverSubscription import org.radroots.studio.ffi.RemovalRequest +import org.radroots.studio.ffi.RequestContextDto +import org.radroots.studio.ffi.SnapshotChangeDto import org.radroots.studio.ffi.StudioAppCore -import org.radroots.studio.ffi.StudioObserver +import org.radroots.studio.ffi.StudioChangeObserver interface RemovalTicket : AutoCloseable +interface GeneratedRecoveryTicket : AutoCloseable { + val account: AccountDto + + fun takeRecoveryNsec(): String + + suspend fun acknowledge(): AppSnapshotDto + + suspend fun cancel(): Boolean +} + interface StudioCoreGateway : AutoCloseable { fun snapshot(): AppSnapshotDto @@ -16,7 +31,7 @@ interface StudioCoreGateway : AutoCloseable { suspend fun bootstrap(): AppSnapshotDto - suspend fun generateAccount(): GeneratedAccountDto + suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto @@ -36,13 +51,14 @@ interface StudioCoreGateway : AutoCloseable { class NativeStudioCoreGateway( private val core: StudioAppCore, ) : StudioCoreGateway { + private val nextRequest = AtomicLong(1) override fun snapshot(): AppSnapshotDto = core.snapshot() override suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable { - val subscription = core.subscribe( - object : StudioObserver { - override fun onSnapshotChanged(snapshot: AppSnapshotDto) { - onSnapshot(snapshot) + val subscription = core.subscribeChangesV2( + object : StudioChangeObserver { + override fun onChange(change: SnapshotChangeDto) { + onSnapshot(change.snapshot) } }, ) @@ -51,11 +67,12 @@ class NativeStudioCoreGateway( override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() - override suspend fun generateAccount(): GeneratedAccountDto = core.generateAccount() + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = + NativeGeneratedRecoveryTicket(core, core.beginGeneratedAccountV2()) override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = try { - core.importSecretKey(secretKey) + core.importAccountV2(requestContext(), secretKey).snapshot } finally { secretKey.fill(0) } @@ -79,9 +96,15 @@ class NativeStudioCoreGateway( } override fun close() { - core.shutdown() + runBlocking { core.shutdownV2() } core.close() } + + private fun requestContext(): RequestContextDto = RequestContextDto( + requestId = "kotlin:${nextRequest.getAndIncrement()}", + expectedRevision = core.snapshot().revision, + deadlineMillis = 30_000UL, + ) } private class NativeSubscription( @@ -100,3 +123,22 @@ private class NativeRemovalTicket( request.close() } } + +private class NativeGeneratedRecoveryTicket( + private val core: StudioAppCore, + private val request: GeneratedRecoveryRequest, +) : GeneratedRecoveryTicket { + override val account: AccountDto = request.account() + + override fun takeRecoveryNsec(): String = request.takeRecoveryNsec() + + override suspend fun acknowledge(): AppSnapshotDto = + core.acknowledgeGeneratedAccountV2(request) + + override suspend fun cancel(): Boolean = core.cancelGeneratedAccountV2(request) + + override fun close() { + runBlocking { runCatching { cancel() } } + request.close() + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt @@ -78,8 +78,7 @@ private class ApplicationGateway : StudioCoreGateway { override suspend fun subscribe(onSnapshot: (org.radroots.studio.ffi.AppSnapshotDto) -> Unit) = AutoCloseable {} override suspend fun bootstrap() = applicationSnapshot(1UL) - override suspend fun generateAccount(): org.radroots.studio.ffi.GeneratedAccountDto = - error("unused") + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") override suspend fun importSecretKey(secretKey: ByteArray) = error("unused") override suspend fun selectAccount(publicKeyHex: String) = error("unused") override suspend fun activateAccount(publicKeyHex: String) = error("unused") diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.test.runTest import org.radroots.studio.ffi.AccountDto import org.radroots.studio.ffi.AppLifecycleDto import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.GeneratedAccountDto import org.radroots.studio.ffi.KeyAvailabilityDto import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.ffi.SignerKindDto @@ -47,6 +46,7 @@ class StudioAppStoreTest { assertEquals("nsec1secret", store.state.value.generatedKeyBackup?.revealNsec()) assertEquals("npub1account", store.state.value.generatedKeyBackup?.npub) store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() assertNull(store.state.value.generatedKeyBackup) store.close() } @@ -144,11 +144,12 @@ private class FakeStudioCoreGateway( override suspend fun bootstrap(): AppSnapshotDto = snapshot(1UL).also(::emit) - override suspend fun generateAccount(): GeneratedAccountDto { - val next = snapshot(current.revision + 1UL) - emit(next) - return GeneratedAccountDto(account(), next, "nsec1secret") - } + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = + FakeGeneratedRecoveryTicket(account()) { committed -> + current = snapshot(current.revision + 1UL) + emit(current) + committed(current) + } override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = current.also { lastImportBuffer = secretKey @@ -171,6 +172,26 @@ private class FakeStudioCoreGateway( } } +private class FakeGeneratedRecoveryTicket( + override val account: AccountDto, + private val commit: (((AppSnapshotDto) -> Unit) -> Unit), +) : GeneratedRecoveryTicket { + private var available = true + + override fun takeRecoveryNsec(): String = "nsec1secret" + + override suspend fun acknowledge(): AppSnapshotDto { + lateinit var snapshot: AppSnapshotDto + commit { snapshot = it } + available = false + return snapshot + } + + override suspend fun cancel(): Boolean = available.also { available = false } + + override fun close() = Unit +} + private class FakeRemovalTicket : RemovalTicket { var closed = false diff --git a/core/crates/ffi/src/commands.rs b/core/crates/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/core/crates/ffi/src/lib.rs b/core/crates/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/core/crates/ffi/src/observer.rs b/core/crates/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); } }