Compare commits

5 Commits

Author SHA1 Message Date
DaZuo0122
875bc54c4f Add: logical verification workflow for bevy 2026-02-13 21:54:30 +08:00
DaZuo0122
8e79bd98e5 Fix: tauri window scaling bug 2026-02-13 17:25:28 +08:00
DaZuo0122
084506e84b Fix: windows x86_64 packaging behavior 2026-02-13 17:11:22 +08:00
DaZuo0122
77f4139392 Add: dummy backend for behavioural testing 2026-02-13 15:34:01 +08:00
DaZuo0122
55fe53235d Add: just commands for release build 2026-02-13 11:22:46 +08:00
28 changed files with 1301 additions and 211 deletions

1
Cargo.lock generated
View File

@@ -6039,6 +6039,7 @@ dependencies = [
"sprimo-config",
"sprimo-platform",
"sprimo-protocol",
"sprimo-runtime-core",
"sprimo-sprite",
"thiserror 2.0.18",
"tokio",

View File

@@ -15,6 +15,7 @@ sprimo-api = { path = "../sprimo-api" }
sprimo-config = { path = "../sprimo-config" }
sprimo-platform = { path = "../sprimo-platform" }
sprimo-protocol = { path = "../sprimo-protocol" }
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
sprimo-sprite = { path = "../sprimo-sprite" }
thiserror.workspace = true
tokio.workspace = true

View File

@@ -6,10 +6,9 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
use image::{DynamicImage, GenericImageView, Rgba};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use sprimo_api::{ApiConfig, ApiState};
use sprimo_config::{save, AppConfig, ConfigError};
use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
};
@@ -20,12 +19,12 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use thiserror::Error;
use tokio::runtime::Runtime;
use tokio::sync::mpsc as tokio_mpsc;
use tracing::{error, info, warn};
use tracing::{info, warn};
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
const WINDOW_PADDING: f32 = 16.0;
const STARTUP_WINDOW_SIZE: f32 = 416.0;
const MAGENTA_KEY: [u8; 3] = [255, 0, 255];
const CHROMA_KEY_TOLERANCE: u8 = 24;
const CHROMA_KEY_FORCE_RATIO: f32 = 0.15;
@@ -34,7 +33,7 @@ const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255];
#[derive(Debug, Error)]
enum AppError {
#[error("{0}")]
Config(#[from] ConfigError),
RuntimeCore(#[from] RuntimeCoreError),
}
#[derive(Resource, Clone)]
@@ -44,10 +43,7 @@ struct SharedSnapshot(Arc<RwLock<FrontendStateSnapshot>>);
struct PlatformResource(Arc<dyn PlatformAdapter>);
#[derive(Resource)]
struct ConfigResource {
path: PathBuf,
config: AppConfig,
}
struct RuntimeCoreResource(Arc<RuntimeCore>);
#[derive(Resource, Copy, Clone)]
struct DurableState {
@@ -180,29 +176,30 @@ fn main() -> Result<(), AppError> {
.compact()
.init();
let (config_path, config) = sprimo_config::load_or_create(APP_NAME)?;
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
let capabilities = platform.capabilities();
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
snapshot.x = config.window.x;
snapshot.y = config.window.y;
snapshot.scale = config.window.scale;
snapshot.flags.click_through = config.window.click_through;
snapshot.flags.always_on_top = config.window.always_on_top;
snapshot.flags.visible = config.window.visible;
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
let shared_snapshot = Arc::new(RwLock::new(snapshot));
let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
let shared_snapshot = runtime_core.snapshot();
let config = runtime_core
.config()
.read()
.expect("runtime core config lock poisoned")
.clone();
let runtime = Runtime::new().expect("tokio runtime");
let (api_command_tx, api_command_rx) = tokio_mpsc::channel(1_024);
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
let (hotkey_tx, hotkey_rx) = mpsc::channel();
spawn_api(&runtime, &config, Arc::clone(&shared_snapshot), api_command_tx);
runtime_core.spawn_api(&runtime);
let command_rx = runtime_core.command_receiver();
runtime.spawn(async move {
let mut rx = api_command_rx;
while let Some(command) = rx.recv().await {
loop {
let next = {
let mut receiver = command_rx.lock().await;
receiver.recv().await
};
let Some(command) = next else {
break;
};
if bevy_command_tx.send(command).is_err() {
break;
}
@@ -223,7 +220,10 @@ fn main() -> Result<(), AppError> {
decorations: false,
resizable: false,
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(
config.window.x.round() as i32,
config.window.y.round() as i32,
@@ -235,11 +235,8 @@ fn main() -> Result<(), AppError> {
app.insert_resource(ClearColor(window_clear_color()));
app.insert_resource(SharedSnapshot(shared_snapshot));
app.insert_resource(RuntimeCoreResource(Arc::clone(&runtime_core)));
app.insert_resource(PlatformResource(Arc::clone(&platform)));
app.insert_resource(ConfigResource {
path: config_path,
config,
});
app.insert_resource(DurableState {
state: FrontendState::Idle,
});
@@ -271,23 +268,6 @@ fn main() -> Result<(), AppError> {
Ok(())
}
fn spawn_api(
runtime: &Runtime,
config: &AppConfig,
shared_snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_command_tx: tokio_mpsc::Sender<CommandEnvelope>,
) {
let mut api_config = ApiConfig::default_with_token(config.api.auth_token.clone());
api_config.bind_addr = ([127, 0, 0, 1], config.api.port).into();
let api_state = Arc::new(ApiState::new(api_config.clone(), shared_snapshot, api_command_tx));
runtime.spawn(async move {
if let Err(err) = sprimo_api::run_server(api_config, api_state).await {
error!(%err, "api server exited");
}
});
}
fn default_asset_root() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
@@ -297,7 +277,7 @@ fn default_asset_root() -> PathBuf {
fn setup_scene(
mut commands: Commands,
config: Res<ConfigResource>,
runtime_core: Res<RuntimeCoreResource>,
root: Res<SpritePackRoot>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut images: ResMut<Assets<Image>>,
@@ -313,7 +293,14 @@ fn setup_scene(
..default()
});
let selected = config.config.sprite.selected_pack.clone();
let selected = runtime_core
.0
.config()
.read()
.expect("runtime core config lock poisoned")
.sprite
.selected_pack
.clone();
let runtime = match load_pack_runtime(
&root.asset_root,
&selected,
@@ -721,7 +708,6 @@ fn attach_window_handle_once(
.read()
.expect("frontend snapshot lock poisoned");
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_window_position(guard.x, guard.y);
*attached = true;
@@ -740,29 +726,23 @@ fn attach_window_handle_once(
fn poll_hotkey_recovery(
ingress: NonSendMut<HotkeyIngress>,
platform: Res<PlatformResource>,
mut config: ResMut<ConfigResource>,
snapshot: Res<SharedSnapshot>,
runtime_core: Res<RuntimeCoreResource>,
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
) {
while ingress.0.try_recv().is_ok() {
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);
if let Ok(mut visibility) = pet_query.get_single_mut() {
*visibility = Visibility::Visible;
}
config.config.window.click_through = false;
config.config.window.visible = true;
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist config after hotkey recovery");
}
if let Ok(mut guard) = snapshot.0.write() {
guard.flags.click_through = false;
guard.flags.visible = true;
guard.last_error = None;
if let Err(err) = runtime_core.0.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(true),
visible: Some(true),
}) {
warn!(%err, "failed to persist recovery flag config");
}
}
}
@@ -774,7 +754,7 @@ fn poll_backend_commands(
asset_server: Res<AssetServer>,
mut images: ResMut<Assets<Image>>,
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
mut config: ResMut<ConfigResource>,
runtime_core: Res<RuntimeCoreResource>,
snapshot: Res<SharedSnapshot>,
mut durable_state: ResMut<DurableState>,
mut reset: ResMut<PendingStateReset>,
@@ -790,6 +770,7 @@ fn poll_backend_commands(
let Ok(envelope) = ingress.0.try_recv() else {
break;
};
let command = envelope.command;
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
pet_query.get_single_mut()
@@ -797,7 +778,14 @@ fn poll_backend_commands(
continue;
};
match envelope.command {
if !matches!(command, FrontendCommand::SetSpritePack { .. }) {
if let Err(err) = runtime_core.0.apply_command(&command) {
warn!(%err, "failed to apply command in runtime core");
continue;
}
}
match command {
FrontendCommand::SetState { state, ttl_ms } => {
if !matches!(state, FrontendState::Success | FrontendState::Error) {
durable_state.state = state;
@@ -868,8 +856,11 @@ fn poll_backend_commands(
window.resolution.set(size.x, size.y);
}
config.config.sprite.selected_pack = requested;
if let Err(err) = save(&config.path, &config.config) {
if let Err(err) = runtime_core.0.apply_command(
&FrontendCommand::SetSpritePack {
pack_id_or_path: requested.clone(),
},
) {
warn!(%err, "failed to persist sprite pack selection");
}
@@ -889,37 +880,17 @@ fn poll_backend_commands(
}
}
FrontendCommand::SetTransform { x, y, scale, .. } => {
if let Some(value) = x {
config.config.window.x = value;
}
if let Some(value) = y {
config.config.window.y = value;
}
if let Some(value) = scale {
transform.scale = Vec3::splat(value);
config.config.window.scale = value;
if let Ok(mut window) = window_query.get_single_mut() {
let size = window_size_for_pack(&current_pack.runtime, value);
window.resolution.set(size.x, size.y);
}
}
let _ = platform
.0
.set_window_position(config.config.window.x, config.config.window.y);
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist transform config");
}
if let Ok(mut guard) = snapshot.0.write() {
if let Some(value) = x {
guard.x = value;
if x.is_some() || y.is_some() {
if let Ok(guard) = snapshot.0.read() {
let _ = platform.0.set_window_position(guard.x, guard.y);
}
if let Some(value) = y {
guard.y = value;
}
if let Some(value) = scale {
guard.scale = value;
}
guard.last_error = None;
}
}
FrontendCommand::SetFlags {
@@ -927,39 +898,18 @@ fn poll_backend_commands(
always_on_top,
visible,
} => {
if let Some(value) = click_through {
let _ = platform.0.set_click_through(value);
config.config.window.click_through = value;
}
let _ = click_through;
if let Some(value) = always_on_top {
let _ = platform.0.set_always_on_top(value);
config.config.window.always_on_top = value;
}
if let Some(value) = visible {
let _ = platform.0.set_visible(value);
config.config.window.visible = value;
*visibility = if value {
Visibility::Visible
} else {
Visibility::Hidden
};
}
if let Err(err) = save(&config.path, &config.config) {
warn!(%err, "failed to persist flag config");
}
if let Ok(mut guard) = snapshot.0.write() {
if let Some(value) = click_through {
guard.flags.click_through = value;
}
if let Some(value) = always_on_top {
guard.flags.always_on_top = value;
}
if let Some(value) = visible {
guard.flags.visible = value;
}
guard.last_error = None;
}
}
FrontendCommand::Toast { text, .. } => {
info!(toast = text, "toast command received");
@@ -970,6 +920,7 @@ fn poll_backend_commands(
fn tick_state_reset(
time: Res<Time>,
runtime_core: Res<RuntimeCoreResource>,
mut reset: ResMut<PendingStateReset>,
mut animation: ResMut<AnimationResource>,
current_pack: Res<CurrentPackResource>,
@@ -986,6 +937,10 @@ fn tick_state_reset(
let next_state = reset.revert_to;
let next_animation = default_animation_for_state(next_state);
let _ = runtime_core.0.apply_command(&FrontendCommand::SetState {
state: next_state,
ttl_ms: None,
});
set_animation(&mut animation, &current_pack.runtime.clips, next_animation);
if let Ok(mut atlas) = atlas_query.get_single_mut() {
if let Some(frame) = current_frame(&animation, &current_pack.runtime.clips) {

View File

@@ -171,7 +171,7 @@ mod windows {
impl PlatformAdapter for WindowsAdapter {
fn capabilities(&self) -> CapabilityFlags {
CapabilityFlags {
supports_click_through: true,
supports_click_through: false,
supports_transparency: true,
supports_tray: false,
supports_global_hotkey: true,

View File

@@ -35,14 +35,16 @@ impl RuntimeCore {
pub fn new_with_config(
config_path: PathBuf,
config_value: AppConfig,
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 = config_value.window.click_through;
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();
@@ -50,14 +52,18 @@ impl RuntimeCore {
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone());
let (command_tx, command_rx) = mpsc::channel(1_024);
Ok(Self {
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>> {
@@ -169,7 +175,7 @@ impl RuntimeCore {
self.persist_config()?;
}
FrontendCommand::SetFlags {
click_through,
click_through: _click_through,
always_on_top,
visible,
} => {
@@ -178,9 +184,7 @@ impl RuntimeCore {
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
if let Some(value) = click_through {
snapshot.flags.click_through = *value;
}
snapshot.flags.click_through = false;
if let Some(value) = always_on_top {
snapshot.flags.always_on_top = *value;
}
@@ -194,9 +198,7 @@ impl RuntimeCore {
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if let Some(value) = click_through {
config.window.click_through = *value;
}
config.window.click_through = false;
if let Some(value) = always_on_top {
config.window.always_on_top = *value;
}
@@ -259,4 +261,26 @@ mod tests {
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);
}
}

View File

@@ -5,6 +5,11 @@
"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"
]

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:event:allow-listen","core:event:allow-unlisten"]}}
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}}

View File

@@ -153,6 +153,8 @@ fn main() -> Result<(), AppError> {
}
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 = {
@@ -163,24 +165,45 @@ fn main() -> Result<(), AppError> {
break;
};
if let Err(err) = runtime_core.apply_command(&envelope.command) {
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.snapshot();
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.emit("runtime:snapshot", value);
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(())
})

View File

@@ -12,8 +12,8 @@
"windows": [
{
"title": "sprimo-tauri",
"width": 640,
"height": 640,
"width": 416,
"height": 416,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,

View File

@@ -18,9 +18,9 @@ Auth: `Authorization: Bearer <token>` required on all endpoints except `/v1/heal
"uptime_seconds": 12,
"active_sprite_pack": "default",
"capabilities": {
"supports_click_through": true,
"supports_click_through": false,
"supports_transparency": true,
"supports_tray": true,
"supports_tray": false,
"supports_global_hotkey": 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
```json

View File

@@ -0,0 +1,194 @@
# Bevy Window Management Verification (Windows-First)
Date: 2026-02-13
## Purpose
This document defines a logical verification workflow for `sprimo-app` window management.
It combines code-path assertions with lightweight runtime validation.
Primary focus:
- window visibility behavior
- always-on-top behavior
- position and scale handling
- recovery hotkey behavior
- snapshot/config consistency
## Scope
In scope:
- `crates/sprimo-app/src/main.rs`
- `crates/sprimo-platform/src/lib.rs`
- window-related runtime-core integration path in `sprimo-app`
- API commands: `SetTransform`, `SetFlags`
Out of scope:
- tray/menu behavior
- tauri frontend window behavior
- long soak/performance testing
## Requirements Traceability
| Requirement | Intent | Code path(s) |
|---|---|---|
| FR-FW-1 | Borderless/transparent overlay | `crates/sprimo-app/src/main.rs` window setup (`decorations=false`, clear color/transparent mode) |
| FR-FW-2 | Always-on-top toggle | `crates/sprimo-app/src/main.rs` (`SetFlags.always_on_top`), `crates/sprimo-platform/src/lib.rs` (`set_always_on_top`) |
| FR-FW-3 | Recovery to visible/interactive | `crates/sprimo-app/src/main.rs` (`poll_hotkey_recovery`) |
| FR-FW-4 | Positioning persistence + move | `crates/sprimo-app/src/main.rs` (`SetTransform x/y`, startup position), runtime-core config persistence |
| FR-FW-5 | Position handling under monitor/DPI context | logical verification of position updates via platform adapter + snapshot/config |
## Code-Path Logical Checklist
1. Startup invariants:
- window is undecorated and non-resizable
- startup position uses persisted `window.x` / `window.y`
- startup top-most intent is configured
2. Handle-attach invariants:
- `attach_window_handle_once` applies at most once
- on Windows, native handle attach occurs before platform calls
- initial snapshot values are applied to platform (`always_on_top`, `visible`, `position`)
3. Command invariants:
- `SetTransform.scale` updates sprite scale and window size (`window_size_for_pack`)
- `SetTransform.x/y` updates logical position state and issues platform move
- `SetFlags.always_on_top` triggers platform top-most call
- `SetFlags.visible` triggers platform visibility call and Bevy `Visibility`
4. Recovery invariants:
- hotkey path enforces `visible=true` and `always_on_top=true`
- recovery updates logical state via runtime-core command path
5. Persistence/snapshot invariants:
- window fields (`x`, `y`, `scale`, `flags`) roundtrip through runtime-core
- `/v1/state` stays consistent with command outcomes
6. Degradation invariants (non-Windows):
- no-op adapter does not crash on window commands
- capability semantics remain conservative
## Lightweight Runtime Checklist (Windows)
Preconditions:
- run `sprimo-app`
- resolve token/port from `%APPDATA%/sprimo/config/config.toml`
### Scenario 1: Baseline
1. `GET /v1/health`
2. `GET /v1/state` with bearer token
3. Record `x`, `y`, `scale`, `flags.visible`, `flags.always_on_top`
Expected:
- health endpoint alive
- state endpoint authorized
- window fields present and coherent
### Scenario 2: Position Command
1. Send `SetTransform` with `x` and `y`.
2. Re-check `/v1/state`.
Expected:
- `x`/`y` update in snapshot
- window visibly moves
### Scenario 3: Scale Command
1. Send `SetTransform` with `scale`.
2. Re-check `/v1/state`.
Expected:
- `scale` updates
- window dimensions update according to frame size + padding
### Scenario 4: Visibility Toggle
1. Send `SetFlags { visible: false }`.
2. Send `SetFlags { visible: true }`.
3. Re-check `/v1/state`.
Expected:
- visible flag transitions correctly
- window hides/shows without crash
### Scenario 5: Always-On-Top Toggle
1. Send `SetFlags { always_on_top: false }`.
2. Send `SetFlags { always_on_top: true }`.
3. Re-check `/v1/state`.
Expected:
- top-most flag transitions correctly
- OS behavior matches expected z-order changes
### Scenario 6: Recovery Hotkey
1. Move to non-ideal state (hidden and/or not top-most).
2. Trigger configured recovery hotkey (default `Ctrl+Alt+P`).
3. Re-check `/v1/state`.
Expected:
- forces `visible=true`
- forces `always_on_top=true`
### Scenario 7: Restart Persistence
1. Restart `sprimo-app`.
2. Re-check `/v1/state`.
Expected:
- `x`, `y`, `scale`, `always_on_top`, `visible` persist
## Suggested Command Set
```powershell
cargo check -p sprimo-app
cargo check -p sprimo-platform
cargo check -p sprimo-runtime-core
cargo test -p sprimo-app
cargo test -p sprimo-runtime-core
just qa-validate
```
Optional runtime stress after directed checks:
```powershell
just random-backend-test
```
## Pass / Fail Criteria
Pass:
- all logical assertions hold
- runtime scenarios behave as expected
- `/v1/state` remains consistent with visible behavior
- no crashes/panics in window-command paths
Fail:
- mismatch between command outcome and snapshot
- recovery hotkey fails to restore visible top-most state
- restart persistence for window fields fails
- any crash in window-management flow
## Evidence Expectations
For issue-driven verification, attach:
- command summary
- before/after screenshots
- `/v1/state` snapshots before and after key transitions
- lifecycle updates in `issues/issueN.md` per `docs/QA_WORKFLOW.md`

View File

@@ -2,9 +2,9 @@
File location:
- Windows: `%APPDATA%/sprimo/config.toml`
- macOS: `~/Library/Application Support/sprimo/config.toml`
- Linux: `~/.config/sprimo/config.toml`
- Windows: `%APPDATA%/sprimo/config/config.toml`
- macOS: `~/Library/Application Support/sprimo/config/config.toml`
- Linux: `~/.config/sprimo/config/config.toml`
## Schema
@@ -45,5 +45,6 @@ backend = "bevy"
- `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.
- 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`).

View File

@@ -10,7 +10,7 @@
# 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.
@@ -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.
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**.
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 drag the pet to a preferred location.
* As a user, I can toggle click-through so the pet doesnt block my mouse.
* As a user, I can toggle always-on-top so the pet stays visible.
* As a user, I can change the character (sprite pack).
@@ -55,7 +54,7 @@ The frontend must be able to run standalone (idle animation) even if the backend
## 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.
### FR-FW-3 Click-through (mouse pass-through)
### FR-FW-3 Interaction model
* Support enabling/disabling click-through:
* ON: mouse events pass to windows underneath.
* OFF: pet receives mouse input (drag, context menu).
* Must provide a **failsafe** mechanism to disable click-through without clicking the pet.
* Click-through is not required.
* Pet remains interactive while visible.
* Must provide a **failsafe** mechanism to recover visibility and interaction state.
**Acceptance**
* With click-through enabled, user can click apps behind pet.
* User can disable click-through via tray or hotkey reliably.
* Recovery hotkey/tray action restores visible, interactive pet state reliably.
### 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.
* 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)
* **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.
* **Linux:** best effort:
* 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? }`
* `SetSpritePack { pack_id_or_path }`
* `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)
### 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:
* Show/Hide
* Toggle Click-through
* Toggle Always-on-top
* Sprite Pack selection (at least “Default” + “Open sprite folder…”)
* 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:
* Toggle click-through OR “enter interactive mode
* Force visible + interactive recovery mode
Example default:
@@ -323,7 +318,7 @@ Example default:
**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)
@@ -348,7 +343,7 @@ Right click pet (when interactive) to open a minimal menu.
* position (x,y) + monitor id (best-effort)
* scale
* always_on_top
* click_through
* click_through (deprecated/ignored; always false)
* visible
* animation:
@@ -432,10 +427,10 @@ Frontend must expose in logs (and optionally `/v1/health`) capability flags:
Example:
* Windows: all true
* macOS: all true
* Windows: click-through false; others vary by implementation status
* macOS: click-through false; others vary by implementation status
* 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
1. Launch: window appears borderless & transparent.
2. Drag: with click-through OFF, drag updates position; restart restores.
3. Click-through: toggle via hotkey; pet becomes non-interactive; toggle back works.
2. Drag: drag updates position; restart restores.
3. Recovery: hotkey restores visible + always-on-top behavior reliably.
4. Always-on-top: verify staying above typical apps.
## Animation

View File

@@ -11,14 +11,15 @@ Date: 2026-02-12
| Command/state pipeline | Implemented | Command queue, state snapshot updates, transient state TTL rollback |
| Config persistence | Implemented | `config.toml` bootstrap/load/save with generated token |
| 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 |
| 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) |
| 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` |
| 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, parity work remains |
| Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring |
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale updates now auto-fit window to avoid top clipping |
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
## Next Major Gaps
@@ -27,5 +28,4 @@ Date: 2026-02-12
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
5. `sprimo-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.
5. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.

View File

@@ -14,7 +14,7 @@
- [x] `SetState` updates state and default animation mapping.
- [x] transient state with `ttl_ms` returns to durable state.
- [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

View File

@@ -4,7 +4,7 @@ Date: 2026-02-12
| 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_tray` | false (current) | 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
- 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.
- Wayland limitations remain an expected degradation in v0.1.

View File

@@ -72,7 +72,16 @@ cargo test --workspace
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 Bevy window-management verification workflows, use:
- `docs/BEVY_WINDOW_VERIFICATION.md`
## Definition of Done

View File

@@ -0,0 +1,100 @@
# Random Backend API Testing
Date: 2026-02-13
## Purpose
This workflow provides randomized backend-like API traffic against a running Sprimo frontend.
It focuses on command endpoints and mixes valid and invalid requests to verify transport and
runtime resilience.
Primary targets:
- `POST /v1/command`
- `POST /v1/commands`
Supporting checks:
- `GET /v1/health`
- `GET /v1/state` (periodic sampling)
## Prerequisites
- Frontend runtime is already running (`sprimo-app` or `sprimo-tauri`).
- Python is available.
- Auth token and port are available from config or passed via CLI flags.
By default, the tester discovers config at:
- Windows: `%APPDATA%/sprimo/config/config.toml` (legacy fallback: `%APPDATA%/sprimo/config.toml`)
- macOS: `~/Library/Application Support/sprimo/config/config.toml`
- Linux: `~/.config/sprimo/config/config.toml`
## Quick Start
```powershell
just random-backend-test
```
Strict mode (non-zero exit if unexpected outcomes appear):
```powershell
just random-backend-test-strict
```
## CLI Examples
Run against explicit host/port/token:
```powershell
python scripts/random_backend_tester.py --host 127.0.0.1 --port 32145 --token "<token>"
```
Deterministic run with higher invalid traffic:
```powershell
python scripts/random_backend_tester.py --seed 42 --invalid-probability 0.35 --duration-seconds 45
```
Write machine-readable summary:
```powershell
python scripts/random_backend_tester.py --json-summary dist/random-backend-summary.json
```
## Important Flags
- `--duration-seconds`: total run time
- `--interval-ms`: delay between random requests
- `--batch-probability`: ratio of `/v1/commands` usage
- `--max-batch-size`: max commands per batch request
- `--invalid-probability`: inject malformed/invalid payloads
- `--unauthorized-probability`: inject auth failures
- `--state-sample-every`: periodic authenticated `/v1/state` checks
- `--strict`: fail run on unexpected outcomes
- `--health-check`: verify API liveness before random traffic
## Expected Result Pattern
In mixed mode, typical status distribution includes:
- `202` for valid command requests
- `400` for malformed/invalid payloads
- `401` for missing/invalid auth
Unexpected outcomes that should be investigated:
- `5xx` responses
- repeated transport failures/timeouts
- strict mode failures (`unexpected_outcomes > 0`)
## Evidence Guidance
When used for issue verification, record:
- command used (including seed/probabilities)
- summary output (status buckets, unexpected outcomes, transport errors)
- linked issue file under `issues/issueN.md`
This test complements `cargo check --workspace`, `cargo test --workspace`, and
`just qa-validate`; it does not replace them.

View File

@@ -4,13 +4,21 @@
Current release package type: portable ZIP.
Expected contents:
Expected contents (Bevy package):
- `sprimo-app.exe`
- `assets/sprite-packs/default/manifest.json`
- `assets/sprite-packs/default/sprite.png`
- `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:
- `dist/sprimo-windows-x64-v<version>.zip`
@@ -23,13 +31,25 @@ Use `just` for command entry:
```powershell
just check
just test
just build-release
just package-win
just smoke-win
just build-release-bevy
just package-win-bevy
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`.
`just smoke-win` calls `scripts/package_windows.py smoke`.
Compatibility aliases:
- `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)
@@ -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.
2. Verify no terminal window appears when launching release build by double-click.
3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces interactive mode.
4. Verify click-through and always-on-top toggles via API commands.
3. Verify global hotkey recovery (`Ctrl+Alt+P`) forces visibility and top-most recovery.
4. Verify `SetFlags` applies always-on-top and visibility via API commands.
5. Verify `/v1/health` and `/v1/state` behavior with auth.
6. Verify `SetSpritePack`:
- 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.
8. Confirm overlay background is transparent (desktop visible behind non-sprite pixels).
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
@@ -74,6 +98,7 @@ Legacy issues may reference `issues/screenshots/issueN.png` as before evidence.
## Tauri Runtime Behavior Testing
Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`.
### Workspace Mode (Required Now)
@@ -90,6 +115,16 @@ Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
- `/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)

View File

@@ -50,12 +50,13 @@ Frontend:
- `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).
1. Add tray/menu parity and window behavior parity with Bevy path.
2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).

View File

@@ -62,12 +62,18 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
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.
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

View File

@@ -2,20 +2,60 @@ 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 UI_BUILD_MARKER = "issue2-fix3";
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")
@@ -24,6 +64,7 @@ function App(): JSX.Element {
if (!mounted) {
return;
}
activePack = pack;
setSnapshot(initialSnapshot);
if (hostRef.current !== null) {
rendererRef.current = await PixiPetRenderer.create(
@@ -32,6 +73,9 @@ function App(): JSX.Element {
initialSnapshot
);
}
scaleFitRef.current = initialSnapshot.scale;
await fitWindowForScale(pack, initialSnapshot.scale);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
@@ -39,6 +83,21 @@ function App(): JSX.Element {
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) => {
@@ -56,12 +115,20 @@ function App(): JSX.Element {
};
}, []);
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">
<main className="app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} />
<section className="debug-panel">
<h1>sprimo-tauri</h1>
<p>ui build: {UI_BUILD_MARKER}</p>
{error !== null ? <p className="error">{error}</p> : null}
{snapshot === null ? (
<p>Loading snapshot...</p>

View File

@@ -75,10 +75,10 @@ export class PixiPetRenderer {
}
const sprite = new Sprite();
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
sprite.position.set(app.renderer.width / 2, app.renderer.height);
app.stage.addChild(sprite);
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
renderer.layoutSprite();
renderer.applySnapshot(snapshot);
renderer.startTicker();
return renderer;
@@ -145,10 +145,12 @@ export class PixiPetRenderer {
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) {
@@ -169,6 +171,10 @@ export class PixiPetRenderer {
});
}
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) ??

View File

@@ -13,6 +13,7 @@ body {
width: 100vw;
height: 100vh;
position: relative;
user-select: none;
}
.canvas-host {

View File

@@ -63,7 +63,7 @@ This breaks sprite presentation and leaves the UI in an error state.
Implemented:
1. `frontend/tauri-ui/package.json`
- Added `@pixi/assets` dependency.
- 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)`.
@@ -85,6 +85,7 @@ Implemented:
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:
@@ -102,6 +103,9 @@ Implemented:
### 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`
@@ -128,8 +132,8 @@ Implemented:
### Result
- Current Status: `Fix Implemented`
- Notes: build/check/qa validation passed; manual runtime visual verification still required.
- Current Status: `Verification Passed`
- Notes: reporter confirmed fix works on runtime behavior; closure gate evidence still to be completed.
## Status History
@@ -143,10 +147,12 @@ Implemented:
- `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: `Fix Implemented`
- Current Status: `Verification Passed`
- Close Date:
- Owner:
- Linked PR/commit:

View File

@@ -18,9 +18,36 @@ package-win:
smoke-win:
{{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:
{{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

View File

@@ -1,5 +1,5 @@
#!/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
@@ -18,7 +18,6 @@ from typing import Iterable
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe"
ASSETS_REL = ROOT / "assets"
@@ -26,8 +25,38 @@ class PackagingError(RuntimeError):
"""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)
class PackageLayout:
frontend: str
version: str
zip_path: Path
checksum_path: Path
@@ -72,12 +101,25 @@ def read_version() -> str:
raise PackagingError("could not determine version")
def ensure_release_binary() -> Path:
if not BIN_REL.exists():
run(["cargo", "build", "--release", "-p", "sprimo-app"])
if not BIN_REL.exists():
raise PackagingError(f"release binary missing: {BIN_REL}")
return BIN_REL
def ensure_release_binary(frontend: FrontendLayout) -> Path:
binary_path = ROOT / "target" / "release" / frontend.binary_name
if not binary_path.exists():
run(["cargo", "build", "--release", "-p", frontend.crate])
if not binary_path.exists():
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:
@@ -108,13 +150,14 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()
def package() -> PackageLayout:
def package(frontend: FrontendLayout) -> PackageLayout:
version = read_version()
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)
artifact_name = f"sprimo-windows-x64-v{version}"
artifact_name = f"{frontend.artifact_name}-v{version}"
zip_path = DIST / f"{artifact_name}.zip"
checksum_path = DIST / f"{artifact_name}.zip.sha256"
@@ -122,13 +165,16 @@ def package() -> PackageLayout:
stage = Path(temp_dir) / artifact_name
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)
readme = stage / "README.txt"
readme.write_text(
"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",
encoding="utf-8",
)
@@ -143,11 +189,16 @@ def package() -> PackageLayout:
checksum = sha256_file(zip_path)
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:
layout = package()
def smoke(frontend: FrontendLayout) -> None:
layout = package(frontend)
print(f"package created: {layout.zip_path}")
print(f"checksum file: {layout.checksum_path}")
@@ -160,10 +211,11 @@ def smoke() -> None:
pkg_root = root_candidates[0]
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" / "sprite.png",
]
required.extend(pkg_root / filename for filename in frontend.runtime_files)
missing = [path for path in required if not path.exists()]
if missing:
joined = ", ".join(str(path) for path in missing)
@@ -175,18 +227,25 @@ def smoke() -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper")
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()
def main() -> int:
args = parse_args()
frontend = FRONTENDS[args.frontend]
try:
if args.command == "package":
layout = package()
layout = package(frontend)
print(f"created: {layout.zip_path}")
print(f"sha256: {layout.checksum_path}")
else:
smoke()
smoke(frontend)
return 0
except PackagingError as exc:
print(f"error: {exc}", file=sys.stderr)

View File

@@ -0,0 +1,568 @@
#!/usr/bin/env python3
"""Randomized backend-style API tester for Sprimo frontend endpoints."""
from __future__ import annotations
import argparse
import json
import os
import random
import re
import statistics
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Send random valid/invalid command traffic to Sprimo frontend API."
)
)
parser.add_argument("--host", default="127.0.0.1", help="API host")
parser.add_argument(
"--port",
type=int,
default=None,
help="API port (default: read from config)",
)
parser.add_argument(
"--token",
default=None,
help="Bearer token (default: read from config)",
)
parser.add_argument(
"--config-path",
default=None,
help="Explicit path to config.toml",
)
parser.add_argument(
"--app-name",
default="sprimo",
help="App name for config path discovery (default: sprimo)",
)
parser.add_argument(
"--duration-seconds",
type=int,
default=30,
help="Total run duration in seconds",
)
parser.add_argument(
"--interval-ms",
type=int,
default=250,
help="Delay between requests in milliseconds",
)
parser.add_argument(
"--batch-probability",
type=float,
default=0.35,
help="Probability of using /v1/commands",
)
parser.add_argument(
"--max-batch-size",
type=int,
default=5,
help="Maximum batch size for /v1/commands",
)
parser.add_argument(
"--invalid-probability",
type=float,
default=0.20,
help="Probability of generating invalid request payloads",
)
parser.add_argument(
"--unauthorized-probability",
type=float,
default=0.05,
help="Probability of sending an invalid/missing auth header",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Deterministic random seed",
)
parser.add_argument(
"--timeout-seconds",
type=float,
default=2.0,
help="HTTP timeout for each request",
)
parser.add_argument(
"--health-check",
action="store_true",
help="Check /v1/health before sending random traffic",
)
parser.add_argument(
"--state-sample-every",
type=int,
default=10,
help="Run GET /v1/state every N traffic requests (0 disables)",
)
parser.add_argument(
"--strict",
action="store_true",
help="Exit non-zero when unexpected errors are observed",
)
parser.add_argument(
"--json-summary",
default=None,
help="Write summary JSON to this file path",
)
return parser.parse_args()
def default_config_path_candidates(app_name: str) -> list[Path]:
if os.name == "nt":
appdata = os.environ.get("APPDATA")
if not appdata:
raise RuntimeError("APPDATA is not set; pass --config-path")
base = Path(appdata) / app_name
return [
base / "config" / "config.toml",
base / "config.toml",
]
home = Path.home()
if sys.platform == "darwin":
base = home / "Library" / "Application Support" / app_name
return [
base / "config" / "config.toml",
base / "config.toml",
]
base = home / ".config" / app_name
return [
base / "config" / "config.toml",
base / "config.toml",
]
def parse_api_from_config(config_path: Path) -> tuple[int, str]:
if not config_path.exists():
raise RuntimeError(f"config path not found: {config_path}")
text = config_path.read_text(encoding="utf-8")
api_match = re.search(
r"(?ms)^\[api\]\s*(.*?)(?=^\[|\Z)",
text,
)
if not api_match:
raise RuntimeError(f"missing [api] section in {config_path}")
api_block = api_match.group(1)
port_match = re.search(r"(?m)^\s*port\s*=\s*(\d+)\s*$", api_block)
token_match = re.search(
r'(?m)^\s*auth_token\s*=\s*"([^"]+)"\s*$',
api_block,
)
if not port_match:
raise RuntimeError(f"missing api.port in {config_path}")
if not token_match:
raise RuntimeError(f"missing api.auth_token in {config_path}")
return int(port_match.group(1)), token_match.group(1)
def now_ts_ms() -> int:
return int(time.time() * 1000)
def command_envelope(command: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(uuid.uuid4()),
"ts_ms": now_ts_ms(),
"command": command,
}
def random_valid_command(rng: random.Random) -> dict[str, Any]:
pick = rng.choice(
(
"set_state",
"play_animation",
"set_sprite_pack",
"set_transform",
"set_flags",
"toast",
)
)
if pick == "set_state":
payload: dict[str, Any] = {"state": rng.choice(
["idle", "active", "success", "error", "dragging", "hidden"]
)}
if rng.random() < 0.5:
payload["ttl_ms"] = rng.choice([500, 1_000, 2_000, 5_000])
else:
payload["ttl_ms"] = None
return {"type": "set_state", "payload": payload}
if pick == "play_animation":
payload = {
"name": rng.choice(
["idle", "dance", "typing", "celebrate", "error", "unknown_anim"]
),
"priority": rng.randint(0, 10),
"duration_ms": rng.choice([None, 250, 500, 1000, 3000]),
"interrupt": rng.choice([None, True, False]),
}
return {"type": "play_animation", "payload": payload}
if pick == "set_sprite_pack":
payload = {
"pack_id_or_path": rng.choice(
["default", "missing-pack", "./assets/sprite-packs/default"]
)
}
return {"type": "set_sprite_pack", "payload": payload}
if pick == "set_transform":
payload = {
"x": rng.choice([None, round(rng.uniform(0, 1400), 2)]),
"y": rng.choice([None, round(rng.uniform(0, 900), 2)]),
"anchor": rng.choice([None, "center", "bottom_left", "bottom_right"]),
"scale": rng.choice([None, round(rng.uniform(0.5, 2.0), 2)]),
"opacity": rng.choice([None, round(rng.uniform(0.2, 1.0), 2)]),
}
return {"type": "set_transform", "payload": payload}
if pick == "set_flags":
payload = {
"click_through": rng.choice([None, False, True]),
"always_on_top": rng.choice([None, False, True]),
"visible": rng.choice([None, False, True]),
}
return {"type": "set_flags", "payload": payload}
payload = {
"text": rng.choice(
["hello", "backend-test", "ping", "status ok", "random toast"]
),
"ttl_ms": rng.choice([None, 500, 1500, 2500]),
}
return {"type": "toast", "payload": payload}
def random_invalid_payload(rng: random.Random, batch: bool) -> str | bytes:
kind = rng.choice(("malformed", "missing_payload", "wrong_type"))
if kind == "malformed":
return b'{"id":"oops","command":'
if batch:
raw = [
{
"id": "not-a-uuid",
"ts_ms": "not-int",
"command": {"type": "set_state"},
}
]
else:
raw = {
"id": "not-a-uuid",
"ts_ms": "not-int",
"command": {"type": "set_state"},
}
if kind == "wrong_type":
if batch:
raw[0]["command"] = {"type": "unknown_command", "payload": {"x": "bad"}}
else:
raw["command"] = {"type": "unknown_command", "payload": {"x": "bad"}}
return json.dumps(raw)
def encode_json_payload(payload: Any) -> bytes:
return json.dumps(payload).encode("utf-8")
@dataclass
class Stats:
start_monotonic: float = field(default_factory=time.monotonic)
total_requests: int = 0
total_commands: int = 0
endpoint_counts: dict[str, int] = field(
default_factory=lambda: {"/v1/command": 0, "/v1/commands": 0, "/v1/state": 0, "/v1/health": 0}
)
status_counts: dict[str, int] = field(default_factory=dict)
transport_errors: int = 0
expected_outcomes: int = 0
unexpected_outcomes: int = 0
latency_ms: list[float] = field(default_factory=list)
def bump_status(self, code: int) -> None:
key = str(code)
self.status_counts[key] = self.status_counts.get(key, 0) + 1
def build_auth_header(
rng: random.Random,
token: str,
unauthorized_probability: float,
) -> dict[str, str]:
if rng.random() >= unauthorized_probability:
return {"Authorization": f"Bearer {token}"}
# Simulate mixed unauthorized scenarios.
mode = rng.choice(("missing", "bad"))
if mode == "missing":
return {}
return {"Authorization": "Bearer invalid-token"}
def request_json(
method: str,
url: str,
body: bytes | None,
timeout_seconds: float,
headers: dict[str, str],
) -> tuple[int | None, str]:
req_headers = {"Content-Type": "application/json", **headers}
request = Request(url=url, data=body, method=method, headers=req_headers)
try:
with urlopen(request, timeout=timeout_seconds) as response:
raw = response.read().decode("utf-8", errors="replace")
return response.status, raw
except HTTPError as err:
raw = err.read().decode("utf-8", errors="replace")
return err.code, raw
except URLError as err:
return None, str(err.reason)
except TimeoutError:
return None, "timeout"
def expected_status(is_invalid_payload: bool, is_unauthorized: bool) -> set[int]:
if is_unauthorized:
return {401}
if is_invalid_payload:
return {400}
return {202}
def health_check(
base_url: str,
timeout_seconds: float,
stats: Stats,
) -> bool:
url = f"{base_url}/v1/health"
stats.total_requests += 1
stats.endpoint_counts["/v1/health"] += 1
started = time.monotonic()
code, _ = request_json(
method="GET",
url=url,
body=None,
timeout_seconds=timeout_seconds,
headers={},
)
elapsed_ms = (time.monotonic() - started) * 1000.0
stats.latency_ms.append(elapsed_ms)
if code is None:
stats.transport_errors += 1
print("health check failed: transport error")
return False
stats.bump_status(code)
if code != 200:
print(f"health check failed: expected 200, got {code}")
return False
return True
def sample_state(
base_url: str,
token: str,
timeout_seconds: float,
stats: Stats,
) -> None:
url = f"{base_url}/v1/state"
stats.total_requests += 1
stats.endpoint_counts["/v1/state"] += 1
started = time.monotonic()
code, _ = request_json(
method="GET",
url=url,
body=None,
timeout_seconds=timeout_seconds,
headers={"Authorization": f"Bearer {token}"},
)
elapsed_ms = (time.monotonic() - started) * 1000.0
stats.latency_ms.append(elapsed_ms)
if code is None:
stats.transport_errors += 1
stats.unexpected_outcomes += 1
return
stats.bump_status(code)
if code == 200:
stats.expected_outcomes += 1
else:
stats.unexpected_outcomes += 1
def run_traffic(
args: argparse.Namespace,
port: int,
token: str,
) -> Stats:
rng = random.Random(args.seed)
stats = Stats()
base_url = f"http://{args.host}:{port}"
if args.health_check and not health_check(base_url, args.timeout_seconds, stats):
return stats
deadline = time.monotonic() + max(1, args.duration_seconds)
req_index = 0
while time.monotonic() < deadline:
req_index += 1
use_batch = rng.random() < args.batch_probability
endpoint = "/v1/commands" if use_batch else "/v1/command"
is_invalid = rng.random() < args.invalid_probability
unauthorized = rng.random() < args.unauthorized_probability
auth_headers = build_auth_header(rng, token, 1.0 if unauthorized else 0.0)
if use_batch:
batch_size = rng.randint(1, max(1, args.max_batch_size))
if is_invalid:
payload = random_invalid_payload(rng, batch=True)
body = payload if isinstance(payload, bytes) else payload.encode("utf-8")
command_count = batch_size
else:
commands = [
command_envelope(random_valid_command(rng))
for _ in range(batch_size)
]
body = encode_json_payload(commands)
command_count = len(commands)
else:
if is_invalid:
payload = random_invalid_payload(rng, batch=False)
body = payload if isinstance(payload, bytes) else payload.encode("utf-8")
command_count = 1
else:
envelope = command_envelope(random_valid_command(rng))
body = encode_json_payload(envelope)
command_count = 1
stats.total_requests += 1
stats.total_commands += command_count
stats.endpoint_counts[endpoint] += 1
started = time.monotonic()
code, _ = request_json(
method="POST",
url=f"{base_url}{endpoint}",
body=body,
timeout_seconds=args.timeout_seconds,
headers=auth_headers,
)
elapsed_ms = (time.monotonic() - started) * 1000.0
stats.latency_ms.append(elapsed_ms)
if code is None:
stats.transport_errors += 1
stats.unexpected_outcomes += 1
else:
stats.bump_status(code)
expected = expected_status(is_invalid, unauthorized)
if code in expected:
stats.expected_outcomes += 1
else:
stats.unexpected_outcomes += 1
if args.state_sample_every > 0 and req_index % args.state_sample_every == 0:
sample_state(base_url, token, args.timeout_seconds, stats)
time.sleep(max(0, args.interval_ms) / 1000.0)
return stats
def summarize(args: argparse.Namespace, port: int, stats: Stats) -> dict[str, Any]:
elapsed = time.monotonic() - stats.start_monotonic
latency_avg = statistics.fmean(stats.latency_ms) if stats.latency_ms else 0.0
latency_min = min(stats.latency_ms) if stats.latency_ms else 0.0
latency_max = max(stats.latency_ms) if stats.latency_ms else 0.0
summary: dict[str, Any] = {
"host": args.host,
"port": port,
"duration_seconds": round(elapsed, 3),
"seed": args.seed,
"requests_total": stats.total_requests,
"commands_total": stats.total_commands,
"endpoint_counts": stats.endpoint_counts,
"status_counts": stats.status_counts,
"transport_errors": stats.transport_errors,
"expected_outcomes": stats.expected_outcomes,
"unexpected_outcomes": stats.unexpected_outcomes,
"latency_ms": {
"avg": round(latency_avg, 2),
"min": round(latency_min, 2),
"max": round(latency_max, 2),
},
"strict": args.strict,
}
return summary
def resolve_port_and_token(args: argparse.Namespace) -> tuple[int, str]:
port = args.port
token = args.token
if port is not None and token:
return port, token
if args.config_path:
candidates = [Path(args.config_path)]
else:
candidates = default_config_path_candidates(args.app_name)
chosen: Path | None = None
for path in candidates:
if path.exists():
chosen = path
break
if chosen is None:
formatted = ", ".join(str(path) for path in candidates)
raise RuntimeError(f"config path not found; tried: {formatted}")
cfg_port, cfg_token = parse_api_from_config(chosen)
return (port or cfg_port), (token or cfg_token)
def main() -> int:
args = parse_args()
if args.max_batch_size < 1:
print("error: --max-batch-size must be >= 1", file=sys.stderr)
return 2
try:
port, token = resolve_port_and_token(args)
except RuntimeError as err:
print(f"error: {err}", file=sys.stderr)
return 2
stats = run_traffic(args, port, token)
summary = summarize(args, port, stats)
print(json.dumps(summary, indent=2))
if args.json_summary:
path = Path(args.json_summary)
path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
if args.strict and summary["unexpected_outcomes"] > 0:
return 1
if summary["requests_total"] == 0:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())