Compare commits

13 Commits

Author SHA1 Message Date
DaZuo0122
f50243ab96 Fix: Clipping bug 2026-02-14 17:55:35 +08:00
DaZuo0122
f2954ad22b Fix: attempt for clipping bug - not fixed yet 2026-02-14 17:31:55 +08:00
DaZuo0122
1fa7080210 Fix: background splitting bug 2026-02-14 17:08:29 +08:00
DaZuo0122
901bf0ffc3 Add: setting window for tauri - bugs not fixed yet 2026-02-14 13:21:56 +08:00
DaZuo0122
907974e61f Add: tray for tauri 2026-02-13 23:10:01 +08:00
DaZuo0122
e5e123cc84 Add: config for controlling debug overlay visibility of tauri 2026-02-13 22:31:22 +08:00
DaZuo0122
875bc54c4f Add: logical verification workflow for bevy 2026-02-13 21:54:30 +08:00
DaZuo0122
8e79bd98e5 Fix: tauri window scaling bug 2026-02-13 17:25:28 +08:00
DaZuo0122
084506e84b Fix: windows x86_64 packaging behavior 2026-02-13 17:11:22 +08:00
DaZuo0122
77f4139392 Add: dummy backend for behavioural testing 2026-02-13 15:34:01 +08:00
DaZuo0122
55fe53235d Add: just commands for release build 2026-02-13 11:22:46 +08:00
DaZuo0122
3c3ca342c9 Add: tauri frontend as bevy alternative 2026-02-13 09:57:08 +08:00
DaZuo0122
b0f462f63e Update: .gitignore 2026-02-12 23:11:21 +08:00
51 changed files with 14978 additions and 284 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
/target /target
/dist /dist
/issues/screenshots /issues/screenshots
codex.txt
/frontend/tauri-ui/node_modules
/frontend/tauri-ui/dist

3626
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -0,0 +1,35 @@
{
"id": "demogorgon",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 6,
"frames": [0, 1]
},
{
"name": "active",
"fps": 10,
"frames": [1, 0]
},
{
"name": "success",
"fps": 10,
"frames": [0, 1, 0],
"one_shot": true
},
{
"name": "error",
"fps": 8,
"frames": [1, 0, 1],
"one_shot": true
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

View File

@@ -0,0 +1,35 @@
{
"id": "ferris",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 6,
"frames": [0, 1]
},
{
"name": "active",
"fps": 10,
"frames": [1, 0]
},
{
"name": "success",
"fps": 10,
"frames": [0, 1, 0],
"one_shot": true
},
{
"name": "error",
"fps": 8,
"frames": [1, 0, 1],
"one_shot": true
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@@ -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

View File

@@ -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(&current_pack.runtime, value); let size = window_size_for_pack(&current_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, &current_pack.runtime.clips, next_animation); set_animation(&mut animation, &current_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, &current_pack.runtime.clips) { if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {

View File

@@ -26,6 +26,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 +38,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(),
} }
} }
} }
@@ -145,6 +147,29 @@ 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,
}
impl Default for FrontendConfig {
fn default() -> Self {
Self {
backend: FrontendBackend::Bevy,
debug_overlay_visible: false,
}
}
}
#[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 =
@@ -198,9 +223,13 @@ 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;
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);
} }
} }

View File

@@ -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,

View File

@@ -0,0 +1,19 @@
[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
[dev-dependencies]
tempfile = "3.12.0"

View File

@@ -0,0 +1,318 @@
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
use sprimo_config::{save, AppConfig, ConfigError};
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use thiserror::Error;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, Mutex};
use tracing::warn;
#[derive(Debug, Error)]
pub enum RuntimeCoreError {
#[error("{0}")]
Config(#[from] ConfigError),
#[error("snapshot lock poisoned")]
SnapshotPoisoned,
#[error("config lock poisoned")]
ConfigPoisoned,
}
pub struct RuntimeCore {
config_path: PathBuf,
config: Arc<RwLock<AppConfig>>,
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_config: ApiConfig,
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 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_token(config_value.api.auth_token.clone());
let (command_tx, command_rx) = mpsc::channel(1_024);
let core = Self {
config_path,
config: Arc::new(RwLock::new(config_value)),
snapshot: Arc::new(RwLock::new(snapshot)),
api_config,
command_tx,
command_rx: Arc::new(Mutex::new(command_rx)),
};
if click_through_was_enabled {
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 api_config(&self) -> ApiConfig {
self.api_config.clone()
}
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();
}
let state = Arc::new(ApiState::new(
cfg.clone(),
Arc::clone(&self.snapshot),
self.command_tx.clone(),
));
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 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 => "idle",
sprimo_protocol::v1::FrontendState::Hidden => "idle",
}
}
fn log_api_error(err: ApiServerError) {
warn!(%err, "runtime core api server exited");
}
#[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 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"));
}
}

View 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 = [] }

View 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()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,852 @@
#![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";
#[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,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSpritePackOption {
id: String,
pack_id_or_path: String,
}
#[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::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 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,
list_sprite_packs,
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 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 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))
}

View File

@@ -0,0 +1,30 @@
{
"$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,
"alwaysOnTop": true,
"resizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}

View File

@@ -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

View File

@@ -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<_>>`.

View 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`

View File

@@ -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,17 @@ 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
``` ```
## 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.

View File

@@ -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 doesnt 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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,100 @@
# 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)
## 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.

View File

@@ -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,59 @@ 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)
### 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.

View File

@@ -64,3 +64,14 @@ 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`).

View File

@@ -0,0 +1,82 @@
# 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.
- 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.

View File

@@ -0,0 +1,187 @@
# 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. 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)
## 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. Toggle `Visible` and verify:
- main overlay hide/show behavior
- persisted value survives restart
6. 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:

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,571 @@
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;
};
type UiSpritePackOption = {
id: string;
pack_id_or_path: string;
};
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;
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 });
}
function fittedWindowSize(
frameWidth: number,
frameHeight: number,
scale: number
): { width: number; height: number } {
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const width = Math.round(Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE));
const height = Math.round(Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE));
return { width, height };
}
function effectiveScaleForWindowSize(pack: UiSpritePack, 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 / Math.max(pack.frame_width, 1);
const scaleByHeight = availableHeight / Math.max(pack.frame_height, 1);
const scale = Math.min(scaleByWidth, scaleByHeight);
return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX));
}
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<number> {
const window = getCurrentWindow();
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
const target = fittedWindowSize(pack.frame_width, pack.frame_height, 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(pack, 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 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);
rendererRef.current = nextRenderer;
activePackRef.current = pack;
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
previousRenderer?.dispose();
return true;
};
const tryFitWindow = async (pack: UiSpritePack, scale: number): Promise<number | null> => {
try {
return await fitWindowForScale(pack, 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(pack, 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(activePackRef.current, 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")
])
.then(async ([pack, initialSnapshot, showDebug]) => {
if (!mountedRef.current) {
return;
}
setDebugOverlayVisible(showDebug);
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));
});
unlisten = () => {
unlistenSnapshot();
unlistenDebug();
};
})
.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 [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")
])
.then(async ([snapshot, options, pack]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
setActivePack(pack);
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
};
});
});
})
.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]
);
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="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>
</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>
);

View File

@@ -0,0 +1,495 @@
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;
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 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();
}
private startTicker(): void {
this.app.ticker.add((ticker) => {
if (this.disposed) {
return;
}
this.layoutSprite();
const frameMs = 1000 / Math.max(this.currentClip.fps, 1);
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);
}
}

View File

@@ -0,0 +1,148 @@
:root {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
body {
margin: 0;
background: transparent;
color: #e2e8f0;
overflow: hidden;
}
.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 {
min-height: 100vh;
display: flex;
align-items: stretch;
justify-content: center;
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
color: #0f172a;
user-select: none;
}
.settings-card {
width: 100%;
max-width: 480px;
margin: 0;
padding: 20px;
display: grid;
gap: 14px;
align-content: start;
}
.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"] {
width: 100%;
}
.field select {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
color: #0f172a;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #1e293b;
}

View 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"]
}

View 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
View 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
View 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
View 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:

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,568 @@
#!/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
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(
["idle", "dance", "typing", "celebrate", "error", "unknown_anim"]
),
"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())