Add: logical verification workflow for bevy
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6039,6 +6039,7 @@ dependencies = [
|
|||||||
"sprimo-config",
|
"sprimo-config",
|
||||||
"sprimo-platform",
|
"sprimo-platform",
|
||||||
"sprimo-protocol",
|
"sprimo-protocol",
|
||||||
|
"sprimo-runtime-core",
|
||||||
"sprimo-sprite",
|
"sprimo-sprite",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sprimo-api = { path = "../sprimo-api" }
|
|||||||
sprimo-config = { path = "../sprimo-config" }
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
sprimo-platform = { path = "../sprimo-platform" }
|
sprimo-platform = { path = "../sprimo-platform" }
|
||||||
sprimo-protocol = { path = "../sprimo-protocol" }
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
|
||||||
sprimo-sprite = { path = "../sprimo-sprite" }
|
sprimo-sprite = { path = "../sprimo-sprite" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
|||||||
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
|
use bevy::window::{PrimaryWindow, WindowLevel, WindowMode, WindowPlugin, WindowPosition};
|
||||||
use image::{DynamicImage, GenericImageView, Rgba};
|
use image::{DynamicImage, GenericImageView, Rgba};
|
||||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
use sprimo_api::{ApiConfig, ApiState};
|
|
||||||
use sprimo_config::{save, AppConfig, ConfigError};
|
|
||||||
use sprimo_platform::{create_adapter, PlatformAdapter};
|
use sprimo_platform::{create_adapter, PlatformAdapter};
|
||||||
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
|
use sprimo_protocol::v1::{CommandEnvelope, FrontendCommand, FrontendState, FrontendStateSnapshot};
|
||||||
|
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
|
||||||
use sprimo_sprite::{
|
use sprimo_sprite::{
|
||||||
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
|
load_manifest, resolve_pack_path, AnimationDefinition, SpriteError, SpritePackManifest,
|
||||||
};
|
};
|
||||||
@@ -20,8 +19,7 @@ use std::sync::{Arc, RwLock};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::mpsc as tokio_mpsc;
|
use tracing::{info, warn};
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
const APP_NAME: &str = "sprimo";
|
const APP_NAME: &str = "sprimo";
|
||||||
const DEFAULT_PACK: &str = "default";
|
const DEFAULT_PACK: &str = "default";
|
||||||
@@ -35,7 +33,7 @@ const WINDOWS_COLOR_KEY: [u8; 3] = [255, 0, 255];
|
|||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
enum AppError {
|
enum AppError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Config(#[from] ConfigError),
|
RuntimeCore(#[from] RuntimeCoreError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Clone)]
|
#[derive(Resource, Clone)]
|
||||||
@@ -45,10 +43,7 @@ struct SharedSnapshot(Arc<RwLock<FrontendStateSnapshot>>);
|
|||||||
struct PlatformResource(Arc<dyn PlatformAdapter>);
|
struct PlatformResource(Arc<dyn PlatformAdapter>);
|
||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
struct ConfigResource {
|
struct RuntimeCoreResource(Arc<RuntimeCore>);
|
||||||
path: PathBuf,
|
|
||||||
config: AppConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource, Copy, Clone)]
|
#[derive(Resource, Copy, Clone)]
|
||||||
struct DurableState {
|
struct DurableState {
|
||||||
@@ -181,35 +176,30 @@ fn main() -> Result<(), AppError> {
|
|||||||
.compact()
|
.compact()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
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 runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
|
||||||
|
let shared_snapshot = runtime_core.snapshot();
|
||||||
let mut snapshot = FrontendStateSnapshot::idle(capabilities);
|
let config = runtime_core
|
||||||
snapshot.x = config.window.x;
|
.config()
|
||||||
snapshot.y = config.window.y;
|
.read()
|
||||||
snapshot.scale = config.window.scale;
|
.expect("runtime core config lock poisoned")
|
||||||
snapshot.flags.click_through = false;
|
.clone();
|
||||||
snapshot.flags.always_on_top = config.window.always_on_top;
|
|
||||||
snapshot.flags.visible = config.window.visible;
|
|
||||||
snapshot.active_sprite_pack = config.sprite.selected_pack.clone();
|
|
||||||
let shared_snapshot = Arc::new(RwLock::new(snapshot));
|
|
||||||
|
|
||||||
let runtime = Runtime::new().expect("tokio runtime");
|
let runtime = Runtime::new().expect("tokio runtime");
|
||||||
let (api_command_tx, api_command_rx) = tokio_mpsc::channel(1_024);
|
|
||||||
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
|
let (bevy_command_tx, bevy_command_rx) = mpsc::channel();
|
||||||
let (hotkey_tx, hotkey_rx) = mpsc::channel();
|
let (hotkey_tx, hotkey_rx) = mpsc::channel();
|
||||||
|
|
||||||
spawn_api(&runtime, &config, Arc::clone(&shared_snapshot), api_command_tx);
|
runtime_core.spawn_api(&runtime);
|
||||||
|
let command_rx = runtime_core.command_receiver();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
let mut rx = api_command_rx;
|
loop {
|
||||||
while let Some(command) = rx.recv().await {
|
let next = {
|
||||||
|
let mut receiver = command_rx.lock().await;
|
||||||
|
receiver.recv().await
|
||||||
|
};
|
||||||
|
let Some(command) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
if bevy_command_tx.send(command).is_err() {
|
if bevy_command_tx.send(command).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -245,11 +235,8 @@ fn main() -> Result<(), AppError> {
|
|||||||
app.insert_resource(ClearColor(window_clear_color()));
|
app.insert_resource(ClearColor(window_clear_color()));
|
||||||
|
|
||||||
app.insert_resource(SharedSnapshot(shared_snapshot));
|
app.insert_resource(SharedSnapshot(shared_snapshot));
|
||||||
|
app.insert_resource(RuntimeCoreResource(Arc::clone(&runtime_core)));
|
||||||
app.insert_resource(PlatformResource(Arc::clone(&platform)));
|
app.insert_resource(PlatformResource(Arc::clone(&platform)));
|
||||||
app.insert_resource(ConfigResource {
|
|
||||||
path: config_path,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
app.insert_resource(DurableState {
|
app.insert_resource(DurableState {
|
||||||
state: FrontendState::Idle,
|
state: FrontendState::Idle,
|
||||||
});
|
});
|
||||||
@@ -281,23 +268,6 @@ fn main() -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_api(
|
|
||||||
runtime: &Runtime,
|
|
||||||
config: &AppConfig,
|
|
||||||
shared_snapshot: Arc<RwLock<FrontendStateSnapshot>>,
|
|
||||||
api_command_tx: tokio_mpsc::Sender<CommandEnvelope>,
|
|
||||||
) {
|
|
||||||
let mut api_config = ApiConfig::default_with_token(config.api.auth_token.clone());
|
|
||||||
api_config.bind_addr = ([127, 0, 0, 1], config.api.port).into();
|
|
||||||
let api_state = Arc::new(ApiState::new(api_config.clone(), shared_snapshot, api_command_tx));
|
|
||||||
|
|
||||||
runtime.spawn(async move {
|
|
||||||
if let Err(err) = sprimo_api::run_server(api_config, api_state).await {
|
|
||||||
error!(%err, "api server exited");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_asset_root() -> PathBuf {
|
fn default_asset_root() -> PathBuf {
|
||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
@@ -307,7 +277,7 @@ fn default_asset_root() -> PathBuf {
|
|||||||
|
|
||||||
fn setup_scene(
|
fn setup_scene(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
config: Res<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
root: Res<SpritePackRoot>,
|
root: Res<SpritePackRoot>,
|
||||||
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
@@ -323,7 +293,14 @@ fn setup_scene(
|
|||||||
..default()
|
..default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let selected = config.config.sprite.selected_pack.clone();
|
let selected = runtime_core
|
||||||
|
.0
|
||||||
|
.config()
|
||||||
|
.read()
|
||||||
|
.expect("runtime core config lock poisoned")
|
||||||
|
.sprite
|
||||||
|
.selected_pack
|
||||||
|
.clone();
|
||||||
let runtime = match load_pack_runtime(
|
let runtime = match load_pack_runtime(
|
||||||
&root.asset_root,
|
&root.asset_root,
|
||||||
&selected,
|
&selected,
|
||||||
@@ -749,8 +726,7 @@ fn attach_window_handle_once(
|
|||||||
fn poll_hotkey_recovery(
|
fn poll_hotkey_recovery(
|
||||||
ingress: NonSendMut<HotkeyIngress>,
|
ingress: NonSendMut<HotkeyIngress>,
|
||||||
platform: Res<PlatformResource>,
|
platform: Res<PlatformResource>,
|
||||||
mut config: ResMut<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
snapshot: Res<SharedSnapshot>,
|
|
||||||
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
|
mut pet_query: Query<&mut Visibility, With<PetSprite>>,
|
||||||
) {
|
) {
|
||||||
while ingress.0.try_recv().is_ok() {
|
while ingress.0.try_recv().is_ok() {
|
||||||
@@ -761,18 +737,12 @@ fn poll_hotkey_recovery(
|
|||||||
if let Ok(mut visibility) = pet_query.get_single_mut() {
|
if let Ok(mut visibility) = pet_query.get_single_mut() {
|
||||||
*visibility = Visibility::Visible;
|
*visibility = Visibility::Visible;
|
||||||
}
|
}
|
||||||
|
if let Err(err) = runtime_core.0.apply_command(&FrontendCommand::SetFlags {
|
||||||
config.config.window.always_on_top = true;
|
click_through: None,
|
||||||
config.config.window.visible = true;
|
always_on_top: Some(true),
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
visible: Some(true),
|
||||||
warn!(%err, "failed to persist config after hotkey recovery");
|
}) {
|
||||||
}
|
warn!(%err, "failed to persist recovery flag config");
|
||||||
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
guard.flags.click_through = false;
|
|
||||||
guard.flags.always_on_top = true;
|
|
||||||
guard.flags.visible = true;
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,7 +754,7 @@ fn poll_backend_commands(
|
|||||||
asset_server: Res<AssetServer>,
|
asset_server: Res<AssetServer>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
mut texture_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||||
mut config: ResMut<ConfigResource>,
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
snapshot: Res<SharedSnapshot>,
|
snapshot: Res<SharedSnapshot>,
|
||||||
mut durable_state: ResMut<DurableState>,
|
mut durable_state: ResMut<DurableState>,
|
||||||
mut reset: ResMut<PendingStateReset>,
|
mut reset: ResMut<PendingStateReset>,
|
||||||
@@ -800,6 +770,7 @@ fn poll_backend_commands(
|
|||||||
let Ok(envelope) = ingress.0.try_recv() else {
|
let Ok(envelope) = ingress.0.try_recv() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
let command = envelope.command;
|
||||||
|
|
||||||
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
|
let Ok((mut transform, mut visibility, mut atlas, mut image_handle)) =
|
||||||
pet_query.get_single_mut()
|
pet_query.get_single_mut()
|
||||||
@@ -807,7 +778,14 @@ fn poll_backend_commands(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match envelope.command {
|
if !matches!(command, FrontendCommand::SetSpritePack { .. }) {
|
||||||
|
if let Err(err) = runtime_core.0.apply_command(&command) {
|
||||||
|
warn!(%err, "failed to apply command in runtime core");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
FrontendCommand::SetState { state, ttl_ms } => {
|
FrontendCommand::SetState { state, ttl_ms } => {
|
||||||
if !matches!(state, FrontendState::Success | FrontendState::Error) {
|
if !matches!(state, FrontendState::Success | FrontendState::Error) {
|
||||||
durable_state.state = state;
|
durable_state.state = state;
|
||||||
@@ -878,8 +856,11 @@ fn poll_backend_commands(
|
|||||||
window.resolution.set(size.x, size.y);
|
window.resolution.set(size.x, size.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.config.sprite.selected_pack = requested;
|
if let Err(err) = runtime_core.0.apply_command(
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
&FrontendCommand::SetSpritePack {
|
||||||
|
pack_id_or_path: requested.clone(),
|
||||||
|
},
|
||||||
|
) {
|
||||||
warn!(%err, "failed to persist sprite pack selection");
|
warn!(%err, "failed to persist sprite pack selection");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,37 +880,17 @@ fn poll_backend_commands(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
FrontendCommand::SetTransform { x, y, scale, .. } => {
|
||||||
if let Some(value) = x {
|
|
||||||
config.config.window.x = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = y {
|
|
||||||
config.config.window.y = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = scale {
|
if let Some(value) = scale {
|
||||||
transform.scale = Vec3::splat(value);
|
transform.scale = Vec3::splat(value);
|
||||||
config.config.window.scale = value;
|
|
||||||
if let Ok(mut window) = window_query.get_single_mut() {
|
if let Ok(mut window) = window_query.get_single_mut() {
|
||||||
let size = window_size_for_pack(¤t_pack.runtime, value);
|
let size = window_size_for_pack(¤t_pack.runtime, value);
|
||||||
window.resolution.set(size.x, size.y);
|
window.resolution.set(size.x, size.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = platform
|
if x.is_some() || y.is_some() {
|
||||||
.0
|
if let Ok(guard) = snapshot.0.read() {
|
||||||
.set_window_position(config.config.window.x, config.config.window.y);
|
let _ = platform.0.set_window_position(guard.x, guard.y);
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
|
||||||
warn!(%err, "failed to persist transform config");
|
|
||||||
}
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
if let Some(value) = x {
|
|
||||||
guard.x = value;
|
|
||||||
}
|
}
|
||||||
if let Some(value) = y {
|
|
||||||
guard.y = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = scale {
|
|
||||||
guard.scale = value;
|
|
||||||
}
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FrontendCommand::SetFlags {
|
FrontendCommand::SetFlags {
|
||||||
@@ -937,36 +898,18 @@ fn poll_backend_commands(
|
|||||||
always_on_top,
|
always_on_top,
|
||||||
visible,
|
visible,
|
||||||
} => {
|
} => {
|
||||||
if click_through.is_some() {
|
let _ = click_through;
|
||||||
config.config.window.click_through = false;
|
|
||||||
}
|
|
||||||
if let Some(value) = always_on_top {
|
if let Some(value) = always_on_top {
|
||||||
let _ = platform.0.set_always_on_top(value);
|
let _ = platform.0.set_always_on_top(value);
|
||||||
config.config.window.always_on_top = value;
|
|
||||||
}
|
}
|
||||||
if let Some(value) = visible {
|
if let Some(value) = visible {
|
||||||
let _ = platform.0.set_visible(value);
|
let _ = platform.0.set_visible(value);
|
||||||
config.config.window.visible = value;
|
|
||||||
*visibility = if value {
|
*visibility = if value {
|
||||||
Visibility::Visible
|
Visibility::Visible
|
||||||
} else {
|
} else {
|
||||||
Visibility::Hidden
|
Visibility::Hidden
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = save(&config.path, &config.config) {
|
|
||||||
warn!(%err, "failed to persist flag config");
|
|
||||||
}
|
|
||||||
if let Ok(mut guard) = snapshot.0.write() {
|
|
||||||
guard.flags.click_through = false;
|
|
||||||
if let Some(value) = always_on_top {
|
|
||||||
guard.flags.always_on_top = value;
|
|
||||||
}
|
|
||||||
if let Some(value) = visible {
|
|
||||||
guard.flags.visible = value;
|
|
||||||
}
|
|
||||||
guard.last_error = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FrontendCommand::Toast { text, .. } => {
|
FrontendCommand::Toast { text, .. } => {
|
||||||
info!(toast = text, "toast command received");
|
info!(toast = text, "toast command received");
|
||||||
@@ -977,6 +920,7 @@ fn poll_backend_commands(
|
|||||||
|
|
||||||
fn tick_state_reset(
|
fn tick_state_reset(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
runtime_core: Res<RuntimeCoreResource>,
|
||||||
mut reset: ResMut<PendingStateReset>,
|
mut reset: ResMut<PendingStateReset>,
|
||||||
mut animation: ResMut<AnimationResource>,
|
mut animation: ResMut<AnimationResource>,
|
||||||
current_pack: Res<CurrentPackResource>,
|
current_pack: Res<CurrentPackResource>,
|
||||||
@@ -993,6 +937,10 @@ fn tick_state_reset(
|
|||||||
|
|
||||||
let next_state = reset.revert_to;
|
let next_state = reset.revert_to;
|
||||||
let next_animation = default_animation_for_state(next_state);
|
let next_animation = default_animation_for_state(next_state);
|
||||||
|
let _ = runtime_core.0.apply_command(&FrontendCommand::SetState {
|
||||||
|
state: next_state,
|
||||||
|
ttl_ms: None,
|
||||||
|
});
|
||||||
set_animation(&mut animation, ¤t_pack.runtime.clips, next_animation);
|
set_animation(&mut animation, ¤t_pack.runtime.clips, next_animation);
|
||||||
if let Ok(mut atlas) = atlas_query.get_single_mut() {
|
if let Ok(mut atlas) = atlas_query.get_single_mut() {
|
||||||
if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) {
|
if let Some(frame) = current_frame(&animation, ¤t_pack.runtime.clips) {
|
||||||
|
|||||||
194
docs/BEVY_WINDOW_VERIFICATION.md
Normal file
194
docs/BEVY_WINDOW_VERIFICATION.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Bevy Window Management Verification (Windows-First)
|
||||||
|
|
||||||
|
Date: 2026-02-13
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines a logical verification workflow for `sprimo-app` window management.
|
||||||
|
It combines code-path assertions with lightweight runtime validation.
|
||||||
|
|
||||||
|
Primary focus:
|
||||||
|
|
||||||
|
- window visibility behavior
|
||||||
|
- always-on-top behavior
|
||||||
|
- position and scale handling
|
||||||
|
- recovery hotkey behavior
|
||||||
|
- snapshot/config consistency
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `crates/sprimo-app/src/main.rs`
|
||||||
|
- `crates/sprimo-platform/src/lib.rs`
|
||||||
|
- window-related runtime-core integration path in `sprimo-app`
|
||||||
|
- API commands: `SetTransform`, `SetFlags`
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- tray/menu behavior
|
||||||
|
- tauri frontend window behavior
|
||||||
|
- long soak/performance testing
|
||||||
|
|
||||||
|
## Requirements Traceability
|
||||||
|
|
||||||
|
| Requirement | Intent | Code path(s) |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-FW-1 | Borderless/transparent overlay | `crates/sprimo-app/src/main.rs` window setup (`decorations=false`, clear color/transparent mode) |
|
||||||
|
| FR-FW-2 | Always-on-top toggle | `crates/sprimo-app/src/main.rs` (`SetFlags.always_on_top`), `crates/sprimo-platform/src/lib.rs` (`set_always_on_top`) |
|
||||||
|
| FR-FW-3 | Recovery to visible/interactive | `crates/sprimo-app/src/main.rs` (`poll_hotkey_recovery`) |
|
||||||
|
| FR-FW-4 | Positioning persistence + move | `crates/sprimo-app/src/main.rs` (`SetTransform x/y`, startup position), runtime-core config persistence |
|
||||||
|
| FR-FW-5 | Position handling under monitor/DPI context | logical verification of position updates via platform adapter + snapshot/config |
|
||||||
|
|
||||||
|
## Code-Path Logical Checklist
|
||||||
|
|
||||||
|
1. Startup invariants:
|
||||||
|
- window is undecorated and non-resizable
|
||||||
|
- startup position uses persisted `window.x` / `window.y`
|
||||||
|
- startup top-most intent is configured
|
||||||
|
|
||||||
|
2. Handle-attach invariants:
|
||||||
|
- `attach_window_handle_once` applies at most once
|
||||||
|
- on Windows, native handle attach occurs before platform calls
|
||||||
|
- initial snapshot values are applied to platform (`always_on_top`, `visible`, `position`)
|
||||||
|
|
||||||
|
3. Command invariants:
|
||||||
|
- `SetTransform.scale` updates sprite scale and window size (`window_size_for_pack`)
|
||||||
|
- `SetTransform.x/y` updates logical position state and issues platform move
|
||||||
|
- `SetFlags.always_on_top` triggers platform top-most call
|
||||||
|
- `SetFlags.visible` triggers platform visibility call and Bevy `Visibility`
|
||||||
|
|
||||||
|
4. Recovery invariants:
|
||||||
|
- hotkey path enforces `visible=true` and `always_on_top=true`
|
||||||
|
- recovery updates logical state via runtime-core command path
|
||||||
|
|
||||||
|
5. Persistence/snapshot invariants:
|
||||||
|
- window fields (`x`, `y`, `scale`, `flags`) roundtrip through runtime-core
|
||||||
|
- `/v1/state` stays consistent with command outcomes
|
||||||
|
|
||||||
|
6. Degradation invariants (non-Windows):
|
||||||
|
- no-op adapter does not crash on window commands
|
||||||
|
- capability semantics remain conservative
|
||||||
|
|
||||||
|
## Lightweight Runtime Checklist (Windows)
|
||||||
|
|
||||||
|
Preconditions:
|
||||||
|
|
||||||
|
- run `sprimo-app`
|
||||||
|
- resolve token/port from `%APPDATA%/sprimo/config/config.toml`
|
||||||
|
|
||||||
|
### Scenario 1: Baseline
|
||||||
|
|
||||||
|
1. `GET /v1/health`
|
||||||
|
2. `GET /v1/state` with bearer token
|
||||||
|
3. Record `x`, `y`, `scale`, `flags.visible`, `flags.always_on_top`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- health endpoint alive
|
||||||
|
- state endpoint authorized
|
||||||
|
- window fields present and coherent
|
||||||
|
|
||||||
|
### Scenario 2: Position Command
|
||||||
|
|
||||||
|
1. Send `SetTransform` with `x` and `y`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `x`/`y` update in snapshot
|
||||||
|
- window visibly moves
|
||||||
|
|
||||||
|
### Scenario 3: Scale Command
|
||||||
|
|
||||||
|
1. Send `SetTransform` with `scale`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `scale` updates
|
||||||
|
- window dimensions update according to frame size + padding
|
||||||
|
|
||||||
|
### Scenario 4: Visibility Toggle
|
||||||
|
|
||||||
|
1. Send `SetFlags { visible: false }`.
|
||||||
|
2. Send `SetFlags { visible: true }`.
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- visible flag transitions correctly
|
||||||
|
- window hides/shows without crash
|
||||||
|
|
||||||
|
### Scenario 5: Always-On-Top Toggle
|
||||||
|
|
||||||
|
1. Send `SetFlags { always_on_top: false }`.
|
||||||
|
2. Send `SetFlags { always_on_top: true }`.
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- top-most flag transitions correctly
|
||||||
|
- OS behavior matches expected z-order changes
|
||||||
|
|
||||||
|
### Scenario 6: Recovery Hotkey
|
||||||
|
|
||||||
|
1. Move to non-ideal state (hidden and/or not top-most).
|
||||||
|
2. Trigger configured recovery hotkey (default `Ctrl+Alt+P`).
|
||||||
|
3. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- forces `visible=true`
|
||||||
|
- forces `always_on_top=true`
|
||||||
|
|
||||||
|
### Scenario 7: Restart Persistence
|
||||||
|
|
||||||
|
1. Restart `sprimo-app`.
|
||||||
|
2. Re-check `/v1/state`.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `x`, `y`, `scale`, `always_on_top`, `visible` persist
|
||||||
|
|
||||||
|
## Suggested Command Set
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo check -p sprimo-app
|
||||||
|
cargo check -p sprimo-platform
|
||||||
|
cargo check -p sprimo-runtime-core
|
||||||
|
cargo test -p sprimo-app
|
||||||
|
cargo test -p sprimo-runtime-core
|
||||||
|
just qa-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional runtime stress after directed checks:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
just random-backend-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pass / Fail Criteria
|
||||||
|
|
||||||
|
Pass:
|
||||||
|
|
||||||
|
- all logical assertions hold
|
||||||
|
- runtime scenarios behave as expected
|
||||||
|
- `/v1/state` remains consistent with visible behavior
|
||||||
|
- no crashes/panics in window-command paths
|
||||||
|
|
||||||
|
Fail:
|
||||||
|
|
||||||
|
- mismatch between command outcome and snapshot
|
||||||
|
- recovery hotkey fails to restore visible top-most state
|
||||||
|
- restart persistence for window fields fails
|
||||||
|
- any crash in window-management flow
|
||||||
|
|
||||||
|
## Evidence Expectations
|
||||||
|
|
||||||
|
For issue-driven verification, attach:
|
||||||
|
|
||||||
|
- command summary
|
||||||
|
- before/after screenshots
|
||||||
|
- `/v1/state` snapshots before and after key transitions
|
||||||
|
- lifecycle updates in `issues/issueN.md` per `docs/QA_WORKFLOW.md`
|
||||||
@@ -18,7 +18,7 @@ Date: 2026-02-12
|
|||||||
| 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 |
|
| 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 |
|
| Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring |
|
||||||
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale updates now auto-fit window to avoid top clipping |
|
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale 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 |
|
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
|
||||||
|
|
||||||
@@ -28,5 +28,4 @@ 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.
|
5. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.
|
||||||
6. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ just random-backend-test
|
|||||||
```
|
```
|
||||||
|
|
||||||
For runtime behavior issues, include screenshot capture paths in the issue file.
|
For runtime behavior issues, include screenshot capture paths in the issue file.
|
||||||
|
For Bevy window-management verification workflows, use:
|
||||||
|
|
||||||
|
- `docs/BEVY_WINDOW_VERIFICATION.md`
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Legacy issues may reference `issues/screenshots/issueN.png` as before evidence.
|
|||||||
## Tauri Runtime Behavior Testing
|
## Tauri Runtime Behavior Testing
|
||||||
|
|
||||||
Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
|
Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
|
||||||
|
Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`.
|
||||||
|
|
||||||
### Workspace Mode (Required Now)
|
### Workspace Mode (Required Now)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,5 @@ Frontend:
|
|||||||
|
|
||||||
## Remaining Work
|
## Remaining Work
|
||||||
|
|
||||||
1. Move Bevy runtime flow to consume `sprimo-runtime-core` as primary state authority.
|
1. Add tray/menu parity and window behavior parity with Bevy path.
|
||||||
2. Add tray/menu parity and window behavior parity with Bevy path.
|
2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).
|
||||||
3. Extend packaging scripts to include `sprimo-tauri` artifact path.
|
|
||||||
4. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user