app

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

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:
Mcore/Cargo.lock | 111+++++++++++++++++--------------------------------------------------------------
Mcore/Cargo.toml | 5++++-
Acore/crates/studio_ffi/Cargo.toml | 40++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/build.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/src/commands.rs | 1121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/src/contract.rs | 9+++++++++
Acore/crates/studio_ffi/src/dto.rs | 729+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/src/lib.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/src/observer.rs | 512+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/studio_ffi/uniffi.toml | 3+++
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"