Compare commits
2 Commits
61825f647d
...
3c3ca342c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3ca342c9 | ||
|
|
b0f462f63e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/dist
|
/dist
|
||||||
/issues/screenshots
|
/issues/screenshots
|
||||||
|
codex.txt
|
||||||
|
/frontend/tauri-ui/node_modules
|
||||||
|
/frontend/tauri-ui/dist
|
||||||
|
|||||||
3625
Cargo.lock
generated
3625
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ members = [
|
|||||||
"crates/sprimo-config",
|
"crates/sprimo-config",
|
||||||
"crates/sprimo-platform",
|
"crates/sprimo-platform",
|
||||||
"crates/sprimo-protocol",
|
"crates/sprimo-protocol",
|
||||||
|
"crates/sprimo-runtime-core",
|
||||||
"crates/sprimo-sprite",
|
"crates/sprimo-sprite",
|
||||||
|
"crates/sprimo-tauri",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
crates/sprimo-runtime-core/Cargo.toml
Normal file
19
crates/sprimo-runtime-core/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "sprimo-runtime-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sprimo-api = { path = "../sprimo-api" }
|
||||||
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.12.0"
|
||||||
262
crates/sprimo-runtime-core/src/lib.rs
Normal file
262
crates/sprimo-runtime-core/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/sprimo-tauri/Cargo.toml
Normal file
29
crates/sprimo-tauri/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "sprimo-tauri"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22.1"
|
||||||
|
serde.workspace = true
|
||||||
|
sprimo-config = { path = "../sprimo-config" }
|
||||||
|
sprimo-platform = { path = "../sprimo-platform" }
|
||||||
|
sprimo-sprite = { path = "../sprimo-sprite" }
|
||||||
|
sprimo-runtime-core = { path = "../sprimo-runtime-core" }
|
||||||
|
sprimo-protocol = { path = "../sprimo-protocol" }
|
||||||
|
tauri = { version = "2.0.0", features = [] }
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0"
|
||||||
|
tauri-plugin-log = "2.0.0"
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
8
crates/sprimo-tauri/build.rs
Normal file
8
crates/sprimo-tauri/build.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/src");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/index.html");
|
||||||
|
println!("cargo:rerun-if-changed=../../frontend/tauri-ui/dist");
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
crates/sprimo-tauri/capabilities/default.json
Normal file
11
crates/sprimo-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
1
crates/sprimo-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
1
crates/sprimo-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:event:allow-listen","core:event:allow-unlisten"]}}
|
||||||
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
2328
crates/sprimo-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
BIN
crates/sprimo-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
221
crates/sprimo-tauri/src/main.rs
Normal file
221
crates/sprimo-tauri/src/main.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/sprimo-tauri/tauri.conf.json
Normal file
30
crates/sprimo-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "sprimo-tauri",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.sprimo.tauri",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../../frontend/tauri-ui/dist",
|
||||||
|
"beforeDevCommand": "",
|
||||||
|
"beforeBuildCommand": ""
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "sprimo-tauri",
|
||||||
|
"width": 640,
|
||||||
|
"height": 640,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
|
"alwaysOnTop": true,
|
||||||
|
"resizable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<_>>`.
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
61
docs/TAURI_FRONTEND_DESIGN.md
Normal file
61
docs/TAURI_FRONTEND_DESIGN.md
Normal 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).
|
||||||
131
docs/TAURI_RUNTIME_TESTING.md
Normal file
131
docs/TAURI_RUNTIME_TESTING.md
Normal 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:
|
||||||
12
frontend/tauri-ui/index.html
Normal file
12
frontend/tauri-ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>sprimo-tauri</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
2178
frontend/tauri-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/tauri-ui/package.json
Normal file
26
frontend/tauri-ui/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sprimo-tauri-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx vite",
|
||||||
|
"build": "npx vite build",
|
||||||
|
"preview": "npx vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pixi/app": "^7.4.3",
|
||||||
|
"@pixi/core": "^7.4.3",
|
||||||
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
93
frontend/tauri-ui/src/main.tsx
Normal file
93
frontend/tauri-ui/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
197
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal file
197
frontend/tauri-ui/src/renderer/pixi_pet.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/tauri-ui/src/styles.css
Normal file
54
frontend/tauri-ui/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
17
frontend/tauri-ui/tsconfig.json
Normal file
17
frontend/tauri-ui/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowJs": false,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
frontend/tauri-ui/vite.config.ts
Normal file
10
frontend/tauri-ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
});
|
||||||
152
issues/issue2.md
Normal file
152
issues/issue2.md
Normal 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:
|
||||||
20
justfile
20
justfile
@@ -1,6 +1,7 @@
|
|||||||
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
set shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
|
|
||||||
python := "python"
|
python := "python"
|
||||||
|
npm := "npm"
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user