Compare commits
6 Commits
test-0.1.0
...
8e79bd98e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e79bd98e5 | ||
|
|
084506e84b | ||
|
|
77f4139392 | ||
|
|
55fe53235d | ||
|
|
3c3ca342c9 | ||
|
|
b0f462f63e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/dist
|
/dist
|
||||||
/issues/screenshots
|
/issues/screenshots
|
||||||
|
codex.txt
|
||||||
|
/frontend/tauri-ui/node_modules
|
||||||
|
/frontend/tauri-ui/dist
|
||||||
|
|||||||
3625
Cargo.lock
generated
3625
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ members = [
|
|||||||
"crates/sprimo-config",
|
"crates/sprimo-config",
|
||||||
"crates/sprimo-platform",
|
"crates/sprimo-platform",
|
||||||
"crates/sprimo-protocol",
|
"crates/sprimo-protocol",
|
||||||
|
"crates/sprimo-runtime-core",
|
||||||
"crates/sprimo-sprite",
|
"crates/sprimo-sprite",
|
||||||
|
"crates/sprimo-tauri",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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;
|
||||||
@@ -180,7 +181,13 @@ fn main() -> Result<(), AppError> {
|
|||||||
.compact()
|
.compact()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let (config_path, config) = sprimo_config::load_or_create(APP_NAME)?;
|
let (config_path, mut config) = sprimo_config::load_or_create(APP_NAME)?;
|
||||||
|
if config.window.click_through {
|
||||||
|
config.window.click_through = false;
|
||||||
|
if let Err(err) = save(&config_path, &config) {
|
||||||
|
warn!(%err, "failed to persist click-through disable at startup");
|
||||||
|
}
|
||||||
|
}
|
||||||
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
|
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
|
||||||
let capabilities = platform.capabilities();
|
let capabilities = platform.capabilities();
|
||||||
|
|
||||||
@@ -188,7 +195,7 @@ fn main() -> Result<(), AppError> {
|
|||||||
snapshot.x = config.window.x;
|
snapshot.x = config.window.x;
|
||||||
snapshot.y = config.window.y;
|
snapshot.y = config.window.y;
|
||||||
snapshot.scale = config.window.scale;
|
snapshot.scale = config.window.scale;
|
||||||
snapshot.flags.click_through = config.window.click_through;
|
snapshot.flags.click_through = false;
|
||||||
snapshot.flags.always_on_top = config.window.always_on_top;
|
snapshot.flags.always_on_top = config.window.always_on_top;
|
||||||
snapshot.flags.visible = config.window.visible;
|
snapshot.flags.visible = config.window.visible;
|
||||||
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
|
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
|
||||||
@@ -223,7 +230,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,
|
||||||
@@ -721,7 +731,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;
|
||||||
@@ -746,14 +755,14 @@ fn poll_hotkey_recovery(
|
|||||||
) {
|
) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
config.config.window.click_through = false;
|
config.config.window.always_on_top = true;
|
||||||
config.config.window.visible = true;
|
config.config.window.visible = true;
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
if let Err(err) = save(&config.path, &config.config) {
|
||||||
warn!(%err, "failed to persist config after hotkey recovery");
|
warn!(%err, "failed to persist config after hotkey recovery");
|
||||||
@@ -761,6 +770,7 @@ fn poll_hotkey_recovery(
|
|||||||
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
if let Ok(mut guard) = snapshot.0.write() {
|
||||||
guard.flags.click_through = false;
|
guard.flags.click_through = false;
|
||||||
|
guard.flags.always_on_top = true;
|
||||||
guard.flags.visible = true;
|
guard.flags.visible = true;
|
||||||
guard.last_error = None;
|
guard.last_error = None;
|
||||||
}
|
}
|
||||||
@@ -927,9 +937,8 @@ fn poll_backend_commands(
|
|||||||
always_on_top,
|
always_on_top,
|
||||||
visible,
|
visible,
|
||||||
} => {
|
} => {
|
||||||
if let Some(value) = click_through {
|
if click_through.is_some() {
|
||||||
let _ = platform.0.set_click_through(value);
|
config.config.window.click_through = false;
|
||||||
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);
|
||||||
@@ -949,9 +958,7 @@ fn poll_backend_commands(
|
|||||||
warn!(%err, "failed to persist flag config");
|
warn!(%err, "failed to persist flag config");
|
||||||
}
|
}
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
if let Ok(mut guard) = snapshot.0.write() {
|
||||||
if let Some(value) = click_through {
|
guard.flags.click_through = false;
|
||||||
guard.flags.click_through = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = always_on_top {
|
if let Some(value) = always_on_top {
|
||||||
guard.flags.always_on_top = value;
|
guard.flags.always_on_top = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FrontendConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: FrontendBackend::Bevy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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 +221,11 @@ 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;
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
19
crates/sprimo-runtime-core/Cargo.toml
Normal file
19
crates/sprimo-runtime-core/Cargo.toml
Normal 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"
|
||||||
286
crates/sprimo-runtime-core/src/lib.rs
Normal file
286
crates/sprimo-runtime-core/src/lib.rs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/sprimo-tauri/Cargo.toml
Normal file
29
crates/sprimo-tauri/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "sprimo-tauri"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22.1"
|
||||||
|
serde.workspace = true
|
||||||
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
|
sprimo-platform = { path = "../sprimo-platform" }
|
||||||
|
sprimo-sprite = { path = "../sprimo-sprite" }
|
||||||
|
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
|
||||||
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
tauri = { version = "2.0.0", features = [] }
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0"
|
||||||
|
tauri-plugin-log = "2.0.0"
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
8
crates/sprimo-tauri/build.rs
Normal file
8
crates/sprimo-tauri/build.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/index.html");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/dist");
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
16
crates/sprimo-tauri/capabilities/default.json
Normal file
16
crates/sprimo-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$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:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
1
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}}
|
||||||
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
244
crates/sprimo-tauri/src/main.rs
Normal file
244
crates/sprimo-tauri/src/main.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use sprimo_platform::{create_adapter, PlatformAdapter};
|
||||||
|
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
|
||||||
|
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
|
||||||
|
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const APP_NAME: &str = "sprimo";
|
||||||
|
const DEFAULT_PACK: &str = "default";
|
||||||
|
|
||||||
|
#[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, serde::Serialize)]
|
||||||
|
struct UiSnapshot {
|
||||||
|
state: String,
|
||||||
|
current_animation: String,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
scale: f32,
|
||||||
|
active_sprite_pack: 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 config = state
|
||||||
|
.runtime_core
|
||||||
|
.config()
|
||||||
|
.read()
|
||||||
|
.map_err(|_| "config lock poisoned".to_string())?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let root = std::env::current_dir()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
.join("assets")
|
||||||
|
.join(config.sprite.sprite_packs_dir);
|
||||||
|
|
||||||
|
let selected = config.sprite.selected_pack;
|
||||||
|
let pack_path = match resolve_pack_path(&root, &selected) {
|
||||||
|
Ok(path) => path,
|
||||||
|
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 atlas_data_url = format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(image_bytes)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(UiSpritePack {
|
||||||
|
id: manifest.id,
|
||||||
|
frame_width: manifest.frame_width,
|
||||||
|
frame_height: manifest.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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
|
.manage(state)
|
||||||
|
.invoke_handler(tauri::generate_handler![current_state, load_active_sprite_pack])
|
||||||
|
.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 app_handle = app.handle().clone();
|
||||||
|
|
||||||
|
if let Ok(snapshot) = runtime_core.snapshot().read() {
|
||||||
|
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_rx = runtime_core.command_receiver();
|
||||||
|
let runtime_core_for_commands = Arc::clone(&runtime_core);
|
||||||
|
let app_handle_for_commands = app_handle.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("runtime:snapshot", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let runtime_core = Arc::clone(&runtime_core);
|
||||||
|
let app_handle = app_handle.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("runtime:snapshot", to_ui_snapshot(&snapshot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/sprimo-tauri/tauri.conf.json
Normal file
30
crates/sprimo-tauri/tauri.conf.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,9 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
|
|||||||
"uptime_seconds": 12,
|
"uptime_seconds": 12,
|
||||||
"active_sprite_pack": "default",
|
"active_sprite_pack": "default",
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"supports_click_through": true,
|
"supports_click_through": false,
|
||||||
"supports_transparency": true,
|
"supports_transparency": true,
|
||||||
"supports_tray": true,
|
"supports_tray": false,
|
||||||
"supports_global_hotkey": true,
|
"supports_global_hotkey": true,
|
||||||
"supports_skip_taskbar": true
|
"supports_skip_taskbar": true
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,11 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
- `SetFlags.click_through` is deprecated for compatibility and ignored at runtime.
|
||||||
|
- Frontend state always reports `flags.click_through = false`.
|
||||||
|
|
||||||
## Error Response
|
## Error Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -3,20 +3,22 @@
|
|||||||
## Workspace Layout
|
## Workspace Layout
|
||||||
|
|
||||||
- `crates/sprimo-app`: process entrypoint and runtime wiring.
|
- `crates/sprimo-app`: process entrypoint and runtime wiring.
|
||||||
|
- `crates/sprimo-tauri`: Tauri 2.0 alternative frontend entrypoint.
|
||||||
- `crates/sprimo-api`: axum-based localhost control server.
|
- `crates/sprimo-api`: axum-based localhost control server.
|
||||||
- `crates/sprimo-config`: config schema, path resolution, persistence.
|
- `crates/sprimo-config`: config schema, path resolution, persistence.
|
||||||
- `crates/sprimo-platform`: platform abstraction for overlay operations.
|
- `crates/sprimo-platform`: platform abstraction for overlay operations.
|
||||||
- `crates/sprimo-protocol`: shared API/state/command protocol types.
|
- `crates/sprimo-protocol`: shared API/state/command protocol types.
|
||||||
|
- `crates/sprimo-runtime-core`: shared runtime core for command/state/API orchestration.
|
||||||
- `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic.
|
- `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic.
|
||||||
|
|
||||||
## Runtime Data Flow
|
## Runtime Data Flow
|
||||||
|
|
||||||
1. `sprimo-app` loads or creates `config.toml`.
|
1. Frontend (`sprimo-app` or `sprimo-tauri`) initializes `sprimo-runtime-core`.
|
||||||
2. App builds initial `FrontendStateSnapshot`.
|
2. Runtime core loads/creates `config.toml` and builds initial `FrontendStateSnapshot`.
|
||||||
3. App starts `sprimo-api` on a Tokio runtime.
|
3. Runtime core starts `sprimo-api` on a Tokio runtime.
|
||||||
4. API authenticates commands and deduplicates IDs.
|
4. API authenticates commands and deduplicates IDs.
|
||||||
5. Commands are bridged from Tokio channel to Bevy main-thread systems.
|
5. Commands are bridged from API channel into frontend-specific command handlers.
|
||||||
6. Bevy systems apply commands to sprite state, window/platform operations, and config persistence.
|
6. Frontend adapter applies rendering/window effects and runtime core applies snapshot/config state.
|
||||||
7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`.
|
7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`.
|
||||||
|
|
||||||
## Sprite Reload Semantics
|
## Sprite Reload Semantics
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
- API task: axum server.
|
- API task: axum server.
|
||||||
- Bridge task: forwards API commands into Bevy ingest channel.
|
- Bridge task: forwards API commands into Bevy ingest channel.
|
||||||
- Bevy main thread: rendering, animation, command application, and window behavior.
|
- Bevy main thread: rendering, animation, command application, and window behavior.
|
||||||
|
- Tauri thread/runtime: webview UI, event loop, and runtime command consumer.
|
||||||
- Optional hotkey thread (Windows): registers global hotkey and pushes recovery events.
|
- Optional hotkey thread (Windows): registers global hotkey and pushes recovery events.
|
||||||
- Snapshot is shared via `Arc<RwLock<_>>`.
|
- Snapshot is shared via `Arc<RwLock<_>>`.
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 @@ level = "info"
|
|||||||
[controls]
|
[controls]
|
||||||
hotkey_enabled = true
|
hotkey_enabled = true
|
||||||
recovery_hotkey = "Ctrl+Alt+P"
|
recovery_hotkey = "Ctrl+Alt+P"
|
||||||
|
|
||||||
|
[frontend]
|
||||||
|
backend = "bevy"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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`).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
# 1. Overview
|
# 1. Overview
|
||||||
|
|
||||||
The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top** and optionally **click-through**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally.
|
The frontend is a **desktop overlay pet renderer** implemented in Rust (Bevy). It presents an animated character in a **transparent, borderless window** that can be **always-on-top**. It receives control instructions from a local backend process via **localhost REST API**, applies them to its animation/state machine, and persists user preferences locally.
|
||||||
|
|
||||||
The frontend must be able to run standalone (idle animation) even if the backend is not running.
|
The frontend must be able to run standalone (idle animation) even if the backend is not running.
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
1. **Render a cute animated character overlay** with smooth sprite animation.
|
1. **Render a cute animated character overlay** with smooth sprite animation.
|
||||||
2. Provide a **stable command interface** (REST) for backend control.
|
2. Provide a **stable command interface** (REST) for backend control.
|
||||||
3. Offer **essential user controls** (tray/menu + hotkeys optional) to avoid “locking” the pet in click-through mode.
|
3. Offer **essential user controls** (tray/menu + hotkeys optional) to keep the pet recoverable and visible.
|
||||||
4. Persist **window position, scale, sprite pack choice, and flags**.
|
4. Persist **window position, scale, sprite pack choice, and flags**.
|
||||||
5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland.
|
5. Be **cross-platform** (Windows/macOS/Linux) with documented degradations, especially on Linux Wayland.
|
||||||
|
|
||||||
@@ -43,7 +43,6 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
* As a user, I can see the pet on my desktop immediately after launch.
|
* As a user, I can see the pet on my desktop immediately after launch.
|
||||||
* As a user, I can drag the pet to a preferred location.
|
* As a user, I can drag the pet to a preferred location.
|
||||||
* As a user, I can toggle click-through so the pet doesn’t block my mouse.
|
|
||||||
* As a user, I can toggle always-on-top so the pet stays visible.
|
* As a user, I can toggle always-on-top so the pet stays visible.
|
||||||
* As a user, I can change the character (sprite pack).
|
* As a user, I can change the character (sprite pack).
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
## Safety
|
## Safety
|
||||||
|
|
||||||
* As a user, I can always recover control of the pet even if click-through is enabled (hotkey/tray item).
|
* As a user, I can always recover the pet visibility/interaction state via hotkey or tray item.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,22 +81,19 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
* When ON, window stays above normal windows.
|
* When ON, window stays above normal windows.
|
||||||
|
|
||||||
### FR-FW-3 Click-through (mouse pass-through)
|
### FR-FW-3 Interaction model
|
||||||
|
|
||||||
* Support enabling/disabling click-through:
|
* Click-through is not required.
|
||||||
|
* Pet remains interactive while visible.
|
||||||
* ON: mouse events pass to windows underneath.
|
* Must provide a **failsafe** mechanism to recover visibility and interaction state.
|
||||||
* OFF: pet receives mouse input (drag, context menu).
|
|
||||||
* Must provide a **failsafe** mechanism to disable click-through without clicking the pet.
|
|
||||||
|
|
||||||
**Acceptance**
|
**Acceptance**
|
||||||
|
|
||||||
* With click-through enabled, user can click apps behind pet.
|
* Recovery hotkey/tray action restores visible, interactive pet state reliably.
|
||||||
* User can disable click-through via tray or hotkey reliably.
|
|
||||||
|
|
||||||
### FR-FW-4 Dragging & anchoring
|
### FR-FW-4 Dragging & anchoring
|
||||||
|
|
||||||
* When click-through is OFF, user can drag the pet.
|
* User can drag the pet.
|
||||||
* Dragging updates persisted position in config.
|
* Dragging updates persisted position in config.
|
||||||
* Optional: snapping to screen edges.
|
* Optional: snapping to screen edges.
|
||||||
|
|
||||||
@@ -130,12 +126,12 @@ The frontend must be able to run standalone (idle animation) even if the backend
|
|||||||
|
|
||||||
### Platform notes (requirements)
|
### Platform notes (requirements)
|
||||||
|
|
||||||
* **Windows:** click-through uses extended window styles (WS_EX_TRANSPARENT / layered), always-on-top via SetWindowPos.
|
* **Windows:** always-on-top via SetWindowPos.
|
||||||
* **macOS:** NSWindow level + ignoresMouseEvents.
|
* **macOS:** NSWindow level + ignoresMouseEvents.
|
||||||
* **Linux:** best effort:
|
* **Linux:** best effort:
|
||||||
|
|
||||||
* X11: possible with shape/input region.
|
* X11: possible with shape/input region.
|
||||||
* Wayland: click-through may be unavailable; document limitation.
|
* Wayland: overlay behavior limitations may apply; document limitation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -273,7 +269,7 @@ Each state maps to a default animation (configurable by sprite pack):
|
|||||||
* `PlayAnimation { name, priority, duration_ms?, interrupt? }`
|
* `PlayAnimation { name, priority, duration_ms?, interrupt? }`
|
||||||
* `SetSpritePack { pack_id_or_path }`
|
* `SetSpritePack { pack_id_or_path }`
|
||||||
* `SetTransform { x?, y?, anchor?, scale?, opacity? }`
|
* `SetTransform { x?, y?, anchor?, scale?, opacity? }`
|
||||||
* `SetFlags { click_through?, always_on_top?, visible? }`
|
* `SetFlags { click_through?, always_on_top?, visible? }` (`click_through` is deprecated/ignored)
|
||||||
* `Toast { text, ttl_ms? }` (optional but recommended)
|
* `Toast { text, ttl_ms? }` (optional but recommended)
|
||||||
|
|
||||||
### FR-API-4 Idempotency & dedupe
|
### FR-API-4 Idempotency & dedupe
|
||||||
@@ -303,7 +299,6 @@ Each state maps to a default animation (configurable by sprite pack):
|
|||||||
Provide tray/menu bar items:
|
Provide tray/menu bar items:
|
||||||
|
|
||||||
* Show/Hide
|
* Show/Hide
|
||||||
* Toggle Click-through
|
|
||||||
* Toggle Always-on-top
|
* Toggle Always-on-top
|
||||||
* Sprite Pack selection (at least “Default” + “Open sprite folder…”)
|
* Sprite Pack selection (at least “Default” + “Open sprite folder…”)
|
||||||
* Reload sprite packs
|
* Reload sprite packs
|
||||||
@@ -315,7 +310,7 @@ If tray is too hard on Linux in v0.1, provide a fallback (hotkey + config).
|
|||||||
|
|
||||||
At minimum one global hotkey:
|
At minimum one global hotkey:
|
||||||
|
|
||||||
* Toggle click-through OR “enter interactive mode”
|
* Force visible + interactive recovery mode
|
||||||
|
|
||||||
Example default:
|
Example default:
|
||||||
|
|
||||||
@@ -323,7 +318,7 @@ Example default:
|
|||||||
|
|
||||||
**Acceptance**
|
**Acceptance**
|
||||||
|
|
||||||
* User can recover control even if pet is click-through and cannot be clicked.
|
* User can recover visibility and interaction state even when the pet was hidden or misplaced.
|
||||||
|
|
||||||
### FR-CTL-3 Context menu (optional)
|
### FR-CTL-3 Context menu (optional)
|
||||||
|
|
||||||
@@ -348,7 +343,7 @@ Right click pet (when interactive) to open a minimal menu.
|
|||||||
* position (x,y) + monitor id (best-effort)
|
* position (x,y) + monitor id (best-effort)
|
||||||
* scale
|
* scale
|
||||||
* always_on_top
|
* always_on_top
|
||||||
* click_through
|
* click_through (deprecated/ignored; always false)
|
||||||
* visible
|
* visible
|
||||||
* animation:
|
* animation:
|
||||||
|
|
||||||
@@ -432,10 +427,10 @@ Frontend must expose in logs (and optionally `/v1/health`) capability flags:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
* Windows: all true
|
* Windows: click-through false; others vary by implementation status
|
||||||
* macOS: all true
|
* macOS: click-through false; others vary by implementation status
|
||||||
* Linux X11: most true
|
* Linux X11: most true
|
||||||
* Linux Wayland: click-through likely false, skip-taskbar variable
|
* Linux Wayland: skip-taskbar variable and overlay behavior limitations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -444,8 +439,8 @@ Example:
|
|||||||
## Window
|
## Window
|
||||||
|
|
||||||
1. Launch: window appears borderless & transparent.
|
1. Launch: window appears borderless & transparent.
|
||||||
2. Drag: with click-through OFF, drag updates position; restart restores.
|
2. Drag: drag updates position; restart restores.
|
||||||
3. Click-through: toggle via hotkey; pet becomes non-interactive; toggle back works.
|
3. Recovery: hotkey restores visible + always-on-top behavior reliably.
|
||||||
4. Always-on-top: verify staying above typical apps.
|
4. Always-on-top: verify staying above typical apps.
|
||||||
|
|
||||||
## Animation
|
## Animation
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ 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 | In progress | `sprimo-runtime-core` extracted with shared config/snapshot/API startup and command application |
|
||||||
|
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale updates now auto-fit window to avoid top clipping |
|
||||||
|
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
|
||||||
|
|
||||||
## Next Major Gaps
|
## Next Major Gaps
|
||||||
|
|
||||||
@@ -24,3 +28,5 @@ Date: 2026-02-12
|
|||||||
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-app` is not yet refactored to consume `sprimo-runtime-core` directly.
|
||||||
|
6. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
- [x] `SetState` updates state and default animation mapping.
|
- [x] `SetState` updates state and default animation mapping.
|
||||||
- [x] transient state with `ttl_ms` returns to durable state.
|
- [x] transient state with `ttl_ms` returns to durable state.
|
||||||
- [x] `SetTransform` persists x/y/scale.
|
- [x] `SetTransform` persists x/y/scale.
|
||||||
- [x] `SetFlags` persists click-through/always-on-top/visible.
|
- [x] `SetFlags` persists always-on-top/visible and ignores deprecated click-through.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Date: 2026-02-12
|
|||||||
|
|
||||||
| Capability | Windows | Linux X11 | Linux Wayland | macOS |
|
| Capability | Windows | Linux X11 | Linux Wayland | macOS |
|
||||||
|------------|---------|-----------|---------------|-------|
|
|------------|---------|-----------|---------------|-------|
|
||||||
| `supports_click_through` | true (implemented) | false (current) | false | false (current) |
|
| `supports_click_through` | false (disabled by product requirement) | false | false | false |
|
||||||
| `supports_transparency` | true | true | true | true |
|
| `supports_transparency` | true | true | true | true |
|
||||||
| `supports_tray` | false (current) | false (current) | false (current) | false (current) |
|
| `supports_tray` | false (current) | false (current) | false (current) | false (current) |
|
||||||
| `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) |
|
| `supports_global_hotkey` | true (implemented) | false (current) | false (current) | false (current) |
|
||||||
@@ -12,6 +12,7 @@ Date: 2026-02-12
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Current code applies real Win32 operations for click-through, visibility, top-most, and positioning.
|
- Current code applies real Win32 operations for visibility, top-most, and positioning.
|
||||||
|
- Click-through is intentionally disabled by current product requirements.
|
||||||
- Non-Windows targets currently use a no-op adapter with conservative flags.
|
- Non-Windows targets currently use a no-op adapter with conservative flags.
|
||||||
- Wayland limitations remain an expected degradation in v0.1.
|
- Wayland limitations remain an expected degradation in v0.1.
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ 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.
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
@@ -83,3 +89,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
|
||||||
|
|||||||
100
docs/RANDOM_BACKEND_TESTING.md
Normal file
100
docs/RANDOM_BACKEND_TESTING.md
Normal 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.
|
||||||
@@ -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,41 @@ 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`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Packaged Mode (Required Once Tauri Packaging Exists)
|
||||||
|
|
||||||
|
When tauri packaging automation is available, repeat runtime behavior checks on packaged artifacts:
|
||||||
|
|
||||||
|
1. Launch packaged tauri app.
|
||||||
|
2. Re-run invoke/event/API checks from workspace mode.
|
||||||
|
3. Attach before/after screenshots and command summaries in linked issue.
|
||||||
|
|||||||
64
docs/TAURI_FRONTEND_DESIGN.md
Normal file
64
docs/TAURI_FRONTEND_DESIGN.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 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)
|
||||||
|
- `runtime:snapshot` event after command application.
|
||||||
|
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
|
||||||
|
from runtime snapshot events.
|
||||||
|
- 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.
|
||||||
|
- Bevy frontend remains intact.
|
||||||
|
- Runtime QA workflow is defined in `docs/TAURI_RUNTIME_TESTING.md`.
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
1. Move Bevy runtime flow to consume `sprimo-runtime-core` as primary state authority.
|
||||||
|
2. Add tray/menu parity and window behavior parity with Bevy path.
|
||||||
|
3. Extend packaging scripts to include `sprimo-tauri` artifact path.
|
||||||
|
4. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).
|
||||||
137
docs/TAURI_RUNTIME_TESTING.md
Normal file
137
docs/TAURI_RUNTIME_TESTING.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `runtime:snapshot` event received on runtime command changes.
|
||||||
|
|
||||||
|
## Evidence Requirements
|
||||||
|
|
||||||
|
For each Tauri runtime issue, include:
|
||||||
|
|
||||||
|
- command output summaries for all strict gate commands
|
||||||
|
- screenshot references:
|
||||||
|
- before: `issues/screenshots/issueN-before-YYYYMMDD-HHMMSS.png`
|
||||||
|
- after: `issues/screenshots/issueN-after-YYYYMMDD-HHMMSS.png`
|
||||||
|
- invoke/event verification notes
|
||||||
|
- API verification notes
|
||||||
|
|
||||||
|
## Issue Lifecycle Integration
|
||||||
|
|
||||||
|
Use standard lifecycle in `issues/issueN.md`:
|
||||||
|
|
||||||
|
1. `Reported`
|
||||||
|
2. `Triaged`
|
||||||
|
3. `In Progress`
|
||||||
|
4. `Fix Implemented`
|
||||||
|
5. `Verification Passed`
|
||||||
|
6. `Closed`
|
||||||
|
|
||||||
|
Tauri runtime issues must remain at `Fix Implemented` if any strict-gate evidence is missing.
|
||||||
|
|
||||||
|
## Failure Classification and Triage
|
||||||
|
|
||||||
|
- `P0`: crash on startup, renderer not visible, auth bypass, or command pipeline broken.
|
||||||
|
- `P1`: animation/state mismatch, event/invoke contract failure, major UX regression.
|
||||||
|
- `P2`: non-blocking rendering/perf issues, minor UI mismatch, cosmetic defects.
|
||||||
|
|
||||||
|
## Test Log Template (Tauri Runtime)
|
||||||
|
|
||||||
|
- Date:
|
||||||
|
- Issue:
|
||||||
|
- Frontend: `sprimo-tauri`
|
||||||
|
- Execution mode: `workspace` or `packaged`
|
||||||
|
- Commands run:
|
||||||
|
- API checks summary:
|
||||||
|
- Invoke/event checks summary:
|
||||||
|
- Before screenshots:
|
||||||
|
- After screenshots:
|
||||||
|
- Result:
|
||||||
|
- Notes:
|
||||||
12
frontend/tauri-ui/index.html
Normal file
12
frontend/tauri-ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>sprimo-tauri</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/tauri-ui/package.json
Normal file
26
frontend/tauri-ui/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sprimo-tauri-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx vite",
|
||||||
|
"build": "npx vite build",
|
||||||
|
"preview": "npx vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pixi/app": "^7.4.3",
|
||||||
|
"@pixi/core": "^7.4.3",
|
||||||
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
160
frontend/tauri-ui/src/main.tsx
Normal file
160
frontend/tauri-ui/src/main.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const WINDOW_PADDING = 16;
|
||||||
|
const MIN_WINDOW_SIZE = 64;
|
||||||
|
const SIZE_EPSILON = 0.5;
|
||||||
|
const SCALE_EPSILON = 0.0001;
|
||||||
|
|
||||||
|
function fittedWindowSize(
|
||||||
|
frameWidth: number,
|
||||||
|
frameHeight: number,
|
||||||
|
scale: number
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||||
|
const width = Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||||
|
const height = Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
const [outerPosition, innerSize] = await Promise.all([
|
||||||
|
window.outerPosition(),
|
||||||
|
window.innerSize()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
|
||||||
|
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
|
||||||
|
const heightChanged = Math.abs(target.height - innerSize.height) > SIZE_EPSILON;
|
||||||
|
if (!widthChanged && !heightChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaWidth = target.width - innerSize.width;
|
||||||
|
const deltaHeight = target.height - innerSize.height;
|
||||||
|
const targetX = outerPosition.x - deltaWidth / 2;
|
||||||
|
const targetY = outerPosition.y - deltaHeight;
|
||||||
|
|
||||||
|
await window.setSize(new LogicalSize(target.width, target.height));
|
||||||
|
await window.setPosition(new LogicalPosition(targetX, targetY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function App(): JSX.Element {
|
||||||
|
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
|
||||||
|
const scaleFitRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let unlisten: null | (() => void) = null;
|
||||||
|
let mounted = true;
|
||||||
|
let activePack: UiSpritePack | null = null;
|
||||||
|
Promise.all([
|
||||||
|
invoke<UiSpritePack>("load_active_sprite_pack"),
|
||||||
|
invoke<UiSnapshot>("current_state")
|
||||||
|
])
|
||||||
|
.then(async ([pack, initialSnapshot]) => {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activePack = pack;
|
||||||
|
setSnapshot(initialSnapshot);
|
||||||
|
if (hostRef.current !== null) {
|
||||||
|
rendererRef.current = await PixiPetRenderer.create(
|
||||||
|
hostRef.current,
|
||||||
|
pack,
|
||||||
|
initialSnapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scaleFitRef.current = initialSnapshot.scale;
|
||||||
|
await fitWindowForScale(pack, initialSnapshot.scale);
|
||||||
|
|
||||||
|
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = event.payload;
|
||||||
|
setSnapshot(value);
|
||||||
|
rendererRef.current?.applySnapshot(value);
|
||||||
|
if (activePack === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
scaleFitRef.current !== null &&
|
||||||
|
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scaleFitRef.current = value.scale;
|
||||||
|
void fitWindowForScale(activePack, value.scale).catch((err) => {
|
||||||
|
if (mounted) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (mounted) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
if (unlisten !== null) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
rendererRef.current?.dispose();
|
||||||
|
rendererRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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" onMouseDown={onMouseDown}>
|
||||||
|
<div className="canvas-host" ref={hostRef} />
|
||||||
|
<section className="debug-panel">
|
||||||
|
<h1>sprimo-tauri</h1>
|
||||||
|
{error !== null ? <p className="error">{error}</p> : null}
|
||||||
|
{snapshot === null ? (
|
||||||
|
<p>Loading snapshot...</p>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
203
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
203
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnimationMap = Map<string, UiAnimationClip>;
|
||||||
|
|
||||||
|
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 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
|
||||||
|
});
|
||||||
|
container.replaceChildren(app.view as HTMLCanvasElement);
|
||||||
|
|
||||||
|
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
|
||||||
|
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
|
||||||
|
throw new Error("Atlas image loaded with invalid dimensions.");
|
||||||
|
}
|
||||||
|
const sprite = new Sprite();
|
||||||
|
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
|
||||||
|
app.stage.addChild(sprite);
|
||||||
|
|
||||||
|
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 keyR = 0xff;
|
||||||
|
const keyG = 0x00;
|
||||||
|
const keyB = 0xff;
|
||||||
|
const tolerance = 28;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const dr = Math.abs(data[i] - keyR);
|
||||||
|
const dg = Math.abs(data[i + 1] - keyG);
|
||||||
|
const db = Math.abs(data[i + 2] - keyB);
|
||||||
|
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
|
||||||
|
data[i + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(frame, 0, 0);
|
||||||
|
resolve(BaseTexture.from(canvas));
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
reject(new Error("Failed to load atlas image data URL."));
|
||||||
|
};
|
||||||
|
image.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.app.ticker.stop();
|
||||||
|
this.app.ticker.destroy();
|
||||||
|
this.sprite.destroy({
|
||||||
|
children: true,
|
||||||
|
texture: false,
|
||||||
|
baseTexture: false
|
||||||
|
});
|
||||||
|
this.app.destroy(true, {
|
||||||
|
children: true,
|
||||||
|
texture: false,
|
||||||
|
baseTexture: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applySnapshot(snapshot: UiSnapshot): void {
|
||||||
|
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.sprite.scale.set(snapshot.scale);
|
||||||
|
this.layoutSprite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTicker(): void {
|
||||||
|
this.app.ticker.add((ticker) => {
|
||||||
|
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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/tauri-ui/src/styles.css
Normal file
55
frontend/tauri-ui/src/styles.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
17
frontend/tauri-ui/tsconfig.json
Normal file
17
frontend/tauri-ui/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowJs": false,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
frontend/tauri-ui/vite.config.ts
Normal file
10
frontend/tauri-ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
});
|
||||||
158
issues/issue2.md
Normal file
158
issues/issue2.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
## Title
|
||||||
|
|
||||||
|
`sprimo-tauri` runtime shows `TypeError: W.fromURL is not a function`; sprite renderer fails to initialize.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
P1
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS: Windows
|
||||||
|
- App: `sprimo-tauri` frontend/runtime path
|
||||||
|
- Reported on: 2026-02-13
|
||||||
|
- Evidence screenshot: `issues/screenshots/issue2.png`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
At runtime, the Tauri UI loads but Pixi sprite rendering fails with:
|
||||||
|
|
||||||
|
- `TypeError: W.fromURL is not a function`
|
||||||
|
|
||||||
|
This breaks sprite presentation and leaves the UI in an error state.
|
||||||
|
|
||||||
|
## Reproduction Steps
|
||||||
|
|
||||||
|
1. Build UI assets and start the Tauri app (`just build-tauri-ui`, `just run-tauri`).
|
||||||
|
2. Open the Tauri window and wait for sprite pack initialization.
|
||||||
|
3. Observe the debug panel error and missing pet rendering.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
- Pixi atlas texture loads successfully from `atlas_data_url`.
|
||||||
|
- Pet sprite renders and animates.
|
||||||
|
- No renderer initialization error in UI.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
- Renderer initialization fails with `TypeError: W.fromURL is not a function`.
|
||||||
|
- Sprite is not rendered.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
- `frontend/tauri-ui/src/renderer/pixi_pet.ts` used `BaseTexture.fromURL(...)`.
|
||||||
|
- In the current Pixi package/runtime composition, that API path is unavailable at runtime
|
||||||
|
(minified symbol resolves to `W.fromURL`, which is undefined).
|
||||||
|
- Result: atlas load throws before a valid sprite texture can be applied.
|
||||||
|
- Follow-up finding: Tauri can run previously embedded frontend assets if Rust build is not
|
||||||
|
re-triggered after UI-only changes, which can make old errors appear even after source fixes.
|
||||||
|
- After stale-build issue was resolved (`ui build: issue2-fix3` visible), a second runtime defect
|
||||||
|
became clear:
|
||||||
|
- `event.listen not allowed` due missing Tauri capability permissions for event listen/unlisten.
|
||||||
|
- sprite pack still rendered with magenta matte because tauri path lacked chroma-key conversion.
|
||||||
|
|
||||||
|
## Fix Plan
|
||||||
|
|
||||||
|
1. Replace `BaseTexture.fromURL` usage with Pixi assets loader (`@pixi/assets` + `Assets.load`).
|
||||||
|
2. Ensure sprite texture is assigned immediately after renderer creation (first frame visible).
|
||||||
|
3. Harden React lifecycle cleanup to avoid stale listeners/renderer leaks.
|
||||||
|
4. Re-run tauri/runtime QA checks and keep issue at `Fix Implemented` until strict gate evidence is complete.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
1. `frontend/tauri-ui/package.json`
|
||||||
|
- Removed temporary `@pixi/assets` dependency after switching loader strategy.
|
||||||
|
|
||||||
|
2. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Switched atlas loading to `Assets.load<Texture>(pack.atlas_data_url)`.
|
||||||
|
- Reused `texture.baseTexture` for frame slicing.
|
||||||
|
- Applied initial frame texture in constructor so sprite appears immediately.
|
||||||
|
- Added explicit renderer/sprite/ticker disposal path.
|
||||||
|
|
||||||
|
3. `frontend/tauri-ui/src/main.tsx`
|
||||||
|
- Added mount guards to prevent state updates after unmount.
|
||||||
|
- Added deterministic cleanup (`unlisten` + renderer `dispose()`).
|
||||||
|
|
||||||
|
4. `crates/sprimo-tauri/build.rs`
|
||||||
|
- Added `cargo:rerun-if-changed` directives for tauri config and frontend UI paths so
|
||||||
|
frontend/dist updates re-trigger asset embedding in `cargo run -p sprimo-tauri`.
|
||||||
|
|
||||||
|
5. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Replaced `Assets.load` path with direct `Image` + `BaseTexture.from(image)` loading to avoid
|
||||||
|
any runtime `*.fromURL` dependency in atlas initialization.
|
||||||
|
|
||||||
|
6. `frontend/tauri-ui/src/main.tsx`
|
||||||
|
- Added visible UI build marker (`issue2-fix3`) to detect stale embedded frontend artifacts.
|
||||||
|
- Removed temporary UI build marker after verification passed.
|
||||||
|
|
||||||
|
7. `crates/sprimo-tauri/capabilities/default.json`
|
||||||
|
- Added default capability with:
|
||||||
|
- `core:default`
|
||||||
|
- `core:event:allow-listen`
|
||||||
|
- `core:event:allow-unlisten`
|
||||||
|
|
||||||
|
8. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
|
||||||
|
- Added tauri-side chroma-key conversion for atlas data URL:
|
||||||
|
- draw atlas to canvas
|
||||||
|
- convert near-`#FF00FF` pixels to alpha 0
|
||||||
|
- create Pixi base texture from converted canvas
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Commands Run
|
||||||
|
|
||||||
|
- [x] `cargo check --workspace`
|
||||||
|
- [x] `cargo test --workspace`
|
||||||
|
- [x] `just qa-validate`
|
||||||
|
- [x] `cargo check -p sprimo-tauri`
|
||||||
|
- [x] `cargo check -p sprimo-runtime-core`
|
||||||
|
- [x] `just build-tauri-ui`
|
||||||
|
- [x] `just run-tauri` (smoke attempt; command is long-running and timed out under automation)
|
||||||
|
- [x] `just qa-validate`
|
||||||
|
|
||||||
|
### Visual Checklist
|
||||||
|
|
||||||
|
- [x] Before screenshot(s): `issues/screenshots/issue2.png`
|
||||||
|
- [x] After screenshot(s): `issues/screenshots/issue2-after-fix3-2026-02-13-094131.png`
|
||||||
|
|
||||||
|
### Runtime Contract Checklist
|
||||||
|
|
||||||
|
- [ ] `current_state` invoke returns structured payload
|
||||||
|
- [ ] `load_active_sprite_pack` invoke returns manifest/atlas payload
|
||||||
|
- [ ] `runtime:snapshot` event observed after runtime command changes
|
||||||
|
|
||||||
|
### API Checklist
|
||||||
|
|
||||||
|
- [ ] `GET /v1/health`
|
||||||
|
- [ ] `GET /v1/state` auth behavior
|
||||||
|
- [ ] `POST /v1/command`
|
||||||
|
- [ ] `POST /v1/commands`
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
- Current Status: `Verification Passed`
|
||||||
|
- Notes: reporter confirmed fix works on runtime behavior; closure gate evidence still to be completed.
|
||||||
|
|
||||||
|
## Status History
|
||||||
|
|
||||||
|
- `2026-02-13 13:20` - reporter - `Reported` - runtime screenshot captured with `TypeError: W.fromURL is not a function`.
|
||||||
|
- `2026-02-13 13:35` - codex - `Triaged` - localized failure to Pixi atlas loader path.
|
||||||
|
- `2026-02-13 13:55` - codex - `In Progress` - replaced loader API and hardened renderer lifecycle.
|
||||||
|
- `2026-02-13 14:05` - codex - `Fix Implemented` - patch completed, verification checklist queued.
|
||||||
|
- `2026-02-13 14:20` - codex - `Fix Implemented` - checks passed (`cargo check`, UI build, QA validation); smoke launch attempted.
|
||||||
|
- `2026-02-13 14:35` - codex - `Fix Implemented` - added build-script change tracking for frontend assets to prevent stale embedded UI.
|
||||||
|
- `2026-02-13 14:55` - codex - `In Progress` - removed all runtime `fromURL` usage from renderer atlas loading path.
|
||||||
|
- `2026-02-13 15:05` - codex - `In Progress` - added explicit UI build marker to detect stale executable/frontend embedding.
|
||||||
|
- `2026-02-13 15:20` - reporter - `In Progress` - provided `issue2-after-fix3` screenshot; stale-build issue resolved, permission + chroma-key defects observed.
|
||||||
|
- `2026-02-13 15:35` - codex - `Fix Implemented` - added tauri capability permission file and tauri-side chroma-key conversion.
|
||||||
|
- `2026-02-13 15:50` - reporter - `Verification Passed` - confirmed the runtime fix works.
|
||||||
|
- `2026-02-13 16:05` - codex - `Verification Passed` - completed workspace check/test checklist and normalized issue record.
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
- Current Status: `Verification Passed`
|
||||||
|
- Close Date:
|
||||||
|
- Owner:
|
||||||
|
- Linked PR/commit:
|
||||||
47
justfile
47
justfile
@@ -1,6 +1,7 @@
|
|||||||
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
|
|
||||||
python := "python"
|
python := "python"
|
||||||
|
npm := "npm"
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
@@ -17,5 +18,51 @@ package-win:
|
|||||||
smoke-win:
|
smoke-win:
|
||||||
{{python}} scripts/package_windows.py smoke
|
{{python}} scripts/package_windows.py smoke
|
||||||
|
|
||||||
|
build-release-bevy:
|
||||||
|
cargo build --release -p sprimo-app
|
||||||
|
|
||||||
|
build-release-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
cargo build --release -p sprimo-tauri
|
||||||
|
|
||||||
|
package-win-bevy:
|
||||||
|
{{python}} scripts/package_windows.py package --frontend bevy
|
||||||
|
|
||||||
|
smoke-win-bevy:
|
||||||
|
{{python}} scripts/package_windows.py smoke --frontend bevy
|
||||||
|
|
||||||
|
package-win-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
{{python}} scripts/package_windows.py package --frontend tauri
|
||||||
|
|
||||||
|
smoke-win-tauri:
|
||||||
|
just build-tauri-ui
|
||||||
|
{{python}} scripts/package_windows.py smoke --frontend tauri
|
||||||
|
|
||||||
qa-validate:
|
qa-validate:
|
||||||
{{python}} scripts/qa_validate.py
|
{{python}} scripts/qa_validate.py
|
||||||
|
|
||||||
|
random-backend-test:
|
||||||
|
{{python}} scripts/random_backend_tester.py --duration-seconds 30 --health-check
|
||||||
|
|
||||||
|
random-backend-test-strict:
|
||||||
|
{{python}} scripts/random_backend_tester.py --duration-seconds 60 --health-check --strict
|
||||||
|
|
||||||
|
check-runtime-core:
|
||||||
|
cargo check -p sprimo-runtime-core
|
||||||
|
|
||||||
|
check-tauri:
|
||||||
|
cargo check -p sprimo-tauri
|
||||||
|
|
||||||
|
install-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} install; Pop-Location
|
||||||
|
|
||||||
|
build-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
|
||||||
|
|
||||||
|
dev-tauri-ui:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run dev; Pop-Location
|
||||||
|
|
||||||
|
run-tauri:
|
||||||
|
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
|
||||||
|
cargo run -p sprimo-tauri
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Build and package a portable Windows ZIP for sprimo-app."""
|
"""Build and package portable Windows ZIPs for Bevy/Tauri frontends."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ from typing import Iterable
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DIST = ROOT / "dist"
|
DIST = ROOT / "dist"
|
||||||
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
|
|
||||||
ASSETS_REL = ROOT / "assets"
|
ASSETS_REL = ROOT / "assets"
|
||||||
|
|
||||||
|
|
||||||
@@ -26,8 +25,38 @@ class PackagingError(RuntimeError):
|
|||||||
"""Raised when packaging preconditions are not met."""
|
"""Raised when packaging preconditions are not met."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrontendLayout:
|
||||||
|
id: str
|
||||||
|
crate: str
|
||||||
|
binary_name: str
|
||||||
|
artifact_name: str
|
||||||
|
readme_run: str
|
||||||
|
runtime_files: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
FRONTENDS: dict[str, FrontendLayout] = {
|
||||||
|
"bevy": FrontendLayout(
|
||||||
|
id="bevy",
|
||||||
|
crate="sprimo-app",
|
||||||
|
binary_name="sprimo-app.exe",
|
||||||
|
artifact_name="sprimo-windows-x64",
|
||||||
|
readme_run="sprimo-app.exe",
|
||||||
|
),
|
||||||
|
"tauri": FrontendLayout(
|
||||||
|
id="tauri",
|
||||||
|
crate="sprimo-tauri",
|
||||||
|
binary_name="sprimo-tauri.exe",
|
||||||
|
artifact_name="sprimo-tauri-windows-x64",
|
||||||
|
readme_run="sprimo-tauri.exe",
|
||||||
|
runtime_files=("WebView2Loader.dll",),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PackageLayout:
|
class PackageLayout:
|
||||||
|
frontend: str
|
||||||
version: str
|
version: str
|
||||||
zip_path: Path
|
zip_path: Path
|
||||||
checksum_path: Path
|
checksum_path: Path
|
||||||
@@ -72,12 +101,25 @@ def read_version() -> str:
|
|||||||
raise PackagingError("could not determine version")
|
raise PackagingError("could not determine version")
|
||||||
|
|
||||||
|
|
||||||
def ensure_release_binary() -> Path:
|
def ensure_release_binary(frontend: FrontendLayout) -> Path:
|
||||||
if not BIN_REL.exists():
|
binary_path = ROOT / "target" / "release" / frontend.binary_name
|
||||||
run(["cargo", "build", "--release", "-p", "sprimo-app"])
|
if not binary_path.exists():
|
||||||
if not BIN_REL.exists():
|
run(["cargo", "build", "--release", "-p", frontend.crate])
|
||||||
raise PackagingError(f"release binary missing: {BIN_REL}")
|
if not binary_path.exists():
|
||||||
return BIN_REL
|
raise PackagingError(f"release binary missing: {binary_path}")
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_files(frontend: FrontendLayout, binary_dir: Path) -> list[Path]:
|
||||||
|
resolved: list[Path] = []
|
||||||
|
for filename in frontend.runtime_files:
|
||||||
|
path = binary_dir / filename
|
||||||
|
if not path.exists():
|
||||||
|
raise PackagingError(
|
||||||
|
f"required runtime file missing for {frontend.id}: {path}"
|
||||||
|
)
|
||||||
|
resolved.append(path)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def ensure_assets() -> None:
|
def ensure_assets() -> None:
|
||||||
@@ -108,13 +150,14 @@ 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()
|
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 +165,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 +189,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 +211,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 +227,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)
|
||||||
|
|||||||
568
scripts/random_backend_tester.py
Normal file
568
scripts/random_backend_tester.py
Normal 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())
|
||||||
Reference in New Issue
Block a user