diff --git a/Cargo.lock b/Cargo.lock index 35d5317..6de6347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6089,6 +6089,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", ] [[package]] diff --git a/assets/sprite-packs/default/atlas.png b/assets/sprite-packs/default/atlas.png deleted file mode 100644 index f1e5d1e..0000000 Binary files a/assets/sprite-packs/default/atlas.png and /dev/null differ diff --git a/assets/sprite-packs/default/manifest.json b/assets/sprite-packs/default/manifest.json index 299bf24..c77226e 100644 --- a/assets/sprite-packs/default/manifest.json +++ b/assets/sprite-packs/default/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 1, + "frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7] }, { "name": "active", - "fps": 10, - "frames": [1, 0] + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "happy", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "love", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "excited", + "fps": 1, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23] + }, + { + "name": "celebrate", + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], + "one_shot": true }, { "name": "success", - "fps": 10, - "frames": [0, 1, 0], + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "snoring", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "working", + "fps": 1, + "frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39] + }, + { + "name": "angry", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "surprised", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "shy", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, { "name": "error", - "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "dragging", + "fps": 1, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/assets/sprite-packs/demogorgon/manifest.json b/assets/sprite-packs/demogorgon/manifest.json index a32e656..033315a 100644 --- a/assets/sprite-packs/demogorgon/manifest.json +++ b/assets/sprite-packs/demogorgon/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 1, + "frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7] }, { "name": "active", - "fps": 10, - "frames": [1, 0] + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "happy", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "love", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "excited", + "fps": 1, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23] + }, + { + "name": "celebrate", + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], + "one_shot": true }, { "name": "success", - "fps": 10, - "frames": [0, 1, 0], + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "snoring", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "working", + "fps": 1, + "frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39] + }, + { + "name": "angry", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "surprised", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "shy", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, { "name": "error", - "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "dragging", + "fps": 1, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/assets/sprite-packs/ferris/manifest.json b/assets/sprite-packs/ferris/manifest.json index 8618138..4393fec 100644 --- a/assets/sprite-packs/ferris/manifest.json +++ b/assets/sprite-packs/ferris/manifest.json @@ -7,25 +7,80 @@ "animations": [ { "name": "idle", - "fps": 6, - "frames": [0, 1] + "fps": 1, + "frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7] }, { "name": "active", - "fps": 10, - "frames": [1, 0] + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "happy", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "love", + "fps": 1, + "frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15] + }, + { + "name": "excited", + "fps": 1, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23] + }, + { + "name": "celebrate", + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], + "one_shot": true }, { "name": "success", - "fps": 10, - "frames": [0, 1, 0], + "fps": 2, + "frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23], "one_shot": true }, + { + "name": "sleepy", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "snoring", + "fps": 1, + "frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31] + }, + { + "name": "working", + "fps": 1, + "frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39] + }, + { + "name": "angry", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "surprised", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "shy", + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, { "name": "error", - "fps": 8, - "frames": [1, 0, 1], - "one_shot": true + "fps": 1, + "frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47] + }, + { + "name": "dragging", + "fps": 1, + "frames": [48, 49, 50, 51, 52, 53, 54, 55] } ], "anchor": { diff --git a/crates/sprimo-api/src/lib.rs b/crates/sprimo-api/src/lib.rs index b83ef33..d0a7a59 100644 --- a/crates/sprimo-api/src/lib.rs +++ b/crates/sprimo-api/src/lib.rs @@ -7,7 +7,7 @@ use axum::Router; use sprimo_protocol::v1::{ CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; @@ -19,7 +19,7 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub struct ApiConfig { pub bind_addr: SocketAddr, - pub auth_token: String, + pub auth_tokens: Vec, pub app_version: String, pub app_build: String, pub dedupe_capacity: usize, @@ -29,9 +29,14 @@ pub struct ApiConfig { impl ApiConfig { #[must_use] pub fn default_with_token(auth_token: String) -> Self { + Self::default_with_tokens(vec![auth_token]) + } + + #[must_use] + pub fn default_with_tokens(auth_tokens: Vec) -> Self { Self { bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)), - auth_token, + auth_tokens, app_version: env!("CARGO_PKG_VERSION").to_string(), app_build: "dev".to_string(), dedupe_capacity: 5_000, @@ -43,7 +48,7 @@ impl ApiConfig { #[derive(Debug)] pub struct ApiState { start_at: Instant, - auth_token: String, + auth_tokens: Arc>>, app_version: String, app_build: String, dedupe_capacity: usize, @@ -59,10 +64,14 @@ impl ApiState { config: ApiConfig, snapshot: Arc>, command_tx: mpsc::Sender, + auth_tokens: Arc>>, ) -> Self { + if let Ok(mut guard) = auth_tokens.write() { + *guard = config.auth_tokens.into_iter().collect(); + } Self { start_at: Instant::now(), - auth_token: config.auth_token, + auth_tokens, app_version: config.app_version, app_build: config.app_build, dedupe_capacity: config.dedupe_capacity, @@ -188,11 +197,18 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> { .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .ok_or(ApiError::Unauthorized)?; - let expected = format!("Bearer {}", state.auth_token); - if raw == expected { - return Ok(()); + let Some(token) = raw.strip_prefix("Bearer ") else { + return Err(ApiError::Unauthorized); + }; + let guard = state + .auth_tokens + .read() + .map_err(|_| ApiError::Internal("auth token lock poisoned".to_string()))?; + if guard.contains(token) { + Ok(()) + } else { + Err(ApiError::Unauthorized) } - Err(ApiError::Unauthorized) } enum ApiError { @@ -240,6 +256,7 @@ mod tests { use sprimo_protocol::v1::{ CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot, }; + use std::collections::HashSet; use std::sync::{Arc, RwLock}; use tokio::sync::mpsc; use tower::ServiceExt; @@ -255,6 +272,7 @@ mod tests { ApiConfig::default_with_token("token".to_string()), snapshot, tx, + Arc::new(RwLock::new(HashSet::new())), )), rx, ) @@ -335,6 +353,48 @@ mod tests { assert_eq!(received.id, command.id); } + #[tokio::test] + async fn command_accepts_with_any_configured_token() { + let snapshot = + FrontendStateSnapshot::idle(CapabilityFlags::default()); + let snapshot = Arc::new(RwLock::new(snapshot)); + let (tx, mut rx) = mpsc::channel(8); + let state = Arc::new(ApiState::new( + ApiConfig::default_with_tokens(vec![ + "token-a".to_string(), + "token-b".to_string(), + ]), + snapshot, + tx, + Arc::new(RwLock::new(HashSet::new())), + )); + let app = app_router(state); + let command = CommandEnvelope { + id: Uuid::new_v4(), + ts_ms: 1, + command: FrontendCommand::Toast { + text: "hi".to_string(), + ttl_ms: None, + }, + }; + let body = serde_json::to_vec(&command).expect("json"); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/command") + .header("content-type", "application/json") + .header("authorization", "Bearer token-b") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let received = rx.recv().await.expect("forwarded command"); + assert_eq!(received.id, command.id); + } + #[tokio::test] async fn malformed_json_returns_bad_request() { let (state, _) = build_state(); diff --git a/crates/sprimo-app/src/main.rs b/crates/sprimo-app/src/main.rs index 7ad9db9..4635e93 100644 --- a/crates/sprimo-app/src/main.rs +++ b/crates/sprimo-app/src/main.rs @@ -1034,7 +1034,7 @@ fn default_animation_for_state(state: FrontendState) -> &'static str { FrontendState::Active => "active", FrontendState::Success => "success", FrontendState::Error => "error", - FrontendState::Dragging => "idle", + FrontendState::Dragging => "dragging", FrontendState::Hidden => "idle", } } diff --git a/crates/sprimo-config/src/lib.rs b/crates/sprimo-config/src/lib.rs index 14110ff..b08188c 100644 --- a/crates/sprimo-config/src/lib.rs +++ b/crates/sprimo-config/src/lib.rs @@ -3,6 +3,7 @@ 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)] @@ -106,13 +107,41 @@ impl Default for SpriteConfig { 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: Uuid::new_v4().to_string(), + 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(), + }], } } } @@ -159,6 +188,7 @@ pub enum FrontendBackend { pub struct FrontendConfig { pub backend: FrontendBackend, pub debug_overlay_visible: bool, + pub tauri_animation_slowdown_factor: u8, } impl Default for FrontendConfig { @@ -166,6 +196,7 @@ impl Default for FrontendConfig { Self { backend: FrontendBackend::Bevy, debug_overlay_visible: false, + tauri_animation_slowdown_factor: 3, } } } @@ -204,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> { 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}; @@ -225,11 +263,13 @@ mod tests { 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); } } diff --git a/crates/sprimo-runtime-core/Cargo.toml b/crates/sprimo-runtime-core/Cargo.toml index f6082aa..a4f7050 100644 --- a/crates/sprimo-runtime-core/Cargo.toml +++ b/crates/sprimo-runtime-core/Cargo.toml @@ -14,6 +14,7 @@ sprimo-protocol = { path = "../sprimo-protocol" } thiserror.workspace = true tokio.workspace = true tracing.workspace = true +uuid.workspace = true [dev-dependencies] tempfile = "3.12.0" diff --git a/crates/sprimo-runtime-core/src/lib.rs b/crates/sprimo-runtime-core/src/lib.rs index 7ceda69..0ca59c4 100644 --- a/crates/sprimo-runtime-core/src/lib.rs +++ b/crates/sprimo-runtime-core/src/lib.rs @@ -1,13 +1,18 @@ use sprimo_api::{ApiConfig, ApiServerError, ApiState}; -use sprimo_config::{save, AppConfig, ConfigError}; +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}")] @@ -16,6 +21,10 @@ pub enum RuntimeCoreError { 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 { @@ -23,6 +32,7 @@ pub struct RuntimeCore { config: Arc>, snapshot: Arc>, api_config: ApiConfig, + auth_store: Arc>>, command_tx: mpsc::Sender, command_rx: Arc>>, } @@ -40,6 +50,7 @@ impl RuntimeCore { ) -> 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; @@ -49,18 +60,34 @@ impl RuntimeCore { 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 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 { + if click_through_was_enabled || tokens_changed { core.persist_config()?; } Ok(core) @@ -104,19 +131,138 @@ impl RuntimeCore { 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 { @@ -244,6 +390,27 @@ impl RuntimeCore { 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 { @@ -252,15 +419,114 @@ fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'s 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::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; @@ -284,6 +550,22 @@ mod tests { 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"); @@ -315,4 +597,89 @@ mod tests { 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")); + } } diff --git a/crates/sprimo-tauri/src/main.rs b/crates/sprimo-tauri/src/main.rs index f1405d0..abd90ee 100644 --- a/crates/sprimo-tauri/src/main.rs +++ b/crates/sprimo-tauri/src/main.rs @@ -31,6 +31,7 @@ const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay"; const MENU_ID_QUIT: &str = "quit"; const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot"; const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible"; +const EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR: &str = "runtime:animation-slowdown-factor"; #[derive(Debug, Clone, serde::Serialize)] struct UiAnimationClip { @@ -80,6 +81,7 @@ struct UiSettingsSnapshot { scale: f32, visible: bool, always_on_top: bool, + tauri_animation_slowdown_factor: u8, } #[derive(Debug, Clone, serde::Serialize)] @@ -88,6 +90,14 @@ struct UiSpritePackOption { pack_id_or_path: String, } +#[derive(Debug, Clone, serde::Serialize)] +struct UiApiToken { + id: String, + label: String, + token: String, + created_at_ms: u64, +} + #[derive(Debug, Error)] enum AppError { #[error("{0}")] @@ -183,9 +193,37 @@ fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result) -> Result { + state + .runtime_core + .frontend_tauri_animation_slowdown_factor() + .map_err(|err| err.to_string()) +} + +#[tauri::command] +fn set_tauri_animation_slowdown_factor( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + factor: u8, +) -> Result { + let persisted = state + .runtime_core + .set_frontend_tauri_animation_slowdown_factor(factor) + .map_err(|err| err.to_string())?; + app_handle + .emit(EVENT_RUNTIME_ANIMATION_SLOWDOWN_FACTOR, persisted) + .map_err(|err| err.to_string())?; + Ok(persisted) +} + #[tauri::command] fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result, String> { let root = sprite_pack_root(state.runtime_core.as_ref())?; @@ -213,6 +251,60 @@ fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result) -> Result, String> { + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + +#[tauri::command] +fn create_api_token( + state: tauri::State<'_, AppState>, + label: Option, +) -> Result { + let entry = state + .runtime_core + .create_api_token(label) + .map_err(|err| err.to_string())?; + Ok(to_ui_api_token(entry)) +} + +#[tauri::command] +fn rename_api_token( + state: tauri::State<'_, AppState>, + id: String, + label: String, +) -> Result, String> { + state + .runtime_core + .rename_api_token(&id, &label) + .map_err(|err| err.to_string())?; + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + +#[tauri::command] +fn revoke_api_token( + state: tauri::State<'_, AppState>, + id: String, +) -> Result, String> { + state + .runtime_core + .revoke_api_token(&id) + .map_err(|err| err.to_string())?; + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + #[tauri::command] fn set_sprite_pack( app_handle: tauri::AppHandle, @@ -349,7 +441,13 @@ fn main() -> Result<(), AppError> { debug_overlay_visible, set_debug_overlay_visible, settings_snapshot, + tauri_animation_slowdown_factor, + set_tauri_animation_slowdown_factor, list_sprite_packs, + list_api_tokens, + create_api_token, + rename_api_token, + revoke_api_token, set_sprite_pack, set_scale, set_visibility, @@ -366,6 +464,9 @@ fn main() -> Result<(), AppError> { if let Ok(mut guard) = tray_state_holder.lock() { *guard = Some(tray_state.clone()); } + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = window.set_shadow(false); + } if let Ok(snapshot) = runtime_core.snapshot().read() { let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot)); } @@ -470,6 +571,15 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot { } } +fn to_ui_api_token(entry: sprimo_config::ApiTokenEntry) -> UiApiToken { + UiApiToken { + id: entry.id, + label: entry.label, + token: entry.token, + created_at_ms: entry.created_at_ms, + } +} + fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result { let sprite_packs_dir = runtime_core .config() diff --git a/crates/sprimo-tauri/tauri.conf.json b/crates/sprimo-tauri/tauri.conf.json index 215d032..a36be94 100644 --- a/crates/sprimo-tauri/tauri.conf.json +++ b/crates/sprimo-tauri/tauri.conf.json @@ -16,6 +16,7 @@ "height": 416, "decorations": false, "transparent": true, + "shadow": false, "alwaysOnTop": true, "resizable": false } diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index e9ef7ca..bf07dac 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -40,6 +40,7 @@ recovery_hotkey = "Ctrl+Alt+P" [frontend] backend = "bevy" debug_overlay_visible = false +tauri_animation_slowdown_factor = 3 ``` ## Notes @@ -50,3 +51,6 @@ debug_overlay_visible = false - On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery. - `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`). - `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown. +- `frontend.tauri_animation_slowdown_factor` controls tauri animation pacing multiplier. + valid range: `1..200` + effective frame interval: `(1000 / clip_fps) * factor` diff --git a/docs/RANDOM_BACKEND_TESTING.md b/docs/RANDOM_BACKEND_TESTING.md index d44c095..65d7e5e 100644 --- a/docs/RANDOM_BACKEND_TESTING.md +++ b/docs/RANDOM_BACKEND_TESTING.md @@ -18,6 +18,13 @@ Supporting checks: - `GET /v1/health` - `GET /v1/state` (periodic sampling) +Animation traffic now targets row-based sprite names plus compatibility aliases: + +- semantic names: `idle`, `happy`, `love`, `excited`, `celebrate`, `sleepy`, `snoring`, + `working`, `angry`, `surprised`, `shy`, `dragging` +- compatibility aliases: `active`, `success`, `error` +- one intentional unknown name is still included to keep invalid animation-path coverage + ## Prerequisites - Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`). diff --git a/docs/SPRITE_PACK_SCHEMA.md b/docs/SPRITE_PACK_SCHEMA.md index 6d4b72a..6fcb939 100644 --- a/docs/SPRITE_PACK_SCHEMA.md +++ b/docs/SPRITE_PACK_SCHEMA.md @@ -75,3 +75,25 @@ For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`: - `frame_height = image_height / 7` - manifest `frame_width` and `frame_height` are ignored for this case. - animation frame indices are validated against the fixed grid frame count (`56`). + +## Recommended 7x8 Row Semantics + +For `sprite.png` packs using the fixed `8x7` topology, the project convention is: + +- row 1 (`0..7`): `idle` +- row 2 (`8..15`): `happy`, `love`, compatibility alias `active` +- row 3 (`16..23`): `excited`, `celebrate`, compatibility alias `success` +- row 4 (`24..31`): `sleepy`, `snoring` +- row 5 (`32..39`): `working` +- row 6 (`40..47`): `angry`, `surprised`, `shy`, compatibility alias `error` +- row 7 (`48..55`): `dragging` + +Default one-shot policy: + +- `celebrate` and `success` are one-shot. +- other row animations loop by default. + +Recommended FPS profile for 8-frame rows: + +- looping rows: `1` fps +- one-shot `celebrate`/`success`: `2` fps diff --git a/docs/TAURI_FRONTEND_DESIGN.md b/docs/TAURI_FRONTEND_DESIGN.md index d1ed744..c420365 100644 --- a/docs/TAURI_FRONTEND_DESIGN.md +++ b/docs/TAURI_FRONTEND_DESIGN.md @@ -60,6 +60,14 @@ Frontend: from runtime snapshot events. - For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a fixed `8x7` grid topology to keep splitting stable across packaged asset resolutions. +- `sprite.png` animation naming now follows row semantics with backward-compatible aliases: + - row1: `idle` + - row2: `happy`/`love` + alias `active` + - row3: `excited`/`celebrate` + alias `success` + - row4: `sleepy`/`snoring` + - row5: `working` + - row6: `angry`/`surprised`/`shy` + alias `error` + - row7: `dragging` - React/Vite frontend now supports two window modes: - `main`: transparent overlay sprite renderer - `settings`: pop-out settings window for character and window controls diff --git a/docs/TAURI_RUNTIME_TESTING.md b/docs/TAURI_RUNTIME_TESTING.md index 3e5e249..9f73800 100644 --- a/docs/TAURI_RUNTIME_TESTING.md +++ b/docs/TAURI_RUNTIME_TESTING.md @@ -115,7 +115,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` 8. Chroma-key quality check: - verify no visible magenta background/fringe remains around sprite edges in normal runtime view, including non-exact gradient magenta atlas backgrounds (for example `ferris`/`demogorgon`) -9. Scale anchor and bounds check: +9. Animation tempo check for 8-frame rows: +- looping row animations should feel very slow/readable (1 fps profile) +- one-shot `celebrate`/`success` should run slightly faster than loops (2 fps profile) +- tauri renderer applies an additional global slowdown factor (`x2`) over clip fps; verify perceived + playback matches this expectation +10. Scale anchor and bounds check: - repeated scale changes should keep window centered without directional drift - window must remain within current monitor bounds during scale adjustments - no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor` @@ -138,10 +143,14 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before ` - main overlay auto-fits without clipping - persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds - value persists after restart -5. Toggle `Visible` and verify: +5. Change animation speed factor slider and verify: +- runtime animation pace updates immediately in main overlay +- value is clamped to `1..200` +- value persists after restart via `frontend.tauri_animation_slowdown_factor` +6. Toggle `Visible` and verify: - main overlay hide/show behavior - persisted value survives restart -6. Toggle `Always on top` and verify: +7. Toggle `Always on top` and verify: - main window z-order behavior updates - persisted value survives restart diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index 8c1e266..06f9877 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -17,6 +17,7 @@ type UiSettingsSnapshot = { scale: number; visible: boolean; always_on_top: boolean; + tauri_animation_slowdown_factor: number; }; type UiSpritePackOption = { @@ -24,6 +25,13 @@ type UiSpritePackOption = { pack_id_or_path: string; }; +type UiApiToken = { + id: string; + label: string; + token: string; + created_at_ms: number; +}; + const WINDOW_PADDING = 16; const WINDOW_WORKAREA_MARGIN = 80; const MIN_WINDOW_SIZE = 64; @@ -33,6 +41,9 @@ const SCALE_MIN = 0.5; const SCALE_MAX = 3.0; const LOGICAL_BASE_FRAME_WIDTH = 512; const LOGICAL_BASE_FRAME_HEIGHT = 512; +const SLOWDOWN_FACTOR_MIN = 1; +const SLOWDOWN_FACTOR_MAX = 200; +const SLOWDOWN_FACTOR_DEFAULT = 3; async function invokeSetSpritePack(packIdOrPath: string): Promise { return invoke("set_sprite_pack", { packIdOrPath }); @@ -50,6 +61,30 @@ async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise { return invoke("set_always_on_top", { alwaysOnTop }); } +async function invokeAnimationSlowdownFactor(): Promise { + return invoke("tauri_animation_slowdown_factor"); +} + +async function invokeSetAnimationSlowdownFactor(factor: number): Promise { + return invoke("set_tauri_animation_slowdown_factor", { factor }); +} + +async function invokeListApiTokens(): Promise { + return invoke("list_api_tokens"); +} + +async function invokeCreateApiToken(label?: string): Promise { + return invoke("create_api_token", { label }); +} + +async function invokeRenameApiToken(id: string, label: string): Promise { + return invoke("rename_api_token", { id, label }); +} + +async function invokeRevokeApiToken(id: string): Promise { + return invoke("revoke_api_token", { id }); +} + function fittedWindowSize( scale: number ): { width: number; height: number } { @@ -142,6 +177,7 @@ function MainOverlayWindow(): JSX.Element { const activePackRef = React.useRef(null); const loadedPackKeyRef = React.useRef(null); const effectiveScaleSyncRef = React.useRef(null); + const slowdownFactorRef = React.useRef(SLOWDOWN_FACTOR_DEFAULT); const loadingPackRef = React.useRef(false); const mountedRef = React.useRef(false); @@ -158,6 +194,7 @@ function MainOverlayWindow(): JSX.Element { } const previousRenderer = rendererRef.current; const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot); + nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current); rendererRef.current = nextRenderer; activePackRef.current = pack; loadedPackKeyRef.current = nextSnapshot.active_sprite_pack; @@ -252,13 +289,18 @@ function MainOverlayWindow(): JSX.Element { Promise.all([ invoke("load_active_sprite_pack"), invoke("current_state"), - invoke("debug_overlay_visible") + invoke("debug_overlay_visible"), + invokeAnimationSlowdownFactor() ]) - .then(async ([pack, initialSnapshot, showDebug]) => { + .then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => { if (!mountedRef.current) { return; } setDebugOverlayVisible(showDebug); + slowdownFactorRef.current = Math.min( + Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); await recreateRenderer(pack, initialSnapshot); await processSnapshot(initialSnapshot); @@ -271,9 +313,23 @@ function MainOverlayWindow(): JSX.Element { } setDebugOverlayVisible(Boolean(event.payload)); }); + const unlistenSlowdown = await listen( + "runtime:animation-slowdown-factor", + (event) => { + if (!mountedRef.current) { + return; + } + slowdownFactorRef.current = Math.min( + Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + rendererRef.current?.setAnimationSlowdownFactor(slowdownFactorRef.current); + } + ); unlisten = () => { unlistenSnapshot(); unlistenDebug(); + unlistenSlowdown(); }; }) .catch((err) => { @@ -363,6 +419,9 @@ function MainOverlayWindow(): JSX.Element { function SettingsWindow(): JSX.Element { const [settings, setSettings] = React.useState(null); const [packs, setPacks] = React.useState([]); + const [tokens, setTokens] = React.useState([]); + const [tokenDrafts, setTokenDrafts] = React.useState>({}); + const [newTokenLabel, setNewTokenLabel] = React.useState(""); const [activePack, setActivePack] = React.useState(null); const [error, setError] = React.useState(null); const [pending, setPending] = React.useState(false); @@ -373,15 +432,23 @@ function SettingsWindow(): JSX.Element { Promise.all([ invoke("settings_snapshot"), invoke("list_sprite_packs"), - invoke("load_active_sprite_pack") + invoke("load_active_sprite_pack"), + invokeListApiTokens() ]) - .then(async ([snapshot, options, pack]) => { + .then(async ([snapshot, options, pack, authTokens]) => { if (!mounted) { return; } setSettings(snapshot); setPacks(options); setActivePack(pack); + setTokens(authTokens); + setTokenDrafts( + authTokens.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); unlisten = await listen("runtime:snapshot", (event) => { if (!mounted) { return; @@ -406,10 +473,29 @@ function SettingsWindow(): JSX.Element { active_sprite_pack: payload.active_sprite_pack, scale: payload.scale, visible: payload.visible, - always_on_top: payload.always_on_top + always_on_top: payload.always_on_top, + tauri_animation_slowdown_factor: + prev.tauri_animation_slowdown_factor ?? SLOWDOWN_FACTOR_DEFAULT }; }); }); + const unlistenSlowdown = await listen("runtime:animation-slowdown-factor", (event) => { + if (!mounted) { + return; + } + const factor = Math.min( + Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + setSettings((prev) => + prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: factor } + ); + }); + const previousUnlisten = unlisten; + unlisten = () => { + previousUnlisten(); + unlistenSlowdown(); + }; }) .catch((err) => { if (mounted) { @@ -499,6 +585,108 @@ function SettingsWindow(): JSX.Element { [withPending] ); + const onAnimationSlowdownFactorChange = React.useCallback( + async (event: React.ChangeEvent) => { + const value = Number(event.target.value); + if (!Number.isFinite(value)) { + return; + } + const clamped = Math.min( + Math.max(Math.round(value), SLOWDOWN_FACTOR_MIN), + SLOWDOWN_FACTOR_MAX + ); + const persisted = await withPending(() => invokeSetAnimationSlowdownFactor(clamped)); + if (persisted === null) { + return; + } + setSettings((prev) => + prev === null + ? prev + : { ...prev, tauri_animation_slowdown_factor: Number(persisted) } + ); + }, + [withPending] + ); + + const onNewTokenLabelChange = React.useCallback( + (event: React.ChangeEvent) => { + setNewTokenLabel(event.target.value); + }, + [] + ); + + const onCreateToken = React.useCallback(async () => { + const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined)); + if (created === null) { + return; + } + const refreshed = await withPending(() => invokeListApiTokens()); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + setNewTokenLabel(""); + }, [newTokenLabel, withPending]); + + const onTokenDraftChange = React.useCallback((id: string, value: string) => { + setTokenDrafts((prev) => ({ + ...prev, + [id]: value + })); + }, []); + + const onRenameToken = React.useCallback( + async (id: string) => { + const nextLabel = tokenDrafts[id] ?? ""; + const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel)); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + }, + [tokenDrafts, withPending] + ); + + const onCopyToken = React.useCallback(async (token: string) => { + try { + await navigator.clipboard.writeText(token); + } catch (err) { + setError(String(err)); + } + }, []); + + const onRevokeToken = React.useCallback( + async (id: string) => { + if (!window.confirm("Revoke this API token?")) { + return; + } + const refreshed = await withPending(() => invokeRevokeApiToken(id)); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + }, + [withPending] + ); + return (
@@ -535,6 +723,21 @@ function SettingsWindow(): JSX.Element { onChange={onScaleChange} /> + +
+

API Tokens

+

+ Use any listed token as `Authorization: Bearer <token>`. +

+
+ + +
+
+ {tokens.map((entry) => ( +
+ + +
+ + + +
+
+ ))} +
+
)}
diff --git a/frontend/tauri-ui/src/renderer/pixi_pet.ts b/frontend/tauri-ui/src/renderer/pixi_pet.ts index 0b9d6ea..287b2cb 100644 --- a/frontend/tauri-ui/src/renderer/pixi_pet.ts +++ b/frontend/tauri-ui/src/renderer/pixi_pet.ts @@ -53,6 +53,9 @@ const HALO_SAT_MIN = 0.15; const HALO_VAL_MIN = 0.04; const RENDER_FIT_PADDING = 16; const MIN_RENDER_SCALE = 0.01; +const ANIMATION_SLOWDOWN_FACTOR_MIN = 1; +const ANIMATION_SLOWDOWN_FACTOR_MAX = 200; +const ANIMATION_SLOWDOWN_FACTOR_DEFAULT = 3; export class PixiPetRenderer { private app: Application; @@ -63,6 +66,7 @@ export class PixiPetRenderer { private frameCursor = 0; private frameElapsedMs = 0; private baseTexture: BaseTexture; + private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT; private disposed = false; private constructor( @@ -431,13 +435,24 @@ export class PixiPetRenderer { this.layoutSprite(); } + setAnimationSlowdownFactor(factor: number): void { + if (!Number.isFinite(factor)) { + return; + } + const rounded = Math.round(factor); + this.animationSlowdownFactor = Math.min( + Math.max(rounded, ANIMATION_SLOWDOWN_FACTOR_MIN), + ANIMATION_SLOWDOWN_FACTOR_MAX + ); + } + private startTicker(): void { this.app.ticker.add((ticker) => { if (this.disposed) { return; } this.layoutSprite(); - const frameMs = 1000 / Math.max(this.currentClip.fps, 1); + const frameMs = (1000 / Math.max(this.currentClip.fps, 1)) * this.animationSlowdownFactor; this.frameElapsedMs += ticker.deltaMS; if (this.frameElapsedMs < frameMs) { return; diff --git a/frontend/tauri-ui/src/styles.css b/frontend/tauri-ui/src/styles.css index 7dd326e..da855b4 100644 --- a/frontend/tauri-ui/src/styles.css +++ b/frontend/tauri-ui/src/styles.css @@ -85,6 +85,7 @@ dd { } .settings-root { + height: 100vh; min-height: 100vh; display: flex; align-items: stretch; @@ -92,6 +93,8 @@ dd { background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%); color: #0f172a; user-select: none; + overflow-y: auto; + overflow-x: hidden; } .settings-card { @@ -102,6 +105,7 @@ dd { display: grid; gap: 14px; align-content: start; + box-sizing: border-box; } .settings-card h1 { @@ -137,7 +141,8 @@ dd { } .field select, -.field input[type="range"] { +.field input[type="range"], +.field input[type="text"] { width: 100%; } @@ -149,6 +154,15 @@ dd { color: #0f172a; } +.field input[type="text"] { + border: 1px solid #94a3b8; + border-radius: 8px; + padding: 8px 10px; + background: #ffffff; + color: #0f172a; + box-sizing: border-box; +} + .toggle { display: flex; align-items: center; @@ -156,3 +170,64 @@ dd { font-size: 14px; color: #1e293b; } + +.token-section { + margin-top: 8px; + border-top: 1px solid #cbd5e1; + padding-top: 12px; + display: grid; + gap: 10px; +} + +.token-section h2 { + margin: 0; + font-size: 18px; +} + +.token-help { + margin: 0; + font-size: 12px; + color: #475569; +} + +.token-create { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.token-create button, +.token-actions button { + border: 1px solid #94a3b8; + border-radius: 8px; + padding: 7px 10px; + background: #ffffff; + color: #0f172a; + cursor: pointer; +} + +.token-create button:disabled, +.token-actions button:disabled { + opacity: 0.5; + cursor: default; +} + +.token-list { + display: grid; + gap: 10px; +} + +.token-item { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px; + display: grid; + gap: 8px; + background: #f8fafc; +} + +.token-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} diff --git a/issues/issue6.md b/issues/issue6.md new file mode 100644 index 0000000..31bbb38 --- /dev/null +++ b/issues/issue6.md @@ -0,0 +1,68 @@ +## Title + +Adopt row-based 7x8 sprite animation semantics with backward-compatible state aliases. + +## Severity + +P2 + +## Summary + +Standardize `sprite.png` packs so each of the 7 rows maps to semantic animation groups, while +keeping runtime compatibility with existing state names (`active`, `success`, `error`). +Also retune 8-frame animation tempo to a slower profile for readability. + +## Scope + +- `assets/sprite-packs/{default,ferris,demogorgon}/manifest.json` +- `crates/sprimo-runtime-core/src/lib.rs` +- `crates/sprimo-app/src/main.rs` +- docs: + - `docs/SPRITE_PACK_SCHEMA.md` + - `docs/TAURI_FRONTEND_DESIGN.md` + +## Row Mapping Contract + +- row 1 (`0..7`): `idle` +- row 2 (`8..15`): `happy`, `love`, alias `active` +- row 3 (`16..23`): `excited`, `celebrate`, alias `success` +- row 4 (`24..31`): `sleepy`, `snoring` +- row 5 (`32..39`): `working` +- row 6 (`40..47`): `angry`, `surprised`, `shy`, alias `error` +- row 7 (`48..55`): `dragging` + +One-shot defaults: + +- `celebrate` and `success`: one-shot +- all others: loop + +## Implementation Notes + +1. Runtime state mapping updated: +- `Dragging` now maps to `"dragging"` instead of `"idle"` in runtime-core and Bevy frontend. +2. All bundled sprite-pack manifests now expose row-based names and compatibility aliases. +3. Added runtime-core unit test to confirm `SetState::Dragging` selects `"dragging"`. +4. Updated schema/design docs to formalize the row convention. +5. Retuned bundled pack fps profile: +- loops: `1` fps +- one-shot `celebrate` and `success`: `2` fps +6. Tauri renderer-level pacing adjusted: +- added global frame interval slowdown factor (`x2`) in Pixi ticker to further reduce perceived speed + without changing manifest schema type (`fps` remains integer). +7. Added persisted tauri slowdown factor control: +- config key: `frontend.tauri_animation_slowdown_factor` (integer, range `1..20`, default `3`) +- new tauri commands/events to read/update factor at runtime +- settings window slider added for live tuning and persistence. + +## Verification + +### Commands Run + +- [x] `cargo test -p sprimo-runtime-core` +- [x] `cargo check -p sprimo-tauri` +- [x] `cargo check -p sprimo-app` + +### Result + +- Status: `Fix Implemented` +- Notes: packaged runtime visual verification pending. diff --git a/scripts/random_backend_tester.py b/scripts/random_backend_tester.py index 7c80baa..a72ba19 100644 --- a/scripts/random_backend_tester.py +++ b/scripts/random_backend_tester.py @@ -18,6 +18,27 @@ from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +ANIMATION_NAMES = ( + "idle", + "happy", + "love", + "excited", + "celebrate", + "sleepy", + "snoring", + "working", + "angry", + "surprised", + "shy", + "dragging", + # Backward-compatible aliases mapped in runtime/manifests. + "active", + "success", + "error", + # Intentionally invalid to keep unknown-animation traffic coverage. + "unknown_anim", +) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -205,9 +226,7 @@ def random_valid_command(rng: random.Random) -> dict[str, Any]: if pick == "play_animation": payload = { - "name": rng.choice( - ["idle", "dance", "typing", "celebrate", "error", "unknown_anim"] - ), + "name": rng.choice(ANIMATION_NAMES), "priority": rng.randint(0, 10), "duration_ms": rng.choice([None, 250, 500, 1000, 3000]), "interrupt": rng.choice([None, True, False]),