use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use thiserror::Error; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; #[derive(Debug, Error)] pub enum ConfigError { #[error("no supported configuration directory for this platform")] MissingProjectDir, #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("invalid config file: {0}")] Parse(#[from] toml::de::Error), #[error("could not encode config: {0}")] Encode(#[from] toml::ser::Error), } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { pub window: WindowConfig, pub animation: AnimationConfig, pub sprite: SpriteConfig, pub api: ApiConfig, pub logging: LoggingConfig, pub controls: ControlsConfig, pub frontend: FrontendConfig, } impl Default for AppConfig { fn default() -> Self { Self { window: WindowConfig::default(), animation: AnimationConfig::default(), sprite: SpriteConfig::default(), api: ApiConfig::default(), logging: LoggingConfig::default(), controls: ControlsConfig::default(), frontend: FrontendConfig::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct WindowConfig { pub x: f32, pub y: f32, pub monitor_id: Option, pub scale: f32, pub always_on_top: bool, pub click_through: bool, pub visible: bool, } impl Default for WindowConfig { fn default() -> Self { Self { x: 200.0, y: 200.0, monitor_id: None, scale: 1.0, always_on_top: true, click_through: false, visible: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AnimationConfig { pub fps: u16, pub idle_timeout_ms: u64, } impl Default for AnimationConfig { fn default() -> Self { Self { fps: 30, idle_timeout_ms: 3_000, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct SpriteConfig { pub selected_pack: String, pub sprite_packs_dir: String, } impl Default for SpriteConfig { fn default() -> Self { Self { selected_pack: "default".to_string(), sprite_packs_dir: "sprite-packs".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ApiConfig { pub port: u16, pub auth_token: String, pub auth_tokens: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct ApiTokenEntry { pub id: String, pub label: String, pub token: String, pub created_at_ms: u64, } impl Default for ApiTokenEntry { fn default() -> Self { Self { id: Uuid::new_v4().to_string(), label: "default".to_string(), token: Uuid::new_v4().to_string(), created_at_ms: now_unix_ms(), } } } impl Default for ApiConfig { fn default() -> Self { let token = Uuid::new_v4().to_string(); Self { port: 32_145, auth_token: token.clone(), auth_tokens: vec![ApiTokenEntry { id: Uuid::new_v4().to_string(), label: "default".to_string(), token, created_at_ms: now_unix_ms(), }], } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct LoggingConfig { pub level: String, } impl Default for LoggingConfig { fn default() -> Self { Self { level: "info".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ControlsConfig { pub hotkey_enabled: bool, pub recovery_hotkey: String, } impl Default for ControlsConfig { fn default() -> Self { Self { hotkey_enabled: true, recovery_hotkey: "Ctrl+Alt+P".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FrontendBackend { Bevy, Tauri, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct FrontendConfig { pub backend: FrontendBackend, pub debug_overlay_visible: bool, pub tauri_animation_slowdown_factor: u8, } impl Default for FrontendConfig { fn default() -> Self { Self { backend: FrontendBackend::Bevy, debug_overlay_visible: false, tauri_animation_slowdown_factor: 3, } } } #[must_use] pub fn config_path(app_name: &str) -> Result { let dirs = ProjectDirs::from("", "", app_name).ok_or(ConfigError::MissingProjectDir)?; Ok(dirs.config_dir().join("config.toml")) } pub fn load_or_create(app_name: &str) -> Result<(PathBuf, AppConfig), ConfigError> { let path = config_path(app_name)?; load_or_create_at(&path) } pub fn load_or_create_at(path: &Path) -> Result<(PathBuf, AppConfig), ConfigError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } if !path.exists() { let cfg = AppConfig::default(); save(path, &cfg)?; return Ok((path.to_path_buf(), cfg)); } let raw = fs::read_to_string(path)?; let cfg = toml::from_str::(&raw)?; Ok((path.to_path_buf(), cfg)) } pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> { let encoded = toml::to_string_pretty(config)?; fs::write(path, encoded)?; Ok(()) } 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::{load_or_create_at, save, AppConfig}; use tempfile::TempDir; #[test] fn bootstrap_writes_default_config() { let temp = TempDir::new().expect("tempdir"); let path = temp.path().join("config.toml"); let (_, config) = load_or_create_at(&path).expect("load or create"); assert_eq!(config.api.port, 32_145); } #[test] fn save_roundtrip() { let temp = TempDir::new().expect("tempdir"); let path = temp.path().join("config.toml"); let mut config = AppConfig::default(); config.window.x = 42.0; config.frontend.backend = super::FrontendBackend::Tauri; config.frontend.debug_overlay_visible = true; config.frontend.tauri_animation_slowdown_factor = 7; save(&path, &config).expect("save"); let (_, loaded) = load_or_create_at(&path).expect("reload"); assert!((loaded.window.x - 42.0).abs() < f32::EPSILON); assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri); assert!(loaded.frontend.debug_overlay_visible); assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7); } }