Compare commits
23 Commits
test-0.1.0
...
c815adb826
| Author | SHA1 | Date | |
|---|---|---|---|
| c815adb826 | |||
| 5b0b0c7d41 | |||
|
|
fa508ced8c | ||
|
|
bed7a052f3 | ||
|
|
832fbda04d | ||
|
|
f20ed1fd9d | ||
|
|
e5417b6799 | ||
|
|
c0efb3915b | ||
| 927fe6641c | |||
|
|
eddf4b9481 | ||
|
|
f50243ab96 | ||
|
|
f2954ad22b | ||
|
|
1fa7080210 | ||
|
|
901bf0ffc3 | ||
|
|
907974e61f | ||
|
|
e5e123cc84 | ||
|
|
875bc54c4f | ||
|
|
8e79bd98e5 | ||
|
|
084506e84b | ||
|
|
77f4139392 | ||
|
|
55fe53235d | ||
|
|
3c3ca342c9 | ||
|
|
b0f462f63e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/dist
|
/dist
|
||||||
/issues/screenshots
|
/issues/screenshots
|
||||||
|
codex.txt
|
||||||
|
/frontend/tauri-ui/node_modules
|
||||||
|
/frontend/tauri-ui/dist
|
||||||
|
|||||||
3627
Cargo.lock
generated
3627
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ members = [
|
|||||||
"crates/sprimo-config",
|
"crates/sprimo-config",
|
||||||
"crates/sprimo-platform",
|
"crates/sprimo-platform",
|
||||||
"crates/sprimo-protocol",
|
"crates/sprimo-protocol",
|
||||||
|
"crates/sprimo-runtime-core",
|
||||||
"crates/sprimo-sprite",
|
"crates/sprimo-sprite",
|
||||||
|
"crates/sprimo-tauri",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 404 B |
@@ -7,25 +7,80 @@
|
|||||||
"animations": [
|
"animations": [
|
||||||
{
|
{
|
||||||
"name": "idle",
|
"name": "idle",
|
||||||
"fps": 6,
|
"fps": 1,
|
||||||
"frames": [0, 1]
|
"frames": [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "active",
|
"name": "active",
|
||||||
"fps": 10,
|
"fps": 1,
|
||||||
"frames": [1, 0]
|
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "happy",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "love",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [8, 8, 8, 9, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 13, 13, 13, 14, 14, 14, 15, 15, 15]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "excited",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "celebrate",
|
||||||
|
"fps": 2,
|
||||||
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
|
||||||
|
"one_shot": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "success",
|
"name": "success",
|
||||||
"fps": 10,
|
"fps": 2,
|
||||||
"frames": [0, 1, 0],
|
"frames": [16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
|
||||||
"one_shot": true
|
"one_shot": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sleepy",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "snoring",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 28, 28, 28, 28, 29, 29, 29, 29, 30, 30, 30, 30, 31, 31, 31, 31]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "working",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 37, 38, 38, 38, 39, 39, 39]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "angry",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "surprised",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "shy",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "error",
|
"name": "error",
|
||||||
"fps": 8,
|
"fps": 1,
|
||||||
"frames": [1, 0, 1],
|
"frames": [40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47]
|
||||||
"one_shot": true
|
},
|
||||||
|
{
|
||||||
|
"name": "dragging",
|
||||||
|
"fps": 1,
|
||||||
|
"frames": [48, 49, 50, 51, 52, 53, 54, 55]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"anchor": {
|
"anchor": {
|
||||||
|
|||||||
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::{
|
use sprimo_protocol::v1::{
|
||||||
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
|
CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -19,7 +19,7 @@ use uuid::Uuid;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApiConfig {
|
pub struct ApiConfig {
|
||||||
pub bind_addr: SocketAddr,
|
pub bind_addr: SocketAddr,
|
||||||
pub auth_token: String,
|
pub auth_tokens: Vec<String>,
|
||||||
pub app_version: String,
|
pub app_version: String,
|
||||||
pub app_build: String,
|
pub app_build: String,
|
||||||
pub dedupe_capacity: usize,
|
pub dedupe_capacity: usize,
|
||||||
@@ -29,9 +29,14 @@ pub struct ApiConfig {
|
|||||||
impl ApiConfig {
|
impl ApiConfig {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn default_with_token(auth_token: String) -> Self {
|
pub fn default_with_token(auth_token: String) -> Self {
|
||||||
|
Self::default_with_tokens(vec![auth_token])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_with_tokens(auth_tokens: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
|
bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)),
|
||||||
auth_token,
|
auth_tokens,
|
||||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
app_build: "dev".to_string(),
|
app_build: "dev".to_string(),
|
||||||
dedupe_capacity: 5_000,
|
dedupe_capacity: 5_000,
|
||||||
@@ -43,7 +48,7 @@ impl ApiConfig {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ApiState {
|
pub struct ApiState {
|
||||||
start_at: Instant,
|
start_at: Instant,
|
||||||
auth_token: String,
|
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||||
app_version: String,
|
app_version: String,
|
||||||
app_build: String,
|
app_build: String,
|
||||||
dedupe_capacity: usize,
|
dedupe_capacity: usize,
|
||||||
@@ -59,10 +64,14 @@ impl ApiState {
|
|||||||
config: ApiConfig,
|
config: ApiConfig,
|
||||||
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
||||||
command_tx: mpsc::Sender<CommandEnvelope>,
|
command_tx: mpsc::Sender<CommandEnvelope>,
|
||||||
|
auth_tokens: Arc<RwLock<HashSet<String>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
if let Ok(mut guard) = auth_tokens.write() {
|
||||||
|
*guard = config.auth_tokens.into_iter().collect();
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
start_at: Instant::now(),
|
start_at: Instant::now(),
|
||||||
auth_token: config.auth_token,
|
auth_tokens,
|
||||||
app_version: config.app_version,
|
app_version: config.app_version,
|
||||||
app_build: config.app_build,
|
app_build: config.app_build,
|
||||||
dedupe_capacity: config.dedupe_capacity,
|
dedupe_capacity: config.dedupe_capacity,
|
||||||
@@ -188,11 +197,18 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> {
|
|||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.ok_or(ApiError::Unauthorized)?;
|
.ok_or(ApiError::Unauthorized)?;
|
||||||
let expected = format!("Bearer {}", state.auth_token);
|
let Some(token) = raw.strip_prefix("Bearer ") else {
|
||||||
if raw == expected {
|
return Err(ApiError::Unauthorized);
|
||||||
return Ok(());
|
};
|
||||||
|
let guard = state
|
||||||
|
.auth_tokens
|
||||||
|
.read()
|
||||||
|
.map_err(|_| ApiError::Internal("auth token lock poisoned".to_string()))?;
|
||||||
|
if guard.contains(token) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::Unauthorized)
|
||||||
}
|
}
|
||||||
Err(ApiError::Unauthorized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApiError {
|
enum ApiError {
|
||||||
@@ -240,6 +256,7 @@ mod tests {
|
|||||||
use sprimo_protocol::v1::{
|
use sprimo_protocol::v1::{
|
||||||
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
|
CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -255,6 +272,7 @@ mod tests {
|
|||||||
ApiConfig::default_with_token("token".to_string()),
|
ApiConfig::default_with_token("token".to_string()),
|
||||||
snapshot,
|
snapshot,
|
||||||
tx,
|
tx,
|
||||||
|
Arc::new(RwLock::new(HashSet::new())),
|
||||||
)),
|
)),
|
||||||
rx,
|
rx,
|
||||||
)
|
)
|
||||||
@@ -335,6 +353,48 @@ mod tests {
|
|||||||
assert_eq!(received.id, command.id);
|
assert_eq!(received.id, command.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn command_accepts_with_any_configured_token() {
|
||||||
|
let snapshot =
|
||||||
|
FrontendStateSnapshot::idle(CapabilityFlags::default());
|
||||||
|
let snapshot = Arc::new(RwLock::new(snapshot));
|
||||||
|
let (tx, mut rx) = mpsc::channel(8);
|
||||||
|
let state = Arc::new(ApiState::new(
|
||||||
|
ApiConfig::default_with_tokens(vec![
|
||||||
|
"token-a".to_string(),
|
||||||
|
"token-b".to_string(),
|
||||||
|
]),
|
||||||
|
snapshot,
|
||||||
|
tx,
|
||||||
|
Arc::new(RwLock::new(HashSet::new())),
|
||||||
|
));
|
||||||
|
let app = app_router(state);
|
||||||
|
let command = CommandEnvelope {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
ts_ms: 1,
|
||||||
|
command: FrontendCommand::Toast {
|
||||||
|
text: "hi".to_string(),
|
||||||
|
ttl_ms: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let body = serde_json::to_vec(&command).expect("json");
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/v1/command")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("authorization", "Bearer token-b")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
let received = rx.recv().await.expect("forwarded command");
|
||||||
|
assert_eq!(received.id, command.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malformed_json_returns_bad_request() {
|
async fn malformed_json_returns_bad_request() {
|
||||||
let (state, _) = build_state();
|
let (state, _) = build_state();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sprimo-api = { path = "../sprimo-api" }
|
|||||||
sprimo-config = { path = "../sprimo-config" }
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
sprimo-platform = { path = "../sprimo-platform" }
|
sprimo-platform = { path = "../sprimo-platform" }
|
||||||
sprimo-protocol = { path = "../sprimo-protocol" }
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
|
||||||
sprimo-sprite = { path = "../sprimo-sprite" }
|
sprimo-sprite = { path = "../sprimo-sprite" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
|||||||
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
|
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
|
||||||
use image::{DynamicImage, GenericImageView, Rgba};
|
use image::{DynamicImage, GenericImageView, Rgba};
|
||||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
use sprimo_api::{ApiConfig, ApiState};
|
|
||||||
use sprimo_config::{save, AppConfig, ConfigError};
|
|
||||||
use sprimo_platform::{create_adapter, PlatformAdapter};
|
use sprimo_platform::{create_adapter, PlatformAdapter};
|
||||||
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
|
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
|
||||||
|
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
|
||||||
use sprimo_sprite::{
|
use sprimo_sprite::{
|
||||||
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
|
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
|
||||||
};
|
};
|
||||||
@@ -20,12 +19,12 @@ use std::sync::{Arc, RwLock};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::mpsc as tokio_mpsc;
|
use tracing::{info, warn};
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
const APP_NAME: &str = "sprimo";
|
const APP_NAME: &str = "sprimo";
|
||||||
const DEFAULT_PACK: &str = "default";
|
const DEFAULT_PACK: &str = "default";
|
||||||
const WINDOW_PADDING: f32 = 16.0;
|
const WINDOW_PADDING: f32 = 16.0;
|
||||||
|
const STARTUP_WINDOW_SIZE: f32 = 416.0;
|
||||||
const MAGENTA_KEY: [u8; 3] = [255, 0, 255];
|
const MAGENTA_KEY: [u8; 3] = [255, 0, 255];
|
||||||
const CHROMA_KEY_TOLERANCE: u8 = 24;
|
const CHROMA_KEY_TOLERANCE: u8 = 24;
|
||||||
const CHROMA_KEY_FORCE_RATIO: f32 = 0.15;
|
const CHROMA_KEY_FORCE_RATIO: f32 = 0.15;
|
||||||
@@ -34,7 +33,7 @@ const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255];
|
|||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
enum AppError {
|
enum AppError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Config(#[from] ConfigError),
|
RuntimeCore(#[from] RuntimeCoreError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Clone)]
|
#[derive(Resource, Clone)]
|
||||||
@@ -44,10 +43,7 @@ struct SharedSnapshot(Arc<RwLock<FrontendStateSnapshot>>);
|
|||||||
struct PlatformResource(Arc<dyn PlatformAdapter>);
|
struct PlatformResource(Arc<dyn PlatformAdapter>);
|
||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
struct ConfigResource {
|
struct RuntimeCoreResource(Arc<RuntimeCore>);
|
||||||
path: PathBuf,
|
|
||||||
config: AppConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource, Copy, Clone)]
|
#[derive(Resource, Copy, Clone)]
|
||||||
struct DurableState {
|
struct DurableState {
|
||||||
@@ -180,29 +176,30 @@ fn main() -> Result<(), AppError> {
|
|||||||
.compact()
|
.compact()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let (config_path, config) = sprimo_config::load_or_create(APP_NAME)?;
|
|
||||||
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
|
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
|
||||||
let capabilities = platform.capabilities();
|
let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
|
||||||
|
let shared_snapshot = runtime_core.snapshot();
|
||||||
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
let config = runtime_core
|
||||||
snapshot.x = config.window.x;
|
.config()
|
||||||
snapshot.y = config.window.y;
|
.read()
|
||||||
snapshot.scale = config.window.scale;
|
.expect("runtime core config lock poisoned")
|
||||||
snapshot.flags.click_through = config.window.click_through;
|
.clone();
|
||||||
snapshot.flags.always_on_top = config.window.always_on_top;
|
|
||||||
snapshot.flags.visible = config.window.visible;
|
|
||||||
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
|
|
||||||
let shared_snapshot = Arc::new(RwLock::new(snapshot));
|
|
||||||
|
|
||||||
let runtime = Runtime::new().expect("tokio runtime");
|
let runtime = Runtime::new().expect("tokio runtime");
|
||||||
let (api_command_tx, api_command_rx) = tokio_mpsc::channel(1_024);
|
|
||||||
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
|
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
|
||||||
let (hotkey_tx, hotkey_rx) = mpsc::channel();
|
let (hotkey_tx, hotkey_rx) = mpsc::channel();
|
||||||
|
|
||||||
spawn_api(&runtime, &config, Arc::clone(&shared_snapshot), api_command_tx);
|
runtime_core.spawn_api(&runtime);
|
||||||
|
let command_rx = runtime_core.command_receiver();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
let mut rx = api_command_rx;
|
loop {
|
||||||
while let Some(command) = rx.recv().await {
|
let next = {
|
||||||
|
let mut receiver = command_rx.lock().await;
|
||||||
|
receiver.recv().await
|
||||||
|
};
|
||||||
|
let Some(command) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
if bevy_command_tx.send(command).is_err() {
|
if bevy_command_tx.send(command).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -223,7 +220,10 @@ fn main() -> Result<(), AppError> {
|
|||||||
decorations: false,
|
decorations: false,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
window_level: WindowLevel::AlwaysOnTop,
|
window_level: WindowLevel::AlwaysOnTop,
|
||||||
resolution: bevy::window::WindowResolution::new(544.0, 544.0),
|
resolution: bevy::window::WindowResolution::new(
|
||||||
|
STARTUP_WINDOW_SIZE,
|
||||||
|
STARTUP_WINDOW_SIZE,
|
||||||
|
),
|
||||||
position: WindowPosition::At(IVec2::new(
|
position: WindowPosition::At(IVec2::new(
|
||||||
config.window.x.round() as i32,
|
config.window.x.round() as i32,
|
||||||
config.window.y.round() as i32,
|
config.window.y.round() as i32,
|
||||||
@@ -235,11 +235,8 @@ fn main() -> Result<(), AppError> {
|
|||||||
app.insert_resource(ClearColor(window_clear_color()));
|
app.insert_resource(ClearColor(window_clear_color()));
|
||||||
|
|
||||||
app.insert_resource(SharedSnapshot(shared_snapshot));
|
app.insert_resource(SharedSnapshot(shared_snapshot));
|
||||||
|
app.insert_resource(RuntimeCoreResource(Arc::clone(&runtime_core)));
|
||||||
app.insert_resource(PlatformResource(Arc::clone(&platform)));
|
app.insert_resource(PlatformResource(Arc::clone(&platform)));
|
||||||
app.insert_resource(ConfigResource {
|
|
||||||
path: config_path,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
app.insert_resource(DurableState {
|
app.insert_resource(DurableState {
|
||||||
state: FrontendState::Idle,
|
state: FrontendState::Idle,
|
||||||
});
|
});
|
||||||
@@ -271,23 +268,6 @@ fn main() -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_api(
|
|
||||||
runtime: &Runtime,
|
|
||||||
config: &AppConfig,
|
|
||||||
shared_snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
|
||||||
api_command_tx: tokio_mpsc::Sender<CommandEnvelope>,
|
|
||||||
) {
|
|
||||||
let mut api_config = ApiConfig::default_with_token(config.api.auth_token.clone());
|
|
||||||
api_config.bind_addr = ([127, 0, 0, 1], config.api.port).into();
|
|
||||||
let api_state = Arc::new(ApiState::new(api_config.clone(), shared_snapshot, api_command_tx));
|
|
||||||
|
|
||||||
runtime.spawn(async move {
|
|
||||||
if let Err(err) = sprimo_api::run_server(api_config, api_state).await {
|
|
||||||
error!(%err, "api server exited");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_asset_root() -> PathBuf {
|
fn default_asset_root() -> PathBuf {
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
@@ -297,7 +277,7 @@ fn default_asset_root() -> PathBuf {
|
|||||||
|
|
||||||
fn setup_scene(
|
fn setup_scene(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
config: Res<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
root: Res<SpritePackRoot>,
|
root: Res<SpritePackRoot>,
|
||||||
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
@@ -313,7 +293,14 @@ fn setup_scene(
|
|||||||
..default()
|
..default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let selected = config.config.sprite.selected_pack.clone();
|
let selected = runtime_core
|
||||||
|
.0
|
||||||
|
.config()
|
||||||
|
.read()
|
||||||
|
.expect("runtime core config lock poisoned")
|
||||||
|
.sprite
|
||||||
|
.selected_pack
|
||||||
|
.clone();
|
||||||
let runtime = match load_pack_runtime(
|
let runtime = match load_pack_runtime(
|
||||||
&root.asset_root,
|
&root.asset_root,
|
||||||
&selected,
|
&selected,
|
||||||
@@ -721,7 +708,6 @@ fn attach_window_handle_once(
|
|||||||
.read()
|
.read()
|
||||||
.expect("frontend snapshot lock poisoned");
|
.expect("frontend snapshot lock poisoned");
|
||||||
let _ = platform.0.set_always_on_top(guard.flags.always_on_top);
|
let _ = platform.0.set_always_on_top(guard.flags.always_on_top);
|
||||||
let _ = platform.0.set_click_through(guard.flags.click_through);
|
|
||||||
let _ = platform.0.set_visible(guard.flags.visible);
|
let _ = platform.0.set_visible(guard.flags.visible);
|
||||||
let _ = platform.0.set_window_position(guard.x, guard.y);
|
let _ = platform.0.set_window_position(guard.x, guard.y);
|
||||||
*attached = true;
|
*attached = true;
|
||||||
@@ -740,29 +726,23 @@ fn attach_window_handle_once(
|
|||||||
fn poll_hotkey_recovery(
|
fn poll_hotkey_recovery(
|
||||||
ingress: NonSendMut<HotkeyIngress>,
|
ingress: NonSendMut<HotkeyIngress>,
|
||||||
platform: Res<PlatformResource>,
|
platform: Res<PlatformResource>,
|
||||||
mut config: ResMut<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
snapshot: Res<SharedSnapshot>,
|
|
||||||
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
|
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
|
||||||
) {
|
) {
|
||||||
while ingress.0.try_recv().is_ok() {
|
while ingress.0.try_recv().is_ok() {
|
||||||
info!("recovery hotkey received");
|
info!("recovery hotkey received");
|
||||||
let _ = platform.0.set_click_through(false);
|
let _ = platform.0.set_always_on_top(true);
|
||||||
let _ = platform.0.set_visible(true);
|
let _ = platform.0.set_visible(true);
|
||||||
|
|
||||||
if let Ok(mut visibility) = pet_query.get_single_mut() {
|
if let Ok(mut visibility) = pet_query.get_single_mut() {
|
||||||
*visibility = Visibility::Visible;
|
*visibility = Visibility::Visible;
|
||||||
}
|
}
|
||||||
|
if let Err(err) = runtime_core.0.apply_command(&FrontendCommand::SetFlags {
|
||||||
config.config.window.click_through = false;
|
click_through: None,
|
||||||
config.config.window.visible = true;
|
always_on_top: Some(true),
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
visible: Some(true),
|
||||||
warn!(%err, "failed to persist config after hotkey recovery");
|
}) {
|
||||||
}
|
warn!(%err, "failed to persist recovery flag config");
|
||||||
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
guard.flags.click_through = false;
|
|
||||||
guard.flags.visible = true;
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,7 +754,7 @@ fn poll_backend_commands(
|
|||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||||
mut config: ResMut<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
snapshot: Res<SharedSnapshot>,
|
snapshot: Res<SharedSnapshot>,
|
||||||
mut durable_state: ResMut<DurableState>,
|
mut durable_state: ResMut<DurableState>,
|
||||||
mut reset: ResMut<PendingStateReset>,
|
mut reset: ResMut<PendingStateReset>,
|
||||||
@@ -790,6 +770,7 @@ fn poll_backend_commands(
|
|||||||
let Ok(envelope) = ingress.0.try_recv() else {
|
let Ok(envelope) = ingress.0.try_recv() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
let command = envelope.command;
|
||||||
|
|
||||||
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
|
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
|
||||||
pet_query.get_single_mut()
|
pet_query.get_single_mut()
|
||||||
@@ -797,7 +778,14 @@ fn poll_backend_commands(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match envelope.command {
|
if !matches!(command, FrontendCommand::SetSpritePack { .. }) {
|
||||||
|
if let Err(err) = runtime_core.0.apply_command(&command) {
|
||||||
|
warn!(%err, "failed to apply command in runtime core");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
FrontendCommand::SetState { state, ttl_ms } => {
|
FrontendCommand::SetState { state, ttl_ms } => {
|
||||||
if !matches!(state, FrontendState::Success | FrontendState::Error) {
|
if !matches!(state, FrontendState::Success | FrontendState::Error) {
|
||||||
durable_state.state = state;
|
durable_state.state = state;
|
||||||
@@ -868,8 +856,11 @@ fn poll_backend_commands(
|
|||||||
window.resolution.set(size.x, size.y);
|
window.resolution.set(size.x, size.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.config.sprite.selected_pack = requested;
|
if let Err(err) = runtime_core.0.apply_command(
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
&FrontendCommand::SetSpritePack {
|
||||||
|
pack_id_or_path: requested.clone(),
|
||||||
|
},
|
||||||
|
) {
|
||||||
warn!(%err, "failed to persist sprite pack selection");
|
warn!(%err, "failed to persist sprite pack selection");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,37 +880,17 @@ fn poll_backend_commands(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
||||||
if let Some(value) = x {
|
|
||||||
config.config.window.x = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = y {
|
|
||||||
config.config.window.y = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = scale {
|
if let Some(value) = scale {
|
||||||
transform.scale = Vec3::splat(value);
|
transform.scale = Vec3::splat(value);
|
||||||
config.config.window.scale = value;
|
|
||||||
if let Ok(mut window) = window_query.get_single_mut() {
|
if let Ok(mut window) = window_query.get_single_mut() {
|
||||||
let size = window_size_for_pack(¤t_pack.runtime, value);
|
let size = window_size_for_pack(¤t_pack.runtime, value);
|
||||||
window.resolution.set(size.x, size.y);
|
window.resolution.set(size.x, size.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = platform
|
if x.is_some() || y.is_some() {
|
||||||
.0
|
if let Ok(guard) = snapshot.0.read() {
|
||||||
.set_window_position(config.config.window.x, config.config.window.y);
|
let _ = platform.0.set_window_position(guard.x, guard.y);
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
|
||||||
warn!(%err, "failed to persist transform config");
|
|
||||||
}
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
if let Some(value) = x {
|
|
||||||
guard.x = value;
|
|
||||||
}
|
}
|
||||||
if let Some(value) = y {
|
|
||||||
guard.y = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = scale {
|
|
||||||
guard.scale = value;
|
|
||||||
}
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FrontendCommand::SetFlags {
|
FrontendCommand::SetFlags {
|
||||||
@@ -927,39 +898,18 @@ fn poll_backend_commands(
|
|||||||
always_on_top,
|
always_on_top,
|
||||||
visible,
|
visible,
|
||||||
} => {
|
} => {
|
||||||
if let Some(value) = click_through {
|
let _ = click_through;
|
||||||
let _ = platform.0.set_click_through(value);
|
|
||||||
config.config.window.click_through = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = always_on_top {
|
if let Some(value) = always_on_top {
|
||||||
let _ = platform.0.set_always_on_top(value);
|
let _ = platform.0.set_always_on_top(value);
|
||||||
config.config.window.always_on_top = value;
|
|
||||||
}
|
}
|
||||||
if let Some(value) = visible {
|
if let Some(value) = visible {
|
||||||
let _ = platform.0.set_visible(value);
|
let _ = platform.0.set_visible(value);
|
||||||
config.config.window.visible = value;
|
|
||||||
*visibility = if value {
|
*visibility = if value {
|
||||||
Visibility::Visible
|
Visibility::Visible
|
||||||
} else {
|
} else {
|
||||||
Visibility::Hidden
|
Visibility::Hidden
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
|
||||||
warn!(%err, "failed to persist flag config");
|
|
||||||
}
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
if let Some(value) = click_through {
|
|
||||||
guard.flags.click_through = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = always_on_top {
|
|
||||||
guard.flags.always_on_top = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = visible {
|
|
||||||
guard.flags.visible = value;
|
|
||||||
}
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FrontendCommand::Toast { text, .. } => {
|
FrontendCommand::Toast { text, .. } => {
|
||||||
info!(toast = text, "toast command received");
|
info!(toast = text, "toast command received");
|
||||||
@@ -970,6 +920,7 @@ fn poll_backend_commands(
|
|||||||
|
|
||||||
fn tick_state_reset(
|
fn tick_state_reset(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
mut reset: ResMut<PendingStateReset>,
|
mut reset: ResMut<PendingStateReset>,
|
||||||
mut animation: ResMut<AnimationResource>,
|
mut animation: ResMut<AnimationResource>,
|
||||||
current_pack: Res<CurrentPackResource>,
|
current_pack: Res<CurrentPackResource>,
|
||||||
@@ -986,6 +937,10 @@ fn tick_state_reset(
|
|||||||
|
|
||||||
let next_state = reset.revert_to;
|
let next_state = reset.revert_to;
|
||||||
let next_animation = default_animation_for_state(next_state);
|
let next_animation = default_animation_for_state(next_state);
|
||||||
|
let _ = runtime_core.0.apply_command(&FrontendCommand::SetState {
|
||||||
|
state: next_state,
|
||||||
|
ttl_ms: None,
|
||||||
|
});
|
||||||
set_animation(&mut animation, ¤t_pack.runtime.clips, next_animation);
|
set_animation(&mut animation, ¤t_pack.runtime.clips, next_animation);
|
||||||
if let Ok(mut atlas) = atlas_query.get_single_mut() {
|
if let Ok(mut atlas) = atlas_query.get_single_mut() {
|
||||||
if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) {
|
if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) {
|
||||||
@@ -1079,7 +1034,7 @@ fn default_animation_for_state(state: FrontendState) -> &'static str {
|
|||||||
FrontendState::Active => "active",
|
FrontendState::Active => "active",
|
||||||
FrontendState::Success => "success",
|
FrontendState::Success => "success",
|
||||||
FrontendState::Error => "error",
|
FrontendState::Error => "error",
|
||||||
FrontendState::Dragging => "idle",
|
FrontendState::Dragging => "dragging",
|
||||||
FrontendState::Hidden => "idle",
|
FrontendState::Hidden => "idle",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -26,6 +27,7 @@ pub struct AppConfig {
|
|||||||
pub api: ApiConfig,
|
pub api: ApiConfig,
|
||||||
pub logging: LoggingConfig,
|
pub logging: LoggingConfig,
|
||||||
pub controls: ControlsConfig,
|
pub controls: ControlsConfig,
|
||||||
|
pub frontend: FrontendConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -37,6 +39,7 @@ impl Default for AppConfig {
|
|||||||
api: ApiConfig::default(),
|
api: ApiConfig::default(),
|
||||||
logging: LoggingConfig::default(),
|
logging: LoggingConfig::default(),
|
||||||
controls: ControlsConfig::default(),
|
controls: ControlsConfig::default(),
|
||||||
|
frontend: FrontendConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,13 +107,41 @@ impl Default for SpriteConfig {
|
|||||||
pub struct ApiConfig {
|
pub struct ApiConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub auth_token: String,
|
pub auth_token: String,
|
||||||
|
pub auth_tokens: Vec<ApiTokenEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ApiTokenEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub token: String,
|
||||||
|
pub created_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ApiTokenEntry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
label: "default".to_string(),
|
||||||
|
token: Uuid::new_v4().to_string(),
|
||||||
|
created_at_ms: now_unix_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ApiConfig {
|
impl Default for ApiConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let token = Uuid::new_v4().to_string();
|
||||||
Self {
|
Self {
|
||||||
port: 32_145,
|
port: 32_145,
|
||||||
auth_token: Uuid::new_v4().to_string(),
|
auth_token: token.clone(),
|
||||||
|
auth_tokens: vec![ApiTokenEntry {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
label: "default".to_string(),
|
||||||
|
token,
|
||||||
|
created_at_ms: now_unix_ms(),
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +176,31 @@ impl Default for ControlsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FrontendBackend {
|
||||||
|
Bevy,
|
||||||
|
Tauri,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FrontendConfig {
|
||||||
|
pub backend: FrontendBackend,
|
||||||
|
pub debug_overlay_visible: bool,
|
||||||
|
pub tauri_animation_slowdown_factor: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FrontendConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: FrontendBackend::Bevy,
|
||||||
|
debug_overlay_visible: false,
|
||||||
|
tauri_animation_slowdown_factor: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> {
|
pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> {
|
||||||
let dirs =
|
let dirs =
|
||||||
@@ -179,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|v| v.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load_or_create_at, save, AppConfig};
|
use super::{load_or_create_at, save, AppConfig};
|
||||||
@@ -198,9 +261,15 @@ mod tests {
|
|||||||
let path = temp.path().join("config.toml");
|
let path = temp.path().join("config.toml");
|
||||||
let mut config = AppConfig::default();
|
let mut config = AppConfig::default();
|
||||||
config.window.x = 42.0;
|
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");
|
save(&path, &config).expect("save");
|
||||||
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
||||||
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
|
||||||
|
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
|
||||||
|
assert!(loaded.frontend.debug_overlay_visible);
|
||||||
|
assert_eq!(loaded.frontend.tauri_animation_slowdown_factor, 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ mod windows {
|
|||||||
impl PlatformAdapter for WindowsAdapter {
|
impl PlatformAdapter for WindowsAdapter {
|
||||||
fn capabilities(&self) -> CapabilityFlags {
|
fn capabilities(&self) -> CapabilityFlags {
|
||||||
CapabilityFlags {
|
CapabilityFlags {
|
||||||
supports_click_through: true,
|
supports_click_through: false,
|
||||||
supports_transparency: true,
|
supports_transparency: true,
|
||||||
supports_tray: false,
|
supports_tray: false,
|
||||||
supports_global_hotkey: true,
|
supports_global_hotkey: true,
|
||||||
|
|||||||
20
crates/sprimo-runtime-core/Cargo.toml
Normal file
20
crates/sprimo-runtime-core/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "sprimo-runtime-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sprimo-api = { path = "../sprimo-api" }
|
||||||
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.12.0"
|
||||||
685
crates/sprimo-runtime-core/src/lib.rs
Normal file
685
crates/sprimo-runtime-core/src/lib.rs
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
|
||||||
|
use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError};
|
||||||
|
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN: u8 = 1;
|
||||||
|
const TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX: u8 = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RuntimeCoreError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Config(#[from] ConfigError),
|
||||||
|
#[error("snapshot lock poisoned")]
|
||||||
|
SnapshotPoisoned,
|
||||||
|
#[error("config lock poisoned")]
|
||||||
|
ConfigPoisoned,
|
||||||
|
#[error("api token not found: {0}")]
|
||||||
|
ApiTokenNotFound(String),
|
||||||
|
#[error("cannot revoke the last API token")]
|
||||||
|
LastApiToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RuntimeCore {
|
||||||
|
config_path: PathBuf,
|
||||||
|
config: Arc<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>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeCore {
|
||||||
|
pub fn new(app_name: &str, capabilities: CapabilityFlags) -> Result<Self, RuntimeCoreError> {
|
||||||
|
let (config_path, config_value) = sprimo_config::load_or_create(app_name)?;
|
||||||
|
Self::new_with_config(config_path, config_value, capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_config(
|
||||||
|
config_path: PathBuf,
|
||||||
|
mut config_value: AppConfig,
|
||||||
|
capabilities: CapabilityFlags,
|
||||||
|
) -> Result<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;
|
||||||
|
snapshot.scale = config_value.window.scale;
|
||||||
|
snapshot.flags.click_through = false;
|
||||||
|
snapshot.flags.always_on_top = config_value.window.always_on_top;
|
||||||
|
snapshot.flags.visible = config_value.window.visible;
|
||||||
|
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
|
||||||
|
|
||||||
|
let api_config = ApiConfig::default_with_tokens(
|
||||||
|
config_value
|
||||||
|
.api
|
||||||
|
.auth_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.token.clone())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let (command_tx, command_rx) = mpsc::channel(1_024);
|
||||||
|
let auth_store = Arc::new(RwLock::new(
|
||||||
|
config_value
|
||||||
|
.api
|
||||||
|
.auth_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.token.clone())
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let core = Self {
|
||||||
|
config_path,
|
||||||
|
config: Arc::new(RwLock::new(config_value)),
|
||||||
|
snapshot: Arc::new(RwLock::new(snapshot)),
|
||||||
|
api_config,
|
||||||
|
auth_store,
|
||||||
|
command_tx,
|
||||||
|
command_rx: Arc::new(Mutex::new(command_rx)),
|
||||||
|
};
|
||||||
|
if click_through_was_enabled || tokens_changed {
|
||||||
|
core.persist_config()?;
|
||||||
|
}
|
||||||
|
Ok(core)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Arc<RwLock<FrontendStateSnapshot>> {
|
||||||
|
Arc::clone(&self.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> Arc<RwLock<AppConfig>> {
|
||||||
|
Arc::clone(&self.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_receiver(&self) -> Arc<Mutex<mpsc::Receiver<CommandEnvelope>>> {
|
||||||
|
Arc::clone(&self.command_rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_sender(&self) -> mpsc::Sender<CommandEnvelope> {
|
||||||
|
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 {
|
||||||
|
log_api_error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_command(&self, command: &FrontendCommand) -> Result<(), RuntimeCoreError> {
|
||||||
|
match command {
|
||||||
|
FrontendCommand::SetState { state, .. } => {
|
||||||
|
let mut snapshot = self
|
||||||
|
.snapshot
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
||||||
|
snapshot.state = *state;
|
||||||
|
snapshot.current_animation = default_animation_for_state(*state).to_string();
|
||||||
|
snapshot.last_error = None;
|
||||||
|
}
|
||||||
|
FrontendCommand::PlayAnimation { name, .. } => {
|
||||||
|
let mut snapshot = self
|
||||||
|
.snapshot
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
||||||
|
snapshot.current_animation = name.clone();
|
||||||
|
snapshot.last_error = None;
|
||||||
|
}
|
||||||
|
FrontendCommand::SetSpritePack { pack_id_or_path } => {
|
||||||
|
{
|
||||||
|
let mut snapshot = self
|
||||||
|
.snapshot
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
||||||
|
snapshot.active_sprite_pack = pack_id_or_path.clone();
|
||||||
|
snapshot.last_error = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut config = self
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
config.sprite.selected_pack = pack_id_or_path.clone();
|
||||||
|
}
|
||||||
|
self.persist_config()?;
|
||||||
|
}
|
||||||
|
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
||||||
|
{
|
||||||
|
let mut snapshot = self
|
||||||
|
.snapshot
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
||||||
|
if let Some(value) = x {
|
||||||
|
snapshot.x = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = y {
|
||||||
|
snapshot.y = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = scale {
|
||||||
|
snapshot.scale = *value;
|
||||||
|
}
|
||||||
|
snapshot.last_error = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut config = self
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
if let Some(value) = x {
|
||||||
|
config.window.x = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = y {
|
||||||
|
config.window.y = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = scale {
|
||||||
|
config.window.scale = *value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.persist_config()?;
|
||||||
|
}
|
||||||
|
FrontendCommand::SetFlags {
|
||||||
|
click_through: _click_through,
|
||||||
|
always_on_top,
|
||||||
|
visible,
|
||||||
|
} => {
|
||||||
|
{
|
||||||
|
let mut snapshot = self
|
||||||
|
.snapshot
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
|
||||||
|
snapshot.flags.click_through = false;
|
||||||
|
if let Some(value) = always_on_top {
|
||||||
|
snapshot.flags.always_on_top = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = visible {
|
||||||
|
snapshot.flags.visible = *value;
|
||||||
|
}
|
||||||
|
snapshot.last_error = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut config = self
|
||||||
|
.config
|
||||||
|
.write()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
config.window.click_through = false;
|
||||||
|
if let Some(value) = always_on_top {
|
||||||
|
config.window.always_on_top = *value;
|
||||||
|
}
|
||||||
|
if let Some(value) = visible {
|
||||||
|
config.window.visible = *value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.persist_config()?;
|
||||||
|
}
|
||||||
|
FrontendCommand::Toast { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_config(&self) -> Result<(), RuntimeCoreError> {
|
||||||
|
let guard = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
save(&self.config_path, &guard)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> {
|
||||||
|
let tokens = {
|
||||||
|
let guard = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
|
||||||
|
guard
|
||||||
|
.api
|
||||||
|
.auth_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.token.clone())
|
||||||
|
.collect::<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 {
|
||||||
|
match state {
|
||||||
|
sprimo_protocol::v1::FrontendState::Idle => "idle",
|
||||||
|
sprimo_protocol::v1::FrontendState::Active => "active",
|
||||||
|
sprimo_protocol::v1::FrontendState::Success => "success",
|
||||||
|
sprimo_protocol::v1::FrontendState::Error => "error",
|
||||||
|
sprimo_protocol::v1::FrontendState::Dragging => "dragging",
|
||||||
|
sprimo_protocol::v1::FrontendState::Hidden => "idle",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_tauri_animation_slowdown_factor(value: u8) -> u8 {
|
||||||
|
value.clamp(
|
||||||
|
TAURI_ANIMATION_SLOWDOWN_FACTOR_MIN,
|
||||||
|
TAURI_ANIMATION_SLOWDOWN_FACTOR_MAX,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_api_error(err: ApiServerError) {
|
||||||
|
warn!(%err, "runtime core api server exited");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_api_tokens(config: &mut AppConfig) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
|
||||||
|
let legacy = config.api.auth_token.trim().to_string();
|
||||||
|
if legacy != config.api.auth_token {
|
||||||
|
config.api.auth_token = legacy.clone();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut entry in config.api.auth_tokens.clone() {
|
||||||
|
let original_id = entry.id.clone();
|
||||||
|
let original_label = entry.label.clone();
|
||||||
|
let original_token = entry.token.clone();
|
||||||
|
let original_created = entry.created_at_ms;
|
||||||
|
|
||||||
|
entry.id = entry.id.trim().to_string();
|
||||||
|
if entry.id.is_empty() {
|
||||||
|
entry.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
entry.label = normalize_token_label(&entry.label);
|
||||||
|
entry.token = entry.token.trim().to_string();
|
||||||
|
if entry.created_at_ms == 0 {
|
||||||
|
entry.created_at_ms = now_unix_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
let field_changed = entry.id != original_id
|
||||||
|
|| entry.label != original_label
|
||||||
|
|| entry.token != original_token
|
||||||
|
|| entry.created_at_ms != original_created;
|
||||||
|
if field_changed {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if entry.token.is_empty() {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !seen.insert(entry.token.clone()) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.is_empty() {
|
||||||
|
let token = if legacy.is_empty() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
legacy
|
||||||
|
};
|
||||||
|
normalized.push(ApiTokenEntry {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
label: "default".to_string(),
|
||||||
|
token,
|
||||||
|
created_at_ms: now_unix_ms(),
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mirror = normalized
|
||||||
|
.first()
|
||||||
|
.map(|entry| entry.token.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if config.api.auth_token != mirror {
|
||||||
|
config.api.auth_token = mirror;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if config.api.auth_tokens != normalized {
|
||||||
|
config.api.auth_tokens = normalized;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_token_label(value: &str) -> String {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"token".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|v| v.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::RuntimeCore;
|
||||||
|
use sprimo_config::AppConfig;
|
||||||
|
use sprimo_protocol::v1::{CapabilityFlags, FrontendCommand, FrontendState};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_command_updates_snapshot() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
core.apply_command(&FrontendCommand::SetState {
|
||||||
|
state: FrontendState::Active,
|
||||||
|
ttl_ms: None,
|
||||||
|
})
|
||||||
|
.expect("apply");
|
||||||
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
||||||
|
assert_eq!(snapshot.state, FrontendState::Active);
|
||||||
|
assert_eq!(snapshot.current_animation, "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragging_state_maps_to_dragging_animation() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
core.apply_command(&FrontendCommand::SetState {
|
||||||
|
state: FrontendState::Dragging,
|
||||||
|
ttl_ms: None,
|
||||||
|
})
|
||||||
|
.expect("apply");
|
||||||
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
||||||
|
assert_eq!(snapshot.state, FrontendState::Dragging);
|
||||||
|
assert_eq!(snapshot.current_animation, "dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn click_through_flag_is_ignored_and_forced_false() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
config.window.click_through = true;
|
||||||
|
let core = RuntimeCore::new_with_config(path, config, CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
|
||||||
|
core.apply_command(&FrontendCommand::SetFlags {
|
||||||
|
click_through: Some(true),
|
||||||
|
always_on_top: None,
|
||||||
|
visible: None,
|
||||||
|
})
|
||||||
|
.expect("apply");
|
||||||
|
|
||||||
|
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
|
||||||
|
assert!(!snapshot.flags.click_through);
|
||||||
|
let config = core.config().read().expect("config lock").clone();
|
||||||
|
assert!(!config.window.click_through);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_debug_overlay_visibility_roundtrips() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
core.set_frontend_debug_overlay_visible(true).expect("set");
|
||||||
|
assert!(core.frontend_debug_overlay_visible().expect("get"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_tauri_animation_slowdown_factor_roundtrips() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
let persisted = core
|
||||||
|
.set_frontend_tauri_animation_slowdown_factor(6)
|
||||||
|
.expect("set");
|
||||||
|
assert_eq!(persisted, 6);
|
||||||
|
assert_eq!(
|
||||||
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frontend_tauri_animation_slowdown_factor_clamps() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
let persisted = core
|
||||||
|
.set_frontend_tauri_animation_slowdown_factor(0)
|
||||||
|
.expect("set");
|
||||||
|
assert_eq!(persisted, 1);
|
||||||
|
assert_eq!(
|
||||||
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let upper = core
|
||||||
|
.set_frontend_tauri_animation_slowdown_factor(201)
|
||||||
|
.expect("set high");
|
||||||
|
assert_eq!(upper, 200);
|
||||||
|
assert_eq!(
|
||||||
|
core.frontend_tauri_animation_slowdown_factor().expect("get"),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_token_create_rename_revoke_roundtrip() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
|
||||||
|
let initial_len = core.list_api_tokens().expect("tokens").len();
|
||||||
|
let created = core
|
||||||
|
.create_api_token(Some("backend-ci".to_string()))
|
||||||
|
.expect("create token");
|
||||||
|
let after_create = core.list_api_tokens().expect("tokens");
|
||||||
|
assert_eq!(after_create.len(), initial_len + 1);
|
||||||
|
assert!(after_create.iter().any(|entry| entry.id == created.id));
|
||||||
|
|
||||||
|
core.rename_api_token(&created.id, "automation")
|
||||||
|
.expect("rename token");
|
||||||
|
let after_rename = core.list_api_tokens().expect("tokens");
|
||||||
|
assert!(
|
||||||
|
after_rename
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.id == created.id && entry.label == "automation")
|
||||||
|
);
|
||||||
|
|
||||||
|
core.revoke_api_token(&created.id).expect("revoke token");
|
||||||
|
let after_revoke = core.list_api_tokens().expect("tokens");
|
||||||
|
assert_eq!(after_revoke.len(), initial_len);
|
||||||
|
assert!(!after_revoke.iter().any(|entry| entry.id == created.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_revoke_last_api_token() {
|
||||||
|
let temp = TempDir::new().expect("tempdir");
|
||||||
|
let path = temp.path().join("config.toml");
|
||||||
|
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
|
||||||
|
.expect("core init");
|
||||||
|
let only = core.list_api_tokens().expect("tokens");
|
||||||
|
assert_eq!(only.len(), 1);
|
||||||
|
let err = core
|
||||||
|
.revoke_api_token(&only[0].id)
|
||||||
|
.expect_err("last token revoke must fail");
|
||||||
|
assert!(format!("{err}").contains("last API token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/sprimo-tauri/Cargo.toml
Normal file
29
crates/sprimo-tauri/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "sprimo-tauri"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22.1"
|
||||||
|
serde.workspace = true
|
||||||
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
|
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 = ["tray-icon"] }
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0"
|
||||||
|
tauri-plugin-log = "2.0.0"
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
8
crates/sprimo-tauri/build.rs
Normal file
8
crates/sprimo-tauri/build.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/index.html");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/dist");
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
17
crates/sprimo-tauri/capabilities/default.json
Normal file
17
crates/sprimo-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for sprimo-tauri main window runtime APIs.",
|
||||||
|
"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
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
1
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +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:window:allow-current-monitor","core:event:allow-listen","core:event:allow-unlisten"]}}
|
||||||
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
962
crates/sprimo-tauri/src/main.rs
Normal file
962
crates/sprimo-tauri/src/main.rs
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
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::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
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 {
|
||||||
|
name: String,
|
||||||
|
fps: u16,
|
||||||
|
frames: Vec<u32>,
|
||||||
|
one_shot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
struct UiAnchor {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
struct UiSpritePack {
|
||||||
|
id: String,
|
||||||
|
frame_width: u32,
|
||||||
|
frame_height: u32,
|
||||||
|
atlas_data_url: String,
|
||||||
|
animations: Vec<UiAnimationClip>,
|
||||||
|
anchor: UiAnchor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct AtlasGeometry {
|
||||||
|
frame_width: u32,
|
||||||
|
frame_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
struct UiSnapshot {
|
||||||
|
state: String,
|
||||||
|
current_animation: String,
|
||||||
|
x: f32,
|
||||||
|
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)]
|
||||||
|
enum AppError {
|
||||||
|
#[error("{0}")]
|
||||||
|
RuntimeCore(#[from] RuntimeCoreError),
|
||||||
|
#[error("tokio runtime init failed: {0}")]
|
||||||
|
Tokio(#[from] std::io::Error),
|
||||||
|
#[error("tauri runtime failed: {0}")]
|
||||||
|
Tauri(#[from] tauri::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
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]
|
||||||
|
fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> {
|
||||||
|
let snapshot = state
|
||||||
|
.runtime_core
|
||||||
|
.snapshot()
|
||||||
|
.read()
|
||||||
|
.map_err(|_| "snapshot lock poisoned".to_string())?
|
||||||
|
.clone();
|
||||||
|
Ok(to_ui_snapshot(&snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
.read()
|
||||||
|
.map_err(|_| "config lock poisoned".to_string())?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let selected = config.sprite.selected_pack;
|
||||||
|
let pack_path = match resolve_pack_path(&root, &selected) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => resolve_pack_path(&root, DEFAULT_PACK).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 = 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(UiSpritePack {
|
||||||
|
id: manifest.id,
|
||||||
|
frame_width: geometry.frame_width,
|
||||||
|
frame_height: geometry.frame_height,
|
||||||
|
atlas_data_url,
|
||||||
|
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
|
||||||
|
anchor: UiAnchor {
|
||||||
|
x: manifest.anchor.x,
|
||||||
|
y: manifest.anchor.y,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
|
||||||
|
let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
|
||||||
|
let runtime = Arc::new(Runtime::new()?);
|
||||||
|
runtime_core.spawn_api(&runtime);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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();
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
let mut receiver = command_rx.lock().await;
|
||||||
|
receiver.recv().await
|
||||||
|
};
|
||||||
|
let Some(envelope) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = runtime_core_for_commands.apply_command(&envelope.command) {
|
||||||
|
warn!(%err, "failed to apply command in tauri runtime");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = {
|
||||||
|
let snapshot = runtime_core_for_commands.snapshot();
|
||||||
|
match snapshot.read() {
|
||||||
|
Ok(s) => Some(to_ui_snapshot(&s)),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(value) = payload {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
x: Some(position.x as f32),
|
||||||
|
y: Some(position.y as f32),
|
||||||
|
anchor: None,
|
||||||
|
scale: None,
|
||||||
|
opacity: None,
|
||||||
|
};
|
||||||
|
if runtime_core.apply_command(&command).is_ok() {
|
||||||
|
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||||
|
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(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
|
||||||
|
UiSnapshot {
|
||||||
|
state: state_name(snapshot.state).to_string(),
|
||||||
|
current_animation: snapshot.current_animation.clone(),
|
||||||
|
x: snapshot.x,
|
||||||
|
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",
|
||||||
|
FrontendState::Active => "active",
|
||||||
|
FrontendState::Success => "success",
|
||||||
|
FrontendState::Error => "error",
|
||||||
|
FrontendState::Dragging => "dragging",
|
||||||
|
FrontendState::Hidden => "hidden",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
|
||||||
|
UiAnimationClip {
|
||||||
|
name: value.name,
|
||||||
|
fps: value.fps.max(1),
|
||||||
|
frames: value.frames,
|
||||||
|
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))
|
||||||
|
}
|
||||||
31
crates/sprimo-tauri/tauri.conf.json
Normal file
31
crates/sprimo-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "sprimo-tauri",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.sprimo.tauri",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../../frontend/tauri-ui/dist",
|
||||||
|
"beforeDevCommand": "",
|
||||||
|
"beforeBuildCommand": ""
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "sprimo-tauri",
|
||||||
|
"width": 416,
|
||||||
|
"height": 416,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
|
"shadow": false,
|
||||||
|
"alwaysOnTop": true,
|
||||||
|
"resizable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,9 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
|
|||||||
"uptime_seconds": 12,
|
"uptime_seconds": 12,
|
||||||
"active_sprite_pack": "default",
|
"active_sprite_pack": "default",
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"supports_click_through": true,
|
"supports_click_through": false,
|
||||||
"supports_transparency": true,
|
"supports_transparency": true,
|
||||||
"supports_tray": true,
|
"supports_tray": false,
|
||||||
"supports_global_hotkey": true,
|
"supports_global_hotkey": true,
|
||||||
"supports_skip_taskbar": true
|
"supports_skip_taskbar": true
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,11 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
- `SetFlags.click_through` is deprecated for compatibility and ignored at runtime.
|
||||||
|
- Frontend state always reports `flags.click_through = false`.
|
||||||
|
|
||||||
## Error Response
|
## Error Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -3,20 +3,22 @@
|
|||||||
## Workspace Layout
|
## Workspace Layout
|
||||||
|
|
||||||
- `crates/sprimo-app`: process entrypoint and runtime wiring.
|
- `crates/sprimo-app`: process entrypoint and runtime wiring.
|
||||||
|
- `crates/sprimo-tauri`: Tauri 2.0 alternative frontend entrypoint.
|
||||||
- `crates/sprimo-api`: axum-based localhost control server.
|
- `crates/sprimo-api`: axum-based localhost control server.
|
||||||
- `crates/sprimo-config`: config schema, path resolution, persistence.
|
- `crates/sprimo-config`: config schema, path resolution, persistence.
|
||||||
- `crates/sprimo-platform`: platform abstraction for overlay operations.
|
- `crates/sprimo-platform`: platform abstraction for overlay operations.
|
||||||
- `crates/sprimo-protocol`: shared API/state/command protocol types.
|
- `crates/sprimo-protocol`: shared API/state/command protocol types.
|
||||||
|
- `crates/sprimo-runtime-core`: shared runtime core for command/state/API orchestration.
|
||||||
- `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic.
|
- `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic.
|
||||||
|
|
||||||
## Runtime Data Flow
|
## Runtime Data Flow
|
||||||
|
|
||||||
1. `sprimo-app` loads or creates `config.toml`.
|
1. Frontend (`sprimo-app` or `sprimo-tauri`) initializes `sprimo-runtime-core`.
|
||||||
2. App builds initial `FrontendStateSnapshot`.
|
2. Runtime core loads/creates `config.toml` and builds initial `FrontendStateSnapshot`.
|
||||||
3. App starts `sprimo-api` on a Tokio runtime.
|
3. Runtime core starts `sprimo-api` on a Tokio runtime.
|
||||||
4. API authenticates commands and deduplicates IDs.
|
4. API authenticates commands and deduplicates IDs.
|
||||||
5. Commands are bridged from Tokio channel to Bevy main-thread systems.
|
5. Commands are bridged from API channel into frontend-specific command handlers.
|
||||||
6. Bevy systems apply commands to sprite state, window/platform operations, and config persistence.
|
6. Frontend adapter applies rendering/window effects and runtime core applies snapshot/config state.
|
||||||
7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`.
|
7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`.
|
||||||
|
|
||||||
## Sprite Reload Semantics
|
## Sprite Reload Semantics
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
- API task: axum server.
|
- API task: axum server.
|
||||||
- Bridge task: forwards API commands into Bevy ingest channel.
|
- Bridge task: forwards API commands into Bevy ingest channel.
|
||||||
- Bevy main thread: rendering, animation, command application, and window behavior.
|
- Bevy main thread: rendering, animation, command application, and window behavior.
|
||||||
|
- Tauri thread/runtime: webview UI, event loop, and runtime command consumer.
|
||||||
- Optional hotkey thread (Windows): registers global hotkey and pushes recovery events.
|
- Optional hotkey thread (Windows): registers global hotkey and pushes recovery events.
|
||||||
- Snapshot is shared via `Arc<RwLock<_>>`.
|
- Snapshot is shared via `Arc<RwLock<_>>`.
|
||||||
|
|
||||||
|
|||||||
194
docs/BEVY_WINDOW_VERIFICATION.md
Normal file
194
docs/BEVY_WINDOW_VERIFICATION.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Bevy Window Management Verification (Windows-First)
|
||||||
|
|
||||||
|
Date: 2026-02-13
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines a logical verification workflow for `sprimo-app` window management.
|
||||||
|
It combines code-path assertions with lightweight runtime validation.
|
||||||
|
|
||||||
|
Primary focus:
|
||||||
|
|
||||||
|
- window visibility behavior
|
||||||
|
- always-on-top behavior
|
||||||
|
- position and scale handling
|
||||||
|
- recovery hotkey behavior
|
||||||
|
- snapshot/config consistency
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `crates/sprimo-app/src/main.rs`
|
||||||
|
- `crates/sprimo-platform/src/lib.rs`
|
||||||
|
- window-related runtime-core integration path in `sprimo-app`
|
||||||
|
- API commands: `SetTransform`, `SetFlags`
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- tray/menu behavior
|
||||||
|
- tauri frontend window behavior
|
||||||
|
- long soak/performance testing
|
||||||
|
|
||||||
|
## Requirements Traceability
|
||||||
|
|
||||||
|
| Requirement | Intent | Code path(s) |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-FW-1 | Borderless/transparent overlay | `crates/sprimo-app/src/main.rs` window setup (`decorations=false`, clear color/transparent mode) |
|
||||||
|
| FR-FW-2 | Always-on-top toggle | `crates/sprimo-app/src/main.rs` (`SetFlags.always_on_top`), `crates/sprimo-platform/src/lib.rs` (`set_always_on_top`) |
|
||||||
|
| FR-FW-3 | Recovery to visible/interactive | `crates/sprimo-app/src/main.rs` (`poll_hotkey_recovery`) |
|
||||||
|
| FR-FW-4 | Positioning persistence + move | `crates/sprimo-app/src/main.rs` (`SetTransform x/y`, startup position), runtime-core config persistence |
|
||||||
|
| FR-FW-5 | Position handling under monitor/DPI context | logical verification of position updates via platform adapter + snapshot/config |
|
||||||
|
|
||||||
|
## Code-Path Logical Checklist
|
||||||
|
|
||||||
|
1. Startup invariants:
|
||||||
|
- window is undecorated and non-resizable
|
||||||
|
- startup position uses persisted `window.x` / `window.y`
|
||||||
|
- startup top-most intent is configured
|
||||||
|
|
||||||
|
2. Handle-attach invariants:
|
||||||
|
- `attach_window_handle_once` applies at most once
|
||||||
|
- on Windows, native handle attach occurs before platform calls
|
||||||
|
- initial snapshot values are applied to platform (`always_on_top`, `visible`, `position`)
|
||||||
|
|
||||||
|
3. Command invariants:
|
||||||
|
- `SetTransform.scale` updates sprite scale and window size (`window_size_for_pack`)
|
||||||
|
- `SetTransform.x/y` updates logical position state and issues platform move
|
||||||
|
- `SetFlags.always_on_top` triggers platform top-most call
|
||||||
|
- `SetFlags.visible` triggers platform visibility call and Bevy `Visibility`
|
||||||
|
|
||||||
|
4. Recovery invariants:
|
||||||
|
- hotkey path enforces `visible=true` and `always_on_top=true`
|
||||||
|
- recovery updates logical state via runtime-core command path
|
||||||
|
|
||||||
|
5. Persistence/snapshot invariants:
|
||||||
|
- window fields (`x`, `y`, `scale`, `flags`) roundtrip through runtime-core
|
||||||
|
- `/v1/state` stays consistent with command outcomes
|
||||||
|
|
||||||
|
6. Degradation invariants (non-Windows):
|
||||||
|
- no-op adapter does not crash on window commands
|
||||||
|
- capability semantics remain conservative
|
||||||
|
|
||||||
|
## Lightweight Runtime Checklist (Windows)
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- run `sprimo-app`
|
||||||
|
- resolve token/port from `%APPDATA%/sprimo/config/config.toml`
|
||||||
|
|
||||||
|
### Scenario 1: Baseline
|
||||||
|
|
||||||
|
1. `GET /v1/health`
|
||||||
|
2. `GET /v1/state` with bearer token
|
||||||
|
3. Record `x`, `y`, `scale`, `flags.visible`, `flags.always_on_top`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- health endpoint alive
|
||||||
|
- state endpoint authorized
|
||||||
|
- window fields present and coherent
|
||||||
|
|
||||||
|
### Scenario 2: Position Command
|
||||||
|
|
||||||
|
1. Send `SetTransform` with `x` and `y`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `x`/`y` update in snapshot
|
||||||
|
- window visibly moves
|
||||||
|
|
||||||
|
### Scenario 3: Scale Command
|
||||||
|
|
||||||
|
1. Send `SetTransform` with `scale`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `scale` updates
|
||||||
|
- window dimensions update according to frame size + padding
|
||||||
|
|
||||||
|
### Scenario 4: Visibility Toggle
|
||||||
|
|
||||||
|
1. Send `SetFlags { visible: false }`.
|
||||||
|
2. Send `SetFlags { visible: true }`.
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- visible flag transitions correctly
|
||||||
|
- window hides/shows without crash
|
||||||
|
|
||||||
|
### Scenario 5: Always-On-Top Toggle
|
||||||
|
|
||||||
|
1. Send `SetFlags { always_on_top: false }`.
|
||||||
|
2. Send `SetFlags { always_on_top: true }`.
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- top-most flag transitions correctly
|
||||||
|
- OS behavior matches expected z-order changes
|
||||||
|
|
||||||
|
### Scenario 6: Recovery Hotkey
|
||||||
|
|
||||||
|
1. Move to non-ideal state (hidden and/or not top-most).
|
||||||
|
2. Trigger configured recovery hotkey (default `Ctrl+Alt+P`).
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- forces `visible=true`
|
||||||
|
- forces `always_on_top=true`
|
||||||
|
|
||||||
|
### Scenario 7: Restart Persistence
|
||||||
|
|
||||||
|
1. Restart `sprimo-app`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `x`, `y`, `scale`, `always_on_top`, `visible` persist
|
||||||
|
|
||||||
|
## Suggested Command Set
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo check -p sprimo-app
|
||||||
|
cargo check -p sprimo-platform
|
||||||
|
cargo check -p sprimo-runtime-core
|
||||||
|
cargo test -p sprimo-app
|
||||||
|
cargo test -p sprimo-runtime-core
|
||||||
|
just qa-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional runtime stress after directed checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just random-backend-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pass / Fail Criteria
|
||||||
|
|
||||||
|
Pass:
|
||||||
|
|
||||||
|
- all logical assertions hold
|
||||||
|
- runtime scenarios behave as expected
|
||||||
|
- `/v1/state` remains consistent with visible behavior
|
||||||
|
- no crashes/panics in window-command paths
|
||||||
|
|
||||||
|
Fail:
|
||||||
|
|
||||||
|
- mismatch between command outcome and snapshot
|
||||||
|
- recovery hotkey fails to restore visible top-most state
|
||||||
|
- restart persistence for window fields fails
|
||||||
|
- any crash in window-management flow
|
||||||
|
|
||||||
|
## Evidence Expectations
|
||||||
|
|
||||||
|
For issue-driven verification, attach:
|
||||||
|
|
||||||
|
- command summary
|
||||||
|
- before/after screenshots
|
||||||
|
- `/v1/state` snapshots before and after key transitions
|
||||||
|
- lifecycle updates in `issues/issueN.md` per `docs/QA_WORKFLOW.md`
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
File location:
|
File location:
|
||||||
|
|
||||||
- Windows: `%APPDATA%/sprimo/config.toml`
|
- Windows: `%APPDATA%/sprimo/config/config.toml`
|
||||||
- macOS: `~/Library/Application Support/sprimo/config.toml`
|
- macOS: `~/Library/Application Support/sprimo/config/config.toml`
|
||||||
- Linux: `~/.config/sprimo/config.toml`
|
- Linux: `~/.config/sprimo/config/config.toml`
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
@@ -36,10 +36,21 @@ level = "info"
|
|||||||
[controls]
|
[controls]
|
||||||
hotkey_enabled = true
|
hotkey_enabled = true
|
||||||
recovery_hotkey = "Ctrl+Alt+P"
|
recovery_hotkey = "Ctrl+Alt+P"
|
||||||
|
|
||||||
|
[frontend]
|
||||||
|
backend = "bevy"
|
||||||
|
debug_overlay_visible = false
|
||||||
|
tauri_animation_slowdown_factor = 3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `auth_token` is generated on first run if config does not exist.
|
- `auth_token` is generated on first run if config does not exist.
|
||||||
- `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands.
|
- `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands.
|
||||||
- On Windows, `recovery_hotkey` provides click-through recovery even when the window is non-interactive.
|
- `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`
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
# 1. Overview
|
# 1. Overview
|
||||||
|
|
||||||
The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top** and optionally **click-through**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally.
|
The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally.
|
||||||
|
|
||||||
The frontend must be able to run standalone (idle animation) even if the backend is not running.
|
The frontend must be able to run standalone (idle animation) even if the backend is not running.
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
1. **Render a cute animated character overlay** with smooth sprite animation.
|
1. **Render a cute animated character overlay** with smooth sprite animation.
|
||||||
2. Provide a **stable command interface** (REST) for backend control.
|
2. Provide a **stable command interface** (REST) for backend control.
|
||||||
3. Offer **essential user controls** (tray/menu + hotkeys optional) to avoid “locking” the pet in click-through mode.
|
3. Offer **essential user controls** (tray/menu + hotkeys optional) to keep the pet recoverable and visible.
|
||||||
4. Persist **window position, scale, sprite pack choice, and flags**.
|
4. Persist **window position, scale, sprite pack choice, and flags**.
|
||||||
5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland.
|
5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland.
|
||||||
|
|
||||||
@@ -43,7 +43,6 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
* As a user, I can see the pet on my desktop immediately after launch.
|
* As a user, I can see the pet on my desktop immediately after launch.
|
||||||
* As a user, I can drag the pet to a preferred location.
|
* As a user, I can drag the pet to a preferred location.
|
||||||
* As a user, I can toggle click-through so the pet doesn’t block my mouse.
|
|
||||||
* As a user, I can toggle always-on-top so the pet stays visible.
|
* As a user, I can toggle always-on-top so the pet stays visible.
|
||||||
* As a user, I can change the character (sprite pack).
|
* As a user, I can change the character (sprite pack).
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
* As a user, I can always recover control of the pet even if click-through is enabled (hotkey/tray item).
|
* As a user, I can always recover the pet visibility/interaction state via hotkey or tray item.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,22 +81,19 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
* When ON, window stays above normal windows.
|
* When ON, window stays above normal windows.
|
||||||
|
|
||||||
### FR-FW-3 Click-through (mouse pass-through)
|
### FR-FW-3 Interaction model
|
||||||
|
|
||||||
* Support enabling/disabling click-through:
|
* Click-through is not required.
|
||||||
|
* Pet remains interactive while visible.
|
||||||
* ON: mouse events pass to windows underneath.
|
* Must provide a **failsafe** mechanism to recover visibility and interaction state.
|
||||||
* OFF: pet receives mouse input (drag, context menu).
|
|
||||||
* Must provide a **failsafe** mechanism to disable click-through without clicking the pet.
|
|
||||||
|
|
||||||
**Acceptance**
|
**Acceptance**
|
||||||
|
|
||||||
* With click-through enabled, user can click apps behind pet.
|
* Recovery hotkey/tray action restores visible, interactive pet state reliably.
|
||||||
* User can disable click-through via tray or hotkey reliably.
|
|
||||||
|
|
||||||
### FR-FW-4 Dragging & anchoring
|
### FR-FW-4 Dragging & anchoring
|
||||||
|
|
||||||
* When click-through is OFF, user can drag the pet.
|
* User can drag the pet.
|
||||||
* Dragging updates persisted position in config.
|
* Dragging updates persisted position in config.
|
||||||
* Optional: snapping to screen edges.
|
* Optional: snapping to screen edges.
|
||||||
|
|
||||||
@@ -130,12 +126,12 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
### Platform notes (requirements)
|
### Platform notes (requirements)
|
||||||
|
|
||||||
* **Windows:** click-through uses extended window styles (WS_EX_TRANSPARENT / layered), always-on-top via SetWindowPos.
|
* **Windows:** always-on-top via SetWindowPos.
|
||||||
* **macOS:** NSWindow level + ignoresMouseEvents.
|
* **macOS:** NSWindow level + ignoresMouseEvents.
|
||||||
* **Linux:** best effort:
|
* **Linux:** best effort:
|
||||||
|
|
||||||
* X11: possible with shape/input region.
|
* X11: possible with shape/input region.
|
||||||
* Wayland: click-through may be unavailable; document limitation.
|
* Wayland: overlay behavior limitations may apply; document limitation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -273,7 +269,7 @@ Each state maps to a default animation (configurable by sprite pack):
|
|||||||
* `PlayAnimation { name, priority, duration_ms?, interrupt? }`
|
* `PlayAnimation { name, priority, duration_ms?, interrupt? }`
|
||||||
* `SetSpritePack { pack_id_or_path }`
|
* `SetSpritePack { pack_id_or_path }`
|
||||||
* `SetTransform { x?, y?, anchor?, scale?, opacity? }`
|
* `SetTransform { x?, y?, anchor?, scale?, opacity? }`
|
||||||
* `SetFlags { click_through?, always_on_top?, visible? }`
|
* `SetFlags { click_through?, always_on_top?, visible? }` (`click_through` is deprecated/ignored)
|
||||||
* `Toast { text, ttl_ms? }` (optional but recommended)
|
* `Toast { text, ttl_ms? }` (optional but recommended)
|
||||||
|
|
||||||
### FR-API-4 Idempotency & dedupe
|
### FR-API-4 Idempotency & dedupe
|
||||||
@@ -303,7 +299,6 @@ Each state maps to a default animation (configurable by sprite pack):
|
|||||||
Provide tray/menu bar items:
|
Provide tray/menu bar items:
|
||||||
|
|
||||||
* Show/Hide
|
* Show/Hide
|
||||||
* Toggle Click-through
|
|
||||||
* Toggle Always-on-top
|
* Toggle Always-on-top
|
||||||
* Sprite Pack selection (at least “Default” + “Open sprite folder…”)
|
* Sprite Pack selection (at least “Default” + “Open sprite folder…”)
|
||||||
* Reload sprite packs
|
* Reload sprite packs
|
||||||
@@ -315,7 +310,7 @@ If tray is too hard on Linux in v0.1, provide a fallback (hotkey + config).
|
|||||||
|
|
||||||
At minimum one global hotkey:
|
At minimum one global hotkey:
|
||||||
|
|
||||||
* Toggle click-through OR “enter interactive mode”
|
* Force visible + interactive recovery mode
|
||||||
|
|
||||||
Example default:
|
Example default:
|
||||||
|
|
||||||
@@ -323,7 +318,7 @@ Example default:
|
|||||||
|
|
||||||
**Acceptance**
|
**Acceptance**
|
||||||
|
|
||||||
* User can recover control even if pet is click-through and cannot be clicked.
|
* User can recover visibility and interaction state even when the pet was hidden or misplaced.
|
||||||
|
|
||||||
### FR-CTL-3 Context menu (optional)
|
### FR-CTL-3 Context menu (optional)
|
||||||
|
|
||||||
@@ -348,7 +343,7 @@ Right click pet (when interactive) to open a minimal menu.
|
|||||||
* position (x,y) + monitor id (best-effort)
|
* position (x,y) + monitor id (best-effort)
|
||||||
* scale
|
* scale
|
||||||
* always_on_top
|
* always_on_top
|
||||||
* click_through
|
* click_through (deprecated/ignored; always false)
|
||||||
* visible
|
* visible
|
||||||
* animation:
|
* animation:
|
||||||
|
|
||||||
@@ -432,10 +427,10 @@ Frontend must expose in logs (and optionally `/v1/health`) capability flags:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
* Windows: all true
|
* Windows: click-through false; others vary by implementation status
|
||||||
* macOS: all true
|
* macOS: click-through false; others vary by implementation status
|
||||||
* Linux X11: most true
|
* Linux X11: most true
|
||||||
* Linux Wayland: click-through likely false, skip-taskbar variable
|
* Linux Wayland: skip-taskbar variable and overlay behavior limitations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -444,8 +439,8 @@ Example:
|
|||||||
## Window
|
## Window
|
||||||
|
|
||||||
1. Launch: window appears borderless & transparent.
|
1. Launch: window appears borderless & transparent.
|
||||||
2. Drag: with click-through OFF, drag updates position; restart restores.
|
2. Drag: drag updates position; restart restores.
|
||||||
3. Click-through: toggle via hotkey; pet becomes non-interactive; toggle back works.
|
3. Recovery: hotkey restores visible + always-on-top behavior reliably.
|
||||||
4. Always-on-top: verify staying above typical apps.
|
4. Always-on-top: verify staying above typical apps.
|
||||||
|
|
||||||
## Animation
|
## Animation
|
||||||
|
|||||||
@@ -11,16 +11,21 @@ Date: 2026-02-12
|
|||||||
| Command/state pipeline | Implemented | Command queue, state snapshot updates, transient state TTL rollback |
|
| Command/state pipeline | Implemented | Command queue, state snapshot updates, transient state TTL rollback |
|
||||||
| Config persistence | Implemented | `config.toml` bootstrap/load/save with generated token |
|
| Config persistence | Implemented | `config.toml` bootstrap/load/save with generated token |
|
||||||
| Sprite pack contract | Implemented | `manifest.json` loader and selected->default fallback |
|
| Sprite pack contract | Implemented | `manifest.json` loader and selected->default fallback |
|
||||||
| Platform abstraction | Implemented | Windows adapter now applies click-through/top-most/visibility/position using Win32 APIs |
|
| Platform abstraction | Implemented | Windows adapter applies top-most/visibility/position using Win32 APIs; click-through is disabled by current requirements |
|
||||||
| Overlay rendering | Implemented (MVP) | Bevy runtime with transparent undecorated window, sprite playback, command bridge |
|
| Overlay rendering | Implemented (MVP) | Bevy runtime with transparent undecorated window, sprite playback, command bridge |
|
||||||
| Global failsafe | Implemented (Windows) | Global recovery hotkey `Ctrl+Alt+P` disables click-through and forces visibility |
|
| Global failsafe | Implemented (Windows) | Global recovery hotkey `Ctrl+Alt+P` forces visibility and top-most recovery |
|
||||||
| Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) |
|
| Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) |
|
||||||
| Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 |
|
| Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 |
|
||||||
|
| 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` |
|
| 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 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
|
## 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.
|
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.
|
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.
|
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
|
||||||
|
5. `sprimo-tauri` still lacks cross-platform tray/menu parity and full acceptance parity tests.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
- [x] `SetState` updates state and default animation mapping.
|
- [x] `SetState` updates state and default animation mapping.
|
||||||
- [x] transient state with `ttl_ms` returns to durable state.
|
- [x] transient state with `ttl_ms` returns to durable state.
|
||||||
- [x] `SetTransform` persists x/y/scale.
|
- [x] `SetTransform` persists x/y/scale.
|
||||||
- [x] `SetFlags` persists click-through/always-on-top/visible.
|
- [x] `SetFlags` persists always-on-top/visible and ignores deprecated click-through.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Date: 2026-02-12
|
|||||||
|
|
||||||
| Capability | Windows | Linux X11 | Linux Wayland | macOS |
|
| Capability | Windows | Linux X11 | Linux Wayland | macOS |
|
||||||
|------------|---------|-----------|---------------|-------|
|
|------------|---------|-----------|---------------|-------|
|
||||||
| `supports_click_through` | true (implemented) | false (current) | false | false (current) |
|
| `supports_click_through` | false (disabled by product requirement) | false | false | false |
|
||||||
| `supports_transparency` | true | true | true | true |
|
| `supports_transparency` | true | true | true | true |
|
||||||
| `supports_tray` | false (current) | false (current) | false (current) | false (current) |
|
| `supports_tray` | false (current) | false (current) | false (current) | false (current) |
|
||||||
| `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) |
|
| `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) |
|
||||||
@@ -12,6 +12,7 @@ Date: 2026-02-12
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Current code applies real Win32 operations for click-through, visibility, top-most, and positioning.
|
- Current code applies real Win32 operations for visibility, top-most, and positioning.
|
||||||
|
- Click-through is intentionally disabled by current product requirements.
|
||||||
- Non-Windows targets currently use a no-op adapter with conservative flags.
|
- Non-Windows targets currently use a no-op adapter with conservative flags.
|
||||||
- Wayland limitations remain an expected degradation in v0.1.
|
- Wayland limitations remain an expected degradation in v0.1.
|
||||||
|
|||||||
@@ -72,7 +72,16 @@ cargo test --workspace
|
|||||||
just qa-validate
|
just qa-validate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional runtime/API stress validation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just random-backend-test
|
||||||
|
```
|
||||||
|
|
||||||
For runtime behavior issues, include screenshot capture paths in the issue file.
|
For runtime behavior issues, include screenshot capture paths in the issue file.
|
||||||
|
For Bevy window-management verification workflows, use:
|
||||||
|
|
||||||
|
- `docs/BEVY_WINDOW_VERIFICATION.md`
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
@@ -83,3 +92,14 @@ An issue is done only when:
|
|||||||
- evidence links resolve to files in repository
|
- evidence links resolve to files in repository
|
||||||
- `just qa-validate` passes
|
- `just qa-validate` passes
|
||||||
|
|
||||||
|
## Tauri Runtime Addendum
|
||||||
|
|
||||||
|
For `sprimo-tauri` runtime behavior issues, follow `docs/TAURI_RUNTIME_TESTING.md`.
|
||||||
|
|
||||||
|
Additional strict requirements:
|
||||||
|
|
||||||
|
- include `current_state` and `load_active_sprite_pack` invoke validation notes
|
||||||
|
- include `runtime:snapshot` event verification notes
|
||||||
|
- include tauri runtime API verification (`/v1/health`, `/v1/state`, `/v1/command`, `/v1/commands`)
|
||||||
|
- do not move issue status to `Closed` until all strict-gate evidence in
|
||||||
|
`docs/TAURI_RUNTIME_TESTING.md` is present
|
||||||
|
|||||||
107
docs/RANDOM_BACKEND_TESTING.md
Normal file
107
docs/RANDOM_BACKEND_TESTING.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Random Backend API Testing
|
||||||
|
|
||||||
|
Date: 2026-02-13
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This workflow provides randomized backend-like API traffic against a running Sprimo frontend.
|
||||||
|
It focuses on command endpoints and mixes valid and invalid requests to verify transport and
|
||||||
|
runtime resilience.
|
||||||
|
|
||||||
|
Primary targets:
|
||||||
|
|
||||||
|
- `POST /v1/command`
|
||||||
|
- `POST /v1/commands`
|
||||||
|
|
||||||
|
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`).
|
||||||
|
- Python is available.
|
||||||
|
- Auth token and port are available from config or passed via CLI flags.
|
||||||
|
|
||||||
|
By default, the tester discovers config at:
|
||||||
|
|
||||||
|
- Windows: `%APPDATA%/sprimo/config/config.toml` (legacy fallback: `%APPDATA%/sprimo/config.toml`)
|
||||||
|
- macOS: `~/Library/Application Support/sprimo/config/config.toml`
|
||||||
|
- Linux: `~/.config/sprimo/config/config.toml`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just random-backend-test
|
||||||
|
```
|
||||||
|
|
||||||
|
Strict mode (non-zero exit if unexpected outcomes appear):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just random-backend-test-strict
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Examples
|
||||||
|
|
||||||
|
Run against explicit host/port/token:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/random_backend_tester.py --host 127.0.0.1 --port 32145 --token "<token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deterministic run with higher invalid traffic:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/random_backend_tester.py --seed 42 --invalid-probability 0.35 --duration-seconds 45
|
||||||
|
```
|
||||||
|
|
||||||
|
Write machine-readable summary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/random_backend_tester.py --json-summary dist/random-backend-summary.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Flags
|
||||||
|
|
||||||
|
- `--duration-seconds`: total run time
|
||||||
|
- `--interval-ms`: delay between random requests
|
||||||
|
- `--batch-probability`: ratio of `/v1/commands` usage
|
||||||
|
- `--max-batch-size`: max commands per batch request
|
||||||
|
- `--invalid-probability`: inject malformed/invalid payloads
|
||||||
|
- `--unauthorized-probability`: inject auth failures
|
||||||
|
- `--state-sample-every`: periodic authenticated `/v1/state` checks
|
||||||
|
- `--strict`: fail run on unexpected outcomes
|
||||||
|
- `--health-check`: verify API liveness before random traffic
|
||||||
|
|
||||||
|
## Expected Result Pattern
|
||||||
|
|
||||||
|
In mixed mode, typical status distribution includes:
|
||||||
|
|
||||||
|
- `202` for valid command requests
|
||||||
|
- `400` for malformed/invalid payloads
|
||||||
|
- `401` for missing/invalid auth
|
||||||
|
|
||||||
|
Unexpected outcomes that should be investigated:
|
||||||
|
|
||||||
|
- `5xx` responses
|
||||||
|
- repeated transport failures/timeouts
|
||||||
|
- strict mode failures (`unexpected_outcomes > 0`)
|
||||||
|
|
||||||
|
## Evidence Guidance
|
||||||
|
|
||||||
|
When used for issue verification, record:
|
||||||
|
|
||||||
|
- command used (including seed/probabilities)
|
||||||
|
- summary output (status buckets, unexpected outcomes, transport errors)
|
||||||
|
- linked issue file under `issues/issueN.md`
|
||||||
|
|
||||||
|
This test complements `cargo check --workspace`, `cargo test --workspace`, and
|
||||||
|
`just qa-validate`; it does not replace them.
|
||||||
@@ -4,13 +4,21 @@
|
|||||||
|
|
||||||
Current release package type: portable ZIP.
|
Current release package type: portable ZIP.
|
||||||
|
|
||||||
Expected contents:
|
Expected contents (Bevy package):
|
||||||
|
|
||||||
- `sprimo-app.exe`
|
- `sprimo-app.exe`
|
||||||
- `assets/sprite-packs/default/manifest.json`
|
- `assets/sprite-packs/default/manifest.json`
|
||||||
- `assets/sprite-packs/default/sprite.png`
|
- `assets/sprite-packs/default/sprite.png`
|
||||||
- `README.txt`
|
- `README.txt`
|
||||||
|
|
||||||
|
Expected contents (Tauri package):
|
||||||
|
|
||||||
|
- `sprimo-tauri.exe`
|
||||||
|
- `WebView2Loader.dll`
|
||||||
|
- `assets/sprite-packs/default/manifest.json`
|
||||||
|
- `assets/sprite-packs/default/sprite.png`
|
||||||
|
- `README.txt`
|
||||||
|
|
||||||
Generated outputs:
|
Generated outputs:
|
||||||
|
|
||||||
- `dist/sprimo-windows-x64-v<version>.zip`
|
- `dist/sprimo-windows-x64-v<version>.zip`
|
||||||
@@ -23,13 +31,25 @@ Use `just` for command entry:
|
|||||||
```powershell
|
```powershell
|
||||||
just check
|
just check
|
||||||
just test
|
just test
|
||||||
just build-release
|
just build-release-bevy
|
||||||
just package-win
|
just package-win-bevy
|
||||||
just smoke-win
|
just smoke-win-bevy
|
||||||
|
just build-release-tauri
|
||||||
|
just package-win-tauri
|
||||||
|
just smoke-win-tauri
|
||||||
|
just random-backend-test
|
||||||
```
|
```
|
||||||
|
|
||||||
`just package-win` calls `scripts/package_windows.py package`.
|
Compatibility aliases:
|
||||||
`just smoke-win` calls `scripts/package_windows.py smoke`.
|
|
||||||
|
- `just build-release` -> Bevy release build.
|
||||||
|
- `just package-win` -> Bevy package.
|
||||||
|
- `just smoke-win` -> Bevy smoke package check.
|
||||||
|
|
||||||
|
Packaging script target selection:
|
||||||
|
|
||||||
|
- Bevy: `python scripts/package_windows.py package --frontend bevy`
|
||||||
|
- Tauri: `python scripts/package_windows.py package --frontend tauri`
|
||||||
|
|
||||||
## Behavior Test Checklist (Packaged App)
|
## Behavior Test Checklist (Packaged App)
|
||||||
|
|
||||||
@@ -37,8 +57,8 @@ Run tests from an unpacked ZIP folder, not from the workspace run.
|
|||||||
|
|
||||||
1. Launch `sprimo-app.exe`; verify default sprite renders.
|
1. Launch `sprimo-app.exe`; verify default sprite renders.
|
||||||
2. Verify no terminal window appears when launching release build by double-click.
|
2. Verify no terminal window appears when launching release build by double-click.
|
||||||
3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces interactive mode.
|
3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces visibility and top-most recovery.
|
||||||
4. Verify click-through and always-on-top toggles via API commands.
|
4. Verify `SetFlags` applies always-on-top and visibility via API commands.
|
||||||
5. Verify `/v1/health` and `/v1/state` behavior with auth.
|
5. Verify `/v1/health` and `/v1/state` behavior with auth.
|
||||||
6. Verify `SetSpritePack`:
|
6. Verify `SetSpritePack`:
|
||||||
- valid pack switches runtime visuals
|
- valid pack switches runtime visuals
|
||||||
@@ -46,6 +66,10 @@ Run tests from an unpacked ZIP folder, not from the workspace run.
|
|||||||
7. Restart app and verify persisted config behavior.
|
7. Restart app and verify persisted config behavior.
|
||||||
8. Confirm overlay background is transparent (desktop visible behind non-sprite pixels).
|
8. Confirm overlay background is transparent (desktop visible behind non-sprite pixels).
|
||||||
9. Confirm no magenta matte remains around sprite in default pack.
|
9. Confirm no magenta matte remains around sprite in default pack.
|
||||||
|
10. Confirm default startup window footprint is reduced (416x416 before runtime pack resize).
|
||||||
|
11. Run randomized backend API interaction and review summary output:
|
||||||
|
- `just random-backend-test`
|
||||||
|
- validate expected mix of `202`/`400`/`401` without process crash.
|
||||||
|
|
||||||
## Test Log Template
|
## Test Log Template
|
||||||
|
|
||||||
@@ -70,3 +94,62 @@ Before release sign-off for a bug fix:
|
|||||||
- `just qa-validate`
|
- `just qa-validate`
|
||||||
|
|
||||||
Legacy issues may reference `issues/screenshots/issueN.png` as before evidence.
|
Legacy issues may reference `issues/screenshots/issueN.png` as before evidence.
|
||||||
|
|
||||||
|
## Tauri Runtime Behavior Testing
|
||||||
|
|
||||||
|
Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
|
||||||
|
Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`.
|
||||||
|
|
||||||
|
### Workspace Mode (Required Now)
|
||||||
|
|
||||||
|
1. `just build-tauri-ui`
|
||||||
|
2. `just check-tauri`
|
||||||
|
3. `just check-runtime-core`
|
||||||
|
4. `just run-tauri` (smoke and runtime observation)
|
||||||
|
5. Verify invoke/event contract behavior:
|
||||||
|
- `current_state`
|
||||||
|
- `load_active_sprite_pack`
|
||||||
|
- `runtime:snapshot`
|
||||||
|
6. Verify API/runtime contract behavior against tauri process:
|
||||||
|
- `/v1/health`
|
||||||
|
- `/v1/state` with auth
|
||||||
|
- `/v1/command`
|
||||||
|
- `/v1/commands`
|
||||||
|
7. Verify tauri frameless drag:
|
||||||
|
- left-mouse drag moves window
|
||||||
|
- window remains non-resizable
|
||||||
|
- moved position updates runtime snapshot `x/y` and persists after restart
|
||||||
|
8. Run randomized backend API interaction:
|
||||||
|
- `just random-backend-test`
|
||||||
|
- verify command traffic remains stable and runtime stays alive.
|
||||||
|
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)
|
||||||
|
|
||||||
|
When tauri packaging automation is available, repeat runtime behavior checks on packaged artifacts:
|
||||||
|
|
||||||
|
1. Launch packaged tauri app.
|
||||||
|
2. Re-run invoke/event/API checks from workspace mode.
|
||||||
|
3. Attach before/after screenshots and command summaries in linked issue.
|
||||||
|
|||||||
@@ -64,3 +64,36 @@ Path: `<pack_dir>/manifest.json`
|
|||||||
- `rows = image_height / frame_height`
|
- `rows = image_height / frame_height`
|
||||||
- Image dimensions must be divisible by frame dimensions.
|
- Image dimensions must be divisible by frame dimensions.
|
||||||
- Every animation frame index must be `< columns * rows`.
|
- 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
|
||||||
|
|||||||
90
docs/TAURI_FRONTEND_DESIGN.md
Normal file
90
docs/TAURI_FRONTEND_DESIGN.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Tauri 2.0 Frontend Design (Bevy Alternative)
|
||||||
|
|
||||||
|
Date: 2026-02-12
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a Tauri 2.0 frontend path as an alternative to Bevy while keeping the existing Bevy
|
||||||
|
implementation and API behavior.
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
- `crates/sprimo-runtime-core`
|
||||||
|
- shared runtime bootstrap for config/snapshot/API/channel setup
|
||||||
|
- shared command-to-snapshot/config application
|
||||||
|
- `crates/sprimo-tauri`
|
||||||
|
- Tauri 2.0 desktop shell
|
||||||
|
- command consumer loop bound to runtime core
|
||||||
|
- invoke command `current_state` for UI state
|
||||||
|
- `frontend/tauri-ui`
|
||||||
|
- React + Vite UI shell for status/control surface
|
||||||
|
|
||||||
|
## Selected Crates
|
||||||
|
|
||||||
|
Rust:
|
||||||
|
|
||||||
|
- `tauri`
|
||||||
|
- `tauri-build`
|
||||||
|
- `tauri-plugin-log`
|
||||||
|
- `tauri-plugin-global-shortcut`
|
||||||
|
- existing internal crates:
|
||||||
|
- `sprimo-runtime-core`
|
||||||
|
- `sprimo-platform`
|
||||||
|
- `sprimo-protocol`
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `react`
|
||||||
|
- `react-dom`
|
||||||
|
- `vite`
|
||||||
|
- `typescript`
|
||||||
|
- `@tauri-apps/api`
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- Tauri binary crate is scaffolded and starts runtime core + API server.
|
||||||
|
- Runtime core receives API commands and updates shared snapshot/config state.
|
||||||
|
- 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. 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.
|
||||||
199
docs/TAURI_RUNTIME_TESTING.md
Normal file
199
docs/TAURI_RUNTIME_TESTING.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# Tauri Runtime Behavior Testing Workflow
|
||||||
|
|
||||||
|
Date: 2026-02-13
|
||||||
|
|
||||||
|
## Purpose and Scope
|
||||||
|
|
||||||
|
This document defines strict testing and evidence requirements for `sprimo-tauri` runtime
|
||||||
|
behaviors. It complements `docs/QA_WORKFLOW.md` and applies to all Tauri runtime behavior issues.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Windows environment for primary runtime validation.
|
||||||
|
- Workspace up to date.
|
||||||
|
- UI dependencies installed:
|
||||||
|
- `just install-tauri-ui`
|
||||||
|
|
||||||
|
## Execution Modes
|
||||||
|
|
||||||
|
### 1. Workspace Mode (Required Now)
|
||||||
|
|
||||||
|
Run and validate from the repository workspace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just build-tauri-ui
|
||||||
|
just check-tauri
|
||||||
|
just check-runtime-core
|
||||||
|
just run-tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Packaged Mode (Required Once Packaging Exists)
|
||||||
|
|
||||||
|
When `sprimo-tauri` packaging automation is implemented, repeat the runtime checklist against the
|
||||||
|
packaged artifact and attach equivalent evidence in the issue.
|
||||||
|
|
||||||
|
## Strict Verification Gate
|
||||||
|
|
||||||
|
An issue touching Tauri runtime behaviors must satisfy all requirements before `Closed`:
|
||||||
|
|
||||||
|
1. Command evidence recorded:
|
||||||
|
- `cargo check -p sprimo-tauri`
|
||||||
|
- `cargo check -p sprimo-runtime-core`
|
||||||
|
- `just build-tauri-ui`
|
||||||
|
- `just run-tauri` smoke result
|
||||||
|
- `just qa-validate`
|
||||||
|
2. Visual evidence recorded:
|
||||||
|
- before screenshot(s)
|
||||||
|
- after screenshot(s)
|
||||||
|
3. Runtime contract evidence recorded:
|
||||||
|
- `current_state` invoke command returns valid structured payload.
|
||||||
|
- `load_active_sprite_pack` invoke command returns manifest/atlas payload.
|
||||||
|
- `runtime:snapshot` event is observed after command application.
|
||||||
|
4. API behavior evidence recorded:
|
||||||
|
- `/v1/health` and `/v1/state` behavior validated against tauri runtime.
|
||||||
|
- `/v1/command` and `/v1/commands` validated with auth behavior.
|
||||||
|
5. Docs synchronized:
|
||||||
|
- issue lifecycle updated
|
||||||
|
- relevant docs updated when behavior or expectations changed
|
||||||
|
|
||||||
|
## Runtime Behavior Checklist
|
||||||
|
|
||||||
|
1. Launch tauri runtime via `just run-tauri`.
|
||||||
|
2. Verify sprite renders in the tauri window.
|
||||||
|
3. Verify animation advances over time.
|
||||||
|
4. Send `PlayAnimation` command and verify clip switch is reflected.
|
||||||
|
5. Send `SetTransform.scale` and verify rendered sprite scale changes without clipping:
|
||||||
|
- at `scale >= 1.0`, full sprite remains visible (no missing upper region)
|
||||||
|
- runtime auto-fits window size to sprite frame size and keeps bottom-center visually stable
|
||||||
|
6. Verify missing animation fallback:
|
||||||
|
- unknown animation name falls back to `idle` or first available clip.
|
||||||
|
7. Verify sprite-pack loading:
|
||||||
|
- valid selected pack loads correctly
|
||||||
|
- invalid pack path failure is surfaced and runtime remains alive
|
||||||
|
8. Verify frameless window drag behavior:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
1. Validate health endpoint:
|
||||||
|
- `GET /v1/health` returns version/build/capabilities.
|
||||||
|
2. Validate authenticated state endpoint:
|
||||||
|
- `GET /v1/state` requires bearer token.
|
||||||
|
3. Validate command endpoint:
|
||||||
|
- `POST /v1/command` accepts valid command envelope.
|
||||||
|
4. Validate batch endpoint:
|
||||||
|
- `POST /v1/commands` applies commands in order.
|
||||||
|
5. Validate malformed request resilience:
|
||||||
|
- malformed JSON returns `400` without process crash.
|
||||||
|
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
|
||||||
|
|
||||||
|
For each Tauri runtime issue, include:
|
||||||
|
|
||||||
|
- command output summaries for all strict gate commands
|
||||||
|
- screenshot references:
|
||||||
|
- before: `issues/screenshots/issueN-before-YYYYMMDD-HHMMSS.png`
|
||||||
|
- after: `issues/screenshots/issueN-after-YYYYMMDD-HHMMSS.png`
|
||||||
|
- invoke/event verification notes
|
||||||
|
- API verification notes
|
||||||
|
|
||||||
|
## Issue Lifecycle Integration
|
||||||
|
|
||||||
|
Use standard lifecycle in `issues/issueN.md`:
|
||||||
|
|
||||||
|
1. `Reported`
|
||||||
|
2. `Triaged`
|
||||||
|
3. `In Progress`
|
||||||
|
4. `Fix Implemented`
|
||||||
|
5. `Verification Passed`
|
||||||
|
6. `Closed`
|
||||||
|
|
||||||
|
Tauri runtime issues must remain at `Fix Implemented` if any strict-gate evidence is missing.
|
||||||
|
|
||||||
|
## Failure Classification and Triage
|
||||||
|
|
||||||
|
- `P0`: crash on startup, renderer not visible, auth bypass, or command pipeline broken.
|
||||||
|
- `P1`: animation/state mismatch, event/invoke contract failure, major UX regression.
|
||||||
|
- `P2`: non-blocking rendering/perf issues, minor UI mismatch, cosmetic defects.
|
||||||
|
|
||||||
|
## Test Log Template (Tauri Runtime)
|
||||||
|
|
||||||
|
- Date:
|
||||||
|
- Issue:
|
||||||
|
- Frontend: `sprimo-tauri`
|
||||||
|
- Execution mode: `workspace` or `packaged`
|
||||||
|
- Commands run:
|
||||||
|
- API checks summary:
|
||||||
|
- Invoke/event checks summary:
|
||||||
|
- Before screenshots:
|
||||||
|
- After screenshots:
|
||||||
|
- Result:
|
||||||
|
- Notes:
|
||||||
12
frontend/tauri-ui/index.html
Normal file
12
frontend/tauri-ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>sprimo-tauri</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/tauri-ui/package.json
Normal file
26
frontend/tauri-ui/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sprimo-tauri-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx vite",
|
||||||
|
"build": "npx vite build",
|
||||||
|
"preview": "npx vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pixi/app": "^7.4.3",
|
||||||
|
"@pixi/core": "^7.4.3",
|
||||||
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
840
frontend/tauri-ui/src/main.tsx
Normal file
840
frontend/tauri-ui/src/main.tsx
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
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 {
|
||||||
|
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(
|
||||||
|
scale: number
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
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<boolean>("debug_overlay_visible"),
|
||||||
|
invokeAnimationSlowdownFactor()
|
||||||
|
])
|
||||||
|
.then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDebugOverlayVisible(showDebug);
|
||||||
|
slowdownFactorRef.current = Math.min(
|
||||||
|
Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
|
||||||
|
SLOWDOWN_FACTOR_MAX
|
||||||
|
);
|
||||||
|
await recreateRenderer(pack, initialSnapshot);
|
||||||
|
await processSnapshot(initialSnapshot);
|
||||||
|
|
||||||
|
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 (mountedRef.current) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (unlisten !== null) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
rendererRef.current?.dispose();
|
||||||
|
rendererRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
void getCurrentWindow().startDragging().catch((err) => {
|
||||||
|
setError(String(err));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app overlay-app" onMouseDown={onMouseDown}>
|
||||||
|
<div className="canvas-host" ref={hostRef} />
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<AppRoot />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
510
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
510
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import { Application } from "@pixi/app";
|
||||||
|
import { BaseTexture, Rectangle, Texture } from "@pixi/core";
|
||||||
|
import { Sprite } from "@pixi/sprite";
|
||||||
|
|
||||||
|
export type UiAnimationClip = {
|
||||||
|
name: string;
|
||||||
|
fps: number;
|
||||||
|
frames: number[];
|
||||||
|
one_shot: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UiSpritePack = {
|
||||||
|
id: string;
|
||||||
|
frame_width: number;
|
||||||
|
frame_height: number;
|
||||||
|
atlas_data_url: string;
|
||||||
|
animations: UiAnimationClip[];
|
||||||
|
anchor: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UiSnapshot = {
|
||||||
|
state: string;
|
||||||
|
current_animation: string;
|
||||||
|
x: number;
|
||||||
|
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;
|
||||||
|
private sprite: Sprite;
|
||||||
|
private pack: UiSpritePack;
|
||||||
|
private animationMap: AnimationMap;
|
||||||
|
private currentClip: UiAnimationClip;
|
||||||
|
private frameCursor = 0;
|
||||||
|
private frameElapsedMs = 0;
|
||||||
|
private baseTexture: BaseTexture;
|
||||||
|
private animationSlowdownFactor = ANIMATION_SLOWDOWN_FACTOR_DEFAULT;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
app: Application,
|
||||||
|
sprite: Sprite,
|
||||||
|
pack: UiSpritePack,
|
||||||
|
baseTexture: BaseTexture
|
||||||
|
) {
|
||||||
|
this.app = app;
|
||||||
|
this.sprite = sprite;
|
||||||
|
this.pack = pack;
|
||||||
|
this.baseTexture = baseTexture;
|
||||||
|
this.animationMap = new Map(pack.animations.map((clip) => [clip.name, clip]));
|
||||||
|
this.currentClip = this.resolveClip("idle");
|
||||||
|
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
container: HTMLElement,
|
||||||
|
pack: UiSpritePack,
|
||||||
|
snapshot: UiSnapshot
|
||||||
|
): Promise<PixiPetRenderer> {
|
||||||
|
const app = new Application({
|
||||||
|
backgroundAlpha: 0,
|
||||||
|
antialias: true,
|
||||||
|
resizeTo: container
|
||||||
|
});
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
renderer.applySnapshot(snapshot);
|
||||||
|
renderer.startTicker();
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static loadBaseTexture(dataUrl: string): Promise<BaseTexture> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx === null) {
|
||||||
|
reject(new Error("Failed to create canvas context for chroma-key conversion."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const data = frame.data;
|
||||||
|
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);
|
||||||
|
resolve(BaseTexture.from(canvas));
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
reject(new Error("Failed to load atlas image data URL."));
|
||||||
|
};
|
||||||
|
image.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.disposed = true;
|
||||||
|
this.app.destroy(true, {
|
||||||
|
children: true,
|
||||||
|
texture: false,
|
||||||
|
baseTexture: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot(snapshot: UiSnapshot): void {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextClip = this.resolveClip(snapshot.current_animation);
|
||||||
|
if (nextClip.name !== this.currentClip.name) {
|
||||||
|
this.currentClip = nextClip;
|
||||||
|
this.frameCursor = 0;
|
||||||
|
this.frameElapsedMs = 0;
|
||||||
|
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
|
||||||
|
}
|
||||||
|
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)) * this.animationSlowdownFactor;
|
||||||
|
this.frameElapsedMs += ticker.deltaMS;
|
||||||
|
if (this.frameElapsedMs < frameMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.frameElapsedMs -= frameMs;
|
||||||
|
|
||||||
|
const frames = this.currentClip.frames;
|
||||||
|
if (frames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.frameCursor >= frames.length) {
|
||||||
|
this.frameCursor = this.currentClip.one_shot ? frames.length - 1 : 0;
|
||||||
|
}
|
||||||
|
const frame = frames[this.frameCursor] ?? 0;
|
||||||
|
this.applyFrameTexture(frame);
|
||||||
|
this.frameCursor += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveClip(name: string): UiAnimationClip {
|
||||||
|
return (
|
||||||
|
this.animationMap.get(name) ??
|
||||||
|
this.animationMap.get("idle") ??
|
||||||
|
this.pack.animations[0] ?? {
|
||||||
|
name: "idle",
|
||||||
|
fps: 1,
|
||||||
|
frames: [0],
|
||||||
|
one_shot: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFrameTexture(frameIndex: number): void {
|
||||||
|
const atlasWidth = this.baseTexture.width;
|
||||||
|
const atlasHeight = this.baseTexture.height;
|
||||||
|
const columns = Math.max(Math.floor(atlasWidth / this.pack.frame_width), 1);
|
||||||
|
const rows = Math.max(Math.floor(atlasHeight / this.pack.frame_height), 1);
|
||||||
|
const totalFrames = Math.max(columns * rows, 1);
|
||||||
|
const safeIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1));
|
||||||
|
const x = (safeIndex % columns) * this.pack.frame_width;
|
||||||
|
const y = Math.floor(safeIndex / columns) * this.pack.frame_height;
|
||||||
|
const rect = new Rectangle(x, y, this.pack.frame_width, this.pack.frame_height);
|
||||||
|
this.sprite.texture = new Texture(this.baseTexture, rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
233
frontend/tauri-ui/src/styles.css
Normal file
233
frontend/tauri-ui/src/styles.css
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
:root {
|
||||||
|
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;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-host {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
17
frontend/tauri-ui/tsconfig.json
Normal file
17
frontend/tauri-ui/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowJs": false,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
frontend/tauri-ui/vite.config.ts
Normal file
10
frontend/tauri-ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
});
|
||||||
158
issues/issue2.md
Normal file
158
issues/issue2.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
## Title
|
||||||
|
|
||||||
|
`sprimo-tauri` runtime shows `TypeError: W.fromURL is not a function`; sprite renderer fails to initialize.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS: Windows
|
||||||
|
- App: `sprimo-tauri` frontend/runtime path
|
||||||
|
- Reported on: 2026-02-13
|
||||||
|
- Evidence screenshot: `issues/screenshots/issue2.png`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
At runtime, the Tauri UI loads but Pixi sprite rendering fails with:
|
||||||
|
|
||||||
|
- `TypeError: W.fromURL is not a function`
|
||||||
|
|
||||||
|
This breaks sprite presentation and leaves the UI in an error state.
|
||||||
|
|
||||||
|
## Reproduction Steps
|
||||||
|
|
||||||
|
1. Build UI assets and start the Tauri app (`just build-tauri-ui`, `just run-tauri`).
|
||||||
|
2. Open the Tauri window and wait for sprite pack initialization.
|
||||||
|
3. Observe the debug panel error and missing pet rendering.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
- Pixi atlas texture loads successfully from `atlas_data_url`.
|
||||||
|
- Pet sprite renders and animates.
|
||||||
|
- No renderer initialization error in UI.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
- Renderer initialization fails with `TypeError: W.fromURL is not a function`.
|
||||||
|
- Sprite is not rendered.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
- `frontend/tauri-ui/src/renderer/pixi_pet.ts` used `BaseTexture.fromURL(...)`.
|
||||||
|
- In the current Pixi package/runtime composition, that API path is unavailable at runtime
|
||||||
|
(minified symbol resolves to `W.fromURL`, which is undefined).
|
||||||
|
- Result: atlas load throws before a valid sprite texture can be applied.
|
||||||
|
- Follow-up finding: Tauri can run previously embedded frontend assets if Rust build is not
|
||||||
|
re-triggered after UI-only changes, which can make old errors appear even after source fixes.
|
||||||
|
- After stale-build issue was resolved (`ui build: issue2-fix3` visible), a second runtime defect
|
||||||
|
became clear:
|
||||||
|
- `event.listen not allowed` due missing Tauri capability permissions for event listen/unlisten.
|
||||||
|
- sprite pack still rendered with magenta matte because tauri path lacked chroma-key conversion.
|
||||||
|
|
||||||
|
## Fix Plan
|
||||||
|
|
||||||
|
1. Replace `BaseTexture.fromURL` usage with Pixi assets loader (`@pixi/assets` + `Assets.load`).
|
||||||
|
2. Ensure sprite texture is assigned immediately after renderer creation (first frame visible).
|
||||||
|
3. Harden React lifecycle cleanup to avoid stale listeners/renderer leaks.
|
||||||
|
4. Re-run tauri/runtime QA checks and keep issue at `Fix Implemented` until strict gate evidence is complete.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
1. `frontend/tauri-ui/package.json`
|
||||||
|
- Removed temporary `@pixi/assets` dependency after switching loader strategy.
|
||||||
|
|
||||||
|
2. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Switched atlas loading to `Assets.load<Texture>(pack.atlas_data_url)`.
|
||||||
|
- Reused `texture.baseTexture` for frame slicing.
|
||||||
|
- Applied initial frame texture in constructor so sprite appears immediately.
|
||||||
|
- Added explicit renderer/sprite/ticker disposal path.
|
||||||
|
|
||||||
|
3. `frontend/tauri-ui/src/main.tsx`
|
||||||
|
- Added mount guards to prevent state updates after unmount.
|
||||||
|
- Added deterministic cleanup (`unlisten` + renderer `dispose()`).
|
||||||
|
|
||||||
|
4. `crates/sprimo-tauri/build.rs`
|
||||||
|
- Added `cargo:rerun-if-changed` directives for tauri config and frontend UI paths so
|
||||||
|
frontend/dist updates re-trigger asset embedding in `cargo run -p sprimo-tauri`.
|
||||||
|
|
||||||
|
5. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Replaced `Assets.load` path with direct `Image` + `BaseTexture.from(image)` loading to avoid
|
||||||
|
any runtime `*.fromURL` dependency in atlas initialization.
|
||||||
|
|
||||||
|
6. `frontend/tauri-ui/src/main.tsx`
|
||||||
|
- Added visible UI build marker (`issue2-fix3`) to detect stale embedded frontend artifacts.
|
||||||
|
- Removed temporary UI build marker after verification passed.
|
||||||
|
|
||||||
|
7. `crates/sprimo-tauri/capabilities/default.json`
|
||||||
|
- Added default capability with:
|
||||||
|
- `core:default`
|
||||||
|
- `core:event:allow-listen`
|
||||||
|
- `core:event:allow-unlisten`
|
||||||
|
|
||||||
|
8. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Added tauri-side chroma-key conversion for atlas data URL:
|
||||||
|
- draw atlas to canvas
|
||||||
|
- convert near-`#FF00FF` pixels to alpha 0
|
||||||
|
- create Pixi base texture from converted canvas
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Commands Run
|
||||||
|
|
||||||
|
- [x] `cargo check --workspace`
|
||||||
|
- [x] `cargo test --workspace`
|
||||||
|
- [x] `just qa-validate`
|
||||||
|
- [x] `cargo check -p sprimo-tauri`
|
||||||
|
- [x] `cargo check -p sprimo-runtime-core`
|
||||||
|
- [x] `just build-tauri-ui`
|
||||||
|
- [x] `just run-tauri` (smoke attempt; command is long-running and timed out under automation)
|
||||||
|
- [x] `just qa-validate`
|
||||||
|
|
||||||
|
### Visual Checklist
|
||||||
|
|
||||||
|
- [x] Before screenshot(s): `issues/screenshots/issue2.png`
|
||||||
|
- [x] After screenshot(s): `issues/screenshots/issue2-after-fix3-2026-02-13-094131.png`
|
||||||
|
|
||||||
|
### Runtime Contract Checklist
|
||||||
|
|
||||||
|
- [ ] `current_state` invoke returns structured payload
|
||||||
|
- [ ] `load_active_sprite_pack` invoke returns manifest/atlas payload
|
||||||
|
- [ ] `runtime:snapshot` event observed after runtime command changes
|
||||||
|
|
||||||
|
### API Checklist
|
||||||
|
|
||||||
|
- [ ] `GET /v1/health`
|
||||||
|
- [ ] `GET /v1/state` auth behavior
|
||||||
|
- [ ] `POST /v1/command`
|
||||||
|
- [ ] `POST /v1/commands`
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
- Current Status: `Verification Passed`
|
||||||
|
- Notes: reporter confirmed fix works on runtime behavior; closure gate evidence still to be completed.
|
||||||
|
|
||||||
|
## Status History
|
||||||
|
|
||||||
|
- `2026-02-13 13:20` - reporter - `Reported` - runtime screenshot captured with `TypeError: W.fromURL is not a function`.
|
||||||
|
- `2026-02-13 13:35` - codex - `Triaged` - localized failure to Pixi atlas loader path.
|
||||||
|
- `2026-02-13 13:55` - codex - `In Progress` - replaced loader API and hardened renderer lifecycle.
|
||||||
|
- `2026-02-13 14:05` - codex - `Fix Implemented` - patch completed, verification checklist queued.
|
||||||
|
- `2026-02-13 14:20` - codex - `Fix Implemented` - checks passed (`cargo check`, UI build, QA validation); smoke launch attempted.
|
||||||
|
- `2026-02-13 14:35` - codex - `Fix Implemented` - added build-script change tracking for frontend assets to prevent stale embedded UI.
|
||||||
|
- `2026-02-13 14:55` - codex - `In Progress` - removed all runtime `fromURL` usage from renderer atlas loading path.
|
||||||
|
- `2026-02-13 15:05` - codex - `In Progress` - added explicit UI build marker to detect stale executable/frontend embedding.
|
||||||
|
- `2026-02-13 15:20` - reporter - `In Progress` - provided `issue2-after-fix3` screenshot; stale-build issue resolved, permission + chroma-key defects observed.
|
||||||
|
- `2026-02-13 15:35` - codex - `Fix Implemented` - added tauri capability permission file and tauri-side chroma-key conversion.
|
||||||
|
- `2026-02-13 15:50` - reporter - `Verification Passed` - confirmed the runtime fix works.
|
||||||
|
- `2026-02-13 16:05` - codex - `Verification Passed` - completed workspace check/test checklist and normalized issue record.
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
- Current Status: `Verification Passed`
|
||||||
|
- Close Date:
|
||||||
|
- Owner:
|
||||||
|
- Linked PR/commit:
|
||||||
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.
|
||||||
47
justfile
47
justfile
@@ -1,6 +1,7 @@
|
|||||||
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
|
|
||||||
python := "python"
|
python := "python"
|
||||||
|
npm := "npm"
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
@@ -17,5 +18,51 @@ package-win:
|
|||||||
smoke-win:
|
smoke-win:
|
||||||
{{python}} scripts/package_windows.py smoke
|
{{python}} scripts/package_windows.py smoke
|
||||||
|
|
||||||
|
build-release-bevy:
|
||||||
|
cargo build --release -p sprimo-app
|
||||||
|
|
||||||
|
build-release-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
cargo build --release -p sprimo-tauri
|
||||||
|
|
||||||
|
package-win-bevy:
|
||||||
|
{{python}} scripts/package_windows.py package --frontend bevy
|
||||||
|
|
||||||
|
smoke-win-bevy:
|
||||||
|
{{python}} scripts/package_windows.py smoke --frontend bevy
|
||||||
|
|
||||||
|
package-win-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
{{python}} scripts/package_windows.py package --frontend tauri
|
||||||
|
|
||||||
|
smoke-win-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
{{python}} scripts/package_windows.py smoke --frontend tauri
|
||||||
|
|
||||||
qa-validate:
|
qa-validate:
|
||||||
{{python}} scripts/qa_validate.py
|
{{python}} scripts/qa_validate.py
|
||||||
|
|
||||||
|
random-backend-test:
|
||||||
|
{{python}} scripts/random_backend_tester.py --duration-seconds 30 --health-check
|
||||||
|
|
||||||
|
random-backend-test-strict:
|
||||||
|
{{python}} scripts/random_backend_tester.py --duration-seconds 60 --health-check --strict
|
||||||
|
|
||||||
|
check-runtime-core:
|
||||||
|
cargo check -p sprimo-runtime-core
|
||||||
|
|
||||||
|
check-tauri:
|
||||||
|
cargo check -p sprimo-tauri
|
||||||
|
|
||||||
|
install-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} install; Pop-Location
|
||||||
|
|
||||||
|
build-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
|
||||||
|
|
||||||
|
dev-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run dev; Pop-Location
|
||||||
|
|
||||||
|
run-tauri:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
|
||||||
|
cargo run -p sprimo-tauri
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Build and package a portable Windows ZIP for sprimo-app."""
|
"""Build and package portable Windows ZIPs for Bevy/Tauri frontends."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ from typing import Iterable
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DIST = ROOT / "dist"
|
DIST = ROOT / "dist"
|
||||||
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
|
|
||||||
ASSETS_REL = ROOT / "assets"
|
ASSETS_REL = ROOT / "assets"
|
||||||
|
|
||||||
|
|
||||||
@@ -26,8 +25,38 @@ class PackagingError(RuntimeError):
|
|||||||
"""Raised when packaging preconditions are not met."""
|
"""Raised when packaging preconditions are not met."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrontendLayout:
|
||||||
|
id: str
|
||||||
|
crate: str
|
||||||
|
binary_name: str
|
||||||
|
artifact_name: str
|
||||||
|
readme_run: str
|
||||||
|
runtime_files: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
FRONTENDS: dict[str, FrontendLayout] = {
|
||||||
|
"bevy": FrontendLayout(
|
||||||
|
id="bevy",
|
||||||
|
crate="sprimo-app",
|
||||||
|
binary_name="sprimo-app.exe",
|
||||||
|
artifact_name="sprimo-windows-x64",
|
||||||
|
readme_run="sprimo-app.exe",
|
||||||
|
),
|
||||||
|
"tauri": FrontendLayout(
|
||||||
|
id="tauri",
|
||||||
|
crate="sprimo-tauri",
|
||||||
|
binary_name="sprimo-tauri.exe",
|
||||||
|
artifact_name="sprimo-tauri-windows-x64",
|
||||||
|
readme_run="sprimo-tauri.exe",
|
||||||
|
runtime_files=("WebView2Loader.dll",),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PackageLayout:
|
class PackageLayout:
|
||||||
|
frontend: str
|
||||||
version: str
|
version: str
|
||||||
zip_path: Path
|
zip_path: Path
|
||||||
checksum_path: Path
|
checksum_path: Path
|
||||||
@@ -72,12 +101,25 @@ def read_version() -> str:
|
|||||||
raise PackagingError("could not determine version")
|
raise PackagingError("could not determine version")
|
||||||
|
|
||||||
|
|
||||||
def ensure_release_binary() -> Path:
|
def ensure_release_binary(frontend: FrontendLayout) -> Path:
|
||||||
if not BIN_REL.exists():
|
binary_path = ROOT / "target" / "release" / frontend.binary_name
|
||||||
run(["cargo", "build", "--release", "-p", "sprimo-app"])
|
if not binary_path.exists():
|
||||||
if not BIN_REL.exists():
|
run(["cargo", "build", "--release", "-p", frontend.crate])
|
||||||
raise PackagingError(f"release binary missing: {BIN_REL}")
|
if not binary_path.exists():
|
||||||
return BIN_REL
|
raise PackagingError(f"release binary missing: {binary_path}")
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_files(frontend: FrontendLayout, binary_dir: Path) -> list[Path]:
|
||||||
|
resolved: list[Path] = []
|
||||||
|
for filename in frontend.runtime_files:
|
||||||
|
path = binary_dir / filename
|
||||||
|
if not path.exists():
|
||||||
|
raise PackagingError(
|
||||||
|
f"required runtime file missing for {frontend.id}: {path}"
|
||||||
|
)
|
||||||
|
resolved.append(path)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def ensure_assets() -> None:
|
def ensure_assets() -> None:
|
||||||
@@ -108,13 +150,16 @@ def sha256_file(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def package() -> PackageLayout:
|
def package(frontend: FrontendLayout) -> PackageLayout:
|
||||||
version = read_version()
|
version = read_version()
|
||||||
ensure_assets()
|
ensure_assets()
|
||||||
binary = ensure_release_binary()
|
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)
|
||||||
|
|
||||||
DIST.mkdir(parents=True, exist_ok=True)
|
DIST.mkdir(parents=True, exist_ok=True)
|
||||||
artifact_name = f"sprimo-windows-x64-v{version}"
|
artifact_name = f"{frontend.artifact_name}-v{version}"
|
||||||
zip_path = DIST / f"{artifact_name}.zip"
|
zip_path = DIST / f"{artifact_name}.zip"
|
||||||
checksum_path = DIST / f"{artifact_name}.zip.sha256"
|
checksum_path = DIST / f"{artifact_name}.zip.sha256"
|
||||||
|
|
||||||
@@ -122,13 +167,16 @@ def package() -> PackageLayout:
|
|||||||
stage = Path(temp_dir) / artifact_name
|
stage = Path(temp_dir) / artifact_name
|
||||||
stage.mkdir(parents=True, exist_ok=True)
|
stage.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
shutil.copy2(binary, stage / "sprimo-app.exe")
|
shutil.copy2(binary, stage / frontend.binary_name)
|
||||||
|
for runtime_file in runtime_files:
|
||||||
|
shutil.copy2(runtime_file, stage / runtime_file.name)
|
||||||
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
|
shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True)
|
||||||
|
|
||||||
readme = stage / "README.txt"
|
readme = stage / "README.txt"
|
||||||
readme.write_text(
|
readme.write_text(
|
||||||
"Sprimo portable package\n"
|
"Sprimo portable package\n"
|
||||||
"Run: sprimo-app.exe\n"
|
f"Frontend: {frontend.id}\n"
|
||||||
|
f"Run: {frontend.readme_run}\n"
|
||||||
"Assets are expected at ./assets relative to the executable.\n",
|
"Assets are expected at ./assets relative to the executable.\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -143,11 +191,16 @@ def package() -> PackageLayout:
|
|||||||
|
|
||||||
checksum = sha256_file(zip_path)
|
checksum = sha256_file(zip_path)
|
||||||
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
|
checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8")
|
||||||
return PackageLayout(version=version, zip_path=zip_path, checksum_path=checksum_path)
|
return PackageLayout(
|
||||||
|
frontend=frontend.id,
|
||||||
|
version=version,
|
||||||
|
zip_path=zip_path,
|
||||||
|
checksum_path=checksum_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def smoke() -> None:
|
def smoke(frontend: FrontendLayout) -> None:
|
||||||
layout = package()
|
layout = package(frontend)
|
||||||
print(f"package created: {layout.zip_path}")
|
print(f"package created: {layout.zip_path}")
|
||||||
print(f"checksum file: {layout.checksum_path}")
|
print(f"checksum file: {layout.checksum_path}")
|
||||||
|
|
||||||
@@ -160,10 +213,11 @@ def smoke() -> None:
|
|||||||
pkg_root = root_candidates[0]
|
pkg_root = root_candidates[0]
|
||||||
|
|
||||||
required = [
|
required = [
|
||||||
pkg_root / "sprimo-app.exe",
|
pkg_root / frontend.binary_name,
|
||||||
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
|
pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json",
|
||||||
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
|
pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png",
|
||||||
]
|
]
|
||||||
|
required.extend(pkg_root / filename for filename in frontend.runtime_files)
|
||||||
missing = [path for path in required if not path.exists()]
|
missing = [path for path in required if not path.exists()]
|
||||||
if missing:
|
if missing:
|
||||||
joined = ", ".join(str(path) for path in missing)
|
joined = ", ".join(str(path) for path in missing)
|
||||||
@@ -175,18 +229,25 @@ def smoke() -> None:
|
|||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
|
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
|
||||||
parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
|
parser.add_argument("command", choices=["package", "smoke"], help="action to execute")
|
||||||
|
parser.add_argument(
|
||||||
|
"--frontend",
|
||||||
|
choices=sorted(FRONTENDS.keys()),
|
||||||
|
default="bevy",
|
||||||
|
help="frontend package target",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
frontend = FRONTENDS[args.frontend]
|
||||||
try:
|
try:
|
||||||
if args.command == "package":
|
if args.command == "package":
|
||||||
layout = package()
|
layout = package(frontend)
|
||||||
print(f"created: {layout.zip_path}")
|
print(f"created: {layout.zip_path}")
|
||||||
print(f"sha256: {layout.checksum_path}")
|
print(f"sha256: {layout.checksum_path}")
|
||||||
else:
|
else:
|
||||||
smoke()
|
smoke(frontend)
|
||||||
return 0
|
return 0
|
||||||
except PackagingError as exc:
|
except PackagingError as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
|||||||
587
scripts/random_backend_tester.py
Normal file
587
scripts/random_backend_tester.py
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Randomized backend-style API tester for Sprimo frontend endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
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(
|
||||||
|
description=(
|
||||||
|
"Send random valid/invalid command traffic to Sprimo frontend API."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="API host")
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="API port (default: read from config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
default=None,
|
||||||
|
help="Bearer token (default: read from config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-path",
|
||||||
|
default=None,
|
||||||
|
help="Explicit path to config.toml",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--app-name",
|
||||||
|
default="sprimo",
|
||||||
|
help="App name for config path discovery (default: sprimo)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--duration-seconds",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Total run duration in seconds",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval-ms",
|
||||||
|
type=int,
|
||||||
|
default=250,
|
||||||
|
help="Delay between requests in milliseconds",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-probability",
|
||||||
|
type=float,
|
||||||
|
default=0.35,
|
||||||
|
help="Probability of using /v1/commands",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-batch-size",
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help="Maximum batch size for /v1/commands",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--invalid-probability",
|
||||||
|
type=float,
|
||||||
|
default=0.20,
|
||||||
|
help="Probability of generating invalid request payloads",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--unauthorized-probability",
|
||||||
|
type=float,
|
||||||
|
default=0.05,
|
||||||
|
help="Probability of sending an invalid/missing auth header",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--seed",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Deterministic random seed",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout-seconds",
|
||||||
|
type=float,
|
||||||
|
default=2.0,
|
||||||
|
help="HTTP timeout for each request",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--health-check",
|
||||||
|
action="store_true",
|
||||||
|
help="Check /v1/health before sending random traffic",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--state-sample-every",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help="Run GET /v1/state every N traffic requests (0 disables)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strict",
|
||||||
|
action="store_true",
|
||||||
|
help="Exit non-zero when unexpected errors are observed",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json-summary",
|
||||||
|
default=None,
|
||||||
|
help="Write summary JSON to this file path",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_path_candidates(app_name: str) -> list[Path]:
|
||||||
|
if os.name == "nt":
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if not appdata:
|
||||||
|
raise RuntimeError("APPDATA is not set; pass --config-path")
|
||||||
|
base = Path(appdata) / app_name
|
||||||
|
return [
|
||||||
|
base / "config" / "config.toml",
|
||||||
|
base / "config.toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
home = Path.home()
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
base = home / "Library" / "Application Support" / app_name
|
||||||
|
return [
|
||||||
|
base / "config" / "config.toml",
|
||||||
|
base / "config.toml",
|
||||||
|
]
|
||||||
|
base = home / ".config" / app_name
|
||||||
|
return [
|
||||||
|
base / "config" / "config.toml",
|
||||||
|
base / "config.toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_api_from_config(config_path: Path) -> tuple[int, str]:
|
||||||
|
if not config_path.exists():
|
||||||
|
raise RuntimeError(f"config path not found: {config_path}")
|
||||||
|
|
||||||
|
text = config_path.read_text(encoding="utf-8")
|
||||||
|
api_match = re.search(
|
||||||
|
r"(?ms)^\[api\]\s*(.*?)(?=^\[|\Z)",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if not api_match:
|
||||||
|
raise RuntimeError(f"missing [api] section in {config_path}")
|
||||||
|
|
||||||
|
api_block = api_match.group(1)
|
||||||
|
port_match = re.search(r"(?m)^\s*port\s*=\s*(\d+)\s*$", api_block)
|
||||||
|
token_match = re.search(
|
||||||
|
r'(?m)^\s*auth_token\s*=\s*"([^"]+)"\s*$',
|
||||||
|
api_block,
|
||||||
|
)
|
||||||
|
if not port_match:
|
||||||
|
raise RuntimeError(f"missing api.port in {config_path}")
|
||||||
|
if not token_match:
|
||||||
|
raise RuntimeError(f"missing api.auth_token in {config_path}")
|
||||||
|
|
||||||
|
return int(port_match.group(1)), token_match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def now_ts_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def command_envelope(command: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"ts_ms": now_ts_ms(),
|
||||||
|
"command": command,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def random_valid_command(rng: random.Random) -> dict[str, Any]:
|
||||||
|
pick = rng.choice(
|
||||||
|
(
|
||||||
|
"set_state",
|
||||||
|
"play_animation",
|
||||||
|
"set_sprite_pack",
|
||||||
|
"set_transform",
|
||||||
|
"set_flags",
|
||||||
|
"toast",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pick == "set_state":
|
||||||
|
payload: dict[str, Any] = {"state": rng.choice(
|
||||||
|
["idle", "active", "success", "error", "dragging", "hidden"]
|
||||||
|
)}
|
||||||
|
if rng.random() < 0.5:
|
||||||
|
payload["ttl_ms"] = rng.choice([500, 1_000, 2_000, 5_000])
|
||||||
|
else:
|
||||||
|
payload["ttl_ms"] = None
|
||||||
|
return {"type": "set_state", "payload": payload}
|
||||||
|
|
||||||
|
if pick == "play_animation":
|
||||||
|
payload = {
|
||||||
|
"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]),
|
||||||
|
}
|
||||||
|
return {"type": "play_animation", "payload": payload}
|
||||||
|
|
||||||
|
if pick == "set_sprite_pack":
|
||||||
|
payload = {
|
||||||
|
"pack_id_or_path": rng.choice(
|
||||||
|
["default", "missing-pack", "./assets/sprite-packs/default"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {"type": "set_sprite_pack", "payload": payload}
|
||||||
|
|
||||||
|
if pick == "set_transform":
|
||||||
|
payload = {
|
||||||
|
"x": rng.choice([None, round(rng.uniform(0, 1400), 2)]),
|
||||||
|
"y": rng.choice([None, round(rng.uniform(0, 900), 2)]),
|
||||||
|
"anchor": rng.choice([None, "center", "bottom_left", "bottom_right"]),
|
||||||
|
"scale": rng.choice([None, round(rng.uniform(0.5, 2.0), 2)]),
|
||||||
|
"opacity": rng.choice([None, round(rng.uniform(0.2, 1.0), 2)]),
|
||||||
|
}
|
||||||
|
return {"type": "set_transform", "payload": payload}
|
||||||
|
|
||||||
|
if pick == "set_flags":
|
||||||
|
payload = {
|
||||||
|
"click_through": rng.choice([None, False, True]),
|
||||||
|
"always_on_top": rng.choice([None, False, True]),
|
||||||
|
"visible": rng.choice([None, False, True]),
|
||||||
|
}
|
||||||
|
return {"type": "set_flags", "payload": payload}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"text": rng.choice(
|
||||||
|
["hello", "backend-test", "ping", "status ok", "random toast"]
|
||||||
|
),
|
||||||
|
"ttl_ms": rng.choice([None, 500, 1500, 2500]),
|
||||||
|
}
|
||||||
|
return {"type": "toast", "payload": payload}
|
||||||
|
|
||||||
|
|
||||||
|
def random_invalid_payload(rng: random.Random, batch: bool) -> str | bytes:
|
||||||
|
kind = rng.choice(("malformed", "missing_payload", "wrong_type"))
|
||||||
|
if kind == "malformed":
|
||||||
|
return b'{"id":"oops","command":'
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"id": "not-a-uuid",
|
||||||
|
"ts_ms": "not-int",
|
||||||
|
"command": {"type": "set_state"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raw = {
|
||||||
|
"id": "not-a-uuid",
|
||||||
|
"ts_ms": "not-int",
|
||||||
|
"command": {"type": "set_state"},
|
||||||
|
}
|
||||||
|
if kind == "wrong_type":
|
||||||
|
if batch:
|
||||||
|
raw[0]["command"] = {"type": "unknown_command", "payload": {"x": "bad"}}
|
||||||
|
else:
|
||||||
|
raw["command"] = {"type": "unknown_command", "payload": {"x": "bad"}}
|
||||||
|
return json.dumps(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_json_payload(payload: Any) -> bytes:
|
||||||
|
return json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Stats:
|
||||||
|
start_monotonic: float = field(default_factory=time.monotonic)
|
||||||
|
total_requests: int = 0
|
||||||
|
total_commands: int = 0
|
||||||
|
endpoint_counts: dict[str, int] = field(
|
||||||
|
default_factory=lambda: {"/v1/command": 0, "/v1/commands": 0, "/v1/state": 0, "/v1/health": 0}
|
||||||
|
)
|
||||||
|
status_counts: dict[str, int] = field(default_factory=dict)
|
||||||
|
transport_errors: int = 0
|
||||||
|
expected_outcomes: int = 0
|
||||||
|
unexpected_outcomes: int = 0
|
||||||
|
latency_ms: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
def bump_status(self, code: int) -> None:
|
||||||
|
key = str(code)
|
||||||
|
self.status_counts[key] = self.status_counts.get(key, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_header(
|
||||||
|
rng: random.Random,
|
||||||
|
token: str,
|
||||||
|
unauthorized_probability: float,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if rng.random() >= unauthorized_probability:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
# Simulate mixed unauthorized scenarios.
|
||||||
|
mode = rng.choice(("missing", "bad"))
|
||||||
|
if mode == "missing":
|
||||||
|
return {}
|
||||||
|
return {"Authorization": "Bearer invalid-token"}
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
body: bytes | None,
|
||||||
|
timeout_seconds: float,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> tuple[int | None, str]:
|
||||||
|
req_headers = {"Content-Type": "application/json", **headers}
|
||||||
|
request = Request(url=url, data=body, method=method, headers=req_headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
raw = response.read().decode("utf-8", errors="replace")
|
||||||
|
return response.status, raw
|
||||||
|
except HTTPError as err:
|
||||||
|
raw = err.read().decode("utf-8", errors="replace")
|
||||||
|
return err.code, raw
|
||||||
|
except URLError as err:
|
||||||
|
return None, str(err.reason)
|
||||||
|
except TimeoutError:
|
||||||
|
return None, "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
def expected_status(is_invalid_payload: bool, is_unauthorized: bool) -> set[int]:
|
||||||
|
if is_unauthorized:
|
||||||
|
return {401}
|
||||||
|
if is_invalid_payload:
|
||||||
|
return {400}
|
||||||
|
return {202}
|
||||||
|
|
||||||
|
|
||||||
|
def health_check(
|
||||||
|
base_url: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
stats: Stats,
|
||||||
|
) -> bool:
|
||||||
|
url = f"{base_url}/v1/health"
|
||||||
|
stats.total_requests += 1
|
||||||
|
stats.endpoint_counts["/v1/health"] += 1
|
||||||
|
started = time.monotonic()
|
||||||
|
code, _ = request_json(
|
||||||
|
method="GET",
|
||||||
|
url=url,
|
||||||
|
body=None,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
||||||
|
stats.latency_ms.append(elapsed_ms)
|
||||||
|
if code is None:
|
||||||
|
stats.transport_errors += 1
|
||||||
|
print("health check failed: transport error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
stats.bump_status(code)
|
||||||
|
if code != 200:
|
||||||
|
print(f"health check failed: expected 200, got {code}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sample_state(
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
stats: Stats,
|
||||||
|
) -> None:
|
||||||
|
url = f"{base_url}/v1/state"
|
||||||
|
stats.total_requests += 1
|
||||||
|
stats.endpoint_counts["/v1/state"] += 1
|
||||||
|
started = time.monotonic()
|
||||||
|
code, _ = request_json(
|
||||||
|
method="GET",
|
||||||
|
url=url,
|
||||||
|
body=None,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
||||||
|
stats.latency_ms.append(elapsed_ms)
|
||||||
|
if code is None:
|
||||||
|
stats.transport_errors += 1
|
||||||
|
stats.unexpected_outcomes += 1
|
||||||
|
return
|
||||||
|
stats.bump_status(code)
|
||||||
|
if code == 200:
|
||||||
|
stats.expected_outcomes += 1
|
||||||
|
else:
|
||||||
|
stats.unexpected_outcomes += 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_traffic(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
port: int,
|
||||||
|
token: str,
|
||||||
|
) -> Stats:
|
||||||
|
rng = random.Random(args.seed)
|
||||||
|
stats = Stats()
|
||||||
|
base_url = f"http://{args.host}:{port}"
|
||||||
|
|
||||||
|
if args.health_check and not health_check(base_url, args.timeout_seconds, stats):
|
||||||
|
return stats
|
||||||
|
|
||||||
|
deadline = time.monotonic() + max(1, args.duration_seconds)
|
||||||
|
req_index = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
req_index += 1
|
||||||
|
use_batch = rng.random() < args.batch_probability
|
||||||
|
endpoint = "/v1/commands" if use_batch else "/v1/command"
|
||||||
|
is_invalid = rng.random() < args.invalid_probability
|
||||||
|
unauthorized = rng.random() < args.unauthorized_probability
|
||||||
|
auth_headers = build_auth_header(rng, token, 1.0 if unauthorized else 0.0)
|
||||||
|
|
||||||
|
if use_batch:
|
||||||
|
batch_size = rng.randint(1, max(1, args.max_batch_size))
|
||||||
|
if is_invalid:
|
||||||
|
payload = random_invalid_payload(rng, batch=True)
|
||||||
|
body = payload if isinstance(payload, bytes) else payload.encode("utf-8")
|
||||||
|
command_count = batch_size
|
||||||
|
else:
|
||||||
|
commands = [
|
||||||
|
command_envelope(random_valid_command(rng))
|
||||||
|
for _ in range(batch_size)
|
||||||
|
]
|
||||||
|
body = encode_json_payload(commands)
|
||||||
|
command_count = len(commands)
|
||||||
|
else:
|
||||||
|
if is_invalid:
|
||||||
|
payload = random_invalid_payload(rng, batch=False)
|
||||||
|
body = payload if isinstance(payload, bytes) else payload.encode("utf-8")
|
||||||
|
command_count = 1
|
||||||
|
else:
|
||||||
|
envelope = command_envelope(random_valid_command(rng))
|
||||||
|
body = encode_json_payload(envelope)
|
||||||
|
command_count = 1
|
||||||
|
|
||||||
|
stats.total_requests += 1
|
||||||
|
stats.total_commands += command_count
|
||||||
|
stats.endpoint_counts[endpoint] += 1
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
code, _ = request_json(
|
||||||
|
method="POST",
|
||||||
|
url=f"{base_url}{endpoint}",
|
||||||
|
body=body,
|
||||||
|
timeout_seconds=args.timeout_seconds,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
||||||
|
stats.latency_ms.append(elapsed_ms)
|
||||||
|
|
||||||
|
if code is None:
|
||||||
|
stats.transport_errors += 1
|
||||||
|
stats.unexpected_outcomes += 1
|
||||||
|
else:
|
||||||
|
stats.bump_status(code)
|
||||||
|
expected = expected_status(is_invalid, unauthorized)
|
||||||
|
if code in expected:
|
||||||
|
stats.expected_outcomes += 1
|
||||||
|
else:
|
||||||
|
stats.unexpected_outcomes += 1
|
||||||
|
|
||||||
|
if args.state_sample_every > 0 and req_index % args.state_sample_every == 0:
|
||||||
|
sample_state(base_url, token, args.timeout_seconds, stats)
|
||||||
|
|
||||||
|
time.sleep(max(0, args.interval_ms) / 1000.0)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(args: argparse.Namespace, port: int, stats: Stats) -> dict[str, Any]:
|
||||||
|
elapsed = time.monotonic() - stats.start_monotonic
|
||||||
|
latency_avg = statistics.fmean(stats.latency_ms) if stats.latency_ms else 0.0
|
||||||
|
latency_min = min(stats.latency_ms) if stats.latency_ms else 0.0
|
||||||
|
latency_max = max(stats.latency_ms) if stats.latency_ms else 0.0
|
||||||
|
summary: dict[str, Any] = {
|
||||||
|
"host": args.host,
|
||||||
|
"port": port,
|
||||||
|
"duration_seconds": round(elapsed, 3),
|
||||||
|
"seed": args.seed,
|
||||||
|
"requests_total": stats.total_requests,
|
||||||
|
"commands_total": stats.total_commands,
|
||||||
|
"endpoint_counts": stats.endpoint_counts,
|
||||||
|
"status_counts": stats.status_counts,
|
||||||
|
"transport_errors": stats.transport_errors,
|
||||||
|
"expected_outcomes": stats.expected_outcomes,
|
||||||
|
"unexpected_outcomes": stats.unexpected_outcomes,
|
||||||
|
"latency_ms": {
|
||||||
|
"avg": round(latency_avg, 2),
|
||||||
|
"min": round(latency_min, 2),
|
||||||
|
"max": round(latency_max, 2),
|
||||||
|
},
|
||||||
|
"strict": args.strict,
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_port_and_token(args: argparse.Namespace) -> tuple[int, str]:
|
||||||
|
port = args.port
|
||||||
|
token = args.token
|
||||||
|
|
||||||
|
if port is not None and token:
|
||||||
|
return port, token
|
||||||
|
|
||||||
|
if args.config_path:
|
||||||
|
candidates = [Path(args.config_path)]
|
||||||
|
else:
|
||||||
|
candidates = default_config_path_candidates(args.app_name)
|
||||||
|
|
||||||
|
chosen: Path | None = None
|
||||||
|
for path in candidates:
|
||||||
|
if path.exists():
|
||||||
|
chosen = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
formatted = ", ".join(str(path) for path in candidates)
|
||||||
|
raise RuntimeError(f"config path not found; tried: {formatted}")
|
||||||
|
|
||||||
|
cfg_port, cfg_token = parse_api_from_config(chosen)
|
||||||
|
return (port or cfg_port), (token or cfg_token)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if args.max_batch_size < 1:
|
||||||
|
print("error: --max-batch-size must be >= 1", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
port, token = resolve_port_and_token(args)
|
||||||
|
except RuntimeError as err:
|
||||||
|
print(f"error: {err}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
stats = run_traffic(args, port, token)
|
||||||
|
summary = summarize(args, port, stats)
|
||||||
|
print(json.dumps(summary, indent=2))
|
||||||
|
|
||||||
|
if args.json_summary:
|
||||||
|
path = Path(args.json_summary)
|
||||||
|
path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
if args.strict and summary["unexpected_outcomes"] > 0:
|
||||||
|
return 1
|
||||||
|
if summary["requests_total"] == 0:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user