commit d7437d29ed464bbc38d3df300b8b870df0e039ab
parent 1b8acc061ac044c56c03c60dd9ad69e230cba072
Author: triesap <tyson@radroots.org>
Date: Sun, 9 Aug 2026 17:52:05 +0000
ffi: own the Studio native boundary
- copy the Studio FFI producer into HarvestCircle core
- register the native boundary as a local workspace member
- rebase compatibility fixture coverage onto the capsule-owned baseline
- refresh build dependencies and lockfile source ownership
Diffstat:
10 files changed, 2582 insertions(+), 89 deletions(-)
diff --git a/core/Cargo.lock b/core/Cargo.lock
@@ -1960,36 +1960,14 @@ dependencies = [
name = "radroots_studio_application"
version = "0.1.0-alpha"
dependencies = [
- "radroots_studio_domain 0.1.0-alpha",
+ "radroots_studio_domain",
"secrecy",
"tokio",
]
[[package]]
-name = "radroots_studio_application"
-version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
-dependencies = [
- "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "secrecy",
- "tokio",
-]
-
-[[package]]
-name = "radroots_studio_domain"
-version = "0.1.0-alpha"
-dependencies = [
- "bech32",
- "radroots_identity",
- "secrecy",
- "url",
- "zeroize",
-]
-
-[[package]]
name = "radroots_studio_domain"
version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
"bech32",
"radroots_identity",
@@ -2001,17 +1979,20 @@ dependencies = [
[[package]]
name = "radroots_studio_ffi"
version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
dependencies = [
"directories",
+ "nostr 0.44.1",
+ "nostr-relay-builder",
+ "nostr-sdk 0.44.0",
"quote",
- "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_runtime 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
+ "radroots_studio_application",
+ "radroots_studio_domain",
+ "radroots_studio_nostr",
+ "radroots_studio_runtime",
+ "radroots_studio_storage",
"sha2",
"syn 2.0.119",
+ "tempfile",
"tokio",
"uniffi",
]
@@ -2024,23 +2005,8 @@ dependencies = [
"nostr-relay-builder",
"nostr-sdk 0.44.0",
"radroots_identity",
- "radroots_studio_application 0.1.0-alpha",
- "radroots_studio_domain 0.1.0-alpha",
- "radroots_transport",
- "radroots_transport_nostr",
- "tokio",
-]
-
-[[package]]
-name = "radroots_studio_nostr"
-version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
-dependencies = [
- "nostr 0.44.1",
- "nostr-sdk 0.44.0",
- "radroots_identity",
- "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
+ "radroots_studio_application",
+ "radroots_studio_domain",
"radroots_transport",
"radroots_transport_nostr",
"tokio",
@@ -2060,39 +2026,26 @@ dependencies = [
"nostr 0.44.1",
"nostr-relay-builder",
"nostr-sdk 0.44.0",
- "radroots_studio_application 0.1.0-alpha",
- "radroots_studio_domain 0.1.0-alpha",
- "radroots_studio_nostr 0.1.0-alpha",
- "radroots_studio_storage 0.1.0-alpha",
+ "radroots_studio_application",
+ "radroots_studio_domain",
+ "radroots_studio_nostr",
+ "radroots_studio_storage",
"tempfile",
"tokio",
"uuid",
]
[[package]]
-name = "radroots_studio_runtime"
-version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
-dependencies = [
- "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_nostr 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_storage 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "tokio",
- "uuid",
-]
-
-[[package]]
name = "radroots_studio_source_lock"
version = "0.1.0-alpha"
dependencies = [
- "radroots_studio_application 0.1.0-alpha",
- "radroots_studio_domain 0.1.0-alpha",
+ "radroots_studio_application",
+ "radroots_studio_domain",
"radroots_studio_ffi",
- "radroots_studio_nostr 0.1.0-alpha",
+ "radroots_studio_nostr",
"radroots_studio_preferences",
- "radroots_studio_runtime 0.1.0-alpha",
- "radroots_studio_storage 0.1.0-alpha",
+ "radroots_studio_runtime",
+ "radroots_studio_storage",
]
[[package]]
@@ -2103,8 +2056,8 @@ dependencies = [
"getrandom 0.2.17",
"hmac",
"keyring",
- "radroots_studio_application 0.1.0-alpha",
- "radroots_studio_domain 0.1.0-alpha",
+ "radroots_studio_application",
+ "radroots_studio_domain",
"refinery",
"rusqlite",
"rustix",
@@ -2114,24 +2067,6 @@ dependencies = [
]
[[package]]
-name = "radroots_studio_storage"
-version = "0.1.0-alpha"
-source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
-dependencies = [
- "fs2",
- "getrandom 0.2.17",
- "hmac",
- "keyring",
- "radroots_studio_application 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "radroots_studio_domain 0.1.0-alpha (git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3)",
- "refinery",
- "rusqlite",
- "rustix",
- "sha2",
- "zeroize",
-]
-
-[[package]]
name = "radroots_transport"
version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3"
diff --git a/core/Cargo.toml b/core/Cargo.toml
@@ -3,6 +3,7 @@ members = [
"crates/source_lock",
"crates/studio_application",
"crates/studio_domain",
+ "crates/studio_ffi",
"crates/studio_nostr",
"crates/studio_preferences",
"crates/studio_runtime",
@@ -30,7 +31,7 @@ pedantic = "deny"
[workspace.dependencies]
radroots_studio_application = { path = "crates/studio_application", version = "=0.1.0-alpha" }
radroots_studio_domain = { path = "crates/studio_domain", version = "=0.1.0-alpha" }
-radroots_studio_ffi = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" }
+radroots_studio_ffi = { path = "crates/studio_ffi", version = "=0.1.0-alpha" }
radroots_studio_nostr = { path = "crates/studio_nostr", version = "=0.1.0-alpha" }
radroots_studio_preferences = { path = "crates/studio_preferences", version = "=0.1.0-alpha" }
radroots_studio_runtime = { path = "crates/studio_runtime", version = "=0.1.0-alpha" }
@@ -40,6 +41,8 @@ radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "09065
radroots_transport_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha", default-features = false }
getrandom = { version = "0.2", default-features = false }
hmac = { version = "0.12", default-features = false }
+quote = { version = "1" }
rustix = { version = "1", features = ["fs", "process", "std"] }
sha2 = { version = "0.10", default-features = false }
+syn = { version = "2", features = ["full", "parsing", "visit", "visit-mut"] }
uuid = { version = "1.22.0", features = ["v4", "v7"] }
diff --git a/core/crates/studio_ffi/Cargo.toml b/core/crates/studio_ffi/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "radroots_studio_ffi"
+description = "Private native FFI boundary for Radroots Studio"
+version = "0.1.0-alpha"
+edition.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+license = "GPL-3.0-only"
+repository.workspace = true
+homepage.workspace = true
+publish = false
+build = "build.rs"
+include = ["build.rs", "src/**", "uniffi.toml", "Cargo.toml"]
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+directories = "=6.0.0"
+radroots_studio_application.workspace = true
+radroots_studio_domain.workspace = true
+radroots_studio_nostr.workspace = true
+radroots_studio_runtime.workspace = true
+radroots_studio_storage.workspace = true
+tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] }
+uniffi = "=0.32.0"
+
+[build-dependencies]
+quote.workspace = true
+sha2.workspace = true
+syn.workspace = true
+
+[dev-dependencies]
+nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
+nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" }
+nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" }
+tempfile = "=3.23.0"
+
+[lints]
+workspace = true
diff --git a/core/crates/studio_ffi/build.rs b/core/crates/studio_ffi/build.rs
@@ -0,0 +1,95 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use quote::ToTokens;
+use sha2::{Digest, Sha256};
+use syn::{ImplItem, Item, Visibility};
+
+const CONTRACT_SOURCES: &[&str] = &[
+ "src/commands.rs",
+ "src/contract.rs",
+ "src/dto.rs",
+ "src/lib.rs",
+ "src/observer.rs",
+];
+
+fn main() {
+ for source in CONTRACT_SOURCES {
+ println!("cargo:rerun-if-changed={source}");
+ }
+ println!("cargo:rerun-if-changed=../studio_storage/migrations");
+
+ let mut metadata = Vec::new();
+ for source in CONTRACT_SOURCES {
+ collect_public_metadata(Path::new(source), &mut metadata);
+ }
+ let mut migrations = fs::read_dir("../studio_storage/migrations")
+ .expect("read Studio migration catalog")
+ .map(|entry| entry.expect("read migration entry").path())
+ .filter(|path| path.extension().is_some_and(|extension| extension == "sql"))
+ .collect::<Vec<_>>();
+ migrations.sort();
+ for migration in migrations {
+ metadata.push(format!(
+ "migration:{}:{}",
+ migration
+ .file_name()
+ .expect("migration filename")
+ .to_string_lossy(),
+ hex_digest(&fs::read(&migration).expect("read migration"))
+ ));
+ }
+ metadata.sort();
+ metadata.dedup();
+ let normalized = metadata.join("\n");
+ println!(
+ "cargo:rustc-env=RADROOTS_STUDIO_FFI_CONTRACT_DIGEST={}",
+ hex_digest(normalized.as_bytes())
+ );
+ fs::write(
+ PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"))
+ .join("ffi_contract_metadata.txt"),
+ normalized,
+ )
+ .expect("write normalized FFI metadata");
+}
+
+fn collect_public_metadata(path: &Path, output: &mut Vec<String>) {
+ let source = fs::read_to_string(path).expect("read FFI source");
+ let file = syn::parse_file(&source).expect("parse FFI source");
+ for item in file.items {
+ match item {
+ Item::Const(item) if is_public(&item.vis) => push_tokens("const", item, output),
+ Item::Enum(item) if is_public(&item.vis) => push_tokens("enum", item, output),
+ Item::Fn(item) if is_public(&item.vis) => push_tokens("fn", item.sig, output),
+ Item::Struct(item) if is_public(&item.vis) => push_tokens("struct", item, output),
+ Item::Trait(item) if is_public(&item.vis) => push_tokens("trait", item, output),
+ Item::Impl(item) => {
+ let owner = item.self_ty.to_token_stream().to_string();
+ for member in item.items {
+ if let ImplItem::Fn(function) = member
+ && is_public(&function.vis)
+ {
+ output.push(format!("method:{owner}:{}", function.sig.to_token_stream()));
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn push_tokens(kind: &str, value: impl ToTokens, output: &mut Vec<String>) {
+ output.push(format!("{kind}:{}", value.to_token_stream()));
+}
+
+const fn is_public(visibility: &Visibility) -> bool {
+ matches!(visibility, Visibility::Public(_))
+}
+
+fn hex_digest(bytes: &[u8]) -> String {
+ Sha256::digest(bytes)
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect()
+}
diff --git a/core/crates/studio_ffi/src/commands.rs b/core/crates/studio_ffi/src/commands.rs
@@ -0,0 +1,1121 @@
+use std::collections::BTreeMap;
+use std::fmt::{self, Display, Formatter};
+use std::num::NonZeroUsize;
+use std::path::{Path, PathBuf};
+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, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode,
+ RemovalConfirmationToken, relay_configuration_from_environment,
+};
+use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
+use radroots_studio_nostr::SdkNostrClient;
+use radroots_studio_runtime::{
+ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
+};
+use radroots_studio_storage::OsKeyringSecretStore;
+
+use crate::{
+ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction,
+ contract::{
+ FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION,
+ PRODUCT_VERSION,
+ },
+ dto::error_policy,
+};
+
+const DATABASE_QUALIFIER: &str = "org";
+const DATABASE_ORGANIZATION: &str = "radroots";
+const DATABASE_APPLICATION: &str = "studio";
+const DATABASE_FILENAME: &str = "studio.sqlite3";
+const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR";
+pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64;
+const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct RequestContextDto {
+ pub request_id: String,
+ pub expected_revision: u64,
+ pub deadline_millis: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct AccountCommandReceiptDto {
+ pub request_id: String,
+ pub committed_revision: u64,
+ pub snapshot: AppSnapshotDto,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct CompatibilityDescriptor {
+ pub product_version: String,
+ pub cargo_package_version: String,
+ pub contract_major: u16,
+ pub contract_minor: u16,
+ pub contract_hash: String,
+ pub minimum_schema_version: u32,
+ pub current_schema_version: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct CompatibilityExpectation {
+ pub contract_major: u16,
+ pub minimum_contract_minor: u16,
+ pub contract_hash: String,
+ pub minimum_schema_version: u32,
+ pub maximum_schema_version: u32,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn compatibility_descriptor() -> CompatibilityDescriptor {
+ CompatibilityDescriptor {
+ product_version: PRODUCT_VERSION.to_owned(),
+ cargo_package_version: env!("CARGO_PKG_VERSION").to_owned(),
+ contract_major: FFI_CONTRACT_MAJOR,
+ contract_minor: FFI_CONTRACT_MINOR,
+ contract_hash: FFI_CONTRACT_HASH.to_owned(),
+ minimum_schema_version: MINIMUM_SCHEMA_VERSION,
+ current_schema_version: radroots_studio_storage::CURRENT_SCHEMA_VERSION,
+ }
+}
+
+#[derive(Debug)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Error))]
+pub enum StudioError {
+ Failure {
+ code: WireErrorCode,
+ category: WireErrorCategory,
+ retryable: bool,
+ recovery_action: WireRecoveryAction,
+ correlation_id: Option<String>,
+ safe_message: String,
+ },
+}
+
+impl Display for StudioError {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Failure { safe_message, .. } => formatter.write_str(safe_message),
+ }
+ }
+}
+
+impl std::error::Error for StudioError {}
+
+impl From<SafeError> for StudioError {
+ fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self::Failure {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ correlation_id: None,
+ safe_message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+impl StudioError {
+ fn correlated(error: SafeError, correlation_id: &str) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self::Failure {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ correlation_id: Some(correlation_id.to_owned()),
+ safe_message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
+pub struct GeneratedRecoveryRequest {
+ handle: GeneratedKeyRecoveryHandle,
+ resolved: AtomicBool,
+}
+
+#[cfg_attr(not(coverage_nightly), 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)
+ }
+}
+
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
+pub struct RemovalRequest {
+ public_key_hex: String,
+ deletes_local_credential: bool,
+ signs_out: bool,
+ expires_at_seconds: i64,
+ token: Mutex<Option<RemovalConfirmationToken>>,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+impl RemovalRequest {
+ pub fn public_key_hex(&self) -> String {
+ self.public_key_hex.clone()
+ }
+
+ pub fn deletes_local_credential(&self) -> bool {
+ self.deletes_local_credential
+ }
+
+ pub fn signs_out(&self) -> bool {
+ self.signs_out
+ }
+
+ pub fn expires_at_seconds(&self) -> i64 {
+ self.expires_at_seconds
+ }
+}
+
+pub(crate) struct RuntimeCore {
+ pub(crate) actor: RuntimeActorHandle,
+ pub(crate) observers: Mutex<
+ BTreeMap<
+ radroots_studio_application::ChangeSubscriptionId,
+ Option<tokio::task::JoinHandle<()>>,
+ >,
+ >,
+ pub(crate) closed: AtomicBool,
+ pub(crate) startup_relay_problem: Option<SafeError>,
+}
+
+impl RuntimeCore {
+ pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto {
+ AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle())
+ }
+
+ pub(crate) fn dto_for(
+ &self,
+ snapshot: &radroots_studio_application::AppSnapshot,
+ ) -> AppSnapshotDto {
+ AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle())
+ }
+
+ pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle {
+ let lifecycle = self.actor.lifecycle();
+ match (lifecycle, self.startup_relay_problem) {
+ (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => {
+ radroots_studio_application::RuntimeLifecycle::Degraded(problem)
+ }
+ _ => lifecycle,
+ }
+ }
+}
+
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
+pub struct StudioAppCore {
+ pub(crate) inner: Arc<RuntimeCore>,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+impl StudioAppCore {
+ /// Verifies the static contract before touching the application data path.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe compatibility error without opening or migrating storage.
+ #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn open_compatible(
+ expectation: CompatibilityExpectation,
+ development_mode: bool,
+ ) -> Result<Arc<Self>, StudioError> {
+ let path = application_database_path(development_mode)?;
+ Self::open_path_compatible(&path, &expectation, development_mode)
+ }
+
+ /// Restores durable public application state.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage, recovery, or application-state error.
+ pub async fn bootstrap(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .bootstrap()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ #[must_use]
+ pub fn snapshot(&self) -> AppSnapshotDto {
+ self.inner.snapshot_dto()
+ }
+
+ /// 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 terminal safe recovery, credential, persistence, timeout, or lifecycle error.
+ /// A failed commit must be recovered by importing the already-saved recovery key.
+ pub async fn acknowledge_generated_account_v2(
+ &self,
+ context: RequestContextDto,
+ request: Arc<GeneratedRecoveryRequest>,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ if request.resolved.swap(true, Ordering::AcqRel) {
+ return Err(generated_recovery_expired());
+ }
+ let request_id = DurableRequestId::parse(context.request_id.clone())
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
+ self.inner
+ .actor
+ .acknowledge_generated_key_stage(
+ request.handle.id(),
+ request_id,
+ radroots_studio_application::SnapshotRevision::from_value(
+ context.expected_revision,
+ ),
+ timeout,
+ )
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(generated_commit_failed)
+ }
+
+ /// 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 or repairs an account using a caller-owned idempotency key.
+ ///
+ /// # Errors
+ ///
+ /// Returns a correlated validation, conflict, timeout, credential, or storage error.
+ pub async fn import_account_v2(
+ &self,
+ context: RequestContextDto,
+ secret_key: Vec<u8>,
+ ) -> Result<AccountCommandReceiptDto, StudioError> {
+ let request_id = DurableRequestId::parse(context.request_id.clone())
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
+ let input = SecretKeyInput::parse_bytes(secret_key)
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ self.inner
+ .actor
+ .import_secret_key(
+ request_id,
+ radroots_studio_application::SnapshotRevision::from_value(
+ context.expected_revision,
+ ),
+ input,
+ timeout,
+ )
+ .await
+ .map(|_| {
+ let snapshot = self.inner.snapshot_dto();
+ AccountCommandReceiptDto {
+ request_id: context.request_id.clone(),
+ committed_revision: snapshot.revision,
+ snapshot,
+ }
+ })
+ .map_err(|error| StudioError::correlated(error, &context.request_id))
+ }
+
+ /// Selects one saved account without activating it.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key, account, or storage error.
+ pub async fn select_account(
+ &self,
+ public_key_hex: String,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .select_account(public_key)
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Activates one saved account after validating its credential.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key, credential, account, or storage error.
+ pub async fn activate_account(
+ &self,
+ public_key_hex: String,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .activate_account(public_key)
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Signs out while retaining accounts and credentials.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe application-state error.
+ pub async fn sign_out(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .sign_out()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Refreshes the active Nostr profile from configured relays.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe storage or application-state error.
+ pub async fn refresh_active_profile(&self) -> Result<AppSnapshotDto, StudioError> {
+ self.inner
+ .actor
+ .refresh_active_profile()
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+
+ /// Issues a revision-bound removal confirmation object.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe public-key or account error.
+ pub async fn request_account_removal(
+ &self,
+ public_key_hex: String,
+ ) -> Result<Arc<RemovalRequest>, StudioError> {
+ let public_key = parse_public_key(&public_key_hex)?;
+ self.inner
+ .actor
+ .request_account_removal(public_key)
+ .await
+ .map(|token| {
+ let impact = token.impact();
+ Arc::new(RemovalRequest {
+ public_key_hex,
+ deletes_local_credential: impact.deletes_local_credential(),
+ signs_out: impact.signs_out(),
+ expires_at_seconds: token.expires_at().as_seconds(),
+ token: Mutex::new(Some(token)),
+ })
+ })
+ .map_err(StudioError::from)
+ }
+
+ /// Permanently removes the account represented by a one-time request.
+ ///
+ /// # Errors
+ ///
+ /// Returns a safe confirmation, credential, recovery, or storage error.
+ pub async fn confirm_account_removal(
+ &self,
+ context: RequestContextDto,
+ request: Arc<RemovalRequest>,
+ ) -> Result<AppSnapshotDto, StudioError> {
+ let token = request
+ .token
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take()
+ .ok_or_else(confirmation_expired)?;
+ let request_id = DurableRequestId::parse(context.request_id.clone())
+ .map_err(|error| StudioError::correlated(error, &context.request_id))?;
+ let timeout = command_timeout(context.deadline_millis, &context.request_id)?;
+ self.inner
+ .actor
+ .confirm_account_removal(
+ token,
+ request_id,
+ radroots_studio_application::SnapshotRevision::from_value(
+ context.expected_revision,
+ ),
+ timeout,
+ )
+ .await
+ .map(|snapshot| self.inner.dto_for(&snapshot))
+ .map_err(StudioError::from)
+ }
+}
+
+fn verify_compatibility(expectation: &CompatibilityExpectation) -> Result<(), StudioError> {
+ let actual = compatibility_descriptor();
+ if expectation.contract_major != actual.contract_major
+ || expectation.minimum_contract_minor > actual.contract_minor
+ || expectation.contract_hash != actual.contract_hash
+ || expectation.minimum_schema_version > actual.current_schema_version
+ || expectation.maximum_schema_version < actual.minimum_schema_version
+ {
+ return Err(compatibility_mismatch());
+ }
+ Ok(())
+}
+
+impl StudioAppCore {
+ fn open_path_compatible(
+ path: &Path,
+ expectation: &CompatibilityExpectation,
+ development_mode: bool,
+ ) -> Result<Arc<Self>, StudioError> {
+ verify_compatibility(expectation)?;
+ std::fs::create_dir_all(path.parent().ok_or_else(path_unavailable)?)
+ .map_err(|_| path_unavailable())?;
+ Self::open_path(path, development_mode)
+ }
+
+ // The concrete product opener binds operating-system paths, keyrings, and
+ // SQLite ownership. Platform installation lanes exercise this adapter;
+ // deterministic coverage owns the compatibility and runtime policies.
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> {
+ let mode = if development_mode {
+ RelayRuntimeMode::Development
+ } else {
+ RelayRuntimeMode::Packaged
+ };
+ let (relays, startup_relay_problem) =
+ local_first_relay_configuration(relay_configuration_from_environment(mode));
+ let runtime = runtime()?;
+ let actor = runtime.block_on(RuntimeActorHandle::open(
+ path,
+ relays,
+ RuntimeDependencies::new(
+ Arc::new(OsKeyringSecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(Duration::from_secs(5))),
+ Arc::new(UuidInstallationIdentitySource),
+ ),
+ actor_mailbox_capacity()?,
+ runtime.handle(),
+ ))?;
+ Ok(Arc::new(Self {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: Mutex::new(BTreeMap::new()),
+ closed: AtomicBool::new(false),
+ startup_relay_problem,
+ }),
+ }))
+ }
+}
+
+fn local_first_relay_configuration(
+ configured: Result<RelayConfiguration, SafeError>,
+) -> (RelayConfiguration, Option<SafeError>) {
+ match configured {
+ Ok(relays) => (relays, None),
+ Err(problem) => (RelayConfiguration::default(), Some(problem)),
+ }
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct SystemClock;
+
+impl Clock for SystemClock {
+ fn now(&self) -> UnixTimestamp {
+ let seconds = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |duration| {
+ i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
+ });
+ UnixTimestamp::from_seconds(seconds).unwrap_or(UnixTimestamp::UNIX_EPOCH)
+ }
+}
+
+// ProjectDirs and the process environment are host integration boundaries.
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> {
+ if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT)
+ {
+ return Ok(PathBuf::from(directory).join(DATABASE_FILENAME));
+ }
+ ProjectDirs::from(
+ DATABASE_QUALIFIER,
+ DATABASE_ORGANIZATION,
+ DATABASE_APPLICATION,
+ )
+ .map(|project| project.data_dir().join(DATABASE_FILENAME))
+ .ok_or_else(path_unavailable)
+}
+
+fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> {
+ PublicKey::from_hex(value).map_err(StudioError::from)
+}
+
+fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> {
+ if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS {
+ return Err(StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Input,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: Some(correlation_id.to_owned()),
+ safe_message: "The command deadline is invalid.".to_owned(),
+ });
+ }
+ Ok(Duration::from_millis(millis))
+}
+
+pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime, StudioError> {
+ static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, ()>> = OnceLock::new();
+ RUNTIME
+ .get_or_init(|| {
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .thread_name("radroots-studio-core")
+ .build()
+ .map_err(|_| ())
+ })
+ .as_ref()
+ .map_err(|()| runtime_unavailable())
+}
+
+fn actor_mailbox_capacity() -> Result<NonZeroUsize, StudioError> {
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).ok_or_else(runtime_unavailable)
+}
+
+fn runtime_unavailable() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: true,
+ recovery_action: WireRecoveryAction::RestartApplication,
+ correlation_id: None,
+ safe_message: "The application runtime is unavailable.".to_owned(),
+ }
+}
+
+fn path_unavailable() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::StorageUnavailable,
+ category: WireErrorCategory::Storage,
+ retryable: true,
+ recovery_action: WireRecoveryAction::RestartApplication,
+ correlation_id: None,
+ safe_message: "The application data directory is unavailable.".to_owned(),
+ }
+}
+
+fn confirmation_expired() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The account removal confirmation is no longer valid.".to_owned(),
+ }
+}
+
+fn generated_recovery_expired() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
+ }
+}
+
+fn generated_commit_failed(error: SafeError) -> StudioError {
+ let (category, _, _) = error_policy(error.code());
+ StudioError::Failure {
+ code: error.code().into(),
+ category,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message:
+ "The generated account could not be saved. Import the recovery key you saved to try again."
+ .to_owned(),
+ }
+}
+
+fn compatibility_mismatch() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::CompatibilityMismatch,
+ category: WireErrorCategory::Compatibility,
+ retryable: false,
+ recovery_action: WireRecoveryAction::UpdateApplication,
+ correlation_id: None,
+ safe_message: "The application and native runtime are incompatible.".to_owned(),
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::sync::Arc;
+
+ use radroots_studio_application::{InMemorySecretStore, RelayConfiguration};
+ use radroots_studio_domain::SafeError;
+ use radroots_studio_nostr::SdkNostrClient;
+ use radroots_studio_runtime::{
+ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
+ };
+
+ use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION};
+
+ use super::{
+ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME,
+ DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR,
+ FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError,
+ SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity,
+ compatibility_descriptor, confirmation_expired, generated_commit_failed,
+ local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable,
+ verify_compatibility,
+ };
+
+ async fn in_memory_core() -> Arc<StudioAppCore> {
+ let actor = RuntimeActorHandle::in_memory(
+ RelayConfiguration::default(),
+ RuntimeDependencies::new(
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
+ Arc::new(UuidInstallationIdentitySource),
+ ),
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
+ runtime().expect("runtime").handle(),
+ )
+ .await
+ .expect("in-memory actor");
+ Arc::new(StudioAppCore {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: std::sync::Mutex::new(std::collections::BTreeMap::new()),
+ closed: std::sync::atomic::AtomicBool::new(false),
+ startup_relay_problem: None,
+ }),
+ })
+ }
+
+ #[tokio::test]
+ async fn exported_bootstrap_and_snapshot_are_revisioned() {
+ let core = in_memory_core().await;
+ let bootstrapped = core.bootstrap().await.expect("bootstrap");
+ let current = core.snapshot();
+
+ assert_eq!(bootstrapped, current);
+ assert_eq!(current.revision, 1);
+ }
+
+ #[tokio::test]
+ async fn request_context_import_replays_one_committed_receipt() {
+ let core = in_memory_core().await;
+ let initial = core.snapshot();
+ let context = RequestContextDto {
+ request_id: "ffi-test-import-1".to_owned(),
+ expected_revision: initial.revision,
+ deadline_millis: 5_000,
+ };
+ let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+ let first = core
+ .import_account_v2(context.clone(), secret.to_vec())
+ .await
+ .expect("first import");
+ let replay = core
+ .import_account_v2(context, secret.to_vec())
+ .await
+ .expect("replayed import");
+
+ assert_eq!(first, replay);
+ assert_eq!(first.snapshot.accounts.len(), 1);
+ 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().await;
+ 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 context = RequestContextDto {
+ request_id: "ffi-test-generate-1".to_owned(),
+ expected_revision: initial.revision,
+ deadline_millis: 5_000,
+ };
+ let committed = core
+ .acknowledge_generated_account_v2(context.clone(), Arc::clone(&recovery))
+ .await
+ .expect("acknowledge");
+ assert_eq!(committed.accounts.len(), 1);
+ let repeated = core
+ .acknowledge_generated_account_v2(context, recovery)
+ .await
+ .expect_err("repeated acknowledgement");
+ assert!(matches!(
+ repeated,
+ StudioError::Failure { safe_message, .. }
+ if safe_message == "The generated-key recovery step is no longer valid."
+ ));
+ }
+
+ #[tokio::test]
+ async fn account_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() {
+ let core = in_memory_core().await;
+ let initial = core.bootstrap().await.expect("bootstrap");
+ let imported = core
+ .import_account_v2(
+ RequestContextDto {
+ request_id: "ffi-lifecycle-import".to_owned(),
+ expected_revision: initial.revision,
+ deadline_millis: 5_000,
+ },
+ b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(),
+ )
+ .await
+ .expect("import account");
+ let public_key = imported.snapshot.accounts[0].public_key_hex.clone();
+
+ let selected = core
+ .select_account(public_key.clone())
+ .await
+ .expect("select account");
+ let active = core
+ .activate_account(public_key.clone())
+ .await
+ .expect("activate account");
+ assert!(active.revision > selected.revision);
+ let signed_out = core.sign_out().await.expect("sign out");
+ assert!(signed_out.revision > active.revision);
+ let refreshed = core
+ .refresh_active_profile()
+ .await
+ .expect("signed-out refresh is a stable no-op");
+ assert_eq!(refreshed.revision, signed_out.revision);
+
+ let removal = core
+ .request_account_removal(public_key.clone())
+ .await
+ .expect("request removal");
+ assert_eq!(removal.public_key_hex(), public_key);
+ assert!(removal.deletes_local_credential());
+ assert!(!removal.signs_out());
+ assert!(removal.expires_at_seconds() > 0);
+ let removed = core
+ .confirm_account_removal(
+ RequestContextDto {
+ request_id: "ffi-lifecycle-remove".to_owned(),
+ expected_revision: signed_out.revision,
+ deadline_millis: 5_000,
+ },
+ Arc::clone(&removal),
+ )
+ .await
+ .expect("confirm removal");
+ assert!(removed.accounts.is_empty());
+ assert!(
+ core.confirm_account_removal(
+ RequestContextDto {
+ request_id: "ffi-lifecycle-remove-repeated".to_owned(),
+ expected_revision: removed.revision,
+ deadline_millis: 5_000,
+ },
+ removal,
+ )
+ .await
+ .is_err()
+ );
+ assert!(
+ core.select_account("not-a-public-key".to_owned())
+ .await
+ .is_err()
+ );
+ }
+
+ #[tokio::test]
+ async fn generated_recovery_cancellation_and_request_validation_fail_closed() {
+ let core = in_memory_core().await;
+ let recovery = core
+ .begin_generated_account_v2()
+ .await
+ .expect("begin generated account");
+ assert_eq!(recovery.account().public_key_hex.len(), 64);
+ assert!(recovery.expires_at_seconds() > 0);
+ assert!(
+ core.cancel_generated_account_v2(Arc::clone(&recovery))
+ .await
+ .expect("first cancellation")
+ );
+ assert!(
+ !core
+ .cancel_generated_account_v2(recovery)
+ .await
+ .expect("second cancellation")
+ );
+
+ for context in [
+ RequestContextDto {
+ request_id: String::new(),
+ expected_revision: 0,
+ deadline_millis: 5_000,
+ },
+ RequestContextDto {
+ request_id: "ffi-zero-deadline".to_owned(),
+ expected_revision: 0,
+ deadline_millis: 0,
+ },
+ RequestContextDto {
+ request_id: "ffi-long-deadline".to_owned(),
+ expected_revision: 0,
+ deadline_millis: 30_001,
+ },
+ ] {
+ assert!(core.import_account_v2(context, vec![0; 32]).await.is_err());
+ }
+ assert!(
+ core.import_account_v2(
+ RequestContextDto {
+ request_id: "ffi-invalid-secret".to_owned(),
+ expected_revision: 0,
+ deadline_millis: 5_000,
+ },
+ vec![0; 31],
+ )
+ .await
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn boundary_failures_remain_typed_and_secret_safe() {
+ assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64);
+ for (error, code, category, retryable, recovery, message) in [
+ (
+ runtime_unavailable(),
+ WireErrorCode::InvalidApplicationState,
+ WireErrorCategory::Lifecycle,
+ true,
+ WireRecoveryAction::RestartApplication,
+ "The application runtime is unavailable.",
+ ),
+ (
+ path_unavailable(),
+ WireErrorCode::StorageUnavailable,
+ WireErrorCategory::Storage,
+ true,
+ WireRecoveryAction::RestartApplication,
+ "The application data directory is unavailable.",
+ ),
+ (
+ confirmation_expired(),
+ WireErrorCode::InvalidApplicationState,
+ WireErrorCategory::Lifecycle,
+ false,
+ WireRecoveryAction::None,
+ "The account removal confirmation is no longer valid.",
+ ),
+ (
+ generated_commit_failed(SafeError::new(
+ radroots_studio_domain::SafeErrorCode::StorageUnavailable,
+ radroots_studio_domain::SafeMessage::new("internal detail"),
+ )),
+ WireErrorCode::StorageUnavailable,
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::None,
+ "The generated account could not be saved. Import the recovery key you saved to try again.",
+ ),
+ ] {
+ assert_eq!(error.to_string(), message);
+ assert!(matches!(
+ error,
+ StudioError::Failure {
+ code: actual_code,
+ category: actual_category,
+ retryable: actual_retryable,
+ recovery_action: actual_recovery,
+ correlation_id: None,
+ safe_message,
+ } if actual_code == code
+ && actual_category == category
+ && actual_retryable == retryable
+ && actual_recovery == recovery
+ && safe_message == message
+ ));
+ }
+ }
+
+ #[test]
+ fn compatibility_matrix_rejects_before_storage_mutation() {
+ let actual = compatibility_descriptor();
+ let compatible = CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR,
+ minimum_contract_minor: FFI_CONTRACT_MINOR,
+ contract_hash: FFI_CONTRACT_HASH.to_owned(),
+ minimum_schema_version: 5,
+ maximum_schema_version: CURRENT_SCHEMA_VERSION,
+ };
+ verify_compatibility(&compatible).expect("compatible");
+
+ for incompatible in [
+ CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ minimum_contract_minor: FFI_CONTRACT_MINOR + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ contract_hash: "wrong-contract".to_owned(),
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ minimum_schema_version: actual.current_schema_version + 1,
+ ..compatible.clone()
+ },
+ CompatibilityExpectation {
+ maximum_schema_version: actual.minimum_schema_version - 1,
+ ..compatible.clone()
+ },
+ ] {
+ assert!(verify_compatibility(&incompatible).is_err());
+ }
+
+ let directory = tempfile::tempdir().expect("directory");
+ let rejected = directory.path().join("rejected").join("studio.sqlite3");
+ let incompatible = CompatibilityExpectation {
+ contract_major: FFI_CONTRACT_MAJOR + 1,
+ ..compatible
+ };
+ assert!(StudioAppCore::open_path_compatible(&rejected, &incompatible, true).is_err());
+ assert!(!rejected.parent().expect("parent").exists());
+ }
+
+ #[test]
+ fn v5_compatibility_fixture_preserves_external_coordinates() {
+ let fixture = include_str!("../../../compatibility/v5-baseline.properties");
+ let property = |key: &str| {
+ fixture.lines().find_map(|line| {
+ line.split_once('=')
+ .filter(|(candidate, _)| *candidate == key)
+ .map(|(_, value)| value)
+ })
+ };
+
+ assert_eq!(property("baseline.id"), Some("studio-runtime-v5"));
+ assert_eq!(property("schema.version"), Some("5"));
+ assert_eq!(CURRENT_SCHEMA_VERSION, 10);
+ assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1"));
+ assert_eq!(property("ffi.snapshot.schema"), Some("1"));
+ assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha"));
+ assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER));
+ assert_eq!(
+ property("database.organization"),
+ Some(DATABASE_ORGANIZATION)
+ );
+ assert_eq!(property("database.application"), Some(DATABASE_APPLICATION));
+ assert_eq!(property("database.filename"), Some(DATABASE_FILENAME));
+ assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE));
+ assert_eq!(
+ property("keyring.account"),
+ 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));
+ }
+ }
+
+ #[test]
+ fn invalid_relay_configuration_preserves_local_startup_as_degraded() {
+ let problem = SafeError::new(
+ radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration,
+ radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."),
+ );
+ let (relays, degraded) = local_first_relay_configuration(Err(problem));
+
+ assert!(relays.relays().is_empty());
+ assert_eq!(degraded, Some(problem));
+ }
+}
diff --git a/core/crates/studio_ffi/src/contract.rs b/core/crates/studio_ffi/src/contract.rs
@@ -0,0 +1,9 @@
+pub const PRODUCT_VERSION: &str = "0.1.0-alpha";
+pub const FFI_CONTRACT_MAJOR: u16 = 3;
+pub const FFI_CONTRACT_MINOR: u16 = 0;
+pub const MINIMUM_SCHEMA_VERSION: u32 = 5;
+pub const FFI_CONTRACT_HASH: &str = env!("RADROOTS_STUDIO_FFI_CONTRACT_DIGEST");
+
+#[cfg(test)]
+pub(crate) const NORMALIZED_CONTRACT_METADATA: &str =
+ include_str!(concat!(env!("OUT_DIR"), "/ffi_contract_metadata.txt"));
diff --git a/core/crates/studio_ffi/src/dto.rs b/core/crates/studio_ffi/src/dto.rs
@@ -0,0 +1,729 @@
+use radroots_studio_application::{
+ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
+ RuntimeLifecycle, SessionState,
+};
+use radroots_studio_domain::{
+ AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum WireErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidAccountMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ AccountAlreadyExists,
+ AccountNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ StorageQuarantined,
+ StorageBackupInvalid,
+ UnsupportedSchemaVersion,
+ RepairUnauthorized,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+ CompatibilityMismatch,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum WireErrorCategory {
+ Input,
+ Conflict,
+ Credential,
+ Storage,
+ Network,
+ Lifecycle,
+ Compatibility,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum WireRecoveryAction {
+ None,
+ Retry,
+ RepairCredential,
+ Authenticate,
+ RepairStorage,
+ RestoreBackup,
+ CheckConfiguration,
+ RestartApplication,
+ UpdateApplication,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct SafeErrorDto {
+ pub code: WireErrorCode,
+ pub category: WireErrorCategory,
+ pub retryable: bool,
+ pub recovery_action: WireRecoveryAction,
+ pub message: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum AppLifecycleDto {
+ Opening,
+ CompatibilityChecking,
+ AcquiringOwnership,
+ Migrating,
+ Recovering,
+ Ready,
+ Degraded,
+ Blocked,
+ ShuttingDown,
+ Closed,
+ Fatal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum SessionStateDto {
+ SignedOut,
+ Activating,
+ Active,
+ SigningOut,
+ Failed,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum RelayConnectionStateDto {
+ Disconnected,
+ Connecting,
+ Connected,
+ Degraded,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum ProfileLoadStateDto {
+ Empty,
+ Loading,
+ Cached,
+ Fresh,
+ Error,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum SignerKindDto {
+ LocalSecret,
+ WatchOnly,
+ RemoteNip46,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))]
+pub enum KeyAvailabilityDto {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+ NotRequired,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct ProfileDto {
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub nip05: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct AccountDto {
+ pub public_key_hex: String,
+ pub npub: String,
+ pub display_label: String,
+ pub signer_kind: SignerKindDto,
+ pub key_availability: KeyAvailabilityDto,
+ pub created_at_seconds: i64,
+ pub last_used_at_seconds: Option<i64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct ActiveAccountDto {
+ pub account: AccountDto,
+ pub relay_state: RelayConnectionStateDto,
+ pub profile_state: ProfileLoadStateDto,
+ pub profile: Option<ProfileDto>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct AppSnapshotDto {
+ pub revision: u64,
+ pub lifecycle: AppLifecycleDto,
+ pub lifecycle_error: Option<SafeErrorDto>,
+ pub configured_relays: Vec<String>,
+ pub accounts: Vec<AccountDto>,
+ pub selected_public_key_hex: Option<String>,
+ pub session: SessionStateDto,
+ pub session_subject_public_key_hex: Option<String>,
+ pub session_error: Option<SafeErrorDto>,
+ pub active_account: Option<ActiveAccountDto>,
+ pub recoverable_problem: Option<SafeErrorDto>,
+}
+
+impl From<&AppSnapshot> for AppSnapshotDto {
+ fn from(snapshot: &AppSnapshot) -> Self {
+ let (lifecycle, lifecycle_error) = match snapshot.lifecycle() {
+ AppLifecycle::Booting => (AppLifecycleDto::Opening, None),
+ AppLifecycle::Ready => (AppLifecycleDto::Ready, None),
+ AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())),
+ };
+ let (session, session_subject_public_key_hex, session_error) = match snapshot.session() {
+ SessionState::SignedOut => (SessionStateDto::SignedOut, None, None),
+ SessionState::Activating(public_key) => {
+ (SessionStateDto::Activating, Some(public_key.to_hex()), None)
+ }
+ SessionState::Active => (SessionStateDto::Active, None, None),
+ SessionState::SigningOut => (SessionStateDto::SigningOut, None, None),
+ SessionState::Failed(error) => (SessionStateDto::Failed, None, Some(error.into())),
+ };
+ Self {
+ revision: snapshot.revision().value(),
+ lifecycle,
+ lifecycle_error,
+ configured_relays: snapshot
+ .relay_configuration()
+ .relays()
+ .iter()
+ .map(|relay| relay.as_str().to_owned())
+ .collect(),
+ accounts: snapshot.accounts().iter().map(AccountDto::from).collect(),
+ selected_public_key_hex: snapshot
+ .selected_account()
+ .map(radroots_studio_domain::PublicKey::to_hex),
+ session,
+ session_subject_public_key_hex,
+ session_error,
+ active_account: snapshot.active_account().map(ActiveAccountDto::from),
+ recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from),
+ }
+ }
+}
+
+impl AppSnapshotDto {
+ pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self {
+ let mut dto = Self::from(snapshot);
+ let (lifecycle, problem) = match runtime {
+ RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None),
+ RuntimeLifecycle::CompatibilityChecking => {
+ (AppLifecycleDto::CompatibilityChecking, None)
+ }
+ RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None),
+ RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None),
+ RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None),
+ RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None),
+ RuntimeLifecycle::Degraded(error) => {
+ (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error)))
+ }
+ RuntimeLifecycle::Blocked(error) => {
+ (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error)))
+ }
+ RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None),
+ RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None),
+ RuntimeLifecycle::Fatal(error) => {
+ (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error)))
+ }
+ };
+ dto.lifecycle = lifecycle;
+ dto.lifecycle_error = problem;
+ dto
+ }
+}
+
+impl From<&AccountSummary> for AccountDto {
+ fn from(account: &AccountSummary) -> Self {
+ Self {
+ public_key_hex: account.public_key().to_hex(),
+ npub: account.npub().as_str().to_owned(),
+ display_label: account.display_label(),
+ signer_kind: SignerKindDto::LocalSecret,
+ key_availability: account.signer().availability().into(),
+ created_at_seconds: account.created_at().timestamp().as_seconds(),
+ last_used_at_seconds: account
+ .last_used_at()
+ .map(radroots_studio_domain::UnixTimestamp::as_seconds),
+ }
+ }
+}
+
+impl From<&ActiveAccountSnapshot> for ActiveAccountDto {
+ fn from(active: &ActiveAccountSnapshot) -> Self {
+ Self {
+ account: active.account().into(),
+ relay_state: active.relay_state().into(),
+ profile_state: active.profile_state().into(),
+ profile: active.profile().map(ProfileDto::from),
+ }
+ }
+}
+
+impl From<&ProfileMetadata> for ProfileDto {
+ fn from(profile: &ProfileMetadata) -> Self {
+ Self {
+ name: profile.name().map(str::to_owned),
+ display_name: profile.display_name().map(str::to_owned),
+ nip05: profile.nip05().map(str::to_owned),
+ about: profile.about().map(str::to_owned),
+ picture: profile.picture().map(str::to_owned),
+ }
+ }
+}
+
+impl From<SafeError> for SafeErrorDto {
+ fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
+ Self {
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ message: error.message().as_str().to_owned(),
+ }
+ }
+}
+
+impl From<SafeErrorCode> for WireErrorCode {
+ fn from(code: SafeErrorCode) -> Self {
+ match code {
+ SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey,
+ SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey,
+ SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata,
+ SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata,
+ SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState,
+ SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists,
+ SafeErrorCode::AccountNotFound => Self::AccountNotFound,
+ SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable,
+ SafeErrorCode::CredentialMissing => Self::CredentialMissing,
+ SafeErrorCode::StorageUnavailable => Self::StorageUnavailable,
+ SafeErrorCode::StorageCorrupt => Self::StorageCorrupt,
+ SafeErrorCode::StorageQuarantined => Self::StorageQuarantined,
+ SafeErrorCode::StorageBackupInvalid => Self::StorageBackupInvalid,
+ SafeErrorCode::UnsupportedSchemaVersion => Self::UnsupportedSchemaVersion,
+ SafeErrorCode::RepairUnauthorized => Self::RepairUnauthorized,
+ SafeErrorCode::PendingOperationRecoveryRequired => {
+ Self::PendingOperationRecoveryRequired
+ }
+ SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration,
+ SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed,
+ SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed,
+ SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed,
+ SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed,
+ }
+ }
+}
+
+pub(crate) const fn error_policy(
+ code: SafeErrorCode,
+) -> (WireErrorCategory, bool, WireRecoveryAction) {
+ match code {
+ SafeErrorCode::InvalidPublicKey
+ | SafeErrorCode::InvalidSecretKey
+ | SafeErrorCode::InvalidAccountMetadata
+ | SafeErrorCode::InvalidProfileMetadata => {
+ (WireErrorCategory::Input, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => {
+ (WireErrorCategory::Conflict, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::KeyringUnavailable => (
+ WireErrorCategory::Credential,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::CredentialMissing => (
+ WireErrorCategory::Credential,
+ false,
+ WireRecoveryAction::RepairCredential,
+ ),
+ SafeErrorCode::StorageUnavailable => (
+ WireErrorCategory::Storage,
+ true,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => (
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::StorageQuarantined => (
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::RepairStorage,
+ ),
+ SafeErrorCode::StorageBackupInvalid => (
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::RestoreBackup,
+ ),
+ SafeErrorCode::UnsupportedSchemaVersion => (
+ WireErrorCategory::Compatibility,
+ false,
+ WireRecoveryAction::UpdateApplication,
+ ),
+ SafeErrorCode::RepairUnauthorized => (
+ WireErrorCategory::Credential,
+ false,
+ WireRecoveryAction::Authenticate,
+ ),
+ SafeErrorCode::InvalidRelayConfiguration => (
+ WireErrorCategory::Network,
+ false,
+ WireRecoveryAction::CheckConfiguration,
+ ),
+ SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => {
+ (WireErrorCategory::Network, true, WireRecoveryAction::Retry)
+ }
+ SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => (
+ WireErrorCategory::Lifecycle,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::NativeLibraryLoadFailed => (
+ WireErrorCategory::Internal,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ }
+}
+
+impl From<BindingAvailability> for KeyAvailabilityDto {
+ fn from(value: BindingAvailability) -> Self {
+ match value {
+ BindingAvailability::Available => Self::Available,
+ BindingAvailability::CredentialMissing => Self::CredentialMissing,
+ BindingAvailability::StoreUnavailable => Self::StoreUnavailable,
+ }
+ }
+}
+
+impl From<RelayConnectionState> for RelayConnectionStateDto {
+ fn from(value: RelayConnectionState) -> Self {
+ match value {
+ RelayConnectionState::Disconnected => Self::Disconnected,
+ RelayConnectionState::Connecting => Self::Connecting,
+ RelayConnectionState::Connected => Self::Connected,
+ RelayConnectionState::Degraded => Self::Degraded,
+ RelayConnectionState::Error(_) => Self::Error,
+ }
+ }
+}
+
+impl From<ProfileLoadState> for ProfileLoadStateDto {
+ fn from(value: ProfileLoadState) -> Self {
+ match value {
+ ProfileLoadState::Empty => Self::Empty,
+ ProfileLoadState::Loading => Self::Loading,
+ ProfileLoadState::Cached => Self::Cached,
+ ProfileLoadState::Fresh => Self::Fresh,
+ ProfileLoadState::Error(_) => Self::Error,
+ }
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use std::sync::Arc;
+
+ use radroots_studio_application::{
+ AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle,
+ };
+ use radroots_studio_nostr::NostrKeyMaterialProvider;
+
+ use radroots_studio_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage};
+
+ use super::{
+ AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileLoadStateDto,
+ RelayConnectionStateDto, SafeErrorDto, WireErrorCategory, WireErrorCode,
+ WireRecoveryAction, error_policy,
+ };
+
+ fn safe_error(code: SafeErrorCode) -> SafeError {
+ SafeError::new(code, SafeMessage::new("Safe compatibility failure."))
+ }
+
+ #[test]
+ fn snapshot_dto_is_revisioned_public_and_secret_free() {
+ let core = AppCore::new(
+ RelayConfiguration::default(),
+ Arc::new(NostrKeyMaterialProvider),
+ );
+ let snapshot = core.bootstrap().expect("bootstrap");
+ let dto = AppSnapshotDto::from(&snapshot);
+ let debug = format!("{dto:?}");
+
+ assert_eq!(dto.revision, 1);
+ assert!(dto.accounts.is_empty());
+ assert!(!debug.contains("nsec"));
+ assert!(!debug.contains("secret_key"));
+ assert!(!debug.contains("server_url"));
+ }
+
+ #[test]
+ fn security_errors_have_explicit_stable_wire_mappings() {
+ for (code, expected_code, expected_recovery) in [
+ (
+ SafeErrorCode::StorageQuarantined,
+ WireErrorCode::StorageQuarantined,
+ WireRecoveryAction::RepairStorage,
+ ),
+ (
+ SafeErrorCode::StorageBackupInvalid,
+ WireErrorCode::StorageBackupInvalid,
+ WireRecoveryAction::RestoreBackup,
+ ),
+ (
+ SafeErrorCode::UnsupportedSchemaVersion,
+ WireErrorCode::UnsupportedSchemaVersion,
+ WireRecoveryAction::UpdateApplication,
+ ),
+ (
+ SafeErrorCode::RepairUnauthorized,
+ WireErrorCode::RepairUnauthorized,
+ WireRecoveryAction::Authenticate,
+ ),
+ ] {
+ let dto = SafeErrorDto::from(radroots_studio_domain::SafeError::new(
+ code,
+ SafeMessage::new("Safe compatibility failure."),
+ ));
+ assert_eq!(dto.code, expected_code);
+ assert_eq!(dto.recovery_action, expected_recovery);
+ assert!(!dto.retryable);
+ }
+ }
+
+ #[test]
+ fn every_safe_error_has_an_explicit_wire_code_and_policy() {
+ let cases = [
+ (
+ SafeErrorCode::InvalidPublicKey,
+ WireErrorCode::InvalidPublicKey,
+ ),
+ (
+ SafeErrorCode::InvalidSecretKey,
+ WireErrorCode::InvalidSecretKey,
+ ),
+ (
+ SafeErrorCode::InvalidAccountMetadata,
+ WireErrorCode::InvalidAccountMetadata,
+ ),
+ (
+ SafeErrorCode::InvalidProfileMetadata,
+ WireErrorCode::InvalidProfileMetadata,
+ ),
+ (
+ SafeErrorCode::InvalidApplicationState,
+ WireErrorCode::InvalidApplicationState,
+ ),
+ (
+ SafeErrorCode::AccountAlreadyExists,
+ WireErrorCode::AccountAlreadyExists,
+ ),
+ (
+ SafeErrorCode::AccountNotFound,
+ WireErrorCode::AccountNotFound,
+ ),
+ (
+ SafeErrorCode::KeyringUnavailable,
+ WireErrorCode::KeyringUnavailable,
+ ),
+ (
+ SafeErrorCode::CredentialMissing,
+ WireErrorCode::CredentialMissing,
+ ),
+ (
+ SafeErrorCode::StorageUnavailable,
+ WireErrorCode::StorageUnavailable,
+ ),
+ (SafeErrorCode::StorageCorrupt, WireErrorCode::StorageCorrupt),
+ (
+ SafeErrorCode::StorageQuarantined,
+ WireErrorCode::StorageQuarantined,
+ ),
+ (
+ SafeErrorCode::StorageBackupInvalid,
+ WireErrorCode::StorageBackupInvalid,
+ ),
+ (
+ SafeErrorCode::UnsupportedSchemaVersion,
+ WireErrorCode::UnsupportedSchemaVersion,
+ ),
+ (
+ SafeErrorCode::RepairUnauthorized,
+ WireErrorCode::RepairUnauthorized,
+ ),
+ (
+ SafeErrorCode::PendingOperationRecoveryRequired,
+ WireErrorCode::PendingOperationRecoveryRequired,
+ ),
+ (
+ SafeErrorCode::InvalidRelayConfiguration,
+ WireErrorCode::InvalidRelayConfiguration,
+ ),
+ (
+ SafeErrorCode::RelayConnectionFailed,
+ WireErrorCode::RelayConnectionFailed,
+ ),
+ (
+ SafeErrorCode::ProfileRefreshFailed,
+ WireErrorCode::ProfileRefreshFailed,
+ ),
+ (
+ SafeErrorCode::ObserverRegistrationFailed,
+ WireErrorCode::ObserverRegistrationFailed,
+ ),
+ (
+ SafeErrorCode::NativeLibraryLoadFailed,
+ WireErrorCode::NativeLibraryLoadFailed,
+ ),
+ ];
+ for (code, expected_wire_code) in cases {
+ let dto = SafeErrorDto::from(safe_error(code));
+ assert_eq!(dto.code, expected_wire_code);
+ assert_eq!(
+ (dto.category, dto.retryable, dto.recovery_action),
+ error_policy(code)
+ );
+ }
+ assert_eq!(
+ error_policy(SafeErrorCode::KeyringUnavailable),
+ (
+ WireErrorCategory::Credential,
+ true,
+ WireRecoveryAction::Retry,
+ )
+ );
+ assert_eq!(
+ error_policy(SafeErrorCode::NativeLibraryLoadFailed),
+ (
+ WireErrorCategory::Internal,
+ false,
+ WireRecoveryAction::RestartApplication,
+ )
+ );
+ }
+
+ #[test]
+ fn runtime_and_connection_states_map_exhaustively_to_wire_states() {
+ let core = AppCore::new(
+ RelayConfiguration::default(),
+ Arc::new(NostrKeyMaterialProvider),
+ );
+ let snapshot = core.bootstrap().expect("bootstrap");
+ for (runtime, expected) in [
+ (RuntimeLifecycle::Opening, AppLifecycleDto::Opening),
+ (
+ RuntimeLifecycle::CompatibilityChecking,
+ AppLifecycleDto::CompatibilityChecking,
+ ),
+ (
+ RuntimeLifecycle::AcquiringOwnership,
+ AppLifecycleDto::AcquiringOwnership,
+ ),
+ (RuntimeLifecycle::Migrating, AppLifecycleDto::Migrating),
+ (RuntimeLifecycle::Recovering, AppLifecycleDto::Recovering),
+ (RuntimeLifecycle::Ready, AppLifecycleDto::Ready),
+ (
+ RuntimeLifecycle::Degraded(safe_error(SafeErrorCode::RelayConnectionFailed)),
+ AppLifecycleDto::Degraded,
+ ),
+ (
+ RuntimeLifecycle::Blocked(safe_error(SafeErrorCode::StorageUnavailable)),
+ AppLifecycleDto::Blocked,
+ ),
+ (
+ RuntimeLifecycle::ShuttingDown,
+ AppLifecycleDto::ShuttingDown,
+ ),
+ (RuntimeLifecycle::Closed, AppLifecycleDto::Closed),
+ (
+ RuntimeLifecycle::Fatal(safe_error(SafeErrorCode::StorageCorrupt)),
+ AppLifecycleDto::Fatal,
+ ),
+ ] {
+ let dto = AppSnapshotDto::from_runtime(&snapshot, runtime);
+ assert_eq!(dto.lifecycle, expected);
+ assert_eq!(
+ dto.lifecycle_error.is_some(),
+ matches!(
+ expected,
+ AppLifecycleDto::Degraded | AppLifecycleDto::Blocked | AppLifecycleDto::Fatal
+ )
+ );
+ }
+
+ for (source, expected) in [
+ (
+ BindingAvailability::Available,
+ KeyAvailabilityDto::Available,
+ ),
+ (
+ BindingAvailability::CredentialMissing,
+ KeyAvailabilityDto::CredentialMissing,
+ ),
+ (
+ BindingAvailability::StoreUnavailable,
+ KeyAvailabilityDto::StoreUnavailable,
+ ),
+ ] {
+ assert_eq!(KeyAvailabilityDto::from(source), expected);
+ }
+ for (source, expected) in [
+ (
+ RelayConnectionState::Disconnected,
+ RelayConnectionStateDto::Disconnected,
+ ),
+ (
+ RelayConnectionState::Connecting,
+ RelayConnectionStateDto::Connecting,
+ ),
+ (
+ RelayConnectionState::Connected,
+ RelayConnectionStateDto::Connected,
+ ),
+ (
+ RelayConnectionState::Degraded,
+ RelayConnectionStateDto::Degraded,
+ ),
+ (
+ RelayConnectionState::Error(safe_error(SafeErrorCode::RelayConnectionFailed)),
+ RelayConnectionStateDto::Error,
+ ),
+ ] {
+ assert_eq!(RelayConnectionStateDto::from(source), expected);
+ }
+ for (source, expected) in [
+ (ProfileLoadState::Empty, ProfileLoadStateDto::Empty),
+ (ProfileLoadState::Loading, ProfileLoadStateDto::Loading),
+ (ProfileLoadState::Cached, ProfileLoadStateDto::Cached),
+ (ProfileLoadState::Fresh, ProfileLoadStateDto::Fresh),
+ (
+ ProfileLoadState::Error(safe_error(SafeErrorCode::ProfileRefreshFailed)),
+ ProfileLoadStateDto::Error,
+ ),
+ ] {
+ assert_eq!(ProfileLoadStateDto::from(source), expected);
+ }
+ }
+}
diff --git a/core/crates/studio_ffi/src/lib.rs b/core/crates/studio_ffi/src/lib.rs
@@ -0,0 +1,46 @@
+#![doc = "Radroots Studio `UniFFI` boundary."]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+
+mod commands;
+mod contract;
+mod dto;
+mod observer;
+
+pub use commands::{
+ AccountCommandReceiptDto, GeneratedRecoveryRequest, RemovalRequest, RequestContextDto,
+ StudioAppCore, StudioError,
+};
+pub use contract::{
+ FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION,
+ PRODUCT_VERSION,
+};
+pub use dto::{
+ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
+ ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
+ WireErrorCategory, WireErrorCode, WireRecoveryAction,
+};
+pub use observer::{
+ ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, StudioChangeObserver,
+};
+
+uniffi::setup_scaffolding!();
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+#[must_use]
+pub fn native_runtime_version() -> String {
+ PRODUCT_VERSION.to_owned()
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ #[test]
+ fn native_runtime_reports_the_product_version_independently() {
+ assert_eq!(super::native_runtime_version(), "0.1.0-alpha");
+ assert_eq!(super::PRODUCT_VERSION, "0.1.0-alpha");
+ assert_eq!(env!("CARGO_PKG_VERSION"), "0.1.0-alpha");
+ assert_eq!(super::FFI_CONTRACT_MAJOR, 3);
+ assert_eq!(super::FFI_CONTRACT_HASH.len(), 64);
+ assert!(!super::contract::NORMALIZED_CONTRACT_METADATA.is_empty());
+ }
+}
diff --git a/core/crates/studio_ffi/src/observer.rs b/core/crates/studio_ffi/src/observer.rs
@@ -0,0 +1,512 @@
+use std::num::NonZeroUsize;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::atomic::Ordering;
+use std::sync::{Arc, Mutex, Weak};
+
+use radroots_studio_application::ChangeSubscriptionId;
+
+use crate::commands::RuntimeCore;
+use crate::{AppSnapshotDto, StudioAppCore, StudioError};
+
+const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(63);
+const MAX_OBSERVERS: usize = 32;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct SnapshotChangeDto {
+ pub snapshot: AppSnapshotDto,
+ pub previous_revision: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))]
+pub struct ShutdownReceiptDto {
+ pub final_revision: u64,
+ pub closed: bool,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export(callback_interface))]
+pub trait StudioChangeObserver: Send + Sync {
+ fn on_change(&self, change: SnapshotChangeDto);
+}
+
+#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))]
+pub struct ObserverSubscription {
+ core: Weak<RuntimeCore>,
+ id: Mutex<Option<ChangeSubscriptionId>>,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+impl ObserverSubscription {
+ pub async fn unsubscribe(&self) {
+ let id = self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ let (Some(core), Some(id)) = (self.core.upgrade(), id) else {
+ return;
+ };
+ let task = {
+ core.observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&id)
+ };
+ if let Some(Some(task)) = task {
+ task.abort();
+ let _ = task.await;
+ }
+ let _ = core.actor.unsubscribe_changes(id).await;
+ }
+}
+
+#[cfg_attr(not(coverage_nightly), 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 runtime_core = Arc::downgrade(&self.inner);
+ let admitted = {
+ let mut observers = self
+ .inner
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if self.inner.closed.load(Ordering::Acquire) || observers.len() >= MAX_OBSERVERS {
+ false
+ } else {
+ observers.insert(id, None);
+ true
+ }
+ };
+ if !admitted {
+ self.inner
+ .actor
+ .unsubscribe_changes(id)
+ .await
+ .map_err(StudioError::from)?;
+ return Err(observer_registration_error());
+ }
+ let task = crate::commands::runtime()?.spawn(async move {
+ while let Some(change) = subscription.receive().await {
+ let Some(runtime_core) = runtime_core.upgrade() else {
+ break;
+ };
+ let delivery = SnapshotChangeDto {
+ snapshot: AppSnapshotDto::from_runtime(
+ change.snapshot(),
+ runtime_core.effective_lifecycle(),
+ ),
+ previous_revision: change
+ .previous_revision()
+ .map(radroots_studio_application::SnapshotRevision::value),
+ };
+ if catch_unwind(AssertUnwindSafe(|| observer.on_change(delivery))).is_err() {
+ break;
+ }
+ }
+ if let Some(runtime_core) = runtime_core.upgrade() {
+ let _ = runtime_core.actor.unsubscribe_changes(id).await;
+ runtime_core
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&id);
+ }
+ });
+ let retained = {
+ let mut observers = self
+ .inner
+ .observers
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if let Some(slot) = observers.get_mut(&id) {
+ *slot = Some(task);
+ true
+ } else {
+ task.abort();
+ false
+ }
+ };
+ if !retained {
+ let _ = self.inner.actor.unsubscribe_changes(id).await;
+ return Err(closed_error());
+ }
+ Ok(Arc::new(ObserverSubscription {
+ core: Arc::downgrade(&self.inner),
+ id: Mutex::new(Some(id)),
+ }))
+ }
+
+ /// 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 {
+ if let Some(task) = task {
+ task.abort();
+ let _ = task.await;
+ }
+ }
+ 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 {
+ StudioError::Failure {
+ code: crate::WireErrorCode::InvalidApplicationState,
+ category: crate::WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: crate::WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The application runtime is closed.".to_owned(),
+ }
+}
+
+fn observer_registration_error() -> StudioError {
+ StudioError::Failure {
+ code: crate::WireErrorCode::ObserverRegistrationFailed,
+ category: crate::WireErrorCategory::Lifecycle,
+ retryable: true,
+ recovery_action: crate::WireRecoveryAction::Retry,
+ correlation_id: None,
+ safe_message: "The change observer could not be registered.".to_owned(),
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use std::num::NonZeroUsize;
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ use nostr::{EventBuilder, Keys, Metadata};
+ use nostr_relay_builder::MockRelay;
+ use nostr_sdk::Client;
+ use radroots_studio_application::{InMemorySecretStore, RelayConfiguration};
+ use radroots_studio_domain::{RelayDestinationPolicy, RelayUrl};
+ use radroots_studio_nostr::SdkNostrClient;
+ use radroots_studio_runtime::{
+ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource,
+ };
+
+ use crate::commands::{ACTOR_MAILBOX_CAPACITY, RuntimeCore, SystemClock, runtime};
+ use crate::{
+ AppSnapshotDto, ProfileLoadStateDto, SnapshotChangeDto, StudioAppCore, StudioChangeObserver,
+ };
+
+ const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7";
+
+ #[derive(Default)]
+ struct RecordingObserver {
+ snapshots: Mutex<Vec<AppSnapshotDto>>,
+ core: Mutex<Option<Arc<StudioAppCore>>>,
+ }
+
+ struct PanickingObserver;
+
+ impl StudioChangeObserver for PanickingObserver {
+ fn on_change(&self, _change: SnapshotChangeDto) {
+ panic!("injected host callback failure");
+ }
+ }
+
+ 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);
+ }
+ self.snapshots.lock().expect("snapshots").push(snapshot);
+ }
+ }
+
+ async fn core() -> Arc<StudioAppCore> {
+ core_with_relays(RelayConfiguration::default()).await
+ }
+
+ async fn core_with_relays(relays: RelayConfiguration) -> Arc<StudioAppCore> {
+ let actor = RuntimeActorHandle::in_memory(
+ relays,
+ RuntimeDependencies::new(
+ Arc::new(InMemorySecretStore::default()),
+ Arc::new(SystemClock),
+ Arc::new(SdkNostrClient::new(std::time::Duration::from_millis(10))),
+ Arc::new(UuidInstallationIdentitySource),
+ ),
+ NonZeroUsize::new(ACTOR_MAILBOX_CAPACITY).expect("capacity"),
+ runtime().expect("runtime").handle(),
+ )
+ .await
+ .expect("actor");
+ Arc::new(StudioAppCore {
+ inner: Arc::new(RuntimeCore {
+ actor,
+ observers: Mutex::new(std::collections::BTreeMap::new()),
+ closed: std::sync::atomic::AtomicBool::new(false),
+ startup_relay_problem: None,
+ }),
+ })
+ }
+
+ #[test]
+ fn callbacks_allow_reentry_and_stop_after_subscription_close() {
+ runtime().expect("runtime").block_on(async {
+ let core = core().await;
+ let observer = Arc::new(RecordingObserver::default());
+ *observer.core.lock().expect("core") = Some(Arc::clone(&core));
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+ wait_for_snapshot_count(&observer, 1).await;
+ core.inner
+ .actor
+ .bootstrap()
+ .await
+ .expect("idempotent bootstrap");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
+ subscription.unsubscribe().await;
+ subscription.unsubscribe().await;
+ core.inner.actor.sign_out().await.expect("sign out");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1);
+ });
+ }
+
+ #[test]
+ fn core_close_deregisters_all_observers_and_rejects_new_subscriptions() {
+ runtime().expect("runtime").block_on(async {
+ let core = core().await;
+ let observer = Arc::new(RecordingObserver::default());
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+ let _active_subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("second subscription");
+ let id = subscription
+ .id
+ .lock()
+ .expect("subscription id")
+ .expect("active subscription id");
+ let handle = core
+ .inner
+ .observers
+ .lock()
+ .expect("observers")
+ .get_mut(&id)
+ .expect("registered observer")
+ .take()
+ .expect("observer task");
+ handle.abort();
+
+ core.shutdown_v2().await.expect("shutdown");
+ assert!(core.shutdown_v2().await.is_err());
+
+ assert!(
+ core.subscribe_changes_v2(Box::new(ArcObserver(observer)))
+ .await
+ .is_err()
+ );
+ assert!(core.inner.observers.lock().expect("observers").is_empty());
+ });
+ }
+
+ #[test]
+ fn subscription_unsubscribe_tolerates_a_dropped_runtime_core() {
+ runtime().expect("runtime").block_on(async {
+ let core = core().await;
+ let observer = Arc::new(RecordingObserver::default());
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+ wait_for_snapshot_count(&observer, 1).await;
+
+ drop(core);
+ subscription.unsubscribe().await;
+ });
+ }
+
+ #[test]
+ fn observer_registration_is_bounded_and_callback_panics_are_contained() {
+ runtime().expect("runtime").block_on(async {
+ let core = core().await;
+ let panic_subscription = core
+ .subscribe_changes_v2(Box::new(PanickingObserver))
+ .await
+ .expect("panic observer registration");
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ assert!(core.inner.observers.lock().expect("observers").is_empty());
+ panic_subscription.unsubscribe().await;
+
+ let observer = Arc::new(RecordingObserver::default());
+ let mut subscriptions = Vec::new();
+ for _ in 0..super::MAX_OBSERVERS {
+ subscriptions.push(
+ core.subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("bounded observer registration"),
+ );
+ }
+ assert!(
+ core.subscribe_changes_v2(Box::new(ArcObserver(observer)))
+ .await
+ .is_err()
+ );
+ for subscription in subscriptions {
+ subscription.unsubscribe().await;
+ }
+ assert!(core.inner.observers.lock().expect("observers").is_empty());
+ });
+ }
+
+ #[tokio::test]
+ async fn ffi_callback_receives_async_profile_refresh_and_stops_after_unsubscribe() {
+ let local_relay = MockRelay::run().await.expect("local relay");
+ let relay_url = local_relay.url().await;
+ let publisher = Client::new(Keys::parse(SECRET_HEX).expect("known key"));
+ publisher
+ .add_relay(relay_url.clone())
+ .await
+ .expect("publisher relay");
+ publisher.connect().await;
+ publisher.wait_for_connection(Duration::from_secs(2)).await;
+ publisher
+ .send_event_builder(EventBuilder::metadata(
+ &Metadata::new().display_name("FFI Profile"),
+ ))
+ .await
+ .expect("publish profile");
+
+ let core = core_with_relays(
+ RelayConfiguration::new(vec![
+ RelayUrl::parse(relay_url.as_str(), RelayDestinationPolicy::Local)
+ .expect("relay URL"),
+ ])
+ .expect("relay configuration"),
+ )
+ .await;
+ core.bootstrap().await.expect("bootstrap");
+ let observer = Arc::new(RecordingObserver::default());
+ *observer.core.lock().expect("core") = Some(Arc::clone(&core));
+ let subscription = core
+ .subscribe_changes_v2(Box::new(ArcObserver(observer.clone())))
+ .await
+ .expect("subscribe");
+ let imported = core
+ .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")
+ .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");
+
+ wait_for_fresh_profile(&observer).await;
+ let snapshots = observer.snapshots.lock().expect("snapshots").clone();
+ assert!(snapshots.iter().any(|snapshot| {
+ snapshot.active_account.as_ref().is_some_and(|active| {
+ active.profile_state == ProfileLoadStateDto::Fresh
+ && active
+ .profile
+ .as_ref()
+ .and_then(|profile| profile.display_name.as_deref())
+ == Some("FFI Profile")
+ })
+ }));
+ subscription.unsubscribe().await;
+ let count = observer.snapshots.lock().expect("snapshots").len();
+ core.sign_out().await.expect("sign out");
+ assert_eq!(observer.snapshots.lock().expect("snapshots").len(), count);
+
+ core.shutdown_v2().await.expect("shutdown");
+ publisher.shutdown().await;
+ local_relay.shutdown();
+ }
+
+ struct ArcObserver(Arc<RecordingObserver>);
+
+ impl StudioChangeObserver for ArcObserver {
+ fn on_change(&self, change: SnapshotChangeDto) {
+ self.0.on_change(change);
+ }
+ }
+
+ async fn wait_for_snapshot_count(observer: &RecordingObserver, minimum: usize) {
+ tokio::time::timeout(Duration::from_secs(1), async {
+ while observer.snapshots.lock().expect("snapshots").len() < minimum {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("snapshot delivery");
+ }
+
+ async fn wait_for_fresh_profile(observer: &RecordingObserver) {
+ tokio::time::timeout(Duration::from_secs(1), async {
+ loop {
+ let fresh = observer
+ .snapshots
+ .lock()
+ .expect("snapshots")
+ .iter()
+ .any(|snapshot| {
+ snapshot.active_account.as_ref().is_some_and(|active| {
+ active.profile_state == ProfileLoadStateDto::Fresh
+ })
+ });
+ if fresh {
+ break;
+ }
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("fresh profile delivery");
+ }
+}
diff --git a/core/crates/studio_ffi/uniffi.toml b/core/crates/studio_ffi/uniffi.toml
@@ -0,0 +1,3 @@
+[crates.radroots_studio_ffi.bindings.kotlin]
+package_name = "org.radroots.studio.ffi"
+cdylib_name = "radroots_studio_ffi"