use sprimo_api::{ApiConfig, ApiServerError, ApiState}; use sprimo_config::{save, AppConfig, ConfigError}; use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot}; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use thiserror::Error; use tokio::runtime::Runtime; use tokio::sync::{mpsc, Mutex}; use tracing::warn; #[derive(Debug, Error)] pub enum RuntimeCoreError { #[error("{0}")] Config(#[from] ConfigError), #[error("snapshot lock poisoned")] SnapshotPoisoned, #[error("config lock poisoned")] ConfigPoisoned, } pub struct RuntimeCore { config_path: PathBuf, config: Arc>, snapshot: Arc>, api_config: ApiConfig, command_tx: mpsc::Sender, command_rx: Arc>>, } impl RuntimeCore { pub fn new(app_name: &str, capabilities: CapabilityFlags) -> Result { let (config_path, config_value) = sprimo_config::load_or_create(app_name)?; Self::new_with_config(config_path, config_value, capabilities) } pub fn new_with_config( config_path: PathBuf, mut config_value: AppConfig, capabilities: CapabilityFlags, ) -> Result { let click_through_was_enabled = config_value.window.click_through; config_value.window.click_through = false; let mut snapshot = FrontendStateSnapshot::idle(capabilities); snapshot.x = config_value.window.x; snapshot.y = config_value.window.y; snapshot.scale = config_value.window.scale; snapshot.flags.click_through = false; snapshot.flags.always_on_top = config_value.window.always_on_top; snapshot.flags.visible = config_value.window.visible; snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone(); let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone()); let (command_tx, command_rx) = mpsc::channel(1_024); let core = Self { config_path, config: Arc::new(RwLock::new(config_value)), snapshot: Arc::new(RwLock::new(snapshot)), api_config, command_tx, command_rx: Arc::new(Mutex::new(command_rx)), }; if click_through_was_enabled { core.persist_config()?; } Ok(core) } pub fn snapshot(&self) -> Arc> { Arc::clone(&self.snapshot) } pub fn config(&self) -> Arc> { Arc::clone(&self.config) } pub fn command_receiver(&self) -> Arc>> { Arc::clone(&self.command_rx) } pub fn command_sender(&self) -> mpsc::Sender { self.command_tx.clone() } pub fn frontend_debug_overlay_visible(&self) -> Result { let guard = self .config .read() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; Ok(guard.frontend.debug_overlay_visible) } pub fn set_frontend_debug_overlay_visible( &self, visible: bool, ) -> Result<(), RuntimeCoreError> { { let mut guard = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; guard.frontend.debug_overlay_visible = visible; } self.persist_config() } pub fn api_config(&self) -> ApiConfig { self.api_config.clone() } pub fn spawn_api(&self, runtime: &Runtime) { let mut cfg = self.api_config.clone(); if let Ok(guard) = self.config.read() { cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into(); } let state = Arc::new(ApiState::new( cfg.clone(), Arc::clone(&self.snapshot), self.command_tx.clone(), )); runtime.spawn(async move { if let Err(err) = sprimo_api::run_server(cfg, state).await { log_api_error(err); } }); } pub fn apply_command(&self, command: &FrontendCommand) -> Result<(), RuntimeCoreError> { match command { FrontendCommand::SetState { state, .. } => { let mut snapshot = self .snapshot .write() .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; snapshot.state = *state; snapshot.current_animation = default_animation_for_state(*state).to_string(); snapshot.last_error = None; } FrontendCommand::PlayAnimation { name, .. } => { let mut snapshot = self .snapshot .write() .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; snapshot.current_animation = name.clone(); snapshot.last_error = None; } FrontendCommand::SetSpritePack { pack_id_or_path } => { { let mut snapshot = self .snapshot .write() .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; snapshot.active_sprite_pack = pack_id_or_path.clone(); snapshot.last_error = None; } { let mut config = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; config.sprite.selected_pack = pack_id_or_path.clone(); } self.persist_config()?; } FrontendCommand::SetTransform { x, y, scale, .. } => { { let mut snapshot = self .snapshot .write() .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; if let Some(value) = x { snapshot.x = *value; } if let Some(value) = y { snapshot.y = *value; } if let Some(value) = scale { snapshot.scale = *value; } snapshot.last_error = None; } { let mut config = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; if let Some(value) = x { config.window.x = *value; } if let Some(value) = y { config.window.y = *value; } if let Some(value) = scale { config.window.scale = *value; } } self.persist_config()?; } FrontendCommand::SetFlags { click_through: _click_through, always_on_top, visible, } => { { let mut snapshot = self .snapshot .write() .map_err(|_| RuntimeCoreError::SnapshotPoisoned)?; snapshot.flags.click_through = false; if let Some(value) = always_on_top { snapshot.flags.always_on_top = *value; } if let Some(value) = visible { snapshot.flags.visible = *value; } snapshot.last_error = None; } { let mut config = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; config.window.click_through = false; if let Some(value) = always_on_top { config.window.always_on_top = *value; } if let Some(value) = visible { config.window.visible = *value; } } self.persist_config()?; } FrontendCommand::Toast { .. } => {} } Ok(()) } fn persist_config(&self) -> Result<(), RuntimeCoreError> { let guard = self .config .read() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; save(&self.config_path, &guard)?; Ok(()) } } fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str { match state { sprimo_protocol::v1::FrontendState::Idle => "idle", sprimo_protocol::v1::FrontendState::Active => "active", sprimo_protocol::v1::FrontendState::Success => "success", sprimo_protocol::v1::FrontendState::Error => "error", sprimo_protocol::v1::FrontendState::Dragging => "idle", sprimo_protocol::v1::FrontendState::Hidden => "idle", } } fn log_api_error(err: ApiServerError) { warn!(%err, "runtime core api server exited"); } #[cfg(test)] mod tests { use super::RuntimeCore; use sprimo_config::AppConfig; use sprimo_protocol::v1::{CapabilityFlags, FrontendCommand, FrontendState}; use tempfile::TempDir; #[test] fn state_command_updates_snapshot() { let temp = TempDir::new().expect("tempdir"); let path = temp.path().join("config.toml"); let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) .expect("core init"); core.apply_command(&FrontendCommand::SetState { state: FrontendState::Active, ttl_ms: None, }) .expect("apply"); let snapshot = core.snapshot().read().expect("snapshot lock").clone(); assert_eq!(snapshot.state, FrontendState::Active); assert_eq!(snapshot.current_animation, "active"); } #[test] fn click_through_flag_is_ignored_and_forced_false() { let temp = TempDir::new().expect("tempdir"); let path = temp.path().join("config.toml"); let mut config = AppConfig::default(); config.window.click_through = true; let core = RuntimeCore::new_with_config(path, config, CapabilityFlags::default()) .expect("core init"); core.apply_command(&FrontendCommand::SetFlags { click_through: Some(true), always_on_top: None, visible: None, }) .expect("apply"); let snapshot = core.snapshot().read().expect("snapshot lock").clone(); assert!(!snapshot.flags.click_through); let config = core.config().read().expect("config lock").clone(); assert!(!config.window.click_through); } #[test] fn frontend_debug_overlay_visibility_roundtrips() { let temp = TempDir::new().expect("tempdir"); let path = temp.path().join("config.toml"); let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) .expect("core init"); core.set_frontend_debug_overlay_visible(true).expect("set"); assert!(core.frontend_debug_overlay_visible().expect("get")); } }