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:
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);
}
}