Compare commits
15 Commits
dev-bevy
...
5b0b0c7d41
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b0b0c7d41 | |||
|
|
fa508ced8c | ||
|
|
bed7a052f3 | ||
|
|
832fbda04d | ||
|
|
f20ed1fd9d | ||
|
|
e5417b6799 | ||
|
|
c0efb3915b | ||
| 927fe6641c | |||
|
|
eddf4b9481 | ||
|
|
f50243ab96 | ||
|
|
f2954ad22b | ||
|
|
1fa7080210 | ||
|
|
901bf0ffc3 | ||
|
|
907974e61f | ||
|
|
e5e123cc84 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6089,6 +6089,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 404 B |
@@ -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": {
|
||||
|
||||
90
assets/sprite-packs/demogorgon/manifest.json
Normal file
90
assets/sprite-packs/demogorgon/manifest.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "demogorgon",
|
||||
"version": "1",
|
||||
"image": "sprite.png",
|
||||
"frame_width": 512,
|
||||
"frame_height": 512,
|
||||
"animations": [
|
||||
{
|
||||
"name": "idle",
|
||||
"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": 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": 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": 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": {
|
||||
"x": 0.5,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
BIN
assets/sprite-packs/demogorgon/sprite.png
Normal file
BIN
assets/sprite-packs/demogorgon/sprite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 MiB |
90
assets/sprite-packs/ferris/manifest.json
Normal file
90
assets/sprite-packs/ferris/manifest.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "ferris",
|
||||
"version": "1",
|
||||
"image": "sprite.png",
|
||||
"frame_width": 512,
|
||||
"frame_height": 512,
|
||||
"animations": [
|
||||
{
|
||||
"name": "idle",
|
||||
"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": 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": 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": 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": {
|
||||
"x": 0.5,
|
||||
"y": 1.0
|
||||
}
|
||||
}
|
||||
BIN
assets/sprite-packs/ferris/sprite.png
Normal file
BIN
assets/sprite-packs/ferris/sprite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 MiB |
@@ -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<String>,
|
||||
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<String>) -> 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<RwLock<HashSet<String>>>,
|
||||
app_version: String,
|
||||
app_build: String,
|
||||
dedupe_capacity: usize,
|
||||
@@ -59,10 +64,14 @@ impl ApiState {
|
||||
config: ApiConfig,
|
||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||
) -> 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();
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,12 +187,16 @@ pub enum FrontendBackend {
|
||||
#[serde(default)]
|
||||
pub struct FrontendConfig {
|
||||
pub backend: FrontendBackend,
|
||||
pub debug_overlay_visible: bool,
|
||||
pub tauri_animation_slowdown_factor: u8,
|
||||
}
|
||||
|
||||
impl Default for FrontendConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: FrontendBackend::Bevy,
|
||||
debug_overlay_visible: false,
|
||||
tauri_animation_slowdown_factor: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,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};
|
||||
@@ -222,10 +262,14 @@ mod tests {
|
||||
let mut config = AppConfig::default();
|
||||
config.window.x = 42.0;
|
||||
config.frontend.backend = super::FrontendBackend::Tauri;
|
||||
config.frontend.debug_overlay_visible = true;
|
||||
config.frontend.tauri_animation_slowdown_factor = 7;
|
||||
|
||||
save(&path, &config).expect("save");
|
||||
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
||||
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
||||
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
|
||||
assert!(loaded.frontend.debug_overlay_visible);
|
||||
assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<RwLock<AppConfig>>,
|
||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||
api_config: ApiConfig,
|
||||
auth_store: Arc<RwLock<HashSet<String>>>,
|
||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
|
||||
}
|
||||
@@ -40,6 +50,7 @@ impl RuntimeCore {
|
||||
) -> Result<Self, RuntimeCoreError> {
|
||||
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)
|
||||
@@ -82,19 +109,160 @@ impl RuntimeCore {
|
||||
self.command_tx.clone()
|
||||
}
|
||||
|
||||
pub fn frontend_debug_overlay_visible(&self) -> Result<bool, RuntimeCoreError> {
|
||||
let guard = self
|
||||
.config
|
||||
.read()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
Ok(guard.frontend.debug_overlay_visible)
|
||||
}
|
||||
|
||||
pub fn set_frontend_debug_overlay_visible(
|
||||
&self,
|
||||
visible: bool,
|
||||
) -> Result<(), RuntimeCoreError> {
|
||||
{
|
||||
let mut guard = self
|
||||
.config
|
||||
.write()
|
||||
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||
guard.frontend.debug_overlay_visible = visible;
|
||||
}
|
||||
self.persist_config()
|
||||
}
|
||||
|
||||
pub fn frontend_tauri_animation_slowdown_factor(&self) -> Result<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 {
|
||||
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) {
|
||||
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 {
|
||||
@@ -222,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::<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 {
|
||||
@@ -230,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;
|
||||
@@ -262,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");
|
||||
@@ -283,4 +587,99 @@ mod tests {
|
||||
let config = core.config().read().expect("config lock").clone();
|
||||
assert!(!config.window.click_through);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_debug_overlay_visibility_roundtrips() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
core.set_frontend_debug_overlay_visible(true).expect("set");
|
||||
assert!(core.frontend_debug_overlay_visible().expect("get"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_tauri_animation_slowdown_factor_roundtrips() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
let persisted = core
|
||||
.set_frontend_tauri_animation_slowdown_factor(6)
|
||||
.expect("set");
|
||||
assert_eq!(persisted, 6);
|
||||
assert_eq!(
|
||||
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_tauri_animation_slowdown_factor_clamps() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
let persisted = core
|
||||
.set_frontend_tauri_animation_slowdown_factor(0)
|
||||
.expect("set");
|
||||
assert_eq!(persisted, 1);
|
||||
assert_eq!(
|
||||
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||
1
|
||||
);
|
||||
|
||||
let upper = core
|
||||
.set_frontend_tauri_animation_slowdown_factor(201)
|
||||
.expect("set high");
|
||||
assert_eq!(upper, 200);
|
||||
assert_eq!(
|
||||
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_token_create_rename_revoke_roundtrip() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
|
||||
let initial_len = core.list_api_tokens().expect("tokens").len();
|
||||
let created = core
|
||||
.create_api_token(Some("backend-ci".to_string()))
|
||||
.expect("create token");
|
||||
let after_create = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(after_create.len(), initial_len + 1);
|
||||
assert!(after_create.iter().any(|entry| entry.id == created.id));
|
||||
|
||||
core.rename_api_token(&created.id, "automation")
|
||||
.expect("rename token");
|
||||
let after_rename = core.list_api_tokens().expect("tokens");
|
||||
assert!(
|
||||
after_rename
|
||||
.iter()
|
||||
.any(|entry| entry.id == created.id && entry.label == "automation")
|
||||
);
|
||||
|
||||
core.revoke_api_token(&created.id).expect("revoke token");
|
||||
let after_revoke = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(after_revoke.len(), initial_len);
|
||||
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_revoke_last_api_token() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let path = temp.path().join("config.toml");
|
||||
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||
.expect("core init");
|
||||
let only = core.list_api_tokens().expect("tokens");
|
||||
assert_eq!(only.len(), 1);
|
||||
let err = core
|
||||
.revoke_api_token(&only[0].id)
|
||||
.expect_err("last token revoke must fail");
|
||||
assert!(format!("{err}").contains("last API token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ sprimo-platform = { path = "../sprimo-platform" }
|
||||
sprimo-sprite = { path = "../sprimo-sprite" }
|
||||
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
|
||||
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||
tauri = { version = "2.0.0", features = [] }
|
||||
tauri = { version = "2.0.0", features = ["tray-icon"] }
|
||||
tauri-plugin-global-shortcut = "2.0.0"
|
||||
tauri-plugin-log = "2.0.0"
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-current-monitor",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}}
|
||||
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:window:allow-current-monitor","core:event:allow-listen","core:event:allow-unlisten"]}}
|
||||
@@ -1,19 +1,37 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use sprimo_platform::{create_adapter, PlatformAdapter};
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use sprimo_platform::{PlatformAdapter, create_adapter};
|
||||
use sprimo_protocol::v1::FrontendCommand;
|
||||
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
|
||||
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
|
||||
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
|
||||
use sprimo_sprite::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry};
|
||||
use thiserror::Error;
|
||||
use tokio::runtime::Runtime;
|
||||
use tracing::warn;
|
||||
|
||||
const APP_NAME: &str = "sprimo";
|
||||
const DEFAULT_PACK: &str = "default";
|
||||
const SPRITE_PNG_FILE: &str = "sprite.png";
|
||||
const SPRITE_GRID_COLUMNS: u32 = 8;
|
||||
const SPRITE_GRID_ROWS: u32 = 7;
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
const TRAY_ID: &str = "main";
|
||||
const MENU_ID_SETTINGS: &str = "settings";
|
||||
const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility";
|
||||
const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top";
|
||||
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 {
|
||||
@@ -39,6 +57,12 @@ struct UiSpritePack {
|
||||
anchor: UiAnchor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct AtlasGeometry {
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSnapshot {
|
||||
state: String,
|
||||
@@ -47,6 +71,31 @@ struct UiSnapshot {
|
||||
y: f32,
|
||||
scale: f32,
|
||||
active_sprite_pack: String,
|
||||
visible: bool,
|
||||
always_on_top: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSettingsSnapshot {
|
||||
active_sprite_pack: String,
|
||||
scale: f32,
|
||||
visible: bool,
|
||||
always_on_top: bool,
|
||||
tauri_animation_slowdown_factor: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct UiSpritePackOption {
|
||||
id: 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)]
|
||||
@@ -63,6 +112,14 @@ enum AppError {
|
||||
struct AppState {
|
||||
runtime_core: Arc<RuntimeCore>,
|
||||
runtime: Arc<Runtime>,
|
||||
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrayMenuState {
|
||||
toggle_visibility: MenuItem<Wry>,
|
||||
toggle_always_on_top: CheckMenuItem<Wry>,
|
||||
toggle_debug_overlay: CheckMenuItem<Wry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -78,6 +135,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
|
||||
|
||||
#[tauri::command]
|
||||
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let config = state
|
||||
.runtime_core
|
||||
.config()
|
||||
@@ -85,11 +143,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
.map_err(|_| "config lock poisoned".to_string())?
|
||||
.clone();
|
||||
|
||||
let root = std::env::current_dir()
|
||||
.map_err(|err| err.to_string())?
|
||||
.join("assets")
|
||||
.join(config.sprite.sprite_packs_dir);
|
||||
|
||||
let selected = config.sprite.selected_pack;
|
||||
let pack_path = match resolve_pack_path(&root, &selected) {
|
||||
Ok(path) => path,
|
||||
@@ -99,6 +152,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
|
||||
let image_path = pack_path.join(&manifest.image);
|
||||
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
|
||||
let geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
|
||||
validate_animation_frames(&manifest)?;
|
||||
let atlas_data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(image_bytes)
|
||||
@@ -106,14 +161,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
|
||||
Ok(UiSpritePack {
|
||||
id: manifest.id,
|
||||
frame_width: manifest.frame_width,
|
||||
frame_height: manifest.frame_height,
|
||||
frame_width: geometry.frame_width,
|
||||
frame_height: geometry.frame_height,
|
||||
atlas_data_url,
|
||||
animations: manifest
|
||||
.animations
|
||||
.into_iter()
|
||||
.map(to_ui_clip)
|
||||
.collect(),
|
||||
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
|
||||
anchor: UiAnchor {
|
||||
x: manifest.anchor.x,
|
||||
y: manifest.anchor.y,
|
||||
@@ -121,6 +172,248 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnapshot, String> {
|
||||
let snapshot = state
|
||||
.runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.clone();
|
||||
Ok(UiSettingsSnapshot {
|
||||
active_sprite_pack: snapshot.active_sprite_pack,
|
||||
scale: snapshot.scale,
|
||||
visible: snapshot.flags.visible,
|
||||
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]
|
||||
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let mut packs = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(root).map_err(|err| err.to_string())?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|err| err.to_string())?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if load_manifest(&path).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
packs.push(UiSpritePackOption {
|
||||
id: dir_name.to_string(),
|
||||
pack_id_or_path: dir_name.to_string(),
|
||||
});
|
||||
}
|
||||
packs.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
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]
|
||||
fn set_sprite_pack(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
pack_id_or_path: String,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
let root = sprite_pack_root(state.runtime_core.as_ref())?;
|
||||
let pack_path = resolve_pack_path(&root, &pack_id_or_path).map_err(|err| err.to_string())?;
|
||||
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
|
||||
let image_path = pack_path.join(&manifest.image);
|
||||
let image_bytes = fs::read(&image_path).map_err(|err| err.to_string())?;
|
||||
let _geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
|
||||
validate_animation_frames(&manifest)?;
|
||||
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetSpritePack { pack_id_or_path })
|
||||
.map_err(|err| err.to_string())?;
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_scale(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
scale: f32,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("scale must be a positive finite number".to_string());
|
||||
}
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetTransform {
|
||||
x: None,
|
||||
y: None,
|
||||
anchor: None,
|
||||
scale: Some(scale),
|
||||
opacity: None,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_visibility(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
visible: bool,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: None,
|
||||
visible: Some(visible),
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if visible {
|
||||
window.show().map_err(|err| err.to_string())?;
|
||||
window.set_focus().map_err(|err| err.to_string())?;
|
||||
} else {
|
||||
window.hide().map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_always_on_top(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
always_on_top: bool,
|
||||
) -> Result<UiSnapshot, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: Some(always_on_top),
|
||||
visible: None,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
window
|
||||
.set_always_on_top(always_on_top)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
emit_ui_refresh(state.inner(), &app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_debug_overlay_visible(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
visible: bool,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.runtime_core
|
||||
.set_frontend_debug_overlay_visible(visible)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle);
|
||||
if let Ok(guard) = state.tray_state.lock() {
|
||||
if let Some(tray_state) = guard.as_ref() {
|
||||
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
|
||||
}
|
||||
}
|
||||
Ok(visible)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), AppError> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("sprimo=info")
|
||||
@@ -136,25 +429,54 @@ fn main() -> Result<(), AppError> {
|
||||
let state = AppState {
|
||||
runtime_core: Arc::clone(&runtime_core),
|
||||
runtime: Arc::clone(&runtime),
|
||||
tray_state: Arc::new(std::sync::Mutex::new(None)),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![current_state, load_active_sprite_pack])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
current_state,
|
||||
load_active_sprite_pack,
|
||||
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,
|
||||
set_always_on_top
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_state: tauri::State<'_, AppState> = app.state();
|
||||
let runtime_core = Arc::clone(&app_state.runtime_core);
|
||||
let runtime = Arc::clone(&app_state.runtime);
|
||||
let tray_state_holder = Arc::clone(&app_state.tray_state);
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot));
|
||||
let tray_state = setup_tray(&app_handle, &runtime_core)?;
|
||||
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));
|
||||
}
|
||||
let _ = emit_debug_overlay_visibility(runtime_core.as_ref(), &app_handle);
|
||||
let _ = refresh_tray_menu_state(runtime_core.as_ref(), &tray_state);
|
||||
|
||||
let command_rx = runtime_core.command_receiver();
|
||||
let runtime_core_for_commands = Arc::clone(&runtime_core);
|
||||
let app_handle_for_commands = app_handle.clone();
|
||||
let tray_state_for_commands = tray_state.clone();
|
||||
runtime.spawn(async move {
|
||||
loop {
|
||||
let next = {
|
||||
@@ -178,7 +500,11 @@ fn main() -> Result<(), AppError> {
|
||||
}
|
||||
};
|
||||
if let Some(value) = payload {
|
||||
let _ = app_handle_for_commands.emit("runtime:snapshot", value);
|
||||
let _ = app_handle_for_commands.emit(EVENT_RUNTIME_SNAPSHOT, value);
|
||||
let _ = refresh_tray_menu_state(
|
||||
runtime_core_for_commands.as_ref(),
|
||||
&tray_state_for_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -186,6 +512,7 @@ fn main() -> Result<(), AppError> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let runtime_core = Arc::clone(&runtime_core);
|
||||
let app_handle = app_handle.clone();
|
||||
let tray_state_for_window = tray_state.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::Moved(position) = event {
|
||||
let command = sprimo_protocol::v1::FrontendCommand::SetTransform {
|
||||
@@ -197,13 +524,32 @@ fn main() -> Result<(), AppError> {
|
||||
};
|
||||
if runtime_core.apply_command(&command).is_ok() {
|
||||
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot));
|
||||
let _ = app_handle
|
||||
.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
|
||||
}
|
||||
let _ = refresh_tray_menu_state(
|
||||
runtime_core.as_ref(),
|
||||
&tray_state_for_window,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let runtime_core_for_menu = Arc::clone(&runtime_core);
|
||||
let app_handle_for_menu = app_handle.clone();
|
||||
let tray_state_for_menu = tray_state.clone();
|
||||
app.on_menu_event(move |_app, event| {
|
||||
if let Err(err) = handle_menu_event(
|
||||
runtime_core_for_menu.as_ref(),
|
||||
&app_handle_for_menu,
|
||||
&tray_state_for_menu,
|
||||
event.id().as_ref(),
|
||||
) {
|
||||
warn!(%err, "tray/menu action failed");
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app;
|
||||
Ok(())
|
||||
})
|
||||
@@ -220,9 +566,289 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
|
||||
y: snapshot.y,
|
||||
scale: snapshot.scale,
|
||||
active_sprite_pack: snapshot.active_sprite_pack.clone(),
|
||||
visible: snapshot.flags.visible,
|
||||
always_on_top: snapshot.flags.always_on_top,
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let sprite_packs_dir = runtime_core
|
||||
.config()
|
||||
.read()
|
||||
.map_err(|_| "config lock poisoned".to_string())?
|
||||
.sprite
|
||||
.sprite_packs_dir
|
||||
.clone();
|
||||
let root = std::env::current_dir()
|
||||
.map_err(|err| err.to_string())?
|
||||
.join("assets")
|
||||
.join(sprite_packs_dir);
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn emit_ui_refresh(state: &AppState, app_handle: &AppHandle<Wry>) -> Result<UiSnapshot, String> {
|
||||
let snapshot = state
|
||||
.runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.clone();
|
||||
let ui_snapshot = to_ui_snapshot(&snapshot);
|
||||
app_handle
|
||||
.emit(EVENT_RUNTIME_SNAPSHOT, ui_snapshot.clone())
|
||||
.map_err(|err| err.to_string())?;
|
||||
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), app_handle);
|
||||
if let Ok(guard) = state.tray_state.lock() {
|
||||
if let Some(tray_state) = guard.as_ref() {
|
||||
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
|
||||
}
|
||||
}
|
||||
Ok(ui_snapshot)
|
||||
}
|
||||
|
||||
fn setup_tray(
|
||||
app_handle: &AppHandle<Wry>,
|
||||
runtime_core: &RuntimeCore,
|
||||
) -> Result<TrayMenuState, tauri::Error> {
|
||||
let snapshot = runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))?
|
||||
.clone();
|
||||
let debug_overlay_visible = runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
|
||||
let settings = MenuItem::with_id(app_handle, MENU_ID_SETTINGS, "Settings", true, None::<&str>)?;
|
||||
let toggle_visibility = MenuItem::with_id(
|
||||
app_handle,
|
||||
MENU_ID_TOGGLE_VISIBILITY,
|
||||
visibility_menu_title(snapshot.flags.visible),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_always_on_top = CheckMenuItem::with_id(
|
||||
app_handle,
|
||||
MENU_ID_TOGGLE_ALWAYS_ON_TOP,
|
||||
"Always on top",
|
||||
true,
|
||||
snapshot.flags.always_on_top,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_debug_overlay = CheckMenuItem::with_id(
|
||||
app_handle,
|
||||
MENU_ID_TOGGLE_DEBUG_OVERLAY,
|
||||
"Debug overlay",
|
||||
true,
|
||||
debug_overlay_visible,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(app_handle, MENU_ID_QUIT, "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
app_handle,
|
||||
&[
|
||||
&settings,
|
||||
&toggle_visibility,
|
||||
&toggle_always_on_top,
|
||||
&toggle_debug_overlay,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
let mut builder = TrayIconBuilder::with_id(TRAY_ID).menu(&menu);
|
||||
if let Some(icon) = app_handle.default_window_icon().cloned() {
|
||||
builder = builder.icon(icon);
|
||||
}
|
||||
builder
|
||||
.tooltip("sprimo-tauri")
|
||||
.show_menu_on_left_click(true)
|
||||
.build(app_handle)?;
|
||||
|
||||
Ok(TrayMenuState {
|
||||
toggle_visibility,
|
||||
toggle_always_on_top,
|
||||
toggle_debug_overlay,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_menu_event(
|
||||
runtime_core: &RuntimeCore,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
tray_state: &TrayMenuState,
|
||||
menu_id: &str,
|
||||
) -> Result<(), String> {
|
||||
match menu_id {
|
||||
MENU_ID_SETTINGS => {
|
||||
open_settings_window(app_handle).map_err(|err| err.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
MENU_ID_TOGGLE_VISIBILITY => {
|
||||
let current = runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.flags
|
||||
.visible;
|
||||
let next = !current;
|
||||
runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: None,
|
||||
visible: Some(next),
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if next {
|
||||
window.show().map_err(|err| err.to_string())?;
|
||||
window.set_focus().map_err(|err| err.to_string())?;
|
||||
} else {
|
||||
window.hide().map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MENU_ID_TOGGLE_ALWAYS_ON_TOP => {
|
||||
let current = runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.flags
|
||||
.always_on_top;
|
||||
let next = !current;
|
||||
runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: Some(next),
|
||||
visible: None,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
window
|
||||
.set_always_on_top(next)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
}
|
||||
MENU_ID_TOGGLE_DEBUG_OVERLAY => {
|
||||
let current = runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| err.to_string())?;
|
||||
let next = !current;
|
||||
runtime_core
|
||||
.set_frontend_debug_overlay_visible(next)
|
||||
.map_err(|err| err.to_string())?;
|
||||
emit_debug_overlay_visibility(runtime_core, app_handle)
|
||||
.map_err(|err| err.to_string())?;
|
||||
}
|
||||
MENU_ID_QUIT => {
|
||||
persist_current_ui_flags(runtime_core)?;
|
||||
app_handle.exit(0);
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
|
||||
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
|
||||
}
|
||||
let _ = emit_debug_overlay_visibility(runtime_core, app_handle);
|
||||
let _ = refresh_tray_menu_state(runtime_core, tray_state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_settings_window(app_handle: &AppHandle<Wry>) -> Result<(), tauri::Error> {
|
||||
if let Some(window) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) {
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let window = WebviewWindowBuilder::new(
|
||||
app_handle,
|
||||
SETTINGS_WINDOW_LABEL,
|
||||
WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("sprimo settings")
|
||||
.inner_size(420.0, 520.0)
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.transparent(false)
|
||||
.always_on_top(false)
|
||||
.visible(true)
|
||||
.build()?;
|
||||
window.set_focus()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
|
||||
let snapshot = runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||
.clone();
|
||||
runtime_core
|
||||
.apply_command(&FrontendCommand::SetFlags {
|
||||
click_through: None,
|
||||
always_on_top: Some(snapshot.flags.always_on_top),
|
||||
visible: Some(snapshot.flags.visible),
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
let debug_overlay = runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| err.to_string())?;
|
||||
runtime_core
|
||||
.set_frontend_debug_overlay_visible(debug_overlay)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn emit_debug_overlay_visibility(
|
||||
runtime_core: &RuntimeCore,
|
||||
app_handle: &AppHandle<Wry>,
|
||||
) -> Result<(), tauri::Error> {
|
||||
let value = runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
app_handle.emit(EVENT_RUNTIME_DEBUG_OVERLAY_VISIBLE, value)
|
||||
}
|
||||
|
||||
fn refresh_tray_menu_state(
|
||||
runtime_core: &RuntimeCore,
|
||||
tray_state: &TrayMenuState,
|
||||
) -> Result<(), tauri::Error> {
|
||||
let snapshot = runtime_core
|
||||
.snapshot()
|
||||
.read()
|
||||
.map_err(|_| tauri::Error::AssetNotFound("snapshot lock poisoned".to_string()))?
|
||||
.clone();
|
||||
let debug_overlay_visible = runtime_core
|
||||
.frontend_debug_overlay_visible()
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
|
||||
tray_state
|
||||
.toggle_visibility
|
||||
.set_text(visibility_menu_title(snapshot.flags.visible))
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
tray_state
|
||||
.toggle_always_on_top
|
||||
.set_checked(snapshot.flags.always_on_top)
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
tray_state
|
||||
.toggle_debug_overlay
|
||||
.set_checked(debug_overlay_visible)
|
||||
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn visibility_menu_title(visible: bool) -> &'static str {
|
||||
if visible { "Hide" } else { "Show" }
|
||||
}
|
||||
|
||||
fn state_name(value: FrontendState) -> &'static str {
|
||||
match value {
|
||||
FrontendState::Idle => "idle",
|
||||
@@ -242,3 +868,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
|
||||
one_shot: value.one_shot.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn atlas_geometry_for_manifest(
|
||||
manifest: &SpritePackManifest,
|
||||
image_bytes: &[u8],
|
||||
) -> Result<AtlasGeometry, String> {
|
||||
let (image_width, image_height) = decode_png_dimensions(image_bytes)?;
|
||||
if image_width == 0 || image_height == 0 {
|
||||
return Err("atlas image dimensions must be non-zero".to_string());
|
||||
}
|
||||
|
||||
if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
|
||||
let frame_width = image_width / SPRITE_GRID_COLUMNS;
|
||||
let frame_height = image_height / SPRITE_GRID_ROWS;
|
||||
if frame_width == 0 || frame_height == 0 {
|
||||
return Err(format!(
|
||||
"sprite atlas too small for {}x{} grid: {}x{}",
|
||||
SPRITE_GRID_COLUMNS, SPRITE_GRID_ROWS, image_width, image_height
|
||||
));
|
||||
}
|
||||
return Ok(AtlasGeometry {
|
||||
frame_width,
|
||||
frame_height,
|
||||
});
|
||||
}
|
||||
|
||||
if manifest.frame_width == 0 || manifest.frame_height == 0 {
|
||||
return Err("manifest frame dimensions must be non-zero".to_string());
|
||||
}
|
||||
Ok(AtlasGeometry {
|
||||
frame_width: manifest.frame_width,
|
||||
frame_height: manifest.frame_height,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_animation_frames(manifest: &SpritePackManifest) -> Result<(), String> {
|
||||
let total_frames = if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
|
||||
SPRITE_GRID_COLUMNS
|
||||
.checked_mul(SPRITE_GRID_ROWS)
|
||||
.ok_or_else(|| "sprite grid frame count overflow".to_string())?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if total_frames == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for clip in &manifest.animations {
|
||||
for &index in &clip.frames {
|
||||
if index >= total_frames {
|
||||
return Err(format!(
|
||||
"animation '{}' references frame {} but max frame index is {}",
|
||||
clip.name,
|
||||
index,
|
||||
total_frames.saturating_sub(1)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_png_dimensions(image_bytes: &[u8]) -> Result<(u32, u32), String> {
|
||||
const PNG_SIGNATURE_LEN: usize = 8;
|
||||
const PNG_IHDR_TOTAL_LEN: usize = 33;
|
||||
const IHDR_TYPE_OFFSET: usize = 12;
|
||||
const IHDR_DATA_OFFSET: usize = 16;
|
||||
const IHDR_WIDTH_OFFSET: usize = 16;
|
||||
const IHDR_HEIGHT_OFFSET: usize = 20;
|
||||
|
||||
if image_bytes.len() < PNG_IHDR_TOTAL_LEN {
|
||||
return Err("atlas image is too small to be a valid PNG".to_string());
|
||||
}
|
||||
let expected_signature: [u8; PNG_SIGNATURE_LEN] = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
if image_bytes[..PNG_SIGNATURE_LEN] != expected_signature {
|
||||
return Err("atlas image must be PNG format".to_string());
|
||||
}
|
||||
if &image_bytes[IHDR_TYPE_OFFSET..IHDR_DATA_OFFSET] != b"IHDR" {
|
||||
return Err("atlas PNG missing IHDR chunk".to_string());
|
||||
}
|
||||
let width = u32::from_be_bytes(
|
||||
image_bytes[IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 4]
|
||||
.try_into()
|
||||
.map_err(|_| "failed to decode PNG width".to_string())?,
|
||||
);
|
||||
let height = u32::from_be_bytes(
|
||||
image_bytes[IHDR_HEIGHT_OFFSET..IHDR_HEIGHT_OFFSET + 4]
|
||||
.try_into()
|
||||
.map_err(|_| "failed to decode PNG height".to_string())?,
|
||||
);
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"height": 416,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"shadow": false,
|
||||
"alwaysOnTop": true,
|
||||
"resizable": false
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ recovery_hotkey = "Ctrl+Alt+P"
|
||||
|
||||
[frontend]
|
||||
backend = "bevy"
|
||||
debug_overlay_visible = false
|
||||
tauri_animation_slowdown_factor = 3
|
||||
```
|
||||
|
||||
## Notes
|
||||
@@ -48,3 +50,7 @@ backend = "bevy"
|
||||
- `window.click_through` is deprecated and ignored at runtime; it is always forced to `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`
|
||||
|
||||
@@ -19,13 +19,13 @@ Date: 2026-02-12
|
||||
| Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant |
|
||||
| QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` |
|
||||
| Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring |
|
||||
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale updates now auto-fit window to avoid top clipping |
|
||||
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, pop-out settings window (character/scale/visibility/always-on-top), persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented |
|
||||
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
|
||||
|
||||
## Next Major Gaps
|
||||
|
||||
1. Tray/menu controls are still not implemented.
|
||||
1. Tauri tray/menu behavior still needs Linux/macOS parity validation beyond Windows-first implementation.
|
||||
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
|
||||
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
|
||||
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
|
||||
5. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.
|
||||
5. `sprimo-tauri` still lacks cross-platform tray/menu parity and full acceptance parity tests.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -125,6 +125,26 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`
|
||||
9. Verify scale-fit behavior in tauri runtime:
|
||||
- send `SetTransform.scale` values above `1.0`
|
||||
- confirm full sprite remains visible and window auto-resizes without top clipping
|
||||
10. Verify pop-out settings window behavior:
|
||||
- open via tray `Settings`
|
||||
- switch character and confirm immediate renderer reload + persistence after restart
|
||||
- change scale slider and confirm runtime resize + persistence
|
||||
- toggle `Visible` and `Always on top` and confirm both runtime behavior + persistence
|
||||
11. Verify packaged tauri reload stability:
|
||||
- run repeated character switch cycles (`default` <-> `ferris`) and move scale slider each cycle
|
||||
- ensure no runtime frontend exception is shown (debug overlay/console)
|
||||
- ensure no visible magenta fringe remains around sprite edges after chroma-key conversion
|
||||
12. Verify packaged tauri scale anchoring and bounds:
|
||||
- repeated scale changes resize around window center (no consistent bottom-right drift)
|
||||
- window remains visible on the current monitor (no off-screen drift)
|
||||
- no runtime scale-path exception appears (for example monitor lookup API errors)
|
||||
- no runtime position-arg exceptions appear during scale (e.g. float passed to integer position API)
|
||||
- at large scale values (>= 1.8), full sprite remains visible without clipping
|
||||
13. Verify packaged tauri frontend freshness:
|
||||
- confirm package run reflects latest `frontend/tauri-ui` changes (no stale embedded UI bundle)
|
||||
14. Verify packaged tauri overlay edge and scale consistency:
|
||||
- place overlay over dark background and confirm no white strip/background bleed on left edge
|
||||
- for same slider value, confirm main-window size is consistent across `default`/`ferris`/`demogorgon`
|
||||
|
||||
### Packaged Mode (Required Once Tauri Packaging Exists)
|
||||
|
||||
|
||||
@@ -64,3 +64,36 @@ Path: `<pack_dir>/manifest.json`
|
||||
- `rows = image_height / frame_height`
|
||||
- Image dimensions must be divisible by frame dimensions.
|
||||
- Every animation frame index must be `< columns * rows`.
|
||||
|
||||
## Tauri `sprite.png` Override
|
||||
|
||||
For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`:
|
||||
|
||||
- runtime uses a fixed grid topology of `8` columns x `7` rows.
|
||||
- frame size is derived from actual image dimensions:
|
||||
- `frame_width = image_width / 8`
|
||||
- `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
|
||||
|
||||
@@ -47,16 +47,44 @@ Frontend:
|
||||
- Tauri backend exposes:
|
||||
- `current_state` command (structured snapshot DTO)
|
||||
- `load_active_sprite_pack` command (manifest + atlas as base64 data URL)
|
||||
- settings commands:
|
||||
- `settings_snapshot`
|
||||
- `list_sprite_packs`
|
||||
- `set_sprite_pack`
|
||||
- `set_scale`
|
||||
- `set_visibility`
|
||||
- `set_always_on_top`
|
||||
- `debug_overlay_visible` / `set_debug_overlay_visible` commands for persisted debug panel control
|
||||
- `runtime:snapshot` event after command application.
|
||||
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
|
||||
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
|
||||
- Tauri window drag is implemented for undecorated mode:
|
||||
- left-mouse drag starts native window dragging
|
||||
- moved position is synced into runtime-core snapshot/config state.
|
||||
- Windows-first tray/menu MVP is implemented:
|
||||
- `Settings` (opens/focuses pop-out settings window)
|
||||
- `Show/Hide`
|
||||
- `Always on top` toggle
|
||||
- `Debug overlay` toggle
|
||||
- `Quit`
|
||||
- Bevy frontend remains intact.
|
||||
- Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
1. Add tray/menu parity and window behavior parity with Bevy path.
|
||||
1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps.
|
||||
2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).
|
||||
3. Add sprite-pack previews/thumbnails in the settings window character selector.
|
||||
|
||||
@@ -74,6 +74,16 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
|
||||
- left-mouse drag moves the window
|
||||
- window remains non-resizable
|
||||
- moved position is reflected in runtime snapshot state (`x`, `y`) and persists after restart
|
||||
9. Verify debug-overlay visibility control:
|
||||
- default startup behavior follows `frontend.debug_overlay_visible` config
|
||||
- `debug_overlay_visible`/`set_debug_overlay_visible` invoke commands toggle panel at runtime
|
||||
- toggle state persists after restart
|
||||
10. Verify Windows tray/menu controls:
|
||||
- tray left-click opens menu without directly toggling visibility
|
||||
- `Show/Hide` toggles window visibility and persists state
|
||||
- `Always on top` toggles top-most behavior and persists state
|
||||
- `Debug overlay` toggles panel visibility and persists state
|
||||
- `Quit` exits cleanly and preserves current persisted visibility/top-most/debug settings
|
||||
|
||||
## API + Runtime Contract Checklist
|
||||
|
||||
@@ -90,7 +100,59 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
|
||||
6. Validate Tauri invoke/event behavior:
|
||||
- `current_state` output parsed successfully.
|
||||
- `load_active_sprite_pack` returns expected fields.
|
||||
- `settings_snapshot` returns valid persisted settings payload.
|
||||
- `list_sprite_packs` returns valid manifest-backed pack options.
|
||||
- `set_sprite_pack` changes active pack and persists.
|
||||
- `set_scale` updates scale and persists.
|
||||
- `set_visibility` updates main window visibility and persists.
|
||||
- `set_always_on_top` updates top-most behavior and persists.
|
||||
- `runtime:snapshot` event received on runtime command changes.
|
||||
- `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config.
|
||||
7. Stress runtime reload stability:
|
||||
- perform at least 10 cycles of character switch (`default` <-> `ferris`) with scale adjustments
|
||||
- no frontend runtime exception (including `TypeError`) is allowed
|
||||
- scaling behavior remains responsive after each pack switch
|
||||
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. 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`
|
||||
API mismatch)
|
||||
- verify window resize uses consistent coordinate units (no accumulated drift over 20 scale changes)
|
||||
- no runtime command/type error from position updates (e.g. `set_position` expects integer coords)
|
||||
- at the same slider scale value, main window size is consistent across packs (`default`, `ferris`,
|
||||
`demogorgon`) within 1px rounding tolerance
|
||||
- no white strip/background bleed is visible along any overlay window edge on dark desktop background
|
||||
|
||||
## Settings Window Checklist
|
||||
|
||||
1. Open settings from tray `Settings` item.
|
||||
2. Confirm repeated tray clicks focus existing settings window instead of creating duplicates.
|
||||
3. Change character in settings and verify:
|
||||
- active pack changes immediately in main overlay
|
||||
- selection persists after restart
|
||||
4. Change scale via slider and verify:
|
||||
- runtime scale changes immediately
|
||||
- main overlay auto-fits without clipping
|
||||
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
|
||||
- value persists after restart
|
||||
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
|
||||
7. Toggle `Always on top` and verify:
|
||||
- main window z-order behavior updates
|
||||
- persisted value survives restart
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
|
||||
@@ -2,111 +2,344 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
PhysicalPosition,
|
||||
PhysicalSize,
|
||||
currentMonitor,
|
||||
getCurrentWindow,
|
||||
monitorFromPoint
|
||||
} from "@tauri-apps/api/window";
|
||||
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
|
||||
import "./styles.css";
|
||||
|
||||
type UiSettingsSnapshot = {
|
||||
active_sprite_pack: string;
|
||||
scale: number;
|
||||
visible: boolean;
|
||||
always_on_top: boolean;
|
||||
tauri_animation_slowdown_factor: number;
|
||||
};
|
||||
|
||||
type UiSpritePackOption = {
|
||||
id: string;
|
||||
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;
|
||||
const SIZE_EPSILON = 0.5;
|
||||
const SCALE_EPSILON = 0.0001;
|
||||
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<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
||||
}
|
||||
|
||||
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_scale", { scale });
|
||||
}
|
||||
|
||||
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
|
||||
return invoke<UiSnapshot>("set_visibility", { visible });
|
||||
}
|
||||
|
||||
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
|
||||
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(
|
||||
frameWidth: number,
|
||||
frameHeight: number,
|
||||
scale: number
|
||||
): { width: number; height: number } {
|
||||
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
const width = Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||
const height = Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||
const width = Math.round(
|
||||
Math.max(LOGICAL_BASE_FRAME_WIDTH * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
|
||||
);
|
||||
const height = Math.round(
|
||||
Math.max(LOGICAL_BASE_FRAME_HEIGHT * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
|
||||
);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
|
||||
const window = getCurrentWindow();
|
||||
const [outerPosition, innerSize] = await Promise.all([
|
||||
window.outerPosition(),
|
||||
window.innerSize()
|
||||
]);
|
||||
|
||||
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
|
||||
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
|
||||
const heightChanged = Math.abs(target.height - innerSize.height) > SIZE_EPSILON;
|
||||
if (!widthChanged && !heightChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaWidth = target.width - innerSize.width;
|
||||
const deltaHeight = target.height - innerSize.height;
|
||||
const targetX = outerPosition.x - deltaWidth / 2;
|
||||
const targetY = outerPosition.y - deltaHeight;
|
||||
|
||||
await window.setSize(new LogicalSize(target.width, target.height));
|
||||
await window.setPosition(new LogicalPosition(targetX, targetY));
|
||||
function effectiveScaleForWindowSize(width: number, height: number): number {
|
||||
const availableWidth = Math.max(width - WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||
const availableHeight = Math.max(height - WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||
const scaleByWidth = availableWidth / LOGICAL_BASE_FRAME_WIDTH;
|
||||
const scaleByHeight = availableHeight / LOGICAL_BASE_FRAME_HEIGHT;
|
||||
const scale = Math.min(scaleByWidth, scaleByHeight);
|
||||
return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX));
|
||||
}
|
||||
|
||||
function App(): JSX.Element {
|
||||
async function fitWindowForScale(scale: number): Promise<number> {
|
||||
const window = getCurrentWindow();
|
||||
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
|
||||
const target = fittedWindowSize(scale);
|
||||
const centerX = outerPosition.x + innerSize.width / 2;
|
||||
const centerY = outerPosition.y + innerSize.height / 2;
|
||||
let targetWidth = target.width;
|
||||
let targetHeight = target.height;
|
||||
let targetX = centerX - targetWidth / 2;
|
||||
let targetY = centerY - targetHeight / 2;
|
||||
let monitor:
|
||||
| {
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
workArea: { position: { x: number; y: number }; size: { width: number; height: number } };
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor());
|
||||
} catch {
|
||||
monitor = null;
|
||||
}
|
||||
|
||||
if (monitor !== null) {
|
||||
const widthCap = Math.max(
|
||||
monitor.workArea.size.width - WINDOW_WORKAREA_MARGIN,
|
||||
MIN_WINDOW_SIZE
|
||||
);
|
||||
const heightCap = Math.max(
|
||||
monitor.workArea.size.height - WINDOW_WORKAREA_MARGIN,
|
||||
MIN_WINDOW_SIZE
|
||||
);
|
||||
targetWidth = Math.min(targetWidth, widthCap);
|
||||
targetHeight = Math.min(targetHeight, heightCap);
|
||||
targetX = centerX - targetWidth / 2;
|
||||
targetY = centerY - targetHeight / 2;
|
||||
}
|
||||
|
||||
const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON;
|
||||
const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON;
|
||||
if (widthChanged || heightChanged) {
|
||||
await window.setSize(new PhysicalSize(targetWidth, targetHeight));
|
||||
if (monitor !== null) {
|
||||
const minX = Math.round(monitor.workArea.position.x);
|
||||
const minY = Math.round(monitor.workArea.position.y);
|
||||
const maxX = Math.round(
|
||||
monitor.workArea.position.x + monitor.workArea.size.width - targetWidth
|
||||
);
|
||||
const maxY = Math.round(
|
||||
monitor.workArea.position.y + monitor.workArea.size.height - targetHeight
|
||||
);
|
||||
targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX);
|
||||
targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY);
|
||||
}
|
||||
await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY)));
|
||||
}
|
||||
|
||||
return effectiveScaleForWindowSize(targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
function MainOverlayWindow(): JSX.Element {
|
||||
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
|
||||
const scaleFitRef = React.useRef<number | null>(null);
|
||||
const activePackRef = React.useRef<UiSpritePack | null>(null);
|
||||
const loadedPackKeyRef = React.useRef<string | null>(null);
|
||||
const effectiveScaleSyncRef = React.useRef<number | null>(null);
|
||||
const slowdownFactorRef = React.useRef<number>(SLOWDOWN_FACTOR_DEFAULT);
|
||||
const loadingPackRef = React.useRef(false);
|
||||
const mountedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let unlisten: null | (() => void) = null;
|
||||
let mounted = true;
|
||||
let activePack: UiSpritePack | null = null;
|
||||
|
||||
const recreateRenderer = async (
|
||||
pack: UiSpritePack,
|
||||
nextSnapshot: UiSnapshot
|
||||
): Promise<boolean> => {
|
||||
if (!mountedRef.current || hostRef.current === null) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
previousRenderer?.dispose();
|
||||
return true;
|
||||
};
|
||||
|
||||
const tryFitWindow = async (scale: number): Promise<number | null> => {
|
||||
try {
|
||||
return await fitWindowForScale(scale);
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(String(err));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise<void> => {
|
||||
if (Math.abs(snapshotScale - effectiveScale) < SCALE_EPSILON) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
effectiveScaleSyncRef.current !== null &&
|
||||
Math.abs(effectiveScaleSyncRef.current - effectiveScale) < SCALE_EPSILON
|
||||
) {
|
||||
return;
|
||||
}
|
||||
effectiveScaleSyncRef.current = effectiveScale;
|
||||
try {
|
||||
await invokeSetScale(effectiveScale);
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
effectiveScaleSyncRef.current !== null &&
|
||||
Math.abs(effectiveScaleSyncRef.current - value.scale) < SCALE_EPSILON
|
||||
) {
|
||||
effectiveScaleSyncRef.current = null;
|
||||
}
|
||||
setSnapshot(value);
|
||||
rendererRef.current?.applySnapshot(value);
|
||||
|
||||
const activePack = activePackRef.current;
|
||||
const needsReload =
|
||||
activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack;
|
||||
if (needsReload && !loadingPackRef.current) {
|
||||
loadingPackRef.current = true;
|
||||
let reloaded = false;
|
||||
try {
|
||||
const pack = await invoke<UiSpritePack>("load_active_sprite_pack");
|
||||
reloaded = await recreateRenderer(pack, value);
|
||||
if (reloaded) {
|
||||
const effectiveScale = await tryFitWindow(value.scale);
|
||||
if (effectiveScale !== null) {
|
||||
await syncEffectiveScale(value.scale, effectiveScale);
|
||||
}
|
||||
if (mountedRef.current && effectiveScale !== null) {
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
console.error("reload_pack_failed", err);
|
||||
setError(String(err));
|
||||
}
|
||||
} finally {
|
||||
loadingPackRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (activePackRef.current === null) {
|
||||
return;
|
||||
}
|
||||
const effectiveScale = await tryFitWindow(value.scale);
|
||||
if (effectiveScale !== null) {
|
||||
await syncEffectiveScale(value.scale, effectiveScale);
|
||||
}
|
||||
if (effectiveScale !== null && mountedRef.current) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invoke<UiSnapshot>("current_state")
|
||||
invoke<UiSnapshot>("current_state"),
|
||||
invoke<boolean>("debug_overlay_visible"),
|
||||
invokeAnimationSlowdownFactor()
|
||||
])
|
||||
.then(async ([pack, initialSnapshot]) => {
|
||||
if (!mounted) {
|
||||
.then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
activePack = pack;
|
||||
setSnapshot(initialSnapshot);
|
||||
if (hostRef.current !== null) {
|
||||
rendererRef.current = await PixiPetRenderer.create(
|
||||
hostRef.current,
|
||||
pack,
|
||||
initialSnapshot
|
||||
);
|
||||
}
|
||||
scaleFitRef.current = initialSnapshot.scale;
|
||||
await fitWindowForScale(pack, initialSnapshot.scale);
|
||||
setDebugOverlayVisible(showDebug);
|
||||
slowdownFactorRef.current = Math.min(
|
||||
Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
|
||||
SLOWDOWN_FACTOR_MAX
|
||||
);
|
||||
await recreateRenderer(pack, initialSnapshot);
|
||||
await processSnapshot(initialSnapshot);
|
||||
|
||||
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const value = event.payload;
|
||||
setSnapshot(value);
|
||||
rendererRef.current?.applySnapshot(value);
|
||||
if (activePack === null) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
scaleFitRef.current !== null &&
|
||||
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scaleFitRef.current = value.scale;
|
||||
void fitWindowForScale(activePack, value.scale).catch((err) => {
|
||||
if (mounted) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||
void processSnapshot(event.payload);
|
||||
});
|
||||
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
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 = () => {
|
||||
unlistenSnapshot();
|
||||
unlistenDebug();
|
||||
unlistenSlowdown();
|
||||
};
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mounted) {
|
||||
if (mountedRef.current) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
mountedRef.current = false;
|
||||
if (unlisten !== null) {
|
||||
unlisten();
|
||||
}
|
||||
@@ -115,6 +348,32 @@ function App(): JSX.Element {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDebugOverlay = React.useCallback(async () => {
|
||||
try {
|
||||
const next = !debugOverlayVisible;
|
||||
const persisted = await invoke<boolean>("set_debug_overlay_visible", {
|
||||
visible: next
|
||||
});
|
||||
setDebugOverlayVisible(persisted);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
}, [debugOverlayVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (!event.ctrlKey || !event.shiftKey || event.code !== "KeyD") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void toggleDebugOverlay();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [toggleDebugOverlay]);
|
||||
|
||||
const onMouseDown = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
@@ -125,36 +384,457 @@ function App(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="app" onMouseDown={onMouseDown}>
|
||||
<main className="app overlay-app" onMouseDown={onMouseDown}>
|
||||
<div className="canvas-host" ref={hostRef} />
|
||||
<section className="debug-panel">
|
||||
<h1>sprimo-tauri</h1>
|
||||
{error !== null ? <p className="error">{error}</p> : null}
|
||||
{snapshot === null ? (
|
||||
<p>Loading snapshot...</p>
|
||||
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
|
||||
{debugOverlayVisible ? (
|
||||
<section className="debug-panel">
|
||||
<h1>sprimo-tauri</h1>
|
||||
<p className="hint">Toggle: Ctrl+Shift+D</p>
|
||||
{error !== null ? <p className="error">{error}</p> : null}
|
||||
{snapshot === null ? (
|
||||
<p>Loading snapshot...</p>
|
||||
) : (
|
||||
<dl>
|
||||
<dt>state</dt>
|
||||
<dd>{snapshot.state}</dd>
|
||||
<dt>animation</dt>
|
||||
<dd>{snapshot.current_animation}</dd>
|
||||
<dt>pack</dt>
|
||||
<dd>{snapshot.active_sprite_pack}</dd>
|
||||
<dt>position</dt>
|
||||
<dd>
|
||||
{snapshot.x}, {snapshot.y}
|
||||
</dd>
|
||||
<dt>scale</dt>
|
||||
<dd>{snapshot.scale}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsWindow(): JSX.Element {
|
||||
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
||||
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 [error, setError] = React.useState<string | null>(null);
|
||||
const [pending, setPending] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let unlisten: null | (() => void) = null;
|
||||
let mounted = true;
|
||||
Promise.all([
|
||||
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
||||
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
||||
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||
invokeListApiTokens()
|
||||
])
|
||||
.then(async ([snapshot, options, pack, authTokens]) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setSettings(snapshot);
|
||||
setPacks(options);
|
||||
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) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const payload = event.payload;
|
||||
if (payload.active_sprite_pack !== activePack?.id) {
|
||||
void invoke<UiSpritePack>("load_active_sprite_pack")
|
||||
.then((nextPack) => {
|
||||
if (mounted) {
|
||||
setActivePack(nextPack);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep existing pack metadata if reload fails.
|
||||
});
|
||||
}
|
||||
setSettings((prev) => {
|
||||
if (prev === null) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
active_sprite_pack: payload.active_sprite_pack,
|
||||
scale: payload.scale,
|
||||
visible: payload.visible,
|
||||
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) => {
|
||||
if (mounted) {
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (unlisten !== null) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, [activePack?.id]);
|
||||
|
||||
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
return null;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPackChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value;
|
||||
const next = await withPending(() => invokeSetSpritePack(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
const refreshedPack = await withPending(() => invoke<UiSpritePack>("load_active_sprite_pack"));
|
||||
if (refreshedPack !== null) {
|
||||
setActivePack(refreshedPack);
|
||||
}
|
||||
setSettings((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
active_sprite_pack: next.active_sprite_pack
|
||||
}
|
||||
);
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onScaleChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = Number(event.target.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const next = await withPending(() => invokeSetScale(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onVisibleChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.checked;
|
||||
const next = await withPending(() => invokeSetVisibility(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, visible: value }));
|
||||
},
|
||||
[withPending]
|
||||
);
|
||||
|
||||
const onAlwaysOnTopChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.checked;
|
||||
const next = await withPending(() => invokeSetAlwaysOnTop(value));
|
||||
if (next === null) {
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value }));
|
||||
},
|
||||
[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 (
|
||||
<main className="settings-root">
|
||||
<section className="settings-card">
|
||||
<h1>Settings</h1>
|
||||
<p className="settings-subtitle">Character and window controls</p>
|
||||
{error !== null ? <p className="settings-error">{error}</p> : null}
|
||||
{settings === null ? (
|
||||
<p>Loading settings...</p>
|
||||
) : (
|
||||
<dl>
|
||||
<dt>state</dt>
|
||||
<dd>{snapshot.state}</dd>
|
||||
<dt>animation</dt>
|
||||
<dd>{snapshot.current_animation}</dd>
|
||||
<dt>pack</dt>
|
||||
<dd>{snapshot.active_sprite_pack}</dd>
|
||||
<dt>position</dt>
|
||||
<dd>
|
||||
{snapshot.x}, {snapshot.y}
|
||||
</dd>
|
||||
<dt>scale</dt>
|
||||
<dd>{snapshot.scale}</dd>
|
||||
</dl>
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Character</span>
|
||||
<select
|
||||
value={settings.active_sprite_pack}
|
||||
disabled={pending}
|
||||
onChange={onPackChange}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
|
||||
{pack.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Scale: {settings.scale.toFixed(2)}x</span>
|
||||
<input
|
||||
type="range"
|
||||
min={SCALE_MIN}
|
||||
max={SCALE_MAX}
|
||||
step={0.05}
|
||||
value={settings.scale}
|
||||
disabled={pending}
|
||||
onChange={onScaleChange}
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.visible}
|
||||
disabled={pending}
|
||||
onChange={onVisibleChange}
|
||||
/>
|
||||
<span>Visible</span>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.always_on_top}
|
||||
disabled={pending}
|
||||
onChange={onAlwaysOnTopChange}
|
||||
/>
|
||||
<span>Always on top</span>
|
||||
</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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoot(): JSX.Element {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
if (windowLabel === "settings") {
|
||||
return <SettingsWindow />;
|
||||
}
|
||||
return <MainOverlayWindow />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppRoot />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -28,9 +28,34 @@ export type UiSnapshot = {
|
||||
y: number;
|
||||
scale: number;
|
||||
active_sprite_pack: string;
|
||||
visible: boolean;
|
||||
always_on_top: boolean;
|
||||
};
|
||||
|
||||
type AnimationMap = Map<string, UiAnimationClip>;
|
||||
const KEY_R = 0xff;
|
||||
const KEY_G = 0x00;
|
||||
const KEY_B = 0xff;
|
||||
const FALLBACK_MIN_CONNECTED_RATIO = 0.005;
|
||||
const CONNECTED_HUE_MIN = 270;
|
||||
const CONNECTED_HUE_MAX = 350;
|
||||
const CONNECTED_SAT_MIN = 0.25;
|
||||
const CONNECTED_VAL_MIN = 0.08;
|
||||
const FALLBACK_HUE_MIN = 255;
|
||||
const FALLBACK_HUE_MAX = 355;
|
||||
const FALLBACK_SAT_MIN = 0.15;
|
||||
const FALLBACK_VAL_MIN = 0.04;
|
||||
const STRONG_MAGENTA_RB_MIN = 72;
|
||||
const STRONG_MAGENTA_DOMINANCE = 24;
|
||||
const HALO_HUE_MIN = 245;
|
||||
const HALO_HUE_MAX = 355;
|
||||
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;
|
||||
@@ -41,6 +66,8 @@ export class PixiPetRenderer {
|
||||
private frameCursor = 0;
|
||||
private frameElapsedMs = 0;
|
||||
private baseTexture: BaseTexture;
|
||||
private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT;
|
||||
private disposed = false;
|
||||
|
||||
private constructor(
|
||||
app: Application,
|
||||
@@ -67,8 +94,6 @@ export class PixiPetRenderer {
|
||||
antialias: true,
|
||||
resizeTo: container
|
||||
});
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
|
||||
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
|
||||
throw new Error("Atlas image loaded with invalid dimensions.");
|
||||
@@ -76,6 +101,7 @@ export class PixiPetRenderer {
|
||||
const sprite = new Sprite();
|
||||
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
|
||||
app.stage.addChild(sprite);
|
||||
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||
|
||||
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
|
||||
renderer.layoutSprite();
|
||||
@@ -99,16 +125,222 @@ export class PixiPetRenderer {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = frame.data;
|
||||
const keyR = 0xff;
|
||||
const keyG = 0x00;
|
||||
const keyB = 0xff;
|
||||
const tolerance = 28;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const dr = Math.abs(data[i] - keyR);
|
||||
const dg = Math.abs(data[i + 1] - keyG);
|
||||
const db = Math.abs(data[i + 2] - keyB);
|
||||
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
|
||||
data[i + 3] = 0;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const pixelCount = width * height;
|
||||
const isKeyLike = new Uint8Array(pixelCount);
|
||||
const removedBg = new Uint8Array(pixelCount);
|
||||
const queue = new Int32Array(pixelCount);
|
||||
let head = 0;
|
||||
let tail = 0;
|
||||
|
||||
const indexFor = (x: number, y: number): number => y * width + x;
|
||||
const channelOffset = (index: number): number => index * 4;
|
||||
const enqueueIfKeyLike = (x: number, y: number): void => {
|
||||
const idx = indexFor(x, y);
|
||||
if (isKeyLike[idx] === 1 && removedBg[idx] === 0) {
|
||||
removedBg[idx] = 1;
|
||||
queue[tail] = idx;
|
||||
tail += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (let idx = 0; idx < pixelCount; idx += 1) {
|
||||
const offset = channelOffset(idx);
|
||||
const [h, s, v] = PixiPetRenderer.rgbToHsv(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
);
|
||||
if (
|
||||
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
|
||||
s >= CONNECTED_SAT_MIN &&
|
||||
v >= CONNECTED_VAL_MIN
|
||||
) {
|
||||
isKeyLike[idx] = 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
PixiPetRenderer.isStrongMagentaFamily(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
)
|
||||
) {
|
||||
isKeyLike[idx] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
enqueueIfKeyLike(x, 0);
|
||||
enqueueIfKeyLike(x, height - 1);
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
enqueueIfKeyLike(0, y);
|
||||
enqueueIfKeyLike(width - 1, y);
|
||||
}
|
||||
|
||||
while (head < tail) {
|
||||
const idx = queue[head];
|
||||
head += 1;
|
||||
const x = idx % width;
|
||||
const y = Math.floor(idx / width);
|
||||
if (x > 0) {
|
||||
enqueueIfKeyLike(x - 1, y);
|
||||
}
|
||||
if (x + 1 < width) {
|
||||
enqueueIfKeyLike(x + 1, y);
|
||||
}
|
||||
if (y > 0) {
|
||||
enqueueIfKeyLike(x, y - 1);
|
||||
}
|
||||
if (y + 1 < height) {
|
||||
enqueueIfKeyLike(x, y + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const connectedRemovedCount = tail;
|
||||
for (let idx = 0; idx < pixelCount; idx += 1) {
|
||||
if (removedBg[idx] !== 1) {
|
||||
continue;
|
||||
}
|
||||
const offset = channelOffset(idx);
|
||||
data[offset + 3] = 0;
|
||||
}
|
||||
|
||||
const needsFallback =
|
||||
connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO;
|
||||
if (needsFallback) {
|
||||
for (let idx = 0; idx < pixelCount; idx += 1) {
|
||||
const offset = channelOffset(idx);
|
||||
const [h, s, v] = PixiPetRenderer.rgbToHsv(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
);
|
||||
const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
KEY_R,
|
||||
KEY_G,
|
||||
KEY_B
|
||||
);
|
||||
if (
|
||||
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
|
||||
s >= FALLBACK_SAT_MIN &&
|
||||
v >= FALLBACK_VAL_MIN) ||
|
||||
PixiPetRenderer.isStrongMagentaFamily(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
) ||
|
||||
maxDistanceFromHardKey <= 96
|
||||
) {
|
||||
data[offset + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic last pass: remove any border-connected magenta-family background.
|
||||
head = 0;
|
||||
tail = 0;
|
||||
removedBg.fill(0);
|
||||
const enqueueIfMagentaBorder = (x: number, y: number): void => {
|
||||
const idx = indexFor(x, y);
|
||||
if (removedBg[idx] === 1) {
|
||||
return;
|
||||
}
|
||||
const offset = channelOffset(idx);
|
||||
if (data[offset + 3] === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!PixiPetRenderer.isStrongMagentaFamily(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
removedBg[idx] = 1;
|
||||
queue[tail] = idx;
|
||||
tail += 1;
|
||||
};
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
enqueueIfMagentaBorder(x, 0);
|
||||
enqueueIfMagentaBorder(x, height - 1);
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
enqueueIfMagentaBorder(0, y);
|
||||
enqueueIfMagentaBorder(width - 1, y);
|
||||
}
|
||||
|
||||
while (head < tail) {
|
||||
const idx = queue[head];
|
||||
head += 1;
|
||||
const x = idx % width;
|
||||
const y = Math.floor(idx / width);
|
||||
if (x > 0) {
|
||||
enqueueIfMagentaBorder(x - 1, y);
|
||||
}
|
||||
if (x + 1 < width) {
|
||||
enqueueIfMagentaBorder(x + 1, y);
|
||||
}
|
||||
if (y > 0) {
|
||||
enqueueIfMagentaBorder(x, y - 1);
|
||||
}
|
||||
if (y + 1 < height) {
|
||||
enqueueIfMagentaBorder(x, y + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < pixelCount; idx += 1) {
|
||||
if (removedBg[idx] !== 1) {
|
||||
continue;
|
||||
}
|
||||
const offset = channelOffset(idx);
|
||||
data[offset + 3] = 0;
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const idx = indexFor(x, y);
|
||||
if (data[channelOffset(idx) + 3] === 0) {
|
||||
continue;
|
||||
}
|
||||
let touchesBackground = false;
|
||||
if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) {
|
||||
touchesBackground = true;
|
||||
} else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) {
|
||||
touchesBackground = true;
|
||||
} else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) {
|
||||
touchesBackground = true;
|
||||
} else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) {
|
||||
touchesBackground = true;
|
||||
}
|
||||
if (!touchesBackground) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = channelOffset(idx);
|
||||
const [h, s, v] = PixiPetRenderer.rgbToHsv(
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2]
|
||||
);
|
||||
if (
|
||||
!PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) ||
|
||||
s < HALO_SAT_MIN ||
|
||||
v < HALO_VAL_MIN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
data[offset] = Math.round(data[offset] * 0.72);
|
||||
data[offset + 2] = Math.round(data[offset + 2] * 0.72);
|
||||
data[offset + 3] = Math.round(data[offset + 3] * 0.86);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
@@ -121,14 +353,67 @@ export class PixiPetRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
private static maxColorDistance(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
keyR: number,
|
||||
keyG: number,
|
||||
keyB: number
|
||||
): number {
|
||||
const dr = Math.abs(r - keyR);
|
||||
const dg = Math.abs(g - keyG);
|
||||
const db = Math.abs(b - keyB);
|
||||
return Math.max(dr, dg, db);
|
||||
}
|
||||
|
||||
private static rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||
const rf = r / 255;
|
||||
const gf = g / 255;
|
||||
const bf = b / 255;
|
||||
const max = Math.max(rf, gf, bf);
|
||||
const min = Math.min(rf, gf, bf);
|
||||
const delta = max - min;
|
||||
|
||||
let hue = 0;
|
||||
if (delta > 0) {
|
||||
if (max === rf) {
|
||||
hue = 60 * (((gf - bf) / delta) % 6);
|
||||
} else if (max === gf) {
|
||||
hue = 60 * ((bf - rf) / delta + 2);
|
||||
} else {
|
||||
hue = 60 * ((rf - gf) / delta + 4);
|
||||
}
|
||||
}
|
||||
if (hue < 0) {
|
||||
hue += 360;
|
||||
}
|
||||
const saturation = max === 0 ? 0 : delta / max;
|
||||
const value = max;
|
||||
return [hue, saturation, value];
|
||||
}
|
||||
|
||||
private static isHueInRange(hue: number, min: number, max: number): boolean {
|
||||
if (min <= max) {
|
||||
return hue >= min && hue <= max;
|
||||
}
|
||||
return hue >= min || hue <= max;
|
||||
}
|
||||
|
||||
private static isStrongMagentaFamily(r: number, g: number, b: number): boolean {
|
||||
const minRb = Math.min(r, b);
|
||||
return (
|
||||
r >= STRONG_MAGENTA_RB_MIN &&
|
||||
b >= STRONG_MAGENTA_RB_MIN &&
|
||||
g + STRONG_MAGENTA_DOMINANCE <= minRb
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.app.ticker.stop();
|
||||
this.app.ticker.destroy();
|
||||
this.sprite.destroy({
|
||||
children: true,
|
||||
texture: false,
|
||||
baseTexture: false
|
||||
});
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
@@ -137,6 +422,9 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: UiSnapshot): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const nextClip = this.resolveClip(snapshot.current_animation);
|
||||
if (nextClip.name !== this.currentClip.name) {
|
||||
this.currentClip = nextClip;
|
||||
@@ -144,14 +432,27 @@ export class PixiPetRenderer {
|
||||
this.frameElapsedMs = 0;
|
||||
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
|
||||
}
|
||||
this.sprite.scale.set(snapshot.scale);
|
||||
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;
|
||||
@@ -172,6 +473,12 @@ export class PixiPetRenderer {
|
||||
}
|
||||
|
||||
private layoutSprite(): void {
|
||||
const availableWidth = Math.max(this.app.renderer.width - RENDER_FIT_PADDING, 1);
|
||||
const availableHeight = Math.max(this.app.renderer.height - RENDER_FIT_PADDING, 1);
|
||||
const fitScaleX = availableWidth / Math.max(this.pack.frame_width, 1);
|
||||
const fitScaleY = availableHeight / Math.max(this.pack.frame_height, 1);
|
||||
const fitScale = Math.max(Math.min(fitScaleX, fitScaleY), MIN_RENDER_SCALE);
|
||||
this.sprite.scale.set(fitScale);
|
||||
this.sprite.position.set(this.app.renderer.width / 2, this.app.renderer.height);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -53,3 +63,171 @@ dd {
|
||||
.error {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
max-width: 320px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(127, 29, 29, 0.75);
|
||||
border-radius: 8px;
|
||||
color: #fee2e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-root {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
|
||||
color: #0f172a;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-card h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 0;
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.field select,
|
||||
.field input[type="range"],
|
||||
.field input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field select {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #ffffff;
|
||||
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;
|
||||
gap: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
98
issues/issue3.md
Normal file
98
issues/issue3.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Title
|
||||
|
||||
`sprimo-tauri` settings window invoke errors for sprite-pack switch and always-on-top toggle.
|
||||
|
||||
## Severity
|
||||
|
||||
P1
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- App version/build: `sprimo-tauri` workspace runtime
|
||||
- Renderer/backend details: Tauri UI settings pop-out window
|
||||
- Evidence screenshots:
|
||||
- `issues/screenshots/issue3.png`
|
||||
- `issues/screenshots/issue3-b.png`
|
||||
|
||||
## Summary
|
||||
|
||||
When using the settings pop-out window, changing character or toggling always-on-top shows
|
||||
invoke argument validation errors and the action fails to apply.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Start `sprimo-tauri` and open tray menu `Settings`.
|
||||
2. Change `Character` selection.
|
||||
3. Toggle `Always on top`.
|
||||
4. Observe red error banner in settings window.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- Character change applies successfully and updates active sprite pack.
|
||||
- Always-on-top toggle applies successfully and updates main window z-order state.
|
||||
- No invoke argument error appears.
|
||||
|
||||
## Actual Result
|
||||
|
||||
- Character change fails with:
|
||||
- `invalid args 'packIdOrPath' for command 'set_sprite_pack'`
|
||||
- Always-on-top toggle fails with:
|
||||
- `invalid args 'alwaysOnTop' for command 'set_always_on_top'`
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
- Frontend invoke payload keys were sent in snake_case (`pack_id_or_path`, `always_on_top`).
|
||||
- Tauri JS invoke argument mapping for these Rust command parameter names expects camelCase
|
||||
keys (`packIdOrPath`, `alwaysOnTop`).
|
||||
- Because the required keys were missing from Tauri's perspective, command handlers were not run.
|
||||
|
||||
## Fix Plan
|
||||
|
||||
1. Update settings invoke payload keys to Tauri-compatible camelCase names.
|
||||
2. Add typed helper wrappers for settings invokes to centralize argument naming.
|
||||
3. Rebuild UI and run Rust compile checks.
|
||||
4. Perform runtime manual validation and capture after screenshot evidence.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented in `frontend/tauri-ui/src/main.tsx`:
|
||||
|
||||
1. Added typed invoke wrappers:
|
||||
- `invokeSetSpritePack(packIdOrPath)`
|
||||
- `invokeSetScale(scale)`
|
||||
- `invokeSetVisibility(visible)`
|
||||
- `invokeSetAlwaysOnTop(alwaysOnTop)`
|
||||
|
||||
2. Updated failing settings call sites to use wrappers and camelCase payload keys.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commands Run
|
||||
|
||||
- [x] `npm --prefix frontend/tauri-ui run build`
|
||||
- [x] `cargo check -p sprimo-tauri`
|
||||
|
||||
### Visual Checklist
|
||||
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue3.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue3-b.png`
|
||||
- [ ] After screenshot(s): `issues/screenshots/issue3-after-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
### Result
|
||||
|
||||
- Status: `Fix Implemented`
|
||||
- Notes: compile/build checks pass; runtime visual verification still required.
|
||||
|
||||
## Status History
|
||||
|
||||
- `2026-02-14 00:00` - reporter - `Reported` - settings errors captured in `issue3.png` and `issue3-b.png`.
|
||||
- `2026-02-14 00:00` - codex - `Triaged` - localized to invoke argument key mismatch.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - updated settings invoke keys to camelCase via typed wrappers.
|
||||
|
||||
## Closure
|
||||
|
||||
- Current Status: `Fix Implemented`
|
||||
- Close Date:
|
||||
- Owner:
|
||||
- Linked PR/commit:
|
||||
200
issues/issue4.md
Normal file
200
issues/issue4.md
Normal file
@@ -0,0 +1,200 @@
|
||||
## Title
|
||||
|
||||
Packaged `sprimo-tauri` sprite rendering breaks after pack switch; default switch errors and
|
||||
scaling stops applying.
|
||||
|
||||
## Severity
|
||||
|
||||
P1
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- App version/build: packaged release (`sprimo-tauri.exe`)
|
||||
- Renderer/backend details: Tauri main overlay + settings pop-out
|
||||
- Evidence screenshots:
|
||||
- `issues/screenshots/issue4.png`
|
||||
- `issues/screenshots/issue4-b.png`
|
||||
- `issues/screenshots/issue4-c.png`
|
||||
- `issues/screenshots/issue4-after-fix2-2026-02-14-145819.png`
|
||||
- `issues/screenshots/issue4-after-fix4-2026-02-14-153233.png`
|
||||
|
||||
## Summary
|
||||
|
||||
In packaged runtime, sprite display is incorrectly split/tiled, switching to `default` can fail,
|
||||
and scaling becomes ineffective after the error.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Run packaged `sprimo-tauri.exe` from ZIP extract.
|
||||
2. Open settings window.
|
||||
3. Switch character between `ferris` and `default`.
|
||||
4. Observe main overlay rendering and debug output.
|
||||
5. Change scale slider.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- Sprite sheet is split into the correct frame grid regardless of image resolution.
|
||||
- Pack switching works for both `ferris` and `default`.
|
||||
- Scale changes continue to apply after pack changes.
|
||||
|
||||
## Actual Result
|
||||
|
||||
- Main overlay shows incorrectly split/tiled sprite sheet.
|
||||
- Pack switch can produce runtime error and break subsequent behavior.
|
||||
- Scale update stops working reliably after the error.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
1. Existing splitting logic relied on fixed pixel frame metadata that did not generalize to
|
||||
packaged `sprite.png` dimension variants.
|
||||
2. Pack metadata inconsistency:
|
||||
- `assets/sprite-packs/ferris/manifest.json` used duplicated `id` (`default`), causing pack
|
||||
identity ambiguity.
|
||||
3. Settings/runtime flow then entered an unstable state after pack switch failures.
|
||||
4. Renderer reload lifecycle in tauri UI was unsafe:
|
||||
- `PixiPetRenderer::dispose` performed duplicate teardown (`ticker.destroy` + `app.destroy`),
|
||||
which could trigger runtime `TypeError` during pack reload.
|
||||
- Renderer replacement disposed previous renderer before new renderer creation succeeded, leaving
|
||||
the view in a broken/cropped state on creation failures.
|
||||
5. Chroma-key conversion tolerance removed most `#FF00FF` background but still left magenta fringe
|
||||
on anti-aliased edges.
|
||||
6. Scale fit used repeated position deltas and caused directional drift during repeated resizing.
|
||||
7. API mismatch in tauri window module:
|
||||
- runtime used `getCurrentWindow().currentMonitor()` but this API version exposes monitor lookup as
|
||||
module function (`currentMonitor`), causing `TypeError` and skipping window fit.
|
||||
8. Scale position math mixed physical window metrics (`outerPosition`/`innerSize`) with logical
|
||||
set operations (`LogicalSize`/`LogicalPosition`), reintroducing cumulative drift in some DPI
|
||||
contexts.
|
||||
9. Ferris background keying needed adaptive key detection; fixed `#FF00FF` assumptions were still
|
||||
too brittle for packaged atlas variants.
|
||||
10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering
|
||||
runtime arg errors (`expected i32`) and bypassing window fit updates.
|
||||
11. Monitor-fit cap remained too optimistic for large frame packs, so max scale could still exceed
|
||||
practical visible bounds and appear clipped.
|
||||
12. Ferris/demogorgon packaged backgrounds are gradient-magenta (not exact `#FF00FF`), requiring a
|
||||
border-connected magenta-family mask instead of exact-key assumptions.
|
||||
13. Clipping persisted because sprite rendering scale followed snapshot/requested scale directly
|
||||
instead of fitting to the actual post-clamp window size.
|
||||
|
||||
## Fix Plan
|
||||
|
||||
1. Introduce generic splitter policy for `sprite.png`:
|
||||
- fixed topology: `8` columns x `7` rows
|
||||
- derive frame size from actual image dimensions
|
||||
- keep chroma-key background handling (`#FF00FF`) in renderer
|
||||
2. Validate animation frame indices against fixed frame count (`56`) for `sprite.png`.
|
||||
3. Ensure pack apply path validates atlas geometry before committing `SetSpritePack`.
|
||||
4. Fix ferris manifest ID uniqueness.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented:
|
||||
|
||||
1. `crates/sprimo-tauri/src/main.rs`
|
||||
- Added `sprite.png`-specific frame derivation (`8x7`) from PNG dimensions.
|
||||
- Added PNG header dimension decoding utility.
|
||||
- Added animation frame index validation against fixed `56` frames for `sprite.png`.
|
||||
- Applied validation in both `load_active_sprite_pack` and `set_sprite_pack`.
|
||||
|
||||
2. `assets/sprite-packs/ferris/manifest.json`
|
||||
- Changed manifest `id` from `default` to `ferris`.
|
||||
|
||||
3. `docs/SPRITE_PACK_SCHEMA.md`
|
||||
- Documented Tauri `sprite.png` override behavior and 8x7 derived frame policy.
|
||||
|
||||
4. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- Made renderer disposal idempotent and removed duplicate ticker destruction.
|
||||
- Delayed DOM canvas replacement until atlas load succeeds.
|
||||
- Improved chroma-key edge handling with soft alpha + magenta spill suppression.
|
||||
|
||||
5. `frontend/tauri-ui/src/main.tsx`
|
||||
- Made pack reload transactional (keep old renderer until new renderer creation succeeds).
|
||||
- Improved fit-window flow so scale apply continues after reload retries.
|
||||
- Added targeted diagnostics for reload failures.
|
||||
6. `frontend/tauri-ui/src/main.tsx`
|
||||
- Changed scaling anchor to window center and clamped resized window position within current
|
||||
monitor bounds.
|
||||
7. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- Replaced tolerance-only chroma key with border-connected `#FF00FF` background flood-fill removal
|
||||
and localized edge halo suppression.
|
||||
8. `crates/sprimo-tauri/capabilities/default.json`
|
||||
- Added `core:window:allow-current-monitor` permission for monitor bounds clamping.
|
||||
9. `frontend/tauri-ui/src/main.tsx`
|
||||
- switched monitor lookup to module-level `currentMonitor()` with safe fallback so window scaling
|
||||
still applies even if monitor introspection is unavailable.
|
||||
10. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- added fallback global key cleanup when border-connected background detection is too sparse.
|
||||
11. `frontend/tauri-ui/src/main.tsx`
|
||||
- moved scale resizing and positioning to physical units (`PhysicalSize`/`PhysicalPosition`) and
|
||||
monitor selection at window-center point (`monitorFromPoint`).
|
||||
12. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- added adaptive border-derived key color selection with fallback key cleanup pass.
|
||||
13. `scripts/package_windows.py`
|
||||
- tauri packaging now explicitly rebuilds UI bundle to avoid stale embedded `dist` output.
|
||||
14. `frontend/tauri-ui/src/main.tsx`
|
||||
- enforced integer physical positioning and monitor work-area size clamping to prevent set-position
|
||||
arg failures and large-scale clipping.
|
||||
15. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background
|
||||
removal and stronger fallback cleanup.
|
||||
16. `frontend/tauri-ui/src/main.tsx`
|
||||
- added stricter monitor work-area guard (`WINDOW_WORKAREA_MARGIN`) in both scale-cap and resize
|
||||
clamp paths to prevent large-pack clipping at high scales.
|
||||
17. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- added deterministic border-connected strong-magenta flood-fill cleanup pass so non-`#FF00FF`
|
||||
gradient backgrounds are removed consistently in packaged ferris/demogorgon atlases.
|
||||
18. `frontend/tauri-ui/src/main.tsx`
|
||||
- changed scale flow to window-driven fit semantics: scale request resizes/clamps main window and
|
||||
then persists effective scale derived from applied window size.
|
||||
19. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||
- renderer sprite scale is now derived from current canvas/window size each layout pass, removing
|
||||
clipping caused by mismatch between requested scale and bounded window dimensions.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commands Run
|
||||
|
||||
- [ ] `just build-release-tauri`
|
||||
- [ ] `just package-win-tauri`
|
||||
- [ ] `just smoke-win-tauri`
|
||||
- [x] `cargo check -p sprimo-tauri`
|
||||
|
||||
### Visual Checklist
|
||||
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4-b.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue4-c.png`
|
||||
- [ ] After screenshot(s): `issues/screenshots/issue4-after-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
### Result
|
||||
|
||||
- Status: `Fix Implemented`
|
||||
- Notes: packaged runtime validation and after screenshots for this round are pending.
|
||||
|
||||
## Status History
|
||||
|
||||
- `2026-02-14 00:00` - reporter - `Reported` - packaged runtime failure screenshots attached.
|
||||
- `2026-02-14 00:00` - codex - `Triaged` - localized to sprite splitting/pack identity behavior.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - applied 8x7 generic splitter policy and pack-ID correction.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported `issue4-after-fix1` still failing in packaged runtime.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - hardened renderer reload/dispose and chroma-key edge cleanup.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - remaining magenta ferris edge + scale drift reported.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to border-connected chroma-key removal and center-anchored, monitor-clamped scale fit.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported `currentMonitor` TypeError and ferris magenta background still visible.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - corrected monitor API call and added fallback chroma cleanup pass.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported ferris magenta background still visible and scale drift recurrence.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported remaining default clipping and ferris magenta background persistence.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - tightened work-area scale guard and added border-connected strong-magenta cleanup pass.
|
||||
- `2026-02-14 00:00` - reporter - `In Progress` - reported clipping still present on `default` and `demogorgon` after prior fixes.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - moved tauri scale to window-driven effective-fit persistence and renderer fit-to-window scaling.
|
||||
|
||||
## Closure
|
||||
|
||||
- Current Status: `Fix Implemented`
|
||||
- Close Date:
|
||||
- Owner:
|
||||
- Linked PR/commit:
|
||||
92
issues/issue5.md
Normal file
92
issues/issue5.md
Normal file
@@ -0,0 +1,92 @@
|
||||
## Title
|
||||
|
||||
Tauri overlay shows left-edge white strip and same scale yields different window sizes by pack.
|
||||
|
||||
## Severity
|
||||
|
||||
P2
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- App version/build: packaged release (`sprimo-tauri.exe`)
|
||||
- Evidence screenshots:
|
||||
- `issues/screenshots/issue5.png`
|
||||
- `issues/screenshots/issue5-b.png`
|
||||
- `issues/screenshots/issue5-c.png`
|
||||
|
||||
## Summary
|
||||
|
||||
Two regressions were observed in packaged Tauri runtime:
|
||||
|
||||
1. A visible white strip appears on the overlay left edge on dark backgrounds.
|
||||
2. At the same slider scale, main overlay window size differs by sprite pack.
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Run packaged `sprimo-tauri.exe`.
|
||||
2. Place overlay over a dark/black background.
|
||||
3. Observe left window edge.
|
||||
4. Open settings and set the same scale value on `default`, `ferris`, and `demogorgon`.
|
||||
5. Compare main window footprint.
|
||||
|
||||
## Expected Result
|
||||
|
||||
- No white strip or white background bleed on transparent overlay edges.
|
||||
- Same scale value produces the same main overlay window size regardless of pack.
|
||||
|
||||
## Actual Result
|
||||
|
||||
- Left edge can show a white strip.
|
||||
- Window size differs across packs at same scale.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
1. Transparency chain was incomplete in CSS:
|
||||
- `body` was transparent, but `html`/`#root` were not explicitly transparent/full-size, allowing
|
||||
white background bleed at edges in transparent frameless window mode.
|
||||
2. Scale-to-window mapping depended on per-pack frame size:
|
||||
- window sizing used pack `frame_width/frame_height`, so identical scale values produced different
|
||||
target sizes across packs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. `frontend/tauri-ui/src/main.tsx`
|
||||
- introduced canonical scale basis `512x512` for window sizing semantics.
|
||||
- changed `fittedWindowSize` and `effectiveScaleForWindowSize` to use canonical dimensions.
|
||||
- removed pack-dependent sizing from `fitWindowForScale`; pack remains used for rendering/splitting.
|
||||
2. `frontend/tauri-ui/src/styles.css`
|
||||
- made `html`, `body`, and `#root` explicit full-size transparent surfaces to avoid white bleed.
|
||||
3. `docs/TAURI_RUNTIME_TESTING.md`
|
||||
- added explicit checks for same-scale cross-pack window-size consistency and no edge white strip.
|
||||
4. `docs/RELEASE_TESTING.md`
|
||||
- added packaged verification steps for white-edge bleed and cross-pack same-scale size consistency.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commands Run
|
||||
|
||||
- [x] `npm --prefix frontend/tauri-ui run build`
|
||||
- [x] `cargo check -p sprimo-tauri`
|
||||
- [ ] `just build-release-tauri`
|
||||
- [ ] `just package-win-tauri`
|
||||
- [ ] `just smoke-win-tauri`
|
||||
|
||||
### Visual Checklist
|
||||
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue5.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue5-b.png`
|
||||
- [x] Before screenshot(s): `issues/screenshots/issue5-c.png`
|
||||
- [ ] After screenshot(s): `issues/screenshots/issue5-after-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
### Result
|
||||
|
||||
- Status: `Fix Implemented`
|
||||
- Notes: packaged runtime verification pending.
|
||||
|
||||
## Status History
|
||||
|
||||
- `2026-02-14 00:00` - reporter - `Reported` - left white strip + same-scale size inconsistency reported.
|
||||
- `2026-02-14 00:00` - codex - `Triaged` - identified transparency chain and scale-basis coupling root causes.
|
||||
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to canonical scale basis and full-surface transparency.
|
||||
|
||||
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.
|
||||
@@ -153,6 +153,8 @@ def sha256_file(path: Path) -> str:
|
||||
def package(frontend: FrontendLayout) -> PackageLayout:
|
||||
version = read_version()
|
||||
ensure_assets()
|
||||
if frontend.id == "tauri":
|
||||
run(["npm", "--prefix", "frontend/tauri-ui", "run", "build"])
|
||||
binary = ensure_release_binary(frontend)
|
||||
runtime_files = ensure_runtime_files(frontend, binary.parent)
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
Reference in New Issue
Block a user