Add: tauri frontend as bevy alternative

This commit is contained in:
DaZuo0122
2026-02-13 09:57:08 +08:00
parent b0f462f63e
commit 3c3ca342c9
33 changed files with 11798 additions and 106 deletions

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

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

View File

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