Update source files
7
crates/borders-desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
31
crates/borders-desktop/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "borders-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tracy = ["dep:tracing-tracy", "dep:tracy-client"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
bevy_ecs = { version = "0.17", default-features = false, features = ["std"] }
|
||||
borders-core = { path = "../borders-core" }
|
||||
chrono = "0.4"
|
||||
flume = "0.11"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-tracy = { version = "0.11", default-features = false, optional = true }
|
||||
tracy-client = { version = "0.18.2", optional = true }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["tauri-build"]
|
||||
3
crates/borders-desktop/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
crates/borders-desktop/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://github.com/Xevion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
crates/borders-desktop/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
crates/borders-desktop/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
crates/borders-desktop/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
crates/borders-desktop/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
crates/borders-desktop/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
crates/borders-desktop/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
crates/borders-desktop/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
crates/borders-desktop/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
crates/borders-desktop/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
crates/borders-desktop/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
crates/borders-desktop/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
crates/borders-desktop/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
crates/borders-desktop/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
crates/borders-desktop/icons/icon.icns
Normal file
BIN
crates/borders-desktop/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
crates/borders-desktop/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
51
crates/borders-desktop/src/analytics.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use borders_core::telemetry::{self, TelemetryEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::Manager;
|
||||
|
||||
/// Analytics event from the frontend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalyticsEventPayload {
|
||||
pub event: String,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Tauri command to track analytics events from the frontend
|
||||
#[tauri::command]
|
||||
pub async fn track_analytics_event(payload: AnalyticsEventPayload) -> Result<(), String> {
|
||||
tracing::debug!("Tracking analytics event: {}", payload.event);
|
||||
|
||||
let event = TelemetryEvent { event: payload.event, properties: payload.properties };
|
||||
|
||||
// Track the event asynchronously (Tauri handles the async context)
|
||||
telemetry::track(event).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command to flush pending analytics events
|
||||
#[tauri::command]
|
||||
pub async fn flush_analytics() -> Result<(), String> {
|
||||
if let Some(client) = telemetry::client() {
|
||||
client.flush().await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Telemetry client not initialized".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to request app exit
|
||||
///
|
||||
/// Simply closes the window - analytics flush happens in ExitRequested event handler
|
||||
#[tauri::command]
|
||||
pub async fn request_exit(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
tracing::debug!("Exit requested via command");
|
||||
|
||||
// Close the window (will trigger ExitRequested event → analytics flush)
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
204
crates/borders-desktop/src/main.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, RunEvent};
|
||||
|
||||
use crate::plugin::{create_game, generate_tauri_context, setup_tauri_resources};
|
||||
use borders_core::time::Time;
|
||||
|
||||
mod analytics;
|
||||
mod plugin;
|
||||
mod transport;
|
||||
|
||||
const TARGET_FPS: f64 = 60.0;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let _guard = tracing::trace_span!("tauri_build").entered();
|
||||
let tauri_app = tauri::Builder::default().plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_process::init()).invoke_handler(tauri::generate_handler![transport::register_binary_channel, transport::send_frontend_message, transport::handle_render_input, transport::get_game_state, analytics::track_analytics_event, analytics::flush_analytics, analytics::request_exit,]).build(generate_tauri_context()).expect("error while building tauri application");
|
||||
|
||||
// Phase 1: Setup Tauri resources WITHOUT creating Game/World
|
||||
// This registers the message queue so frontend can send messages
|
||||
let tauri_resources = setup_tauri_resources(&tauri_app);
|
||||
tracing::debug!("Tauri resources ready, waiting for StartGame...");
|
||||
|
||||
// Game doesn't exist yet - will be created when StartGame arrives
|
||||
let mut game: Option<borders_core::game::Game> = None;
|
||||
let mut tauri_app = tauri_app;
|
||||
|
||||
// Main loop (60 FPS)
|
||||
let target_frame_duration = Duration::from_secs_f64(1.0 / TARGET_FPS);
|
||||
|
||||
loop {
|
||||
let _guard = tracing::trace_span!("main_frame").entered();
|
||||
|
||||
// Process Tauri events
|
||||
#[allow(deprecated)]
|
||||
tauri_app.run_iteration(move |_, event: RunEvent| {
|
||||
match event {
|
||||
RunEvent::Ready => {
|
||||
// Event acknowledged, actual setup happens before loop
|
||||
}
|
||||
RunEvent::ExitRequested { .. } => {
|
||||
// Track session end and flush analytics before exit
|
||||
if borders_core::telemetry::client().is_some() {
|
||||
tracing::debug!("ExitRequested: tracking session end and flushing analytics");
|
||||
|
||||
// Create a minimal runtime for blocking operations
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().enable_io().build().expect("Failed to create tokio runtime for flush");
|
||||
|
||||
runtime.block_on(async {
|
||||
// Track session end event
|
||||
borders_core::telemetry::track_session_end().await;
|
||||
|
||||
// Flush all pending events
|
||||
if let Some(client) = borders_core::telemetry::client() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
match tokio::time::timeout(timeout, client.flush()).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!("Analytics flushed successfully before exit")
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Analytics flush timed out after 500ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
});
|
||||
|
||||
// Exit if all windows are closed
|
||||
if tauri_app.webview_windows().is_empty() {
|
||||
tauri_app.cleanup_before_exit();
|
||||
break;
|
||||
}
|
||||
|
||||
// Phase 2: Create Game when StartGame arrives
|
||||
if game.is_none()
|
||||
&& let Ok(mut messages) = tauri_resources.transport.inbound_messages().lock()
|
||||
&& let Some(idx) = messages.iter().position(|msg| matches!(msg, borders_core::ui::protocol::FrontendMessage::StartGame))
|
||||
{
|
||||
tracing::info!("StartGame received, creating Game/World...");
|
||||
// Remove StartGame from queue so it's not processed again
|
||||
messages.remove(idx);
|
||||
drop(messages); // Release lock before creating Game
|
||||
|
||||
// Create the full Game with all plugins and resources
|
||||
game = Some(create_game(&tauri_resources));
|
||||
tracing::info!("Game/World created, game is ready");
|
||||
}
|
||||
|
||||
// Check for QuitGame and drop the Game
|
||||
if game.is_some()
|
||||
&& let Ok(mut messages) = tauri_resources.transport.inbound_messages().lock()
|
||||
&& let Some(idx) = messages.iter().position(|msg| matches!(msg, borders_core::ui::protocol::FrontendMessage::QuitGame))
|
||||
{
|
||||
tracing::info!("QuitGame received, dropping Game/World...");
|
||||
messages.remove(idx);
|
||||
drop(messages);
|
||||
|
||||
// Drop the entire Game/World (no cleanup needed)
|
||||
game = None;
|
||||
tracing::info!("Game/World dropped, ready for next game");
|
||||
}
|
||||
|
||||
// Run game systems only if Game exists
|
||||
if let Some(ref mut game_instance) = game {
|
||||
// Tick time to measure frame delta
|
||||
let frame_start = {
|
||||
if let Some(mut time) = game_instance.world_mut().get_resource_mut::<Time>() {
|
||||
time.tick();
|
||||
}
|
||||
std::time::Instant::now() // For frame rate limiting
|
||||
};
|
||||
|
||||
// Run game systems
|
||||
game_instance.update();
|
||||
|
||||
// Frame rate limiting
|
||||
let frame_duration = frame_start.elapsed();
|
||||
if frame_duration < target_frame_duration {
|
||||
std::thread::sleep(target_frame_duration - frame_duration);
|
||||
}
|
||||
} else {
|
||||
// No game yet - just sleep to avoid busy waiting
|
||||
std::thread::sleep(target_frame_duration);
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Initialize tracing before Bevy
|
||||
#[cfg(feature = "tracy")]
|
||||
{
|
||||
use tracing_subscriber::fmt::format::DefaultFields;
|
||||
// Initialize Tracy profiler client
|
||||
let _ = tracy_client::Client::start();
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
struct BareTracyConfig {
|
||||
fmt: DefaultFields,
|
||||
}
|
||||
|
||||
impl tracing_tracy::Config for BareTracyConfig {
|
||||
type Formatter = DefaultFields;
|
||||
|
||||
fn formatter(&self) -> &Self::Formatter {
|
||||
&self.fmt
|
||||
}
|
||||
|
||||
fn format_fields_in_zone_name(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
let tracy_layer = tracing_tracy::TracyLayer::new(BareTracyConfig { fmt: DefaultFields::default() });
|
||||
|
||||
tracing::subscriber::set_global_default(tracing_subscriber::registry().with(tracy_layer)).expect("setup tracy layer");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tracy"))]
|
||||
{
|
||||
use tracing_subscriber::fmt::time::FormatTime;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let log_filter = "borders_core=debug,borders_protocol=debug,borders_desktop=debug,iron_borders=debug,info";
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let log_filter = "borders_core=warn,borders_protocol=warn,iron_borders=warn,error";
|
||||
|
||||
struct CustomTimeFormat;
|
||||
|
||||
impl FormatTime for CustomTimeFormat {
|
||||
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
|
||||
write!(w, "{}", chrono::Local::now().format("%H:%M:%S%.6f"))
|
||||
}
|
||||
}
|
||||
|
||||
tracing_subscriber::registry().with(tracing_subscriber::EnvFilter::new(log_filter)).with(tracing_subscriber::fmt::layer().with_timer(CustomTimeFormat)).init();
|
||||
}
|
||||
|
||||
// Log build information
|
||||
tracing::info!(git_commit = borders_core::build_info::git_commit_short(), build_time = borders_core::build_info::BUILD_TIME, "Iron Borders v{} © 2025 Ryan Walters. All Rights Reserved.", borders_core::build_info::VERSION);
|
||||
|
||||
// Initialize telemetry in background (non-blocking)
|
||||
std::thread::spawn(|| {
|
||||
let _guard = tracing::trace_span!("telemetry_init").entered();
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
borders_core::telemetry::init(borders_core::telemetry::TelemetryConfig::default()).await;
|
||||
borders_core::telemetry::track_session_start().await;
|
||||
tracing::info!("Observability ready");
|
||||
});
|
||||
});
|
||||
|
||||
run();
|
||||
}
|
||||
90
crates/borders-desktop/src/plugin.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Tauri-Bevy integration setup
|
||||
//!
|
||||
//! This module provides the integration setup between Tauri and Bevy,
|
||||
//! configuring shared resources and systems.
|
||||
|
||||
use borders_core::game::NationId;
|
||||
use borders_core::game::input::InputQueue;
|
||||
use borders_core::ui::protocol::LeaderboardSnapshot;
|
||||
use borders_core::{
|
||||
game::{Game, GameBuilder, TerrainData, Update},
|
||||
networking::NetworkMode,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::Manager;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::transport::{TauriTransport, cache_leaderboard_snapshot_system};
|
||||
|
||||
pub fn generate_tauri_context() -> tauri::Context {
|
||||
tauri::generate_context!()
|
||||
}
|
||||
|
||||
/// Resources needed before Game creation
|
||||
#[allow(dead_code)]
|
||||
pub struct TauriResources {
|
||||
pub transport: TauriTransport,
|
||||
pub shared_leaderboard_state: Arc<Mutex<Option<LeaderboardSnapshot>>>,
|
||||
pub input_queue: Arc<InputQueue>,
|
||||
}
|
||||
|
||||
/// Phase 1: Setup Tauri resources and register message queue
|
||||
///
|
||||
/// This happens at startup, BEFORE the Game/World is created.
|
||||
/// It creates the message queue so frontend can send messages.
|
||||
pub fn setup_tauri_resources(tauri_app: &tauri::App) -> TauriResources {
|
||||
let _guard = tracing::debug_span!("setup_tauri_resources").entered();
|
||||
tracing::debug!("Setting up Tauri resources (no World yet)");
|
||||
|
||||
// Create shared state for game state recovery (leaderboard only)
|
||||
let shared_leaderboard_state = Arc::new(Mutex::new(None::<LeaderboardSnapshot>));
|
||||
|
||||
// Create transport for Tauri frontend (handles both render and UI communication)
|
||||
let transport = TauriTransport::new(tauri_app.handle().clone());
|
||||
|
||||
// Create input queue once - will be reused across game instances
|
||||
let input_queue = Arc::new(InputQueue::new());
|
||||
let input_sender = input_queue.sender();
|
||||
|
||||
// Register message queue, binary channel, and input sender with Tauri
|
||||
tauri_app.manage(transport.inbound_messages());
|
||||
tauri_app.manage(transport.binary_channel());
|
||||
tauri_app.manage(input_sender);
|
||||
tauri_app.manage(shared_leaderboard_state.clone());
|
||||
|
||||
tracing::debug!("Tauri resources registered, ready to receive messages");
|
||||
|
||||
TauriResources { transport, shared_leaderboard_state, input_queue }
|
||||
}
|
||||
|
||||
/// Phase 2: Create Game with all plugins and resources
|
||||
///
|
||||
/// This happens when StartGame arrives. Creates the full Game/World in one shot.
|
||||
pub fn create_game(tauri_resources: &TauriResources) -> Game {
|
||||
let _guard = tracing::debug_span!("create_game").entered();
|
||||
|
||||
// Load terrain FIRST, before creating the Game
|
||||
info!("Loading terrain data...");
|
||||
let terrain_data = match TerrainData::load_world_map() {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to load World map: {}", e);
|
||||
panic!("Cannot start game without terrain data");
|
||||
}
|
||||
};
|
||||
|
||||
let terrain_arc = Arc::new(terrain_data);
|
||||
|
||||
// Create the Game with all configuration via GameBuilder
|
||||
info!("Creating Game with GameBuilder...");
|
||||
let mut game = GameBuilder::new().with_map(terrain_arc).with_bots(500).with_local_player(NationId::ZERO).with_network(NetworkMode::Local).with_frontend(tauri_resources.transport.clone()).with_input_queue(tauri_resources.input_queue.clone()).build();
|
||||
|
||||
// Insert shared resources into ECS (Tauri-specific resources)
|
||||
game.insert_non_send_resource(tauri_resources.shared_leaderboard_state.clone());
|
||||
|
||||
// Add the leaderboard caching system (Tauri-specific)
|
||||
game.add_systems(Update, cache_leaderboard_snapshot_system);
|
||||
|
||||
info!("Game created and initialized successfully");
|
||||
game
|
||||
}
|
||||
143
crates/borders-desktop/src/transport.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Tauri-specific frontend transport and command handlers
|
||||
//!
|
||||
//! This module provides the Tauri implementation of FrontendTransport,
|
||||
//! along with Tauri command handlers for input events and state recovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::message::MessageReader;
|
||||
use bevy_ecs::system::NonSend;
|
||||
use borders_core::game::input::InputEvent;
|
||||
use borders_core::ui::FrontendTransport;
|
||||
use borders_core::ui::protocol::{BackendMessage, BinaryMessageType, FrontendMessage, LeaderboardSnapshot, encode_binary_message};
|
||||
use tauri::{AppHandle, Emitter, ipc::Channel};
|
||||
|
||||
/// Storage for the unified binary channel used for streaming initialization and delta data
|
||||
pub struct BinaryChannelStorage(pub Arc<Mutex<Option<Channel<Vec<u8>>>>>);
|
||||
|
||||
/// Tauri-specific frontend transport using Tauri channels for binary data
|
||||
#[derive(Clone)]
|
||||
pub struct TauriTransport {
|
||||
app_handle: AppHandle,
|
||||
/// Inbound messages from the frontend
|
||||
inbound_messages: Arc<Mutex<VecDeque<FrontendMessage>>>,
|
||||
/// Unified binary channel for streaming all binary data (init + deltas)
|
||||
binary_channel: Arc<Mutex<Option<Channel<Vec<u8>>>>>,
|
||||
}
|
||||
|
||||
impl TauriTransport {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle, inbound_messages: Arc::new(Mutex::new(VecDeque::new())), binary_channel: Arc::new(Mutex::new(None)) }
|
||||
}
|
||||
|
||||
/// Get a reference to the inbound messages queue (for Tauri command handler)
|
||||
pub fn inbound_messages(&self) -> Arc<Mutex<VecDeque<FrontendMessage>>> {
|
||||
self.inbound_messages.clone()
|
||||
}
|
||||
|
||||
/// Get the binary channel for registration
|
||||
pub fn binary_channel(&self) -> BinaryChannelStorage {
|
||||
BinaryChannelStorage(self.binary_channel.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontendTransport for TauriTransport {
|
||||
fn send_backend_message(&self, message: &BackendMessage) -> Result<(), String> {
|
||||
let _guard = tracing::trace_span!(
|
||||
"tauri_send_backend_message",
|
||||
message_type = match message {
|
||||
BackendMessage::LeaderboardSnapshot(_) => "LeaderboardSnapshot",
|
||||
BackendMessage::AttacksUpdate(_) => "AttacksUpdate",
|
||||
BackendMessage::ShipsUpdate(_) => "ShipsUpdate",
|
||||
BackendMessage::GameEnded { .. } => "GameEnded",
|
||||
BackendMessage::SpawnPhaseUpdate { .. } => "SpawnPhaseUpdate",
|
||||
BackendMessage::SpawnPhaseEnded => "SpawnPhaseEnded",
|
||||
BackendMessage::HighlightNation { .. } => "HighlightNation",
|
||||
}
|
||||
)
|
||||
.entered();
|
||||
|
||||
self.app_handle.emit("backend:message", message).map_err(|e| format!("Failed to emit backend message: {}", e))
|
||||
}
|
||||
|
||||
fn send_binary(&self, msg_type: BinaryMessageType, payload: Vec<u8>) -> Result<(), String> {
|
||||
let msg_type_str = match msg_type {
|
||||
BinaryMessageType::Init => "init",
|
||||
BinaryMessageType::Delta => "delta",
|
||||
};
|
||||
let _guard = tracing::trace_span!("tauri_send_binary", msg_type = msg_type_str, size = payload.len()).entered();
|
||||
|
||||
// Encode with type tag envelope
|
||||
let data = encode_binary_message(msg_type, payload);
|
||||
|
||||
let ch_lock = self.binary_channel.lock().map_err(|_| "Failed to lock binary channel")?;
|
||||
let channel = ch_lock.as_ref().ok_or("Binary channel not registered")?;
|
||||
|
||||
channel.send(data).map_err(|e| format!("Failed to send binary data via channel: {}", e))
|
||||
}
|
||||
|
||||
fn try_recv_frontend_message(&self) -> Option<FrontendMessage> {
|
||||
if let Ok(mut messages) = self.inbound_messages.lock() { messages.pop_front() } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to register the unified binary channel for streaming all binary data
|
||||
#[tauri::command]
|
||||
pub fn register_binary_channel(channel: Channel<Vec<u8>>, channel_storage: tauri::State<BinaryChannelStorage>) -> Result<(), String> {
|
||||
tracing::info!("Binary channel registered (handles init + deltas)");
|
||||
channel_storage
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| {
|
||||
tracing::error!("Failed to acquire lock on binary channel storage");
|
||||
"Failed to acquire lock on binary channel storage".to_string()
|
||||
})?
|
||||
.replace(channel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command handler for receiving frontend messages
|
||||
#[tauri::command]
|
||||
pub fn send_frontend_message(message: FrontendMessage, bridge: tauri::State<Arc<Mutex<VecDeque<FrontendMessage>>>>) -> Result<(), String> {
|
||||
tracing::info!("Frontend sent message: {:?}", message);
|
||||
if let Ok(mut messages) = bridge.lock() {
|
||||
messages.push_back(message);
|
||||
tracing::debug!("Message queued, queue size: {}", messages.len());
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::error!("Failed to acquire lock on message queue");
|
||||
Err("Failed to acquire lock on message queue".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle input events from the frontend
|
||||
#[tauri::command]
|
||||
pub fn handle_render_input(event: InputEvent, input_sender: tauri::State<flume::Sender<InputEvent>>) -> Result<(), String> {
|
||||
// Send to input queue
|
||||
input_sender.send(event).map_err(|e| format!("Failed to send input event: {}", e))
|
||||
}
|
||||
|
||||
/// Get current game state for frontend recovery after reload
|
||||
/// Note: Initialization data (terrain, territory, nation palette) is not recoverable after reload.
|
||||
/// The frontend must wait for a fresh game to start.
|
||||
#[tauri::command]
|
||||
pub fn get_game_state(leaderboard_state: tauri::State<Arc<Mutex<Option<LeaderboardSnapshot>>>>) -> Result<Option<LeaderboardSnapshot>, String> {
|
||||
leaderboard_state.lock().map(|state| state.clone()).map_err(|e| format!("Failed to lock leaderboard state: {}", e))
|
||||
}
|
||||
|
||||
/// System to cache leaderboard snapshots for state recovery
|
||||
pub fn cache_leaderboard_snapshot_system(mut events: MessageReader<BackendMessage>, shared_leaderboard_state: Option<NonSend<Arc<Mutex<Option<LeaderboardSnapshot>>>>>) {
|
||||
let Some(shared_state) = shared_leaderboard_state else {
|
||||
return;
|
||||
};
|
||||
|
||||
for event in events.read() {
|
||||
if let BackendMessage::LeaderboardSnapshot(snapshot) = event
|
||||
&& let Ok(mut state) = shared_state.lock()
|
||||
{
|
||||
*state = Some(snapshot.clone());
|
||||
tracing::trace!("Cached leaderboard snapshot for state recovery");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
crates/borders-desktop/tauri.conf.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "iron-borders",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.xevion.iron-borders",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build:desktop",
|
||||
"frontendDist": "../../frontend/dist/client"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Iron Borders",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"process": {
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["appimage", "deb", "rpm", "dmg"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||