Add: tauri frontend as bevy alternative
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
||||
/dist
|
||||
/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-platform",
|
||||
"crates/sprimo-protocol",
|
||||
"crates/sprimo-runtime-core",
|
||||
"crates/sprimo-sprite",
|
||||
"crates/sprimo-tauri",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct AppConfig {
|
||||
pub api: ApiConfig,
|
||||
pub logging: LoggingConfig,
|
||||
pub controls: ControlsConfig,
|
||||
pub frontend: FrontendConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -37,6 +38,7 @@ impl Default for AppConfig {
|
||||
api: ApiConfig::default(),
|
||||
logging: LoggingConfig::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]
|
||||
pub fn config_path(app_name: &str) -> Result<PathBuf, ConfigError> {
|
||||
let dirs =
|
||||
@@ -198,9 +221,11 @@ mod tests {
|
||||
let path = temp.path().join("config.toml");
|
||||
let mut config = AppConfig::default();
|
||||
config.window.x = 42.0;
|
||||
config.frontend.backend = super::FrontendBackend::Tauri;
|
||||
|
||||
save(&path, &config).expect("save");
|
||||
let (_, loaded) = load_or_create_at(&path).expect("reload");
|
||||
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
|
||||
|
||||
- `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-config`: config schema, path resolution, persistence.
|
||||
- `crates/sprimo-platform`: platform abstraction for overlay operations.
|
||||
- `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.
|
||||
|
||||
## Runtime Data Flow
|
||||
|
||||
1. `sprimo-app` loads or creates `config.toml`.
|
||||
2. App builds initial `FrontendStateSnapshot`.
|
||||
3. App starts `sprimo-api` on a Tokio runtime.
|
||||
1. Frontend (`sprimo-app` or `sprimo-tauri`) initializes `sprimo-runtime-core`.
|
||||
2. Runtime core loads/creates `config.toml` and builds initial `FrontendStateSnapshot`.
|
||||
3. Runtime core starts `sprimo-api` on a Tokio runtime.
|
||||
4. API authenticates commands and deduplicates IDs.
|
||||
5. Commands are bridged from Tokio channel to Bevy main-thread systems.
|
||||
6. Bevy systems apply commands to sprite state, window/platform operations, and config persistence.
|
||||
5. Commands are bridged from API channel into frontend-specific command handlers.
|
||||
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`.
|
||||
|
||||
## Sprite Reload Semantics
|
||||
@@ -32,6 +34,7 @@
|
||||
- API task: axum server.
|
||||
- Bridge task: forwards API commands into Bevy ingest channel.
|
||||
- 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.
|
||||
- Snapshot is shared via `Arc<RwLock<_>>`.
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ level = "info"
|
||||
[controls]
|
||||
hotkey_enabled = true
|
||||
recovery_hotkey = "Ctrl+Alt+P"
|
||||
|
||||
[frontend]
|
||||
backend = "bevy"
|
||||
```
|
||||
|
||||
## Notes
|
||||
@@ -43,3 +46,4 @@ recovery_hotkey = "Ctrl+Alt+P"
|
||||
- `auth_token` is generated on first run if config does not exist.
|
||||
- `window.x`, `window.y`, `window.scale`, and flag fields are persisted after matching commands.
|
||||
- On Windows, `recovery_hotkey` provides click-through recovery even when the window is non-interactive.
|
||||
- `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) |
|
||||
| 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` |
|
||||
| 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
|
||||
|
||||
@@ -24,3 +27,5 @@ Date: 2026-02-12
|
||||
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
|
||||
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
|
||||
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.
|
||||
5. `sprimo-app` is not yet refactored to consume `sprimo-runtime-core` directly.
|
||||
6. `sprimo-tauri` still lacks tray/menu/window-behavior parity and full acceptance parity tests.
|
||||
|
||||
@@ -83,3 +83,14 @@ An issue is done only when:
|
||||
- evidence links resolve to files in repository
|
||||
- `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`
|
||||
|
||||
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"]
|
||||
|
||||
python := "python"
|
||||
npm := "npm"
|
||||
|
||||
check:
|
||||
cargo check --workspace
|
||||
@@ -19,3 +20,22 @@ smoke-win:
|
||||
|
||||
qa-validate:
|
||||
{{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