Compare commits
8 Commits
927fe6641c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c815adb826 | |||
| 5b0b0c7d41 | |||
|
|
fa508ced8c | ||
|
|
bed7a052f3 | ||
|
|
832fbda04d | ||
|
|
f20ed1fd9d | ||
|
|
e5417b6799 | ||
|
|
c0efb3915b |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6089,6 +6089,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 404 B |
@@ -7,25 +7,80 @@
|
|||||||
"animations": [
|
"animations": [
|
||||||
{
|
{
|
||||||
"name": "idle",
|
"name": "idle",
|
||||||
"fps": 6,
|
"fps": 1,
|
||||||
"frames": [0, 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",
|
"name": "active",
|
||||||
"fps": 10,
|
"fps": 1,
|
||||||
"frames": [1, 0]
|
"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",
|
"name": "success",
|
||||||
"fps": 10,
|
"fps": 2,
|
||||||
"frames": [0, 1, 0],
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
|
||||||
"one_shot": true
|
"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",
|
"name": "error",
|
||||||
"fps": 8,
|
"fps": 1,
|
||||||
"frames": [1, 0, 1],
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
"one_shot": true
|
},
|
||||||
|
{
|
||||||
|
"name": "dragging",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"anchor": {
|
"anchor": {
|
||||||
|
|||||||
@@ -7,25 +7,80 @@
|
|||||||
"animations": [
|
"animations": [
|
||||||
{
|
{
|
||||||
"name": "idle",
|
"name": "idle",
|
||||||
"fps": 6,
|
"fps": 1,
|
||||||
"frames": [0, 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",
|
"name": "active",
|
||||||
"fps": 10,
|
"fps": 1,
|
||||||
"frames": [1, 0]
|
"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",
|
"name": "success",
|
||||||
"fps": 10,
|
"fps": 2,
|
||||||
"frames": [0, 1, 0],
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
|
||||||
"one_shot": true
|
"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",
|
"name": "error",
|
||||||
"fps": 8,
|
"fps": 1,
|
||||||
"frames": [1, 0, 1],
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
"one_shot": true
|
},
|
||||||
|
{
|
||||||
|
"name": "dragging",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"anchor": {
|
"anchor": {
|
||||||
|
|||||||
@@ -7,25 +7,80 @@
|
|||||||
"animations": [
|
"animations": [
|
||||||
{
|
{
|
||||||
"name": "idle",
|
"name": "idle",
|
||||||
"fps": 6,
|
"fps": 1,
|
||||||
"frames": [0, 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",
|
"name": "active",
|
||||||
"fps": 10,
|
"fps": 1,
|
||||||
"frames": [1, 0]
|
"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",
|
"name": "success",
|
||||||
"fps": 10,
|
"fps": 2,
|
||||||
"frames": [0, 1, 0],
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
|
||||||
"one_shot": true
|
"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",
|
"name": "error",
|
||||||
"fps": 8,
|
"fps": 1,
|
||||||
"frames": [1, 0, 1],
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
"one_shot": true
|
},
|
||||||
|
{
|
||||||
|
"name": "dragging",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"anchor": {
|
"anchor": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use axum::Router;
|
|||||||
use sprimo_protocol::v1::{
|
use sprimo_protocol::v1::{
|
||||||
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
|
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -19,7 +19,7 @@ use uuid::Uuid;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApiConfig {
|
pub struct ApiConfig {
|
||||||
pub bind_addr: SocketAddr,
|
pub bind_addr: SocketAddr,
|
||||||
pub auth_token: String,
|
pub auth_tokens: Vec<String>,
|
||||||
pub app_version: String,
|
pub app_version: String,
|
||||||
pub app_build: String,
|
pub app_build: String,
|
||||||
pub dedupe_capacity: usize,
|
pub dedupe_capacity: usize,
|
||||||
@@ -29,9 +29,14 @@ pub struct ApiConfig {
|
|||||||
impl ApiConfig {
|
impl ApiConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn default_with_token(auth_token: String) -> Self {
|
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<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
|
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
|
||||||
auth_token,
|
auth_tokens,
|
||||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
app_build: "dev".to_string(),
|
app_build: "dev".to_string(),
|
||||||
dedupe_capacity: 5_000,
|
dedupe_capacity: 5_000,
|
||||||
@@ -43,7 +48,7 @@ impl ApiConfig {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ApiState {
|
pub struct ApiState {
|
||||||
start_at: Instant,
|
start_at: Instant,
|
||||||
auth_token: String,
|
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||||
app_version: String,
|
app_version: String,
|
||||||
app_build: String,
|
app_build: String,
|
||||||
dedupe_capacity: usize,
|
dedupe_capacity: usize,
|
||||||
@@ -59,10 +64,14 @@ impl ApiState {
|
|||||||
config: ApiConfig,
|
config: ApiConfig,
|
||||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||||
|
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
if let Ok(mut guard) = auth_tokens.write() {
|
||||||
|
*guard = config.auth_tokens.into_iter().collect();
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
start_at: Instant::now(),
|
start_at: Instant::now(),
|
||||||
auth_token: config.auth_token,
|
auth_tokens,
|
||||||
app_version: config.app_version,
|
app_version: config.app_version,
|
||||||
app_build: config.app_build,
|
app_build: config.app_build,
|
||||||
dedupe_capacity: config.dedupe_capacity,
|
dedupe_capacity: config.dedupe_capacity,
|
||||||
@@ -188,12 +197,19 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> {
|
|||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.ok_or(ApiError::Unauthorized)?;
|
.ok_or(ApiError::Unauthorized)?;
|
||||||
let expected = format!("Bearer {}", state.auth_token);
|
let Some(token) = raw.strip_prefix("Bearer ") else {
|
||||||
if raw == expected {
|
return Err(ApiError::Unauthorized);
|
||||||
return Ok(());
|
};
|
||||||
}
|
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 {
|
enum ApiError {
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
@@ -240,6 +256,7 @@ mod tests {
|
|||||||
use sprimo_protocol::v1::{
|
use sprimo_protocol::v1::{
|
||||||
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
|
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -255,6 +272,7 @@ mod tests {
|
|||||||
ApiConfig::default_with_token("token".to_string()),
|
ApiConfig::default_with_token("token".to_string()),
|
||||||
snapshot,
|
snapshot,
|
||||||
tx,
|
tx,
|
||||||
|
Arc::new(RwLock::new(HashSet::new())),
|
||||||
)),
|
)),
|
||||||
rx,
|
rx,
|
||||||
)
|
)
|
||||||
@@ -335,6 +353,48 @@ mod tests {
|
|||||||
assert_eq!(received.id, command.id);
|
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]
|
#[tokio::test]
|
||||||
async fn malformed_json_returns_bad_request() {
|
async fn malformed_json_returns_bad_request() {
|
||||||
let (state, _) = build_state();
|
let (state, _) = build_state();
|
||||||
|
|||||||
@@ -1034,7 +1034,7 @@ fn default_animation_for_state(state: FrontendState) -> &'static str {
|
|||||||
FrontendState::Active => "active",
|
FrontendState::Active => "active",
|
||||||
FrontendState::Success => "success",
|
FrontendState::Success => "success",
|
||||||
FrontendState::Error => "error",
|
FrontendState::Error => "error",
|
||||||
FrontendState::Dragging => "idle",
|
FrontendState::Dragging => "dragging",
|
||||||
FrontendState::Hidden => "idle",
|
FrontendState::Hidden => "idle",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -106,13 +107,41 @@ impl Default for SpriteConfig {
|
|||||||
pub struct ApiConfig {
|
pub struct ApiConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub auth_token: String,
|
pub auth_token: String,
|
||||||
|
pub auth_tokens: Vec<ApiTokenEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
impl Default for ApiConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let token = Uuid::new_v4().to_string();
|
||||||
Self {
|
Self {
|
||||||
port: 32_145,
|
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 struct FrontendConfig {
|
||||||
pub backend: FrontendBackend,
|
pub backend: FrontendBackend,
|
||||||
pub debug_overlay_visible: bool,
|
pub debug_overlay_visible: bool,
|
||||||
|
pub tauri_animation_slowdown_factor: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FrontendConfig {
|
impl Default for FrontendConfig {
|
||||||
@@ -166,6 +196,7 @@ impl Default for FrontendConfig {
|
|||||||
Self {
|
Self {
|
||||||
backend: FrontendBackend::Bevy,
|
backend: FrontendBackend::Bevy,
|
||||||
debug_overlay_visible: false,
|
debug_overlay_visible: false,
|
||||||
|
tauri_animation_slowdown_factor: 3,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|v| v.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load_or_create_at, save, AppConfig};
|
use super::{load_or_create_at, save, AppConfig};
|
||||||
@@ -225,11 +263,13 @@ mod tests {
|
|||||||
config.window.x = 42.0;
|
config.window.x = 42.0;
|
||||||
config.frontend.backend = super::FrontendBackend::Tauri;
|
config.frontend.backend = super::FrontendBackend::Tauri;
|
||||||
config.frontend.debug_overlay_visible = true;
|
config.frontend.debug_overlay_visible = true;
|
||||||
|
config.frontend.tauri_animation_slowdown_factor = 7;
|
||||||
|
|
||||||
save(&path, &config).expect("save");
|
save(&path, &config).expect("save");
|
||||||
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
||||||
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
||||||
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
|
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
|
||||||
assert!(loaded.frontend.debug_overlay_visible);
|
assert!(loaded.frontend.debug_overlay_visible);
|
||||||
|
assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ sprimo-protocol = { path = "../sprimo-protocol" }
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.12.0"
|
tempfile = "3.12.0"
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
|
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 sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1;
|
||||||
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 200;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RuntimeCoreError {
|
pub enum RuntimeCoreError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -16,6 +21,10 @@ pub enum RuntimeCoreError {
|
|||||||
SnapshotPoisoned,
|
SnapshotPoisoned,
|
||||||
#[error("config lock poisoned")]
|
#[error("config lock poisoned")]
|
||||||
ConfigPoisoned,
|
ConfigPoisoned,
|
||||||
|
#[error("api token not found: {0}")]
|
||||||
|
ApiTokenNotFound(String),
|
||||||
|
#[error("cannot revoke the last API token")]
|
||||||
|
LastApiToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RuntimeCore {
|
pub struct RuntimeCore {
|
||||||
@@ -23,6 +32,7 @@ pub struct RuntimeCore {
|
|||||||
config: Arc<RwLock<AppConfig>>,
|
config: Arc<RwLock<AppConfig>>,
|
||||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||||
api_config: ApiConfig,
|
api_config: ApiConfig,
|
||||||
|
auth_store: Arc<RwLock<HashSet<String>>>,
|
||||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||||
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
|
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
|
||||||
}
|
}
|
||||||
@@ -40,6 +50,7 @@ impl RuntimeCore {
|
|||||||
) -> Result<Self, RuntimeCoreError> {
|
) -> Result<Self, RuntimeCoreError> {
|
||||||
let click_through_was_enabled = config_value.window.click_through;
|
let click_through_was_enabled = config_value.window.click_through;
|
||||||
config_value.window.click_through = false;
|
config_value.window.click_through = false;
|
||||||
|
let tokens_changed = normalize_api_tokens(&mut config_value);
|
||||||
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
||||||
snapshot.x = config_value.window.x;
|
snapshot.x = config_value.window.x;
|
||||||
snapshot.y = config_value.window.y;
|
snapshot.y = config_value.window.y;
|
||||||
@@ -49,18 +60,34 @@ impl RuntimeCore {
|
|||||||
snapshot.flags.visible = config_value.window.visible;
|
snapshot.flags.visible = config_value.window.visible;
|
||||||
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
|
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 (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 {
|
let core = Self {
|
||||||
config_path,
|
config_path,
|
||||||
config: Arc::new(RwLock::new(config_value)),
|
config: Arc::new(RwLock::new(config_value)),
|
||||||
snapshot: Arc::new(RwLock::new(snapshot)),
|
snapshot: Arc::new(RwLock::new(snapshot)),
|
||||||
api_config,
|
api_config,
|
||||||
|
auth_store,
|
||||||
command_tx,
|
command_tx,
|
||||||
command_rx: Arc::new(Mutex::new(command_rx)),
|
command_rx: Arc::new(Mutex::new(command_rx)),
|
||||||
};
|
};
|
||||||
if click_through_was_enabled {
|
if click_through_was_enabled || tokens_changed {
|
||||||
core.persist_config()?;
|
core.persist_config()?;
|
||||||
}
|
}
|
||||||
Ok(core)
|
Ok(core)
|
||||||
@@ -104,19 +131,138 @@ impl RuntimeCore {
|
|||||||
self.persist_config()
|
self.persist_config()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result<u8, RuntimeCoreError> {
|
||||||
|
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<u8, RuntimeCoreError> {
|
||||||
|
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 {
|
pub fn api_config(&self) -> ApiConfig {
|
||||||
self.api_config.clone()
|
self.api_config.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenEntry>, RuntimeCoreError> {
|
||||||
|
let guard = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
Ok(guard.api.auth_tokens.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_api_token(
|
||||||
|
&self,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> Result<ApiTokenEntry, RuntimeCoreError> {
|
||||||
|
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) {
|
pub fn spawn_api(&self, runtime: &Runtime) {
|
||||||
let mut cfg = self.api_config.clone();
|
let mut cfg = self.api_config.clone();
|
||||||
if let Ok(guard) = self.config.read() {
|
if let Ok(guard) = self.config.read() {
|
||||||
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
|
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(
|
let state = Arc::new(ApiState::new(
|
||||||
cfg.clone(),
|
cfg.clone(),
|
||||||
Arc::clone(&self.snapshot),
|
Arc::clone(&self.snapshot),
|
||||||
self.command_tx.clone(),
|
self.command_tx.clone(),
|
||||||
|
Arc::clone(&self.auth_store),
|
||||||
));
|
));
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
if let Err(err) = sprimo_api::run_server(cfg, state).await {
|
if let Err(err) = sprimo_api::run_server(cfg, state).await {
|
||||||
@@ -244,6 +390,27 @@ impl RuntimeCore {
|
|||||||
save(&self.config_path, &guard)?;
|
save(&self.config_path, &guard)?;
|
||||||
Ok(())
|
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::<HashSet<_>>()
|
||||||
|
};
|
||||||
|
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 {
|
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::Active => "active",
|
||||||
sprimo_protocol::v1::FrontendState::Success => "success",
|
sprimo_protocol::v1::FrontendState::Success => "success",
|
||||||
sprimo_protocol::v1::FrontendState::Error => "error",
|
sprimo_protocol::v1::FrontendState::Error => "error",
|
||||||
sprimo_protocol::v1::FrontendState::Dragging => "idle",
|
sprimo_protocol::v1::FrontendState::Dragging => "dragging",
|
||||||
sprimo_protocol::v1::FrontendState::Hidden => "idle",
|
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) {
|
fn log_api_error(err: ApiServerError) {
|
||||||
warn!(%err, "runtime core api server exited");
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::RuntimeCore;
|
use super::RuntimeCore;
|
||||||
@@ -284,6 +550,22 @@ mod tests {
|
|||||||
assert_eq!(snapshot.current_animation, "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]
|
#[test]
|
||||||
fn click_through_flag_is_ignored_and_forced_false() {
|
fn click_through_flag_is_ignored_and_forced_false() {
|
||||||
let temp = TempDir::new().expect("tempdir");
|
let temp = TempDir::new().expect("tempdir");
|
||||||
@@ -315,4 +597,89 @@ mod tests {
|
|||||||
core.set_frontend_debug_overlay_visible(true).expect("set");
|
core.set_frontend_debug_overlay_visible(true).expect("set");
|
||||||
assert!(core.frontend_debug_overlay_visible().expect("get"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
|
|||||||
const MENU_ID_QUIT: &str = "quit";
|
const MENU_ID_QUIT: &str = "quit";
|
||||||
const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot";
|
const EVENT_RUNTIME_SNAPSHOT: &str = "runtime:snapshot";
|
||||||
const EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE: &str = "runtime:debug-overlay-visible";
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
struct UiAnimationClip {
|
struct UiAnimationClip {
|
||||||
@@ -80,6 +81,7 @@ struct UiSettingsSnapshot {
|
|||||||
scale: f32,
|
scale: f32,
|
||||||
visible: bool,
|
visible: bool,
|
||||||
always_on_top: bool,
|
always_on_top: bool,
|
||||||
|
tauri_animation_slowdown_factor: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -88,6 +90,14 @@ struct UiSpritePackOption {
|
|||||||
pack_id_or_path: String,
|
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)]
|
#[derive(Debug, Error)]
|
||||||
enum AppError {
|
enum AppError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -183,9 +193,37 @@ fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnap
|
|||||||
scale: snapshot.scale,
|
scale: snapshot.scale,
|
||||||
visible: snapshot.flags.visible,
|
visible: snapshot.flags.visible,
|
||||||
always_on_top: snapshot.flags.always_on_top,
|
always_on_top: snapshot.flags.always_on_top,
|
||||||
|
tauri_animation_slowdown_factor: state
|
||||||
|
.runtime_core
|
||||||
|
.frontend_tauri_animation_slowdown_factor()
|
||||||
|
.map_err(|err| err.to_string())?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn tauri_animation_slowdown_factor(state: tauri::State<'_, AppState>) -> Result<u8, String> {
|
||||||
|
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<u8, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
|
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
|
||||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||||
@@ -213,6 +251,60 @@ fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePa
|
|||||||
Ok(packs)
|
Ok(packs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_api_tokens(state: tauri::State<'_, AppState>) -> Result<Vec<UiApiToken>, 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<String>,
|
||||||
|
) -> Result<UiApiToken, String> {
|
||||||
|
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<Vec<UiApiToken>, 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<Vec<UiApiToken>, 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]
|
#[tauri::command]
|
||||||
fn set_sprite_pack(
|
fn set_sprite_pack(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -349,7 +441,13 @@ fn main() -> Result<(), AppError> {
|
|||||||
debug_overlay_visible,
|
debug_overlay_visible,
|
||||||
set_debug_overlay_visible,
|
set_debug_overlay_visible,
|
||||||
settings_snapshot,
|
settings_snapshot,
|
||||||
|
tauri_animation_slowdown_factor,
|
||||||
|
set_tauri_animation_slowdown_factor,
|
||||||
list_sprite_packs,
|
list_sprite_packs,
|
||||||
|
list_api_tokens,
|
||||||
|
create_api_token,
|
||||||
|
rename_api_token,
|
||||||
|
revoke_api_token,
|
||||||
set_sprite_pack,
|
set_sprite_pack,
|
||||||
set_scale,
|
set_scale,
|
||||||
set_visibility,
|
set_visibility,
|
||||||
@@ -366,6 +464,9 @@ fn main() -> Result<(), AppError> {
|
|||||||
if let Ok(mut guard) = tray_state_holder.lock() {
|
if let Ok(mut guard) = tray_state_holder.lock() {
|
||||||
*guard = Some(tray_state.clone());
|
*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() {
|
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||||
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
|
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<std::path::PathBuf, String> {
|
fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result<std::path::PathBuf, String> {
|
||||||
let sprite_packs_dir = runtime_core
|
let sprite_packs_dir = runtime_core
|
||||||
.config()
|
.config()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"height": 416,
|
"height": 416,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
|
"shadow": false,
|
||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
"resizable": false
|
"resizable": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ recovery_hotkey = "Ctrl+Alt+P"
|
|||||||
[frontend]
|
[frontend]
|
||||||
backend = "bevy"
|
backend = "bevy"
|
||||||
debug_overlay_visible = false
|
debug_overlay_visible = false
|
||||||
|
tauri_animation_slowdown_factor = 3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -50,3 +51,6 @@ debug_overlay_visible = false
|
|||||||
- On Windows, `recovery_hotkey` now forces `visible = true` and `always_on_top = true` for recovery.
|
- 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.backend` selects runtime frontend implementation (`bevy` or `tauri`).
|
||||||
- `frontend.debug_overlay_visible` controls whether tauri window debug diagnostics panel is shown.
|
- `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`
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ Supporting checks:
|
|||||||
- `GET /v1/health`
|
- `GET /v1/health`
|
||||||
- `GET /v1/state` (periodic sampling)
|
- `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
|
## Prerequisites
|
||||||
|
|
||||||
- Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`).
|
- Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`).
|
||||||
|
|||||||
@@ -75,3 +75,25 @@ For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`:
|
|||||||
- `frame_height = image_height / 7`
|
- `frame_height = image_height / 7`
|
||||||
- manifest `frame_width` and `frame_height` are ignored for this case.
|
- manifest `frame_width` and `frame_height` are ignored for this case.
|
||||||
- animation frame indices are validated against the fixed grid frame count (`56`).
|
- 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
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ Frontend:
|
|||||||
from runtime snapshot events.
|
from runtime snapshot events.
|
||||||
- For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a
|
- 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.
|
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:
|
- React/Vite frontend now supports two window modes:
|
||||||
- `main`: transparent overlay sprite renderer
|
- `main`: transparent overlay sprite renderer
|
||||||
- `settings`: pop-out settings window for character and window controls
|
- `settings`: pop-out settings window for character and window controls
|
||||||
|
|||||||
@@ -115,7 +115,12 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
|
|||||||
8. Chroma-key quality check:
|
8. Chroma-key quality check:
|
||||||
- verify no visible magenta background/fringe remains around sprite edges in normal runtime view,
|
- 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`)
|
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
|
- repeated scale changes should keep window centered without directional drift
|
||||||
- window must remain within current monitor bounds during scale adjustments
|
- window must remain within current monitor bounds during scale adjustments
|
||||||
- no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor`
|
- 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
|
- main overlay auto-fits without clipping
|
||||||
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
|
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
|
||||||
- value persists after restart
|
- 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
|
- main overlay hide/show behavior
|
||||||
- persisted value survives restart
|
- persisted value survives restart
|
||||||
6. Toggle `Always on top` and verify:
|
7. Toggle `Always on top` and verify:
|
||||||
- main window z-order behavior updates
|
- main window z-order behavior updates
|
||||||
- persisted value survives restart
|
- persisted value survives restart
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type UiSettingsSnapshot = {
|
|||||||
scale: number;
|
scale: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
always_on_top: boolean;
|
always_on_top: boolean;
|
||||||
|
tauri_animation_slowdown_factor: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UiSpritePackOption = {
|
type UiSpritePackOption = {
|
||||||
@@ -24,6 +25,13 @@ type UiSpritePackOption = {
|
|||||||
pack_id_or_path: string;
|
pack_id_or_path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UiApiToken = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
token: string;
|
||||||
|
created_at_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
const WINDOW_PADDING = 16;
|
const WINDOW_PADDING = 16;
|
||||||
const WINDOW_WORKAREA_MARGIN = 80;
|
const WINDOW_WORKAREA_MARGIN = 80;
|
||||||
const MIN_WINDOW_SIZE = 64;
|
const MIN_WINDOW_SIZE = 64;
|
||||||
@@ -33,6 +41,9 @@ const SCALE_MIN = 0.5;
|
|||||||
const SCALE_MAX = 3.0;
|
const SCALE_MAX = 3.0;
|
||||||
const LOGICAL_BASE_FRAME_WIDTH = 512;
|
const LOGICAL_BASE_FRAME_WIDTH = 512;
|
||||||
const LOGICAL_BASE_FRAME_HEIGHT = 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<UiSnapshot> {
|
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
|
||||||
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
||||||
@@ -50,6 +61,30 @@ async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
|
|||||||
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
|
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invokeAnimationSlowdownFactor(): Promise<number> {
|
||||||
|
return invoke<number>("tauri_animation_slowdown_factor");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number> {
|
||||||
|
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeListApiTokens(): Promise<UiApiToken[]> {
|
||||||
|
return invoke<UiApiToken[]>("list_api_tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
|
||||||
|
return invoke<UiApiToken>("create_api_token", { label });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
|
||||||
|
return invoke<UiApiToken[]>("rename_api_token", { id, label });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
|
||||||
|
return invoke<UiApiToken[]>("revoke_api_token", { id });
|
||||||
|
}
|
||||||
|
|
||||||
function fittedWindowSize(
|
function fittedWindowSize(
|
||||||
scale: number
|
scale: number
|
||||||
): { width: number; height: number } {
|
): { width: number; height: number } {
|
||||||
@@ -142,6 +177,7 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
const activePackRef = React.useRef<UiSpritePack | null>(null);
|
const activePackRef = React.useRef<UiSpritePack | null>(null);
|
||||||
const loadedPackKeyRef = React.useRef<string | null>(null);
|
const loadedPackKeyRef = React.useRef<string | null>(null);
|
||||||
const effectiveScaleSyncRef = React.useRef<number | null>(null);
|
const effectiveScaleSyncRef = React.useRef<number | null>(null);
|
||||||
|
const slowdownFactorRef = React.useRef<number>(SLOWDOWN_FACTOR_DEFAULT);
|
||||||
const loadingPackRef = React.useRef(false);
|
const loadingPackRef = React.useRef(false);
|
||||||
const mountedRef = React.useRef(false);
|
const mountedRef = React.useRef(false);
|
||||||
|
|
||||||
@@ -158,6 +194,7 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
}
|
}
|
||||||
const previousRenderer = rendererRef.current;
|
const previousRenderer = rendererRef.current;
|
||||||
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
|
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
|
||||||
|
nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current);
|
||||||
rendererRef.current = nextRenderer;
|
rendererRef.current = nextRenderer;
|
||||||
activePackRef.current = pack;
|
activePackRef.current = pack;
|
||||||
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
|
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
|
||||||
@@ -252,13 +289,18 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||||
invoke<UiSnapshot>("current_state"),
|
invoke<UiSnapshot>("current_state"),
|
||||||
invoke<boolean>("debug_overlay_visible")
|
invoke<boolean>("debug_overlay_visible"),
|
||||||
|
invokeAnimationSlowdownFactor()
|
||||||
])
|
])
|
||||||
.then(async ([pack, initialSnapshot, showDebug]) => {
|
.then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDebugOverlayVisible(showDebug);
|
setDebugOverlayVisible(showDebug);
|
||||||
|
slowdownFactorRef.current = Math.min(
|
||||||
|
Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
|
||||||
|
SLOWDOWN_FACTOR_MAX
|
||||||
|
);
|
||||||
await recreateRenderer(pack, initialSnapshot);
|
await recreateRenderer(pack, initialSnapshot);
|
||||||
await processSnapshot(initialSnapshot);
|
await processSnapshot(initialSnapshot);
|
||||||
|
|
||||||
@@ -271,9 +313,23 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
}
|
}
|
||||||
setDebugOverlayVisible(Boolean(event.payload));
|
setDebugOverlayVisible(Boolean(event.payload));
|
||||||
});
|
});
|
||||||
|
const unlistenSlowdown = await listen<number>(
|
||||||
|
"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 = () => {
|
unlisten = () => {
|
||||||
unlistenSnapshot();
|
unlistenSnapshot();
|
||||||
unlistenDebug();
|
unlistenDebug();
|
||||||
|
unlistenSlowdown();
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -363,6 +419,9 @@ function MainOverlayWindow(): JSX.Element {
|
|||||||
function SettingsWindow(): JSX.Element {
|
function SettingsWindow(): JSX.Element {
|
||||||
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
||||||
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
||||||
|
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
|
||||||
|
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
|
||||||
|
const [newTokenLabel, setNewTokenLabel] = React.useState("");
|
||||||
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
|
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
@@ -373,15 +432,23 @@ function SettingsWindow(): JSX.Element {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
||||||
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
||||||
invoke<UiSpritePack>("load_active_sprite_pack")
|
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||||
|
invokeListApiTokens()
|
||||||
])
|
])
|
||||||
.then(async ([snapshot, options, pack]) => {
|
.then(async ([snapshot, options, pack, authTokens]) => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSettings(snapshot);
|
setSettings(snapshot);
|
||||||
setPacks(options);
|
setPacks(options);
|
||||||
setActivePack(pack);
|
setActivePack(pack);
|
||||||
|
setTokens(authTokens);
|
||||||
|
setTokenDrafts(
|
||||||
|
authTokens.reduce<Record<string, string>>((acc, token) => {
|
||||||
|
acc[token.id] = token.label;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -406,10 +473,29 @@ function SettingsWindow(): JSX.Element {
|
|||||||
active_sprite_pack: payload.active_sprite_pack,
|
active_sprite_pack: payload.active_sprite_pack,
|
||||||
scale: payload.scale,
|
scale: payload.scale,
|
||||||
visible: payload.visible,
|
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<number>("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) => {
|
.catch((err) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -499,6 +585,108 @@ function SettingsWindow(): JSX.Element {
|
|||||||
[withPending]
|
[withPending]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onAnimationSlowdownFactorChange = React.useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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<Record<string, string>>((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<Record<string, string>>((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<Record<string, string>>((acc, token) => {
|
||||||
|
acc[token.id] = token.label;
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[withPending]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="settings-root">
|
<main className="settings-root">
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
@@ -535,6 +723,21 @@ function SettingsWindow(): JSX.Element {
|
|||||||
onChange={onScaleChange}
|
onChange={onScaleChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>
|
||||||
|
Animation Slowdown Factor (higher = slower): x
|
||||||
|
{settings.tauri_animation_slowdown_factor}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={SLOWDOWN_FACTOR_MIN}
|
||||||
|
max={SLOWDOWN_FACTOR_MAX}
|
||||||
|
step={1}
|
||||||
|
value={settings.tauri_animation_slowdown_factor}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={onAnimationSlowdownFactorChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="toggle">
|
<label className="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -553,6 +756,68 @@ function SettingsWindow(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<span>Always on top</span>
|
<span>Always on top</span>
|
||||||
</label>
|
</label>
|
||||||
|
<section className="token-section">
|
||||||
|
<h2>API Tokens</h2>
|
||||||
|
<p className="token-help">
|
||||||
|
Use any listed token as `Authorization: Bearer <token>`.
|
||||||
|
</p>
|
||||||
|
<div className="token-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTokenLabel}
|
||||||
|
placeholder="New token label"
|
||||||
|
disabled={pending}
|
||||||
|
onChange={onNewTokenLabelChange}
|
||||||
|
/>
|
||||||
|
<button type="button" disabled={pending} onClick={onCreateToken}>
|
||||||
|
Create token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="token-list">
|
||||||
|
{tokens.map((entry) => (
|
||||||
|
<article className="token-item" key={entry.id}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Label</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tokenDrafts[entry.id] ?? ""}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) =>
|
||||||
|
onTokenDraftChange(entry.id, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Token</span>
|
||||||
|
<input type="text" value={entry.token} readOnly />
|
||||||
|
</label>
|
||||||
|
<div className="token-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onCopyToken(entry.token)}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onRenameToken(entry.id)}
|
||||||
|
>
|
||||||
|
Save label
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending || tokens.length <= 1}
|
||||||
|
onClick={() => onRevokeToken(entry.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const HALO_SAT_MIN = 0.15;
|
|||||||
const HALO_VAL_MIN = 0.04;
|
const HALO_VAL_MIN = 0.04;
|
||||||
const RENDER_FIT_PADDING = 16;
|
const RENDER_FIT_PADDING = 16;
|
||||||
const MIN_RENDER_SCALE = 0.01;
|
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 {
|
export class PixiPetRenderer {
|
||||||
private app: Application;
|
private app: Application;
|
||||||
@@ -63,6 +66,7 @@ export class PixiPetRenderer {
|
|||||||
private frameCursor = 0;
|
private frameCursor = 0;
|
||||||
private frameElapsedMs = 0;
|
private frameElapsedMs = 0;
|
||||||
private baseTexture: BaseTexture;
|
private baseTexture: BaseTexture;
|
||||||
|
private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -431,13 +435,24 @@ export class PixiPetRenderer {
|
|||||||
this.layoutSprite();
|
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 {
|
private startTicker(): void {
|
||||||
this.app.ticker.add((ticker) => {
|
this.app.ticker.add((ticker) => {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.layoutSprite();
|
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;
|
this.frameElapsedMs += ticker.deltaMS;
|
||||||
if (this.frameElapsedMs < frameMs) {
|
if (this.frameElapsedMs < frameMs) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-root {
|
.settings-root {
|
||||||
|
height: 100vh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -92,6 +93,8 @@ dd {
|
|||||||
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card {
|
.settings-card {
|
||||||
@@ -102,6 +105,7 @@ dd {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card h1 {
|
.settings-card h1 {
|
||||||
@@ -137,7 +141,8 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field select,
|
.field select,
|
||||||
.field input[type="range"] {
|
.field input[type="range"],
|
||||||
|
.field input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +154,15 @@ dd {
|
|||||||
color: #0f172a;
|
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 {
|
.toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -156,3 +170,64 @@ dd {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1e293b;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
68
issues/issue6.md
Normal file
68
issues/issue6.md
Normal file
@@ -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.
|
||||||
@@ -18,6 +18,27 @@ from typing import Any
|
|||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
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:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -205,9 +226,7 @@ def random_valid_command(rng: random.Random) -> dict[str, Any]:
|
|||||||
|
|
||||||
if pick == "play_animation":
|
if pick == "play_animation":
|
||||||
payload = {
|
payload = {
|
||||||
"name": rng.choice(
|
"name": rng.choice(ANIMATION_NAMES),
|
||||||
["idle", "dance", "typing", "celebrate", "error", "unknown_anim"]
|
|
||||||
),
|
|
||||||
"priority": rng.randint(0, 10),
|
"priority": rng.randint(0, 10),
|
||||||
"duration_ms": rng.choice([None, 250, 500, 1000, 3000]),
|
"duration_ms": rng.choice([None, 250, 500, 1000, 3000]),
|
||||||
"interrupt": rng.choice([None, True, False]),
|
"interrupt": rng.choice([None, True, False]),
|
||||||
|
|||||||
Reference in New Issue
Block a user