app

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

commit 54978418ef203527a49493f53e178fa1efcf448c
parent 7f6ee5116fe5dafa5d1f990fcf6bfeaa5e235734
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 13:07:48 -0700

app: add sdk runtime foundation

- add app SDK config and lifecycle runtime wrapper
- derive SDK storage under the app data root
- start one SDK worker from desktop bootstrap
- verify metadata, app-core tests, and app check

Diffstat:
MCargo.lock | 2++
Mcrates/desktop/src/runtime.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/runtime/Cargo.toml | 2++
Mcrates/runtime/src/lib.rs | 7+++++++
Acrates/runtime/src/sdk.rs | 586+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 709 insertions(+), 24 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5138,9 +5138,11 @@ dependencies = [ "radroots_app_view", "radroots_local_events", "radroots_runtime_paths", + "radroots_sdk", "serde", "serde_json", "thiserror 2.0.18", + "tokio", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -8,7 +8,8 @@ use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}; use chrono::{DateTime, Duration, Utc}; use radroots_app_core::{ AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode, - AppRuntimePathsError, AppRuntimeSnapshot, AppSharedAccountsPaths, PackDayExportWriteError, + AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkRuntime, AppSdkRuntimeError, + AppSdkRuntimeStatus, AppSharedAccountsPaths, PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root, shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle, }; @@ -476,20 +477,22 @@ impl AppSyncTransport for SdkDirectRelayAppSyncTransport { #[derive(Clone, Debug)] pub struct DesktopAppRuntime { state: Arc<Mutex<DesktopAppRuntimeState>>, + sdk_runtime: Arc<Mutex<Option<AppSdkRuntime>>>, } impl DesktopAppRuntime { pub fn bootstrap(nostr_relay_urls: Vec<String>, runtime_snapshot: AppRuntimeSnapshot) -> Self { - let state = - match DesktopAppRuntimeState::try_bootstrap(nostr_relay_urls, runtime_snapshot.clone()) - { - Ok(state) => state, - Err(error) => { - DesktopAppRuntimeState::degraded_with_snapshot(error, runtime_snapshot) - } - }; + let paths = match AppDesktopRuntimePaths::current_desktop() { + Ok(paths) => paths, + Err(error) => { + return Self::from_state(DesktopAppRuntimeState::degraded_with_snapshot( + error.into(), + runtime_snapshot, + )); + } + }; - Self::from_state(state) + Self::bootstrap_from_paths_with_snapshot(paths, nostr_relay_urls, runtime_snapshot) } pub fn bootstrap_with_paths( @@ -497,16 +500,36 @@ impl DesktopAppRuntime { nostr_relay_urls: Vec<String>, ) -> Self { let runtime_snapshot = default_runtime_snapshot(); + Self::bootstrap_from_paths_with_snapshot(paths, nostr_relay_urls, runtime_snapshot) + } + + fn bootstrap_from_paths_with_snapshot( + paths: AppDesktopRuntimePaths, + nostr_relay_urls: Vec<String>, + runtime_snapshot: AppRuntimeSnapshot, + ) -> Self { let state = match DesktopAppRuntimeState::bootstrap_from_paths( - paths, - nostr_relay_urls, + paths.clone(), + nostr_relay_urls.clone(), runtime_snapshot.clone(), ) { Ok(state) => state, - Err(error) => DesktopAppRuntimeState::degraded_with_snapshot(error, runtime_snapshot), + Err(error) => { + return Self::from_state(DesktopAppRuntimeState::degraded_with_snapshot( + error, + runtime_snapshot, + )); + } }; - Self::from_state(state) + match start_desktop_sdk_runtime(&paths, nostr_relay_urls) { + Ok(sdk_runtime) => Self::from_state_with_sdk_runtime(state, sdk_runtime), + Err(error) => { + let mut state = state; + state.startup_issue = Some(error.to_string()); + Self::from_state(state) + } + } } pub fn summary(&self) -> DesktopAppRuntimeSummary { @@ -548,6 +571,34 @@ impl DesktopAppRuntime { self.lock_state().nostr_relay_urls.clone() } + pub fn sdk_status(&self) -> Option<AppSdkRuntimeStatus> { + self.sdk_runtime + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(AppSdkRuntime::status) + } + + pub fn wait_for_sdk_startup(&self, timeout: StdDuration) -> Option<AppSdkRuntimeStatus> { + self.sdk_runtime + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(|runtime| runtime.wait_for_startup(timeout)) + } + + pub fn shutdown_sdk_runtime(&self) -> Result<bool, AppSdkRuntimeError> { + let mut sdk_runtime = self + .sdk_runtime + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(runtime) = sdk_runtime.take() else { + return Ok(false); + }; + runtime.shutdown()?; + Ok(true) + } + pub fn selected_settings_section(&self) -> SettingsSection { self.lock_state() .state_store @@ -1157,6 +1208,17 @@ impl DesktopAppRuntime { fn from_state(state: DesktopAppRuntimeState) -> Self { Self { state: Arc::new(Mutex::new(state)), + sdk_runtime: Arc::new(Mutex::new(None)), + } + } + + fn from_state_with_sdk_runtime( + state: DesktopAppRuntimeState, + sdk_runtime: AppSdkRuntime, + ) -> Self { + Self { + state: Arc::new(Mutex::new(state)), + sdk_runtime: Arc::new(Mutex::new(Some(sdk_runtime))), } } @@ -1204,6 +1266,13 @@ fn default_runtime_snapshot() -> AppRuntimeSnapshot { ) } +fn start_desktop_sdk_runtime( + paths: &AppDesktopRuntimePaths, + nostr_relay_urls: Vec<String>, +) -> Result<AppSdkRuntime, AppSdkRuntimeError> { + AppSdkRuntime::start(AppSdkConfig::from_desktop_paths(paths, nostr_relay_urls)) +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DesktopAppSyncStatusSummary { pub account_id: Option<String>, @@ -1406,14 +1475,6 @@ impl fmt::Debug for DesktopAppRuntimeState { } impl DesktopAppRuntimeState { - fn try_bootstrap( - nostr_relay_urls: Vec<String>, - runtime_snapshot: AppRuntimeSnapshot, - ) -> Result<Self, DesktopAppRuntimeBootstrapError> { - let paths = AppDesktopRuntimePaths::current_desktop()?; - Self::bootstrap_from_paths(paths, nostr_relay_urls, runtime_snapshot) - } - fn bootstrap_from_paths( paths: AppDesktopRuntimePaths, nostr_relay_urls: Vec<String>, @@ -9807,14 +9868,15 @@ mod tests { sync::mpsc, sync::{Arc, Mutex}, thread, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, }; use chrono::{Duration, Utc}; use futures_util::{SinkExt, StreamExt}; use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, - AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, + AppSdkLifecycleState, AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, + SHARED_IDENTITY_FILE_NAME, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -13368,6 +13430,32 @@ mod tests { } #[test] + fn runtime_bootstrap_starts_sdk_runtime_under_app_data_root() { + let (runtime, paths) = bootstrapped_runtime("sdk_runtime"); + let status = runtime + .wait_for_sdk_startup(StdDuration::from_secs(5)) + .expect("sdk runtime should be present"); + + assert_eq!(status.state, AppSdkLifecycleState::Ready); + assert_eq!(status.storage_root, paths.app.data.join("sdk")); + assert_eq!( + status + .storage_paths + .as_ref() + .expect("sdk storage paths") + .event_store_path, + paths.app.data.join("sdk").join("event_store.sqlite") + ); + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn clearing_startup_pending_remote_signer_session_is_idempotent_without_record() { let paths = temp_remote_signer_paths("clear_pending_none"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -12,9 +12,11 @@ chrono.workspace = true radroots_app_view.workspace = true radroots_local_events.workspace = true radroots_runtime_paths.workspace = true +radroots_sdk.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +tokio.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs @@ -4,6 +4,7 @@ mod logging; mod pack_day_export; mod paths; mod runtime; +mod sdk; mod startup; pub use logging::{ @@ -34,4 +35,10 @@ pub use runtime::{ AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode, AppRuntimeSnapshot, runtime_mode_label, }; +pub use sdk::{ + APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, + AppSdkLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime, AppSdkRuntimeError, + AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths, + app_sdk_storage_root_from_data_root, +}; pub use startup::{AppStartupEvent, AppStartupEventMetadata, launch_startup_event}; diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs @@ -0,0 +1,586 @@ +use std::{ + fmt, io, + path::{Path, PathBuf}, + sync::{ + Arc, Condvar, Mutex, MutexGuard, + mpsc::{self, Receiver, SyncSender, TrySendError}, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use radroots_sdk::{ + RadrootsSdk, RadrootsSdkError, RadrootsSdkStoragePaths, + SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, +}; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::runtime::Builder as TokioRuntimeBuilder; + +use crate::AppDesktopRuntimePaths; + +pub const APP_SDK_STORAGE_DIR_NAME: &str = "sdk"; +pub const APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY: usize = 32; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppSdkRelayUrlPolicy { + Public, + Localhost, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppSdkLifecycleState { + Starting, + Ready, + Degraded, + Pausing, + Paused, + Restoring, + RebuildingProjections, + ShuttingDown, + Stopped, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSdkConfig { + pub storage_root: PathBuf, + pub relay_urls: Vec<String>, + pub relay_url_policy: AppSdkRelayUrlPolicy, + pub command_queue_capacity: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSdkStoragePaths { + pub event_store_path: PathBuf, + pub outbox_path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AppSdkRuntimeIssue { + pub code: String, + pub class: String, + pub retryable: bool, + pub message: String, + pub recovery_actions: Vec<String>, + pub detail_json: Value, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AppSdkRuntimeStatus { + pub state: AppSdkLifecycleState, + pub storage_root: PathBuf, + pub relay_urls: Vec<String>, + pub relay_url_policy: AppSdkRelayUrlPolicy, + pub storage_paths: Option<AppSdkStoragePaths>, + pub last_issue: Option<AppSdkRuntimeIssue>, +} + +#[derive(Debug, Error)] +pub enum AppSdkRuntimeError { + #[error("app sdk command queue capacity must be greater than zero")] + CommandQueueCapacityZero, + #[error("failed to start app sdk worker: {0}")] + WorkerSpawn(#[from] io::Error), + #[error("app sdk command queue is full")] + CommandQueueFull, + #[error("app sdk command queue is closed")] + CommandQueueClosed, + #[error("app sdk shutdown acknowledgement failed")] + ShutdownAck, + #[error("app sdk worker failed to join")] + WorkerJoin, +} + +#[derive(Debug)] +pub struct AppSdkRuntime { + command_sender: SyncSender<AppSdkWorkerCommand>, + shared: Arc<AppSdkRuntimeShared>, + worker: Mutex<Option<JoinHandle<()>>>, +} + +#[derive(Debug)] +struct AppSdkRuntimeShared { + status: Mutex<AppSdkRuntimeStatus>, + status_changed: Condvar, +} + +enum AppSdkWorkerCommand { + Shutdown(mpsc::Sender<()>), +} + +impl fmt::Debug for AppSdkWorkerCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Shutdown(_) => formatter.write_str("Shutdown"), + } + } +} + +impl AppSdkConfig { + pub fn from_desktop_paths(paths: &AppDesktopRuntimePaths, relay_urls: Vec<String>) -> Self { + Self::from_app_data_root(paths.app.data.as_path(), relay_urls) + } + + pub fn from_app_data_root(data_root: &Path, relay_urls: Vec<String>) -> Self { + Self { + storage_root: app_sdk_storage_root_from_data_root(data_root), + relay_url_policy: app_sdk_relay_url_policy(relay_urls.as_slice()), + relay_urls, + command_queue_capacity: APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, + } + } + + pub fn with_command_queue_capacity(mut self, capacity: usize) -> Self { + self.command_queue_capacity = capacity; + self + } +} + +impl AppSdkRuntime { + pub fn start(config: AppSdkConfig) -> Result<Self, AppSdkRuntimeError> { + if config.command_queue_capacity == 0 { + return Err(AppSdkRuntimeError::CommandQueueCapacityZero); + } + + let initial_status = + AppSdkRuntimeStatus::from_config(&config, AppSdkLifecycleState::Starting, None, None); + let shared = Arc::new(AppSdkRuntimeShared { + status: Mutex::new(initial_status), + status_changed: Condvar::new(), + }); + let (command_sender, command_receiver) = mpsc::sync_channel(config.command_queue_capacity); + let worker_shared = Arc::clone(&shared); + let worker = thread::Builder::new() + .name("radroots-app-sdk-runtime".to_owned()) + .spawn(move || run_app_sdk_worker(config, worker_shared, command_receiver))?; + + Ok(Self { + command_sender, + shared, + worker: Mutex::new(Some(worker)), + }) + } + + pub fn status(&self) -> AppSdkRuntimeStatus { + lock_status(&self.shared).clone() + } + + pub fn wait_for_startup(&self, timeout: Duration) -> AppSdkRuntimeStatus { + let deadline = Instant::now() + .checked_add(timeout) + .unwrap_or_else(Instant::now); + let mut status = lock_status(&self.shared); + loop { + if !matches!(status.state, AppSdkLifecycleState::Starting) { + return status.clone(); + } + let now = Instant::now(); + if now >= deadline { + return status.clone(); + } + let remaining = deadline.saturating_duration_since(now); + let wait_result = self.shared.status_changed.wait_timeout(status, remaining); + let (next_status, timeout_result) = wait_result.unwrap_or_else(|poisoned| { + let (guard, timeout_result) = poisoned.into_inner(); + (guard, timeout_result) + }); + status = next_status; + if timeout_result.timed_out() { + return status.clone(); + } + } + } + + pub fn shutdown(&self) -> Result<(), AppSdkRuntimeError> { + if matches!(self.status().state, AppSdkLifecycleState::Stopped) { + return self.join_worker(); + } + + let (ack_sender, ack_receiver) = mpsc::channel(); + match self + .command_sender + .try_send(AppSdkWorkerCommand::Shutdown(ack_sender)) + { + Ok(()) => {} + Err(TrySendError::Full(_)) => return Err(AppSdkRuntimeError::CommandQueueFull), + Err(TrySendError::Disconnected(_)) => { + transition_status_state(&self.shared, AppSdkLifecycleState::Stopped); + return self.join_worker(); + } + } + ack_receiver + .recv() + .map_err(|_| AppSdkRuntimeError::ShutdownAck)?; + self.join_worker() + } + + fn join_worker(&self) -> Result<(), AppSdkRuntimeError> { + let mut worker = self + .worker + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(worker) = worker.take() else { + return Ok(()); + }; + worker.join().map_err(|_| AppSdkRuntimeError::WorkerJoin) + } +} + +impl Drop for AppSdkRuntime { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +impl From<AppSdkRelayUrlPolicy> for SdkRuntimeRelayUrlPolicy { + fn from(policy: AppSdkRelayUrlPolicy) -> Self { + match policy { + AppSdkRelayUrlPolicy::Public => Self::Public, + AppSdkRelayUrlPolicy::Localhost => Self::Localhost, + } + } +} + +impl From<&RadrootsSdkStoragePaths> for AppSdkStoragePaths { + fn from(paths: &RadrootsSdkStoragePaths) -> Self { + Self { + event_store_path: paths.event_store_path.clone(), + outbox_path: paths.outbox_path.clone(), + } + } +} + +impl AppSdkRuntimeIssue { + fn from_sdk_error(error: &RadrootsSdkError) -> Self { + Self { + code: error.code().to_owned(), + class: sdk_error_class_label(error), + retryable: error.retryable(), + message: error.to_string(), + recovery_actions: error + .recovery_actions() + .into_iter() + .filter_map(|action| serde_json::to_value(action).ok()) + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect(), + detail_json: error.detail_json(), + } + } + + fn runtime_error(code: &'static str, message: String) -> Self { + Self { + code: code.to_owned(), + class: "runtime".to_owned(), + retryable: true, + message: message.clone(), + recovery_actions: vec!["retry_startup".to_owned()], + detail_json: json!({ + "code": code, + "class": "runtime", + "retryable": true, + "message": message, + "recovery_actions": ["retry_startup"], + "detail": {} + }), + } + } +} + +impl AppSdkRuntimeStatus { + fn from_config( + config: &AppSdkConfig, + state: AppSdkLifecycleState, + storage_paths: Option<AppSdkStoragePaths>, + last_issue: Option<AppSdkRuntimeIssue>, + ) -> Self { + Self { + state, + storage_root: config.storage_root.clone(), + relay_urls: config.relay_urls.clone(), + relay_url_policy: config.relay_url_policy, + storage_paths, + last_issue, + } + } +} + +pub fn app_sdk_storage_root_from_data_root(data_root: &Path) -> PathBuf { + data_root.join(APP_SDK_STORAGE_DIR_NAME) +} + +fn app_sdk_relay_url_policy(relay_urls: &[String]) -> AppSdkRelayUrlPolicy { + if relay_urls + .iter() + .any(|relay_url| relay_url.trim().to_ascii_lowercase().starts_with("ws://")) + { + AppSdkRelayUrlPolicy::Localhost + } else { + AppSdkRelayUrlPolicy::Public + } +} + +fn run_app_sdk_worker( + config: AppSdkConfig, + shared: Arc<AppSdkRuntimeShared>, + command_receiver: Receiver<AppSdkWorkerCommand>, +) { + let runtime = match TokioRuntimeBuilder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + replace_status( + &shared, + AppSdkRuntimeStatus::from_config( + &config, + AppSdkLifecycleState::Degraded, + None, + Some(AppSdkRuntimeIssue::runtime_error( + "tokio_runtime_init", + error.to_string(), + )), + ), + ); + run_degraded_worker(config, shared, command_receiver); + return; + } + }; + + let mut sdk = match runtime.block_on(build_sdk_runtime(&config)) { + Ok(sdk) => { + replace_status( + &shared, + AppSdkRuntimeStatus::from_config( + &config, + AppSdkLifecycleState::Ready, + sdk.storage_paths().map(AppSdkStoragePaths::from), + None, + ), + ); + Some(sdk) + } + Err(error) => { + replace_status( + &shared, + AppSdkRuntimeStatus::from_config( + &config, + AppSdkLifecycleState::Degraded, + None, + Some(AppSdkRuntimeIssue::from_sdk_error(&error)), + ), + ); + None + } + }; + + while let Ok(command) = command_receiver.recv() { + match command { + AppSdkWorkerCommand::Shutdown(ack_sender) => { + transition_status_state(&shared, AppSdkLifecycleState::ShuttingDown); + drop(sdk.take()); + transition_status_state(&shared, AppSdkLifecycleState::Stopped); + let _ = ack_sender.send(()); + return; + } + } + } + + drop(sdk.take()); + transition_status_state(&shared, AppSdkLifecycleState::Stopped); +} + +fn run_degraded_worker( + config: AppSdkConfig, + shared: Arc<AppSdkRuntimeShared>, + command_receiver: Receiver<AppSdkWorkerCommand>, +) { + while let Ok(command) = command_receiver.recv() { + match command { + AppSdkWorkerCommand::Shutdown(ack_sender) => { + transition_status_state(&shared, AppSdkLifecycleState::ShuttingDown); + let last_issue = lock_status(&shared).last_issue.clone(); + replace_status( + &shared, + AppSdkRuntimeStatus::from_config( + &config, + AppSdkLifecycleState::Stopped, + None, + last_issue, + ), + ); + let _ = ack_sender.send(()); + return; + } + } + } + + let last_issue = lock_status(&shared).last_issue.clone(); + replace_status( + &shared, + AppSdkRuntimeStatus::from_config(&config, AppSdkLifecycleState::Stopped, None, last_issue), + ); +} + +async fn build_sdk_runtime(config: &AppSdkConfig) -> Result<RadrootsSdk, RadrootsSdkError> { + let mut builder = RadrootsSdk::builder() + .directory_storage(config.storage_root.clone()) + .relay_url_policy(config.relay_url_policy.into()); + for relay_url in &config.relay_urls { + builder = builder.relay_url(relay_url.clone()); + } + builder.build().await +} + +fn replace_status(shared: &AppSdkRuntimeShared, status: AppSdkRuntimeStatus) { + *lock_status(shared) = status; + shared.status_changed.notify_all(); +} + +fn transition_status_state(shared: &AppSdkRuntimeShared, state: AppSdkLifecycleState) { + lock_status(shared).state = state; + shared.status_changed.notify_all(); +} + +fn lock_status(shared: &AppSdkRuntimeShared) -> MutexGuard<'_, AppSdkRuntimeStatus> { + shared + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn sdk_error_class_label(error: &RadrootsSdkError) -> String { + serde_json::to_value(error.class()) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| format!("{:?}", error.class())) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use crate::{ + APP_RUNTIME_NAMESPACE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment, + AppRuntimePlatform, + }; + + use super::{ + APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState, AppSdkRelayUrlPolicy, + AppSdkRuntime, app_sdk_storage_root_from_data_root, + }; + + #[test] + fn sdk_config_uses_app_data_sdk_storage_root() { + let paths = AppDesktopRuntimePaths::for_desktop( + AppRuntimePlatform::Macos, + AppRuntimeHostEnvironment { + home_dir: Some("/Users/treesap".into()), + ..AppRuntimeHostEnvironment::default() + }, + ) + .expect("desktop paths should resolve"); + let config = + AppSdkConfig::from_desktop_paths(&paths, vec!["wss://relay.example".to_owned()]); + + assert_eq!( + config.storage_root, + paths.app.data.join(APP_SDK_STORAGE_DIR_NAME) + ); + assert_eq!( + config.storage_root, + app_sdk_storage_root_from_data_root(paths.app.data.as_path()) + ); + assert_eq!(config.storage_root.parent(), Some(paths.app.data.as_path())); + assert!(paths.app.data.ends_with(APP_RUNTIME_NAMESPACE)); + assert_eq!(config.relay_url_policy, AppSdkRelayUrlPolicy::Public); + } + + #[test] + fn sdk_config_uses_localhost_policy_for_ws_relay_urls() { + let config = AppSdkConfig::from_app_data_root( + "/tmp/radroots-app-data".as_ref(), + vec![ + "wss://relay.example".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + ], + ); + + assert_eq!(config.relay_url_policy, AppSdkRelayUrlPolicy::Localhost); + } + + #[test] + fn sdk_runtime_reaches_ready_with_directory_storage() { + let storage_root = temp_storage_root("ready"); + let config = AppSdkConfig::from_app_data_root( + storage_root + .parent() + .expect("storage root should have parent"), + vec!["ws://127.0.0.1:8080".to_owned()], + ); + let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start"); + + let status = runtime.wait_for_startup(Duration::from_secs(5)); + + assert_eq!(status.state, AppSdkLifecycleState::Ready); + assert_eq!(status.storage_root, storage_root); + assert_eq!(status.relay_url_policy, AppSdkRelayUrlPolicy::Localhost); + let storage_paths = status + .storage_paths + .expect("storage paths should be present"); + assert_eq!( + storage_paths.event_store_path, + storage_root.join("event_store.sqlite") + ); + assert_eq!( + storage_paths.outbox_path, + storage_root.join("outbox.sqlite") + ); + runtime.shutdown().expect("sdk runtime should shut down"); + assert_eq!(runtime.status().state, AppSdkLifecycleState::Stopped); + let _ = fs::remove_dir_all(storage_root); + } + + #[test] + fn sdk_runtime_degrades_with_structured_sdk_error() { + let storage_root = temp_storage_root("invalid_relay"); + let config = AppSdkConfig::from_app_data_root( + storage_root + .parent() + .expect("storage root should have parent"), + vec!["ws://relay.example".to_owned()], + ); + let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start"); + + let status = runtime.wait_for_startup(Duration::from_secs(5)); + + assert_eq!(status.state, AppSdkLifecycleState::Degraded); + let issue = status + .last_issue + .expect("degraded status should include issue"); + assert_eq!(issue.code, "invalid_relay_url"); + assert_eq!(issue.class, "configuration"); + assert!(!issue.retryable); + assert!( + issue + .recovery_actions + .contains(&"configure_relay_targets".to_owned()) + ); + assert_eq!(issue.detail_json["code"], "invalid_relay_url"); + runtime.shutdown().expect("sdk runtime should shut down"); + let _ = fs::remove_dir_all(storage_root); + } + + fn temp_storage_root(label: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir() + .join(format!("radroots_app_sdk_runtime_{label}_{nanos}")) + .join(APP_SDK_STORAGE_DIR_NAME) + } +}