Compare commits

2 Commits

Author SHA1 Message Date
DaZuo0122
3c3ca342c9 Add: tauri frontend as bevy alternative 2026-02-13 09:57:08 +08:00
DaZuo0122
b0f462f63e Update: .gitignore 2026-02-12 23:11:21 +08:00
33 changed files with 11799 additions and 106 deletions

3
.gitignore vendored
View File

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

3625
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,9 @@ members = [
"crates/sprimo-config", "crates/sprimo-config",
"crates/sprimo-platform", "crates/sprimo-platform",
"crates/sprimo-protocol", "crates/sprimo-protocol",
"crates/sprimo-runtime-core",
"crates/sprimo-sprite", "crates/sprimo-sprite",
"crates/sprimo-tauri",
] ]
resolver = "2" resolver = "2"

View File

@@ -26,6 +26,7 @@ pub struct AppConfig {
pub api: ApiConfig, pub api: ApiConfig,
pub logging: LoggingConfig, pub logging: LoggingConfig,
pub controls: ControlsConfig, pub controls: ControlsConfig,
pub frontend: FrontendConfig,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -37,6 +38,7 @@ impl Default for AppConfig {
api: ApiConfig::default(), api: ApiConfig::default(),
logging: LoggingConfig::default(), logging: LoggingConfig::default(),
controls: ControlsConfig::default(), controls: ControlsConfig::default(),
frontend: FrontendConfig::default(),
} }
} }
} }
@@ -145,6 +147,27 @@ impl Default for ControlsConfig {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FrontendBackend {
Bevy,
Tauri,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FrontendConfig {
pub backend: FrontendBackend,
}
impl Default for FrontendConfig {
fn default() -> Self {
Self {
backend: FrontendBackend::Bevy,
}
}
}
#[must_use] #[must_use]
pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> { pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> {
let dirs = let dirs =
@@ -198,9 +221,11 @@ mod tests {
let path = temp.path().join("config.toml"); let path = temp.path().join("config.toml");
let mut config = AppConfig::default(); let mut config = AppConfig::default();
config.window.x = 42.0; config.window.x = 42.0;
config.frontend.backend = super::FrontendBackend::Tauri;
save(&path, &config).expect("save"); save(&path, &config).expect("save");
let (_, loaded) = load_or_create_at(&path).expect("reload"); let (_, loaded) = load_or_create_at(&path).expect("reload");
assert!((loaded.window.x - 42.0).abs() < f32::EPSILON); assert!((loaded.window.x - 42.0).abs() < f32::EPSILON);
assert_eq!(loaded.frontend.backend, super::FrontendBackend::Tauri);
} }
} }

View File

@@ -0,0 +1,19 @@
[package]
name = "sprimo-runtime-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
sprimo-api = { path = "../sprimo-api" }
sprimo-config = { path = "../sprimo-config" }
sprimo-protocol = { path = "../sprimo-protocol" }
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile = "3.12.0"

View File

@@ -0,0 +1,262 @@
use sprimo_api::{ApiConfig, ApiServerError, ApiState};
use sprimo_config::{save, AppConfig, ConfigError};
use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use thiserror::Error;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, Mutex};
use tracing::warn;
#[derive(Debug, Error)]
pub enum RuntimeCoreError {
#[error("{0}")]
Config(#[from] ConfigError),
#[error("snapshot lock poisoned")]
SnapshotPoisoned,
#[error("config lock poisoned")]
ConfigPoisoned,
}
pub struct RuntimeCore {
config_path: PathBuf,
config: Arc<RwLock<AppConfig>>,
snapshot: Arc<RwLock<FrontendStateSnapshot>>,
api_config: ApiConfig,
command_tx: mpsc::Sender<CommandEnvelope>,
command_rx: Arc<Mutex<mpsc::Receiver<CommandEnvelope>>>,
}
impl RuntimeCore {
pub fn new(app_name: &str, capabilities: CapabilityFlags) -> Result<Self, RuntimeCoreError> {
let (config_path, config_value) = sprimo_config::load_or_create(app_name)?;
Self::new_with_config(config_path, config_value, capabilities)
}
pub fn new_with_config(
config_path: PathBuf,
config_value: AppConfig,
capabilities: CapabilityFlags,
) -> Result<Self, RuntimeCoreError> {
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.always_on_top = config_value.window.always_on_top;
snapshot.flags.visible = config_value.window.visible;
snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone();
let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone());
let (command_tx, command_rx) = mpsc::channel(1_024);
Ok(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)),
})
}
pub fn snapshot(&self) -> Arc<RwLock<FrontendStateSnapshot>> {
Arc::clone(&self.snapshot)
}
pub fn config(&self) -> Arc<RwLock<AppConfig>> {
Arc::clone(&self.config)
}
pub fn command_receiver(&self) -> Arc<Mutex<mpsc::Receiver<CommandEnvelope>>> {
Arc::clone(&self.command_rx)
}
pub fn command_sender(&self) -> mpsc::Sender<CommandEnvelope> {
self.command_tx.clone()
}
pub fn api_config(&self) -> ApiConfig {
self.api_config.clone()
}
pub fn spawn_api(&self, runtime: &Runtime) {
let mut cfg = self.api_config.clone();
if let Ok(guard) = self.config.read() {
cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into();
}
let state = Arc::new(ApiState::new(
cfg.clone(),
Arc::clone(&self.snapshot),
self.command_tx.clone(),
));
runtime.spawn(async move {
if let Err(err) = sprimo_api::run_server(cfg, state).await {
log_api_error(err);
}
});
}
pub fn apply_command(&self, command: &FrontendCommand) -> Result<(), RuntimeCoreError> {
match command {
FrontendCommand::SetState { state, .. } => {
let mut snapshot = self
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
snapshot.state = *state;
snapshot.current_animation = default_animation_for_state(*state).to_string();
snapshot.last_error = None;
}
FrontendCommand::PlayAnimation { name, .. } => {
let mut snapshot = self
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
snapshot.current_animation = name.clone();
snapshot.last_error = None;
}
FrontendCommand::SetSpritePack { pack_id_or_path } => {
{
let mut snapshot = self
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
snapshot.active_sprite_pack = pack_id_or_path.clone();
snapshot.last_error = None;
}
{
let mut config = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
config.sprite.selected_pack = pack_id_or_path.clone();
}
self.persist_config()?;
}
FrontendCommand::SetTransform { x, y, scale, .. } => {
{
let mut snapshot = self
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
if let Some(value) = x {
snapshot.x = *value;
}
if let Some(value) = y {
snapshot.y = *value;
}
if let Some(value) = scale {
snapshot.scale = *value;
}
snapshot.last_error = None;
}
{
let mut config = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if let Some(value) = x {
config.window.x = *value;
}
if let Some(value) = y {
config.window.y = *value;
}
if let Some(value) = scale {
config.window.scale = *value;
}
}
self.persist_config()?;
}
FrontendCommand::SetFlags {
click_through,
always_on_top,
visible,
} => {
{
let mut snapshot = self
.snapshot
.write()
.map_err(|_| RuntimeCoreError::SnapshotPoisoned)?;
if let Some(value) = click_through {
snapshot.flags.click_through = *value;
}
if let Some(value) = always_on_top {
snapshot.flags.always_on_top = *value;
}
if let Some(value) = visible {
snapshot.flags.visible = *value;
}
snapshot.last_error = None;
}
{
let mut config = self
.config
.write()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
if let Some(value) = click_through {
config.window.click_through = *value;
}
if let Some(value) = always_on_top {
config.window.always_on_top = *value;
}
if let Some(value) = visible {
config.window.visible = *value;
}
}
self.persist_config()?;
}
FrontendCommand::Toast { .. } => {}
}
Ok(())
}
fn persist_config(&self) -> Result<(), RuntimeCoreError> {
let guard = self
.config
.read()
.map_err(|_| RuntimeCoreError::ConfigPoisoned)?;
save(&self.config_path, &guard)?;
Ok(())
}
}
fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str {
match state {
sprimo_protocol::v1::FrontendState::Idle => "idle",
sprimo_protocol::v1::FrontendState::Active => "active",
sprimo_protocol::v1::FrontendState::Success => "success",
sprimo_protocol::v1::FrontendState::Error => "error",
sprimo_protocol::v1::FrontendState::Dragging => "idle",
sprimo_protocol::v1::FrontendState::Hidden => "idle",
}
}
fn log_api_error(err: ApiServerError) {
warn!(%err, "runtime core api server exited");
}
#[cfg(test)]
mod tests {
use super::RuntimeCore;
use sprimo_config::AppConfig;
use sprimo_protocol::v1::{CapabilityFlags, FrontendCommand, FrontendState};
use tempfile::TempDir;
#[test]
fn state_command_updates_snapshot() {
let temp = TempDir::new().expect("tempdir");
let path = temp.path().join("config.toml");
let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default())
.expect("core init");
core.apply_command(&FrontendCommand::SetState {
state: FrontendState::Active,
ttl_ms: None,
})
.expect("apply");
let snapshot = core.snapshot().read().expect("snapshot lock").clone();
assert_eq!(snapshot.state, FrontendState::Active);
assert_eq!(snapshot.current_animation, "active");
}
}

View File

@@ -0,0 +1,29 @@
[package]
name = "sprimo-tauri"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
build = "build.rs"
[lints]
workspace = true
[dependencies]
base64 = "0.22.1"
serde.workspace = true
sprimo-config = { path = "../sprimo-config" }
sprimo-platform = { path = "../sprimo-platform" }
sprimo-sprite = { path = "../sprimo-sprite" }
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
sprimo-protocol = { path = "../sprimo-protocol" }
tauri = { version = "2.0.0", features = [] }
tauri-plugin-global-shortcut = "2.0.0"
tauri-plugin-log = "2.0.0"
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde_json.workspace = true
[build-dependencies]
tauri-build = { version = "2.0.0", features = [] }

View File

@@ -0,0 +1,8 @@
fn main() {
println!("cargo:rerun-if-changed=tauri.conf.json");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/src");
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/index.html");
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/dist");
tauri_build::build()
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for sprimo-tauri main window runtime APIs.",
"windows": ["*"],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
}

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,221 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
use std::sync::Arc;
use tauri::{Emitter, Manager};
use thiserror::Error;
use tokio::runtime::Runtime;
use tracing::warn;
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
#[derive(Debug, Clone, serde::Serialize)]
struct UiAnimationClip {
name: String,
fps: u16,
frames: Vec<u32>,
one_shot: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiAnchor {
x: f32,
y: f32,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSpritePack {
id: String,
frame_width: u32,
frame_height: u32,
atlas_data_url: String,
animations: Vec<UiAnimationClip>,
anchor: UiAnchor,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSnapshot {
state: String,
current_animation: String,
x: f32,
y: f32,
scale: f32,
active_sprite_pack: String,
}
#[derive(Debug, Error)]
enum AppError {
#[error("{0}")]
RuntimeCore(#[from] RuntimeCoreError),
#[error("tokio runtime init failed: {0}")]
Tokio(#[from] std::io::Error),
#[error("tauri runtime failed: {0}")]
Tauri(#[from] tauri::Error),
}
#[derive(Clone)]
struct AppState {
runtime_core: Arc<RuntimeCore>,
runtime: Arc<Runtime>,
}
#[tauri::command]
fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String> {
let snapshot = state
.runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
Ok(to_ui_snapshot(&snapshot))
}
#[tauri::command]
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
let config = state
.runtime_core
.config()
.read()
.map_err(|_| "config lock poisoned".to_string())?
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(config.sprite.sprite_packs_dir);
let selected = config.sprite.selected_pack;
let pack_path = match resolve_pack_path(&root, &selected) {
Ok(path) => path,
Err(_) => resolve_pack_path(&root, DEFAULT_PACK).map_err(|err| err.to_string())?,
};
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
let atlas_data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(image_bytes)
);
Ok(UiSpritePack {
id: manifest.id,
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
atlas_data_url,
animations: manifest
.animations
.into_iter()
.map(to_ui_clip)
.collect(),
anchor: UiAnchor {
x: manifest.anchor.x,
y: manifest.anchor.y,
},
})
}
fn main() -> Result<(), AppError> {
tracing_subscriber::fmt()
.with_env_filter("sprimo=info")
.with_target(false)
.compact()
.init();
let platform: Arc<dyn PlatformAdapter> = create_adapter().into();
let runtime_core = Arc::new(RuntimeCore::new(APP_NAME, platform.capabilities())?);
let runtime = Arc::new(Runtime::new()?);
runtime_core.spawn_api(&runtime);
let state = AppState {
runtime_core: Arc::clone(&runtime_core),
runtime: Arc::clone(&runtime),
};
tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(state)
.invoke_handler(tauri::generate_handler![current_state, load_active_sprite_pack])
.setup(|app| {
let app_state: tauri::State<'_, AppState> = app.state();
let runtime_core = Arc::clone(&app_state.runtime_core);
let runtime = Arc::clone(&app_state.runtime);
let app_handle = app.handle().clone();
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit("runtime:snapshot", to_ui_snapshot(&snapshot));
}
let command_rx = runtime_core.command_receiver();
runtime.spawn(async move {
loop {
let next = {
let mut receiver = command_rx.lock().await;
receiver.recv().await
};
let Some(envelope) = next else {
break;
};
if let Err(err) = runtime_core.apply_command(&envelope.command) {
warn!(%err, "failed to apply command in tauri runtime");
continue;
}
let payload = {
let snapshot = runtime_core.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;
Ok(())
})
.run(tauri::generate_context!())?;
Ok(())
}
fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
UiSnapshot {
state: state_name(snapshot.state).to_string(),
current_animation: snapshot.current_animation.clone(),
x: snapshot.x,
y: snapshot.y,
scale: snapshot.scale,
active_sprite_pack: snapshot.active_sprite_pack.clone(),
}
}
fn state_name(value: FrontendState) -> &'static str {
match value {
FrontendState::Idle => "idle",
FrontendState::Active => "active",
FrontendState::Success => "success",
FrontendState::Error => "error",
FrontendState::Dragging => "dragging",
FrontendState::Hidden => "hidden",
}
}
fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
UiAnimationClip {
name: value.name,
fps: value.fps.max(1),
frames: value.frames,
one_shot: value.one_shot.unwrap_or(false),
}
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "sprimo-tauri",
"version": "0.1.0",
"identifier": "com.sprimo.tauri",
"build": {
"frontendDist": "../../frontend/tauri-ui/dist",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"app": {
"windows": [
{
"title": "sprimo-tauri",
"width": 640,
"height": 640,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"resizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}

View File

@@ -3,20 +3,22 @@
## Workspace Layout ## Workspace Layout
- `crates/sprimo-app`: process entrypoint and runtime wiring. - `crates/sprimo-app`: process entrypoint and runtime wiring.
- `crates/sprimo-tauri`: Tauri 2.0 alternative frontend entrypoint.
- `crates/sprimo-api`: axum-based localhost control server. - `crates/sprimo-api`: axum-based localhost control server.
- `crates/sprimo-config`: config schema, path resolution, persistence. - `crates/sprimo-config`: config schema, path resolution, persistence.
- `crates/sprimo-platform`: platform abstraction for overlay operations. - `crates/sprimo-platform`: platform abstraction for overlay operations.
- `crates/sprimo-protocol`: shared API/state/command protocol types. - `crates/sprimo-protocol`: shared API/state/command protocol types.
- `crates/sprimo-runtime-core`: shared runtime core for command/state/API orchestration.
- `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic. - `crates/sprimo-sprite`: sprite pack manifest loading and fallback logic.
## Runtime Data Flow ## Runtime Data Flow
1. `sprimo-app` loads or creates `config.toml`. 1. Frontend (`sprimo-app` or `sprimo-tauri`) initializes `sprimo-runtime-core`.
2. App builds initial `FrontendStateSnapshot`. 2. Runtime core loads/creates `config.toml` and builds initial `FrontendStateSnapshot`.
3. App starts `sprimo-api` on a Tokio runtime. 3. Runtime core starts `sprimo-api` on a Tokio runtime.
4. API authenticates commands and deduplicates IDs. 4. API authenticates commands and deduplicates IDs.
5. Commands are bridged from Tokio channel to Bevy main-thread systems. 5. Commands are bridged from API channel into frontend-specific command handlers.
6. Bevy systems apply commands to sprite state, window/platform operations, and config persistence. 6. Frontend adapter applies rendering/window effects and runtime core applies snapshot/config state.
7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`. 7. Shared snapshot is exposed by API via `/v1/state` and `/v1/health`.
## Sprite Reload Semantics ## Sprite Reload Semantics
@@ -32,6 +34,7 @@
- API task: axum server. - API task: axum server.
- Bridge task: forwards API commands into Bevy ingest channel. - Bridge task: forwards API commands into Bevy ingest channel.
- Bevy main thread: rendering, animation, command application, and window behavior. - Bevy main thread: rendering, animation, command application, and window behavior.
- Tauri thread/runtime: webview UI, event loop, and runtime command consumer.
- Optional hotkey thread (Windows): registers global hotkey and pushes recovery events. - Optional hotkey thread (Windows): registers global hotkey and pushes recovery events.
- Snapshot is shared via `Arc<RwLock<_>>`. - Snapshot is shared via `Arc<RwLock<_>>`.

View File

@@ -36,6 +36,9 @@ level = "info"
[controls] [controls]
hotkey_enabled = true hotkey_enabled = true
recovery_hotkey = "Ctrl+Alt+P" recovery_hotkey = "Ctrl+Alt+P"
[frontend]
backend = "bevy"
``` ```
## Notes ## Notes
@@ -43,3 +46,4 @@ recovery_hotkey = "Ctrl+Alt+P"
- `auth_token` is generated on first run if config does not exist. - `auth_token` is generated on first run if config does not exist.
- `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands. - `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands.
- On Windows, `recovery_hotkey` provides click-through recovery even when the window is non-interactive. - On Windows, `recovery_hotkey` provides click-through recovery even when the window is non-interactive.
- `frontend.backend` selects runtime frontend implementation (`bevy` or `tauri`).

View File

@@ -17,6 +17,9 @@ Date: 2026-02-12
| Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) | | Embedded default pack | Implemented | Bundled under `assets/sprite-packs/default/` using `sprite.png` (8x7, 512x512 frames) |
| Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 | | Build/package automation | Implemented (Windows) | `justfile` and `scripts/package_windows.py` generate portable ZIP + SHA256 |
| QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` | | QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` |
| Shared runtime core | In progress | `sprimo-runtime-core` extracted with shared config/snapshot/API startup and command application |
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell, parity work remains |
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
## Next Major Gaps ## Next Major Gaps
@@ -24,3 +27,5 @@ Date: 2026-02-12
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter. 2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error. 3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure. 4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
5. `sprimo-app` is not yet refactored to consume `sprimo-runtime-core` directly.
6. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.

View File

@@ -83,3 +83,14 @@ An issue is done only when:
- evidence links resolve to files in repository - evidence links resolve to files in repository
- `just qa-validate` passes - `just qa-validate` passes
## Tauri Runtime Addendum
For `sprimo-tauri` runtime behavior issues, follow `docs/TAURI_RUNTIME_TESTING.md`.
Additional strict requirements:
- include `current_state` and `load_active_sprite_pack` invoke validation notes
- include `runtime:snapshot` event verification notes
- include tauri runtime API verification (`/v1/health`, `/v1/state`, `/v1/command`, `/v1/commands`)
- do not move issue status to `Closed` until all strict-gate evidence in
`docs/TAURI_RUNTIME_TESTING.md` is present

View File

@@ -70,3 +70,31 @@ Before release sign-off for a bug fix:
- `just qa-validate` - `just qa-validate`
Legacy issues may reference `issues/screenshots/issueN.png` as before evidence. Legacy issues may reference `issues/screenshots/issueN.png` as before evidence.
## Tauri Runtime Behavior Testing
Authoritative workflow: `docs/TAURI_RUNTIME_TESTING.md`.
### Workspace Mode (Required Now)
1. `just build-tauri-ui`
2. `just check-tauri`
3. `just check-runtime-core`
4. `just run-tauri` (smoke and runtime observation)
5. Verify invoke/event contract behavior:
- `current_state`
- `load_active_sprite_pack`
- `runtime:snapshot`
6. Verify API/runtime contract behavior against tauri process:
- `/v1/health`
- `/v1/state` with auth
- `/v1/command`
- `/v1/commands`
### Packaged Mode (Required Once Tauri Packaging Exists)
When tauri packaging automation is available, repeat runtime behavior checks on packaged artifacts:
1. Launch packaged tauri app.
2. Re-run invoke/event/API checks from workspace mode.
3. Attach before/after screenshots and command summaries in linked issue.

View File

@@ -0,0 +1,61 @@
# Tauri 2.0 Frontend Design (Bevy Alternative)
Date: 2026-02-12
## Goal
Add a Tauri 2.0 frontend path as an alternative to Bevy while keeping the existing Bevy
implementation and API behavior.
## New Components
- `crates/sprimo-runtime-core`
- shared runtime bootstrap for config/snapshot/API/channel setup
- shared command-to-snapshot/config application
- `crates/sprimo-tauri`
- Tauri 2.0 desktop shell
- command consumer loop bound to runtime core
- invoke command `current_state` for UI state
- `frontend/tauri-ui`
- React + Vite UI shell for status/control surface
## Selected Crates
Rust:
- `tauri`
- `tauri-build`
- `tauri-plugin-log`
- `tauri-plugin-global-shortcut`
- existing internal crates:
- `sprimo-runtime-core`
- `sprimo-platform`
- `sprimo-protocol`
Frontend:
- `react`
- `react-dom`
- `vite`
- `typescript`
- `@tauri-apps/api`
## Current State
- Tauri binary crate is scaffolded and starts runtime core + API server.
- Runtime core receives API commands and updates shared snapshot/config state.
- Tauri backend exposes:
- `current_state` command (structured snapshot DTO)
- `load_active_sprite_pack` command (manifest + atlas as base64 data URL)
- `runtime:snapshot` event after command application.
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
from runtime snapshot events.
- 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).

View File

@@ -0,0 +1,131 @@
# Tauri Runtime Behavior Testing Workflow
Date: 2026-02-13
## Purpose and Scope
This document defines strict testing and evidence requirements for `sprimo-tauri` runtime
behaviors. It complements `docs/QA_WORKFLOW.md` and applies to all Tauri runtime behavior issues.
## Prerequisites
- Windows environment for primary runtime validation.
- Workspace up to date.
- UI dependencies installed:
- `just install-tauri-ui`
## Execution Modes
### 1. Workspace Mode (Required Now)
Run and validate from the repository workspace:
```powershell
just build-tauri-ui
just check-tauri
just check-runtime-core
just run-tauri
```
### 2. Packaged Mode (Required Once Packaging Exists)
When `sprimo-tauri` packaging automation is implemented, repeat the runtime checklist against the
packaged artifact and attach equivalent evidence in the issue.
## Strict Verification Gate
An issue touching Tauri runtime behaviors must satisfy all requirements before `Closed`:
1. Command evidence recorded:
- `cargo check -p sprimo-tauri`
- `cargo check -p sprimo-runtime-core`
- `just build-tauri-ui`
- `just run-tauri` smoke result
- `just qa-validate`
2. Visual evidence recorded:
- before screenshot(s)
- after screenshot(s)
3. Runtime contract evidence recorded:
- `current_state` invoke command returns valid structured payload.
- `load_active_sprite_pack` invoke command returns manifest/atlas payload.
- `runtime:snapshot` event is observed after command application.
4. API behavior evidence recorded:
- `/v1/health` and `/v1/state` behavior validated against tauri runtime.
- `/v1/command` and `/v1/commands` validated with auth behavior.
5. Docs synchronized:
- issue lifecycle updated
- relevant docs updated when behavior or expectations changed
## Runtime Behavior Checklist
1. Launch tauri runtime via `just run-tauri`.
2. Verify sprite renders in the tauri window.
3. Verify animation advances over time.
4. Send `PlayAnimation` command and verify clip switch is reflected.
5. Send `SetTransform.scale` and verify rendered sprite scale changes.
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
## API + Runtime Contract Checklist
1. Validate health endpoint:
- `GET /v1/health` returns version/build/capabilities.
2. Validate authenticated state endpoint:
- `GET /v1/state` requires bearer token.
3. Validate command endpoint:
- `POST /v1/command` accepts valid command envelope.
4. Validate batch endpoint:
- `POST /v1/commands` applies commands in order.
5. Validate malformed request resilience:
- malformed JSON returns `400` without process crash.
6. Validate Tauri invoke/event behavior:
- `current_state` output parsed successfully.
- `load_active_sprite_pack` returns expected fields.
- `runtime:snapshot` event received on runtime command changes.
## Evidence Requirements
For each Tauri runtime issue, include:
- command output summaries for all strict gate commands
- screenshot references:
- before: `issues/screenshots/issueN-before-YYYYMMDD-HHMMSS.png`
- after: `issues/screenshots/issueN-after-YYYYMMDD-HHMMSS.png`
- invoke/event verification notes
- API verification notes
## Issue Lifecycle Integration
Use standard lifecycle in `issues/issueN.md`:
1. `Reported`
2. `Triaged`
3. `In Progress`
4. `Fix Implemented`
5. `Verification Passed`
6. `Closed`
Tauri runtime issues must remain at `Fix Implemented` if any strict-gate evidence is missing.
## Failure Classification and Triage
- `P0`: crash on startup, renderer not visible, auth bypass, or command pipeline broken.
- `P1`: animation/state mismatch, event/invoke contract failure, major UX regression.
- `P2`: non-blocking rendering/perf issues, minor UI mismatch, cosmetic defects.
## Test Log Template (Tauri Runtime)
- Date:
- Issue:
- Frontend: `sprimo-tauri`
- Execution mode: `workspace` or `packaged`
- Commands run:
- API checks summary:
- Invoke/event checks summary:
- Before screenshots:
- After screenshots:
- Result:
- Notes:

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sprimo-tauri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2178
frontend/tauri-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "sprimo-tauri-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"preview": "npx vite preview"
},
"dependencies": {
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@tauri-apps/api": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}

View File

@@ -0,0 +1,93 @@
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 { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
import "./styles.css";
const UI_BUILD_MARKER = "issue2-fix3";
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);
React.useEffect(() => {
let unlisten: null | (() => void) = null;
let mounted = true;
Promise.all([
invoke<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state")
])
.then(async ([pack, initialSnapshot]) => {
if (!mounted) {
return;
}
setSnapshot(initialSnapshot);
if (hostRef.current !== null) {
rendererRef.current = await PixiPetRenderer.create(
hostRef.current,
pack,
initialSnapshot
);
}
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const value = event.payload;
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
});
})
.catch((err) => {
if (mounted) {
setError(String(err));
}
});
return () => {
mounted = false;
if (unlisten !== null) {
unlisten();
}
rendererRef.current?.dispose();
rendererRef.current = null;
};
}, []);
return (
<main className="app">
<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>
) : (
<dl>
<dt>state</dt>
<dd>{snapshot.state}</dd>
<dt>animation</dt>
<dd>{snapshot.current_animation}</dd>
<dt>pack</dt>
<dd>{snapshot.active_sprite_pack}</dd>
<dt>position</dt>
<dd>
{snapshot.x}, {snapshot.y}
</dd>
<dt>scale</dt>
<dd>{snapshot.scale}</dd>
</dl>
)}
</section>
</main>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,197 @@
import { Application } from "@pixi/app";
import { BaseTexture, Rectangle, Texture } from "@pixi/core";
import { Sprite } from "@pixi/sprite";
export type UiAnimationClip = {
name: string;
fps: number;
frames: number[];
one_shot: boolean;
};
export type UiSpritePack = {
id: string;
frame_width: number;
frame_height: number;
atlas_data_url: string;
animations: UiAnimationClip[];
anchor: {
x: number;
y: number;
};
};
export type UiSnapshot = {
state: string;
current_animation: string;
x: number;
y: number;
scale: number;
active_sprite_pack: string;
};
type AnimationMap = Map<string, UiAnimationClip>;
export class PixiPetRenderer {
private app: Application;
private sprite: Sprite;
private pack: UiSpritePack;
private animationMap: AnimationMap;
private currentClip: UiAnimationClip;
private frameCursor = 0;
private frameElapsedMs = 0;
private baseTexture: BaseTexture;
private constructor(
app: Application,
sprite: Sprite,
pack: UiSpritePack,
baseTexture: BaseTexture
) {
this.app = app;
this.sprite = sprite;
this.pack = pack;
this.baseTexture = baseTexture;
this.animationMap = new Map(pack.animations.map((clip) => [clip.name, clip]));
this.currentClip = this.resolveClip("idle");
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
}
static async create(
container: HTMLElement,
pack: UiSpritePack,
snapshot: UiSnapshot
): Promise<PixiPetRenderer> {
const app = new Application({
backgroundAlpha: 0,
antialias: true,
resizeTo: container
});
container.replaceChildren(app.view as HTMLCanvasElement);
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
throw new Error("Atlas image loaded with invalid dimensions.");
}
const sprite = new Sprite();
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
sprite.position.set(app.renderer.width / 2, app.renderer.height);
app.stage.addChild(sprite);
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
renderer.applySnapshot(snapshot);
renderer.startTicker();
return renderer;
}
private static loadBaseTexture(dataUrl: string): Promise<BaseTexture> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
if (ctx === null) {
reject(new Error("Failed to create canvas context for chroma-key conversion."));
return;
}
ctx.drawImage(image, 0, 0);
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = frame.data;
const keyR = 0xff;
const keyG = 0x00;
const keyB = 0xff;
const tolerance = 28;
for (let i = 0; i < data.length; i += 4) {
const dr = Math.abs(data[i] - keyR);
const dg = Math.abs(data[i + 1] - keyG);
const db = Math.abs(data[i + 2] - keyB);
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
data[i + 3] = 0;
}
}
ctx.putImageData(frame, 0, 0);
resolve(BaseTexture.from(canvas));
};
image.onerror = () => {
reject(new Error("Failed to load atlas image data URL."));
};
image.src = dataUrl;
});
}
dispose(): void {
this.app.ticker.stop();
this.app.ticker.destroy();
this.sprite.destroy({
children: true,
texture: false,
baseTexture: false
});
this.app.destroy(true, {
children: true,
texture: false,
baseTexture: false
});
}
applySnapshot(snapshot: UiSnapshot): void {
const nextClip = this.resolveClip(snapshot.current_animation);
if (nextClip.name !== this.currentClip.name) {
this.currentClip = nextClip;
this.frameCursor = 0;
this.frameElapsedMs = 0;
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
}
this.sprite.scale.set(snapshot.scale);
}
private startTicker(): void {
this.app.ticker.add((ticker) => {
const frameMs = 1000 / Math.max(this.currentClip.fps, 1);
this.frameElapsedMs += ticker.deltaMS;
if (this.frameElapsedMs < frameMs) {
return;
}
this.frameElapsedMs -= frameMs;
const frames = this.currentClip.frames;
if (frames.length === 0) {
return;
}
if (this.frameCursor >= frames.length) {
this.frameCursor = this.currentClip.one_shot ? frames.length - 1 : 0;
}
const frame = frames[this.frameCursor] ?? 0;
this.applyFrameTexture(frame);
this.frameCursor += 1;
});
}
private resolveClip(name: string): UiAnimationClip {
return (
this.animationMap.get(name) ??
this.animationMap.get("idle") ??
this.pack.animations[0] ?? {
name: "idle",
fps: 1,
frames: [0],
one_shot: false
}
);
}
private applyFrameTexture(frameIndex: number): void {
const atlasWidth = this.baseTexture.width;
const atlasHeight = this.baseTexture.height;
const columns = Math.max(Math.floor(atlasWidth / this.pack.frame_width), 1);
const rows = Math.max(Math.floor(atlasHeight / this.pack.frame_height), 1);
const totalFrames = Math.max(columns * rows, 1);
const safeIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1));
const x = (safeIndex % columns) * this.pack.frame_width;
const y = Math.floor(safeIndex / columns) * this.pack.frame_height;
const rect = new Rectangle(x, y, this.pack.frame_width, this.pack.frame_height);
this.sprite.texture = new Texture(this.baseTexture, rect);
}
}

View File

@@ -0,0 +1,54 @@
:root {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
body {
margin: 0;
background: transparent;
color: #e2e8f0;
overflow: hidden;
}
.app {
width: 100vw;
height: 100vh;
position: relative;
}
.canvas-host {
position: absolute;
inset: 0;
}
.debug-panel {
position: absolute;
top: 8px;
left: 8px;
min-width: 220px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(4px);
border-radius: 8px;
}
dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 12px;
}
dt {
font-weight: 700;
text-transform: uppercase;
font-size: 12px;
opacity: 0.8;
}
dd {
margin: 0;
}
.error {
color: #fecaca;
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": false,
"strict": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 1420,
strictPort: true
}
});

152
issues/issue2.md Normal file
View File

@@ -0,0 +1,152 @@
## Title
`sprimo-tauri` runtime shows `TypeError: W.fromURL is not a function`; sprite renderer fails to initialize.
## Severity
P1
## Environment
- OS: Windows
- App: `sprimo-tauri` frontend/runtime path
- Reported on: 2026-02-13
- Evidence screenshot: `issues/screenshots/issue2.png`
## Summary
At runtime, the Tauri UI loads but Pixi sprite rendering fails with:
- `TypeError: W.fromURL is not a function`
This breaks sprite presentation and leaves the UI in an error state.
## Reproduction Steps
1. Build UI assets and start the Tauri app (`just build-tauri-ui`, `just run-tauri`).
2. Open the Tauri window and wait for sprite pack initialization.
3. Observe the debug panel error and missing pet rendering.
## Expected Result
- Pixi atlas texture loads successfully from `atlas_data_url`.
- Pet sprite renders and animates.
- No renderer initialization error in UI.
## Actual Result
- Renderer initialization fails with `TypeError: W.fromURL is not a function`.
- Sprite is not rendered.
## Root Cause Analysis
- `frontend/tauri-ui/src/renderer/pixi_pet.ts` used `BaseTexture.fromURL(...)`.
- In the current Pixi package/runtime composition, that API path is unavailable at runtime
(minified symbol resolves to `W.fromURL`, which is undefined).
- Result: atlas load throws before a valid sprite texture can be applied.
- Follow-up finding: Tauri can run previously embedded frontend assets if Rust build is not
re-triggered after UI-only changes, which can make old errors appear even after source fixes.
- After stale-build issue was resolved (`ui build: issue2-fix3` visible), a second runtime defect
became clear:
- `event.listen not allowed` due missing Tauri capability permissions for event listen/unlisten.
- sprite pack still rendered with magenta matte because tauri path lacked chroma-key conversion.
## Fix Plan
1. Replace `BaseTexture.fromURL` usage with Pixi assets loader (`@pixi/assets` + `Assets.load`).
2. Ensure sprite texture is assigned immediately after renderer creation (first frame visible).
3. Harden React lifecycle cleanup to avoid stale listeners/renderer leaks.
4. Re-run tauri/runtime QA checks and keep issue at `Fix Implemented` until strict gate evidence is complete.
## Implementation Notes
Implemented:
1. `frontend/tauri-ui/package.json`
- Added `@pixi/assets` dependency.
2. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Switched atlas loading to `Assets.load<Texture>(pack.atlas_data_url)`.
- Reused `texture.baseTexture` for frame slicing.
- Applied initial frame texture in constructor so sprite appears immediately.
- Added explicit renderer/sprite/ticker disposal path.
3. `frontend/tauri-ui/src/main.tsx`
- Added mount guards to prevent state updates after unmount.
- Added deterministic cleanup (`unlisten` + renderer `dispose()`).
4. `crates/sprimo-tauri/build.rs`
- Added `cargo:rerun-if-changed` directives for tauri config and frontend UI paths so
frontend/dist updates re-trigger asset embedding in `cargo run -p sprimo-tauri`.
5. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Replaced `Assets.load` path with direct `Image` + `BaseTexture.from(image)` loading to avoid
any runtime `*.fromURL` dependency in atlas initialization.
6. `frontend/tauri-ui/src/main.tsx`
- Added visible UI build marker (`issue2-fix3`) to detect stale embedded frontend artifacts.
7. `crates/sprimo-tauri/capabilities/default.json`
- Added default capability with:
- `core:default`
- `core:event:allow-listen`
- `core:event:allow-unlisten`
8. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Added tauri-side chroma-key conversion for atlas data URL:
- draw atlas to canvas
- convert near-`#FF00FF` pixels to alpha 0
- create Pixi base texture from converted canvas
## Verification
### Commands Run
- [x] `cargo check -p sprimo-tauri`
- [x] `cargo check -p sprimo-runtime-core`
- [x] `just build-tauri-ui`
- [x] `just run-tauri` (smoke attempt; command is long-running and timed out under automation)
- [x] `just qa-validate`
### Visual Checklist
- [x] Before screenshot(s): `issues/screenshots/issue2.png`
- [x] After screenshot(s): `issues/screenshots/issue2-after-fix3-2026-02-13-094131.png`
### Runtime Contract Checklist
- [ ] `current_state` invoke returns structured payload
- [ ] `load_active_sprite_pack` invoke returns manifest/atlas payload
- [ ] `runtime:snapshot` event observed after runtime command changes
### API Checklist
- [ ] `GET /v1/health`
- [ ] `GET /v1/state` auth behavior
- [ ] `POST /v1/command`
- [ ] `POST /v1/commands`
### Result
- Current Status: `Fix Implemented`
- Notes: build/check/qa validation passed; manual runtime visual verification still required.
## Status History
- `2026-02-13 13:20` - reporter - `Reported` - runtime screenshot captured with `TypeError: W.fromURL is not a function`.
- `2026-02-13 13:35` - codex - `Triaged` - localized failure to Pixi atlas loader path.
- `2026-02-13 13:55` - codex - `In Progress` - replaced loader API and hardened renderer lifecycle.
- `2026-02-13 14:05` - codex - `Fix Implemented` - patch completed, verification checklist queued.
- `2026-02-13 14:20` - codex - `Fix Implemented` - checks passed (`cargo check`, UI build, QA validation); smoke launch attempted.
- `2026-02-13 14:35` - codex - `Fix Implemented` - added build-script change tracking for frontend assets to prevent stale embedded UI.
- `2026-02-13 14:55` - codex - `In Progress` - removed all runtime `fromURL` usage from renderer atlas loading path.
- `2026-02-13 15:05` - codex - `In Progress` - added explicit UI build marker to detect stale executable/frontend embedding.
- `2026-02-13 15:20` - reporter - `In Progress` - provided `issue2-after-fix3` screenshot; stale-build issue resolved, permission + chroma-key defects observed.
- `2026-02-13 15:35` - codex - `Fix Implemented` - added tauri capability permission file and tauri-side chroma-key conversion.
## Closure
- Current Status: `Fix Implemented`
- Close Date:
- Owner:
- Linked PR/commit:

View File

@@ -1,6 +1,7 @@
set shell := ["powershell.exe", "-NoLogo", "-Command"] set shell := ["powershell.exe", "-NoLogo", "-Command"]
python := "python" python := "python"
npm := "npm"
check: check:
cargo check --workspace cargo check --workspace
@@ -19,3 +20,22 @@ smoke-win:
qa-validate: qa-validate:
{{python}} scripts/qa_validate.py {{python}} scripts/qa_validate.py
check-runtime-core:
cargo check -p sprimo-runtime-core
check-tauri:
cargo check -p sprimo-tauri
install-tauri-ui:
Push-Location frontend/tauri-ui; {{npm}} install; Pop-Location
build-tauri-ui:
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
dev-tauri-ui:
Push-Location frontend/tauri-ui; {{npm}} run dev; Pop-Location
run-tauri:
Push-Location frontend/tauri-ui; {{npm}} run build; Pop-Location
cargo run -p sprimo-tauri