use sprimo_api::{ApiConfig, ApiServerError, ApiState}; use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError}; use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot}; use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; use tokio::runtime::Runtime; use tokio::sync::{mpsc, Mutex}; use tracing::warn; const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1; const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 200; #[derive(Debug, Error)] pub enum RuntimeCoreError { #[error("{0}")] Config(#[from] ConfigError), #[error("snapshot lock poisoned")] SnapshotPoisoned, #[error("config lock poisoned")] ConfigPoisoned, #[error("api token not found: {0}")] ApiTokenNotFound(String), #[error("cannot revoke the last API token")] LastApiToken, } pub struct RuntimeCore { config_path: PathBuf, config: Arc>, snapshot: Arc>, api_config: ApiConfig, auth_store: Arc>>, 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 tokens_changed = normalize_api_tokens(&mut config_value); 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_tokens( config_value .api .auth_tokens .iter() .map(|entry| entry.token.clone()) .collect(), ); let (command_tx, command_rx) = mpsc::channel(1_024); let auth_store = Arc::new(RwLock::new( config_value .api .auth_tokens .iter() .map(|entry| entry.token.clone()) .collect(), )); let core = Self { config_path, config: Arc::new(RwLock::new(config_value)), snapshot: Arc::new(RwLock::new(snapshot)), api_config, auth_store, command_tx, command_rx: Arc::new(Mutex::new(command_rx)), }; if click_through_was_enabled || tokens_changed { 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 frontend_tauri_animation_slowdown_factor(&self) -> Result { let guard = self .config .read() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; Ok(clamp_tauri_animation_slowdown_factor( guard.frontend.tauri_animation_slowdown_factor, )) } pub fn set_frontend_tauri_animation_slowdown_factor( &self, value: u8, ) -> Result { let clamped = clamp_tauri_animation_slowdown_factor(value); { let mut guard = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; guard.frontend.tauri_animation_slowdown_factor = clamped; } self.persist_config()?; Ok(clamped) } pub fn api_config(&self) -> ApiConfig { self.api_config.clone() } pub fn list_api_tokens(&self) -> Result, RuntimeCoreError> { let guard = self .config .read() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; Ok(guard.api.auth_tokens.clone()) } pub fn create_api_token( &self, label: Option, ) -> Result { let entry = ApiTokenEntry { id: uuid::Uuid::new_v4().to_string(), label: normalize_token_label(label.as_deref().unwrap_or("")), token: uuid::Uuid::new_v4().to_string(), created_at_ms: now_unix_ms(), }; { let mut guard = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; guard.api.auth_tokens.push(entry.clone()); guard.api.auth_token = guard .api .auth_tokens .first() .map(|token| token.token.clone()) .unwrap_or_default(); } self.persist_config()?; self.refresh_auth_store_from_config()?; Ok(entry) } pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> { let mut found = false; { let mut guard = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; for entry in &mut guard.api.auth_tokens { if entry.id == id { entry.label = normalize_token_label(label); found = true; break; } } } if !found { return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string())); } self.persist_config()?; Ok(()) } pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> { let removed = { let mut guard = self .config .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; if guard.api.auth_tokens.len() <= 1 { return Err(RuntimeCoreError::LastApiToken); } let before = guard.api.auth_tokens.len(); guard.api.auth_tokens.retain(|entry| entry.id != id); let after = guard.api.auth_tokens.len(); guard.api.auth_token = guard .api .auth_tokens .first() .map(|token| token.token.clone()) .unwrap_or_default(); before != after }; if !removed { return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string())); } self.persist_config()?; self.refresh_auth_store_from_config()?; Ok(()) } 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(); cfg.auth_tokens = guard .api .auth_tokens .iter() .map(|entry| entry.token.clone()) .collect(); } let state = Arc::new(ApiState::new( cfg.clone(), Arc::clone(&self.snapshot), self.command_tx.clone(), Arc::clone(&self.auth_store), )); 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 refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> { let tokens = { let guard = self .config .read() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; guard .api .auth_tokens .iter() .map(|entry| entry.token.clone()) .collect::>() }; let mut auth = self .auth_store .write() .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; *auth = tokens; 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 => "dragging", sprimo_protocol::v1::FrontendState::Hidden => "idle", } } fn clamp_tauri_animation_slowdown_factor(value: u8) -> u8 { value.clamp( TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN, TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX, ) } fn log_api_error(err: ApiServerError) { warn!(%err, "runtime core api server exited"); } fn normalize_api_tokens(config: &mut AppConfig) -> bool { let mut changed = false; let mut seen = HashSet::new(); let mut normalized = Vec::new(); let legacy = config.api.auth_token.trim().to_string(); if legacy != config.api.auth_token { config.api.auth_token = legacy.clone(); changed = true; } for mut entry in config.api.auth_tokens.clone() { let original_id = entry.id.clone(); let original_label = entry.label.clone(); let original_token = entry.token.clone(); let original_created = entry.created_at_ms; entry.id = entry.id.trim().to_string(); if entry.id.is_empty() { entry.id = uuid::Uuid::new_v4().to_string(); } entry.label = normalize_token_label(&entry.label); entry.token = entry.token.trim().to_string(); if entry.created_at_ms == 0 { entry.created_at_ms = now_unix_ms(); } let field_changed = entry.id != original_id || entry.label != original_label || entry.token != original_token || entry.created_at_ms != original_created; if field_changed { changed = true; } if entry.token.is_empty() { changed = true; continue; } if !seen.insert(entry.token.clone()) { changed = true; continue; } normalized.push(entry); } if normalized.is_empty() { let token = if legacy.is_empty() { uuid::Uuid::new_v4().to_string() } else { legacy }; normalized.push(ApiTokenEntry { id: uuid::Uuid::new_v4().to_string(), label: "default".to_string(), token, created_at_ms: now_unix_ms(), }); changed = true; } let mirror = normalized .first() .map(|entry| entry.token.clone()) .unwrap_or_default(); if config.api.auth_token != mirror { config.api.auth_token = mirror; changed = true; } if config.api.auth_tokens != normalized { config.api.auth_tokens = normalized; changed = true; } changed } fn normalize_token_label(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { "token".to_string() } else { trimmed.to_string() } } fn now_unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|v| v.as_millis() as u64) .unwrap_or(0) } #[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 dragging_state_maps_to_dragging_animation() { 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::Dragging, ttl_ms: None, }) .expect("apply"); let snapshot = core.snapshot().read().expect("snapshot lock").clone(); assert_eq!(snapshot.state, FrontendState::Dragging); assert_eq!(snapshot.current_animation, "dragging"); } #[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")); } #[test] fn frontend_tauri_animation_slowdown_factor_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"); let persisted = core .set_frontend_tauri_animation_slowdown_factor(6) .expect("set"); assert_eq!(persisted, 6); assert_eq!( core.frontend_tauri_animation_slowdown_factor().expect("get"), 6 ); } #[test] fn frontend_tauri_animation_slowdown_factor_clamps() { 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"); let persisted = core .set_frontend_tauri_animation_slowdown_factor(0) .expect("set"); assert_eq!(persisted, 1); assert_eq!( core.frontend_tauri_animation_slowdown_factor().expect("get"), 1 ); let upper = core .set_frontend_tauri_animation_slowdown_factor(201) .expect("set high"); assert_eq!(upper, 200); assert_eq!( core.frontend_tauri_animation_slowdown_factor().expect("get"), 200 ); } #[test] fn api_token_create_rename_revoke_roundtrip() { 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"); let initial_len = core.list_api_tokens().expect("tokens").len(); let created = core .create_api_token(Some("backend-ci".to_string())) .expect("create token"); let after_create = core.list_api_tokens().expect("tokens"); assert_eq!(after_create.len(), initial_len + 1); assert!(after_create.iter().any(|entry| entry.id == created.id)); core.rename_api_token(&created.id, "automation") .expect("rename token"); let after_rename = core.list_api_tokens().expect("tokens"); assert!( after_rename .iter() .any(|entry| entry.id == created.id && entry.label == "automation") ); core.revoke_api_token(&created.id).expect("revoke token"); let after_revoke = core.list_api_tokens().expect("tokens"); assert_eq!(after_revoke.len(), initial_len); assert!(!after_revoke.iter().any(|entry| entry.id == created.id)); } #[test] fn cannot_revoke_last_api_token() { 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"); let only = core.list_api_tokens().expect("tokens"); assert_eq!(only.len(), 1); let err = core .revoke_api_token(&only[0].id) .expect_err("last token revoke must fail"); assert!(format!("{err}").contains("last API token")); } }