initial working demo sans networking
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
27
crates/app/src/camera.rs
Normal file
27
crates/app/src/camera.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Camera configuration
|
||||
//!
|
||||
//! This module handles the 3D camera setup for the cube demo.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub struct CameraPlugin;
|
||||
|
||||
impl Plugin for CameraPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_camera);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up the 3D camera
|
||||
///
|
||||
/// Camera is positioned at (4, 3, 6) looking at the cube's initial position (0, 0.5, 0).
|
||||
/// This provides a good viewing angle to see the cube, ground plane, and any movements.
|
||||
fn setup_camera(mut commands: Commands) {
|
||||
info!("Setting up camera");
|
||||
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
Transform::from_xyz(4.0, 3.0, 6.0)
|
||||
.looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
|
||||
));
|
||||
}
|
||||
65
crates/app/src/cube.rs
Normal file
65
crates/app/src/cube.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Cube entity management
|
||||
|
||||
use bevy::prelude::*;
|
||||
use lib::{
|
||||
networking::{NetworkedEntity, NetworkedTransform, Synced},
|
||||
persistence::Persisted,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Marker component for the replicated cube
|
||||
#[derive(Component, Reflect, Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[reflect(Component)]
|
||||
pub struct CubeMarker;
|
||||
|
||||
pub struct CubePlugin;
|
||||
|
||||
impl Plugin for CubePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.register_type::<CubeMarker>()
|
||||
.add_systems(Startup, spawn_cube);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the synced cube on startup
|
||||
fn spawn_cube(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
node_clock: Option<Res<lib::networking::NodeVectorClock>>,
|
||||
) {
|
||||
// Wait until NodeVectorClock is available (after networking plugin initializes)
|
||||
let Some(clock) = node_clock else {
|
||||
warn!("NodeVectorClock not ready, deferring cube spawn");
|
||||
return;
|
||||
};
|
||||
|
||||
let entity_id = Uuid::new_v4();
|
||||
let node_id = clock.node_id;
|
||||
|
||||
info!("Spawning cube with network ID: {}", entity_id);
|
||||
|
||||
commands.spawn((
|
||||
CubeMarker,
|
||||
// Bevy 3D components
|
||||
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.8, 0.3, 0.6),
|
||||
perceptual_roughness: 0.7,
|
||||
metallic: 0.3,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_xyz(0.0, 0.5, 0.0),
|
||||
GlobalTransform::default(),
|
||||
// Networking
|
||||
NetworkedEntity::with_id(entity_id, node_id),
|
||||
NetworkedTransform,
|
||||
// Persistence
|
||||
Persisted::with_id(entity_id),
|
||||
// Sync marker
|
||||
Synced,
|
||||
));
|
||||
|
||||
info!("Cube spawned successfully");
|
||||
}
|
||||
89
crates/app/src/debug_ui.rs
Normal file
89
crates/app/src/debug_ui.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Debug UI overlay using egui
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
||||
use lib::networking::{GossipBridge, NodeVectorClock};
|
||||
|
||||
pub struct DebugUiPlugin;
|
||||
|
||||
impl Plugin for DebugUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(EguiPrimaryContextPass, render_debug_ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the debug UI panel
|
||||
fn render_debug_ui(
|
||||
mut contexts: EguiContexts,
|
||||
node_clock: Option<Res<NodeVectorClock>>,
|
||||
gossip_bridge: Option<Res<GossipBridge>>,
|
||||
cube_query: Query<(&Transform, &lib::networking::NetworkedEntity), With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
let Ok(ctx) = contexts.ctx_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
egui::Window::new("Debug Info")
|
||||
.default_pos([10.0, 10.0])
|
||||
.default_width(300.0)
|
||||
.resizable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Network Status");
|
||||
ui.separator();
|
||||
|
||||
// Node information
|
||||
if let Some(clock) = &node_clock {
|
||||
ui.label(format!("Node ID: {}", &clock.node_id.to_string()[..8]));
|
||||
// Show the current node's clock value (timestamp)
|
||||
let current_timestamp = clock.clock.clocks.get(&clock.node_id).copied().unwrap_or(0);
|
||||
ui.label(format!("Clock: {}", current_timestamp));
|
||||
ui.label(format!("Known nodes: {}", clock.clock.clocks.len()));
|
||||
} else {
|
||||
ui.label("Node: Not initialized");
|
||||
}
|
||||
|
||||
ui.add_space(5.0);
|
||||
|
||||
// Gossip bridge status
|
||||
if let Some(bridge) = &gossip_bridge {
|
||||
ui.label(format!("Bridge Node: {}", &bridge.node_id().to_string()[..8]));
|
||||
ui.label("Status: Connected");
|
||||
} else {
|
||||
ui.label("Gossip: Not ready");
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.heading("Cube State");
|
||||
ui.separator();
|
||||
|
||||
// Cube information
|
||||
match cube_query.iter().next() {
|
||||
Some((transform, networked)) => {
|
||||
let pos = transform.translation;
|
||||
ui.label(format!("Position: ({:.2}, {:.2}, {:.2})", pos.x, pos.y, pos.z));
|
||||
|
||||
let (axis, angle) = transform.rotation.to_axis_angle();
|
||||
let angle_deg: f32 = angle.to_degrees();
|
||||
ui.label(format!("Rotation: {:.2}° around ({:.2}, {:.2}, {:.2})",
|
||||
angle_deg, axis.x, axis.y, axis.z));
|
||||
|
||||
ui.label(format!("Scale: ({:.2}, {:.2}, {:.2})",
|
||||
transform.scale.x, transform.scale.y, transform.scale.z));
|
||||
|
||||
ui.add_space(5.0);
|
||||
ui.label(format!("Network ID: {}", &networked.network_id.to_string()[..8]));
|
||||
ui.label(format!("Owner: {}", &networked.owner_node_id.to_string()[..8]));
|
||||
}
|
||||
None => {
|
||||
ui.label("Cube: Not spawned yet");
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.heading("Controls");
|
||||
ui.separator();
|
||||
ui.label("Left drag: Move cube (XY)");
|
||||
ui.label("Right drag: Rotate cube");
|
||||
ui.label("Scroll: Move cube (Z)");
|
||||
});
|
||||
}
|
||||
79
crates/app/src/input/mouse.rs
Normal file
79
crates/app/src/input/mouse.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Mouse input handling for macOS
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
|
||||
pub struct MouseInputPlugin;
|
||||
|
||||
impl Plugin for MouseInputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, handle_mouse_input);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mouse interaction state
|
||||
#[derive(Resource, Default)]
|
||||
struct MouseState {
|
||||
/// Whether the left mouse button is currently pressed
|
||||
left_pressed: bool,
|
||||
/// Whether the right mouse button is currently pressed
|
||||
right_pressed: bool,
|
||||
}
|
||||
|
||||
/// Handle mouse input to move and rotate the cube
|
||||
fn handle_mouse_input(
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
mut mouse_motion: EventReader<MouseMotion>,
|
||||
mut mouse_wheel: EventReader<MouseWheel>,
|
||||
mut mouse_state: Local<Option<MouseState>>,
|
||||
mut cube_query: Query<&mut Transform, With<crate::cube::CubeMarker>>,
|
||||
) {
|
||||
// Initialize mouse state if needed
|
||||
if mouse_state.is_none() {
|
||||
*mouse_state = Some(MouseState::default());
|
||||
}
|
||||
let state = mouse_state.as_mut().unwrap();
|
||||
|
||||
// Update button states
|
||||
state.left_pressed = mouse_buttons.pressed(MouseButton::Left);
|
||||
state.right_pressed = mouse_buttons.pressed(MouseButton::Right);
|
||||
|
||||
// Get total mouse delta this frame
|
||||
let mut total_delta = Vec2::ZERO;
|
||||
for motion in mouse_motion.read() {
|
||||
total_delta += motion.delta;
|
||||
}
|
||||
|
||||
// Process mouse motion
|
||||
if total_delta != Vec2::ZERO {
|
||||
for mut transform in cube_query.iter_mut() {
|
||||
if state.left_pressed {
|
||||
// Left drag: Move cube in XY plane
|
||||
// Scale factor for sensitivity
|
||||
let sensitivity = 0.01;
|
||||
transform.translation.x += total_delta.x * sensitivity;
|
||||
transform.translation.y -= total_delta.y * sensitivity; // Invert Y
|
||||
} else if state.right_pressed {
|
||||
// Right drag: Rotate cube
|
||||
let sensitivity = 0.01;
|
||||
let rotation_x = Quat::from_rotation_y(total_delta.x * sensitivity);
|
||||
let rotation_y = Quat::from_rotation_x(-total_delta.y * sensitivity);
|
||||
transform.rotation = rotation_x * transform.rotation * rotation_y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process mouse wheel for Z-axis movement
|
||||
let mut total_scroll = 0.0;
|
||||
for wheel in mouse_wheel.read() {
|
||||
total_scroll += wheel.y;
|
||||
}
|
||||
|
||||
if total_scroll != 0.0 {
|
||||
for mut transform in cube_query.iter_mut() {
|
||||
// Scroll: Move in Z axis
|
||||
let sensitivity = 0.1;
|
||||
transform.translation.z += total_scroll * sensitivity;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/app/src/lib.rs
Normal file
12
crates/app/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Replicated cube demo app
|
||||
//!
|
||||
//! This app demonstrates real-time CRDT synchronization between iPad and Mac
|
||||
//! with Apple Pencil input controlling a 3D cube.
|
||||
|
||||
pub mod camera;
|
||||
pub mod cube;
|
||||
pub mod debug_ui;
|
||||
pub mod rendering;
|
||||
pub mod setup;
|
||||
|
||||
pub use cube::CubeMarker;
|
||||
101
crates/app/src/main.rs
Normal file
101
crates/app/src/main.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Replicated cube demo - macOS and iPad
|
||||
//!
|
||||
//! This demonstrates real-time CRDT synchronization with Apple Pencil input.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::EguiPlugin;
|
||||
use lib::{
|
||||
networking::{NetworkingConfig, NetworkingPlugin},
|
||||
persistence::{PersistenceConfig, PersistencePlugin},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod camera;
|
||||
mod cube;
|
||||
mod debug_ui;
|
||||
mod rendering;
|
||||
mod setup;
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
mod input {
|
||||
pub mod mouse;
|
||||
pub use mouse::MouseInputPlugin;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
mod input {
|
||||
pub mod pencil;
|
||||
pub use pencil::PencilInputPlugin;
|
||||
}
|
||||
|
||||
use camera::*;
|
||||
use cube::*;
|
||||
use debug_ui::*;
|
||||
use input::*;
|
||||
use rendering::*;
|
||||
use setup::*;
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("wgpu=error".parse().unwrap())
|
||||
.add_directive("naga=warn".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Create node ID (in production, load from config or generate once)
|
||||
let node_id = Uuid::new_v4();
|
||||
info!("Starting app with node ID: {}", node_id);
|
||||
|
||||
// Database path
|
||||
let db_path = PathBuf::from("cube_demo.db");
|
||||
|
||||
// Create Bevy app
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: format!("Replicated Cube Demo - Node {}", &node_id.to_string()[..8]),
|
||||
resolution: (1280, 720).into(),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
})
|
||||
.disable::<bevy::log::LogPlugin>() // Disable Bevy's logger, using tracing-subscriber instead
|
||||
)
|
||||
.add_plugins(EguiPlugin::default())
|
||||
// Networking (bridge will be set up in startup)
|
||||
.add_plugins(NetworkingPlugin::new(NetworkingConfig {
|
||||
node_id,
|
||||
sync_interval_secs: 1.0,
|
||||
prune_interval_secs: 60.0,
|
||||
tombstone_gc_interval_secs: 300.0,
|
||||
}))
|
||||
// Persistence
|
||||
.add_plugins(PersistencePlugin::with_config(
|
||||
db_path,
|
||||
PersistenceConfig {
|
||||
flush_interval_secs: 2,
|
||||
checkpoint_interval_secs: 30,
|
||||
battery_adaptive: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
// Camera
|
||||
.add_plugins(CameraPlugin)
|
||||
// Rendering
|
||||
.add_plugins(RenderingPlugin)
|
||||
// Input
|
||||
.add_plugins(MouseInputPlugin)
|
||||
// Cube management
|
||||
.add_plugins(CubePlugin)
|
||||
// Debug UI
|
||||
.add_plugins(DebugUiPlugin)
|
||||
// Gossip networking setup
|
||||
.add_systems(Startup, setup_gossip_networking)
|
||||
.add_systems(Update, poll_gossip_bridge)
|
||||
.run();
|
||||
}
|
||||
53
crates/app/src/rendering.rs
Normal file
53
crates/app/src/rendering.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Lighting and ground plane setup
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub struct RenderingPlugin;
|
||||
|
||||
impl Plugin for RenderingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_lighting_and_ground);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up lighting and ground plane
|
||||
///
|
||||
/// Creates a directional light (sun), ambient light, and a green ground plane.
|
||||
/// Camera setup is handled separately in the camera module.
|
||||
fn setup_lighting_and_ground(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
info!("Setting up lighting and ground plane");
|
||||
|
||||
// Directional light (sun)
|
||||
commands.spawn((
|
||||
DirectionalLight {
|
||||
illuminance: 10000.0,
|
||||
shadows_enabled: true,
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
));
|
||||
|
||||
// Ambient light
|
||||
commands.insert_resource(AmbientLight {
|
||||
color: Color::WHITE,
|
||||
brightness: 150.0,
|
||||
affects_lightmapped_meshes: false,
|
||||
});
|
||||
|
||||
// Ground plane
|
||||
commands.spawn((
|
||||
Mesh3d(meshes.add(Plane3d::default().mesh().size(20.0, 20.0))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.3, 0.5, 0.3),
|
||||
perceptual_roughness: 0.9,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_xyz(0.0, 0.0, 0.0),
|
||||
));
|
||||
|
||||
info!("Lighting and ground setup complete");
|
||||
}
|
||||
279
crates/app/src/setup.rs
Normal file
279
crates/app/src/setup.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
//! Gossip networking setup with dedicated tokio runtime
|
||||
//!
|
||||
//! This module manages iroh-gossip networking with a tokio runtime running as a sidecar to Bevy.
|
||||
//! The tokio runtime runs in a dedicated background thread, separate from Bevy's ECS loop.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────┐
|
||||
//! │ Bevy Main Thread │
|
||||
//! │ ┌────────────────────────────────┐ │
|
||||
//! │ │ setup_gossip_networking() │ │ Startup System
|
||||
//! │ │ - Creates channel │ │
|
||||
//! │ │ - Spawns background thread │ │
|
||||
//! │ └────────────────────────────────┘ │
|
||||
//! │ ┌────────────────────────────────┐ │
|
||||
//! │ │ poll_gossip_bridge() │ │ Update System
|
||||
//! │ │ - Receives GossipBridge │ │ (runs every frame)
|
||||
//! │ │ - Inserts as resource │ │
|
||||
//! │ └────────────────────────────────┘ │
|
||||
//! └─────────────────────────────────────┘
|
||||
//! ↕ (crossbeam channel)
|
||||
//! ┌─────────────────────────────────────┐
|
||||
//! │ Background Thread (macOS only) │
|
||||
//! │ ┌────────────────────────────────┐ │
|
||||
//! │ │ Tokio Runtime │ │
|
||||
//! │ │ ┌────────────────────────────┐ │ │
|
||||
//! │ │ │ init_gossip() │ │ │
|
||||
//! │ │ │ - Creates iroh endpoint │ │ │
|
||||
//! │ │ │ - Sets up mDNS discovery │ │ │
|
||||
//! │ │ │ - Subscribes to topic │ │ │
|
||||
//! │ │ │ - Creates GossipBridge │ │ │
|
||||
//! │ │ └────────────────────────────┘ │ │
|
||||
//! │ │ ┌────────────────────────────┐ │ │
|
||||
//! │ │ │ spawn_bridge_tasks() │ │ │
|
||||
//! │ │ │ - Task 1: Forward outgoing │ │ │
|
||||
//! │ │ │ - Task 2: Forward incoming │ │ │
|
||||
//! │ │ └────────────────────────────┘ │ │
|
||||
//! │ └────────────────────────────────┘ │
|
||||
//! └─────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! # Communication Pattern
|
||||
//!
|
||||
//! 1. **Bevy → Tokio**: GossipBridge's internal queue (try_recv_outgoing)
|
||||
//! 2. **Tokio → Bevy**: GossipBridge's internal queue (push_incoming)
|
||||
//! 3. **Thread handoff**: crossbeam_channel (one-time GossipBridge transfer)
|
||||
|
||||
use anyhow::Result;
|
||||
use bevy::prelude::*;
|
||||
use lib::networking::GossipBridge;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Channel for receiving the GossipBridge from the background thread
|
||||
///
|
||||
/// This resource exists temporarily during startup. Once the GossipBridge
|
||||
/// is received and inserted, this channel resource is removed.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
#[derive(Resource)]
|
||||
pub struct GossipBridgeChannel(crossbeam_channel::Receiver<GossipBridge>);
|
||||
|
||||
/// Set up gossip networking with iroh
|
||||
///
|
||||
/// This is a Bevy startup system that initializes the gossip networking stack.
|
||||
/// On macOS, it spawns a dedicated thread with a tokio runtime. On iOS, it logs
|
||||
/// a warning (iOS networking not yet implemented).
|
||||
///
|
||||
/// # Platform Support
|
||||
///
|
||||
/// - **macOS**: Full support with mDNS discovery
|
||||
/// - **iOS**: Not yet implemented
|
||||
pub fn setup_gossip_networking(mut commands: Commands) {
|
||||
info!("Setting up gossip networking...");
|
||||
|
||||
// Spawn dedicated thread with Tokio runtime for gossip initialization
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
{
|
||||
let (sender, receiver) = crossbeam_channel::unbounded();
|
||||
commands.insert_resource(GossipBridgeChannel(receiver));
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
match init_gossip().await {
|
||||
Ok(bridge) => {
|
||||
info!("Gossip bridge initialized successfully");
|
||||
if let Err(e) = sender.send(bridge) {
|
||||
error!("Failed to send bridge to main thread: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to initialize gossip: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
warn!("iOS networking not yet implemented - gossip networking disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the channel for the GossipBridge and insert it when ready
|
||||
///
|
||||
/// This is a Bevy update system that runs every frame. It checks the channel
|
||||
/// for the GossipBridge created in the background thread. Once received, it
|
||||
/// inserts the bridge as a resource and removes the channel.
|
||||
///
|
||||
/// # Platform Support
|
||||
///
|
||||
/// - **macOS**: Polls the channel and inserts GossipBridge
|
||||
/// - **iOS**: No-op (networking not implemented)
|
||||
pub fn poll_gossip_bridge(
|
||||
mut commands: Commands,
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
channel: Option<Res<GossipBridgeChannel>>,
|
||||
) {
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
if let Some(channel) = channel {
|
||||
if let Ok(bridge) = channel.0.try_recv() {
|
||||
info!("Inserting GossipBridge resource into Bevy world");
|
||||
commands.insert_resource(bridge);
|
||||
commands.remove_resource::<GossipBridgeChannel>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize iroh-gossip networking stack
|
||||
///
|
||||
/// This async function runs in the background tokio runtime and:
|
||||
/// 1. Creates an iroh endpoint with mDNS discovery
|
||||
/// 2. Spawns the gossip protocol
|
||||
/// 3. Sets up the router to accept gossip connections
|
||||
/// 4. Subscribes to a shared topic (ID: [42; 32])
|
||||
/// 5. Waits for join with a 2-second timeout
|
||||
/// 6. Creates and configures the GossipBridge
|
||||
/// 7. Spawns forwarding tasks to bridge messages
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(GossipBridge)`: Successfully initialized networking
|
||||
/// - `Err(anyhow::Error)`: Initialization failed
|
||||
///
|
||||
/// # Platform Support
|
||||
///
|
||||
/// This function is only compiled on non-iOS platforms.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
async fn init_gossip() -> Result<GossipBridge> {
|
||||
use iroh::discovery::mdns::MdnsDiscovery;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::Endpoint;
|
||||
use iroh_gossip::net::Gossip;
|
||||
use iroh_gossip::proto::TopicId;
|
||||
|
||||
info!("Creating endpoint with mDNS discovery...");
|
||||
let endpoint = Endpoint::builder()
|
||||
.discovery(MdnsDiscovery::builder())
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
let endpoint_id = endpoint.addr().id;
|
||||
info!("Endpoint created: {}", endpoint_id);
|
||||
|
||||
// Convert endpoint ID to UUID
|
||||
let id_bytes = endpoint_id.as_bytes();
|
||||
let mut uuid_bytes = [0u8; 16];
|
||||
uuid_bytes.copy_from_slice(&id_bytes[..16]);
|
||||
let node_id = Uuid::from_bytes(uuid_bytes);
|
||||
|
||||
info!("Spawning gossip protocol...");
|
||||
let gossip = Gossip::builder().spawn(endpoint.clone());
|
||||
|
||||
info!("Setting up router...");
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_gossip::ALPN, gossip.clone())
|
||||
.spawn();
|
||||
|
||||
// Subscribe to shared topic
|
||||
let topic_id = TopicId::from_bytes([42; 32]);
|
||||
info!("Subscribing to topic...");
|
||||
let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?;
|
||||
|
||||
let (sender, mut receiver) = subscribe_handle.split();
|
||||
|
||||
// Wait for join (with timeout since we might be the first node)
|
||||
info!("Waiting for gossip join...");
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), receiver.joined()).await {
|
||||
Ok(Ok(())) => info!("Joined gossip swarm"),
|
||||
Ok(Err(e)) => warn!("Join error: {} (proceeding anyway)", e),
|
||||
Err(_) => info!("Join timeout (first node in swarm)"),
|
||||
}
|
||||
|
||||
// Create bridge
|
||||
let bridge = GossipBridge::new(node_id);
|
||||
info!("GossipBridge created with node ID: {}", node_id);
|
||||
|
||||
// Spawn forwarding tasks - pass endpoint, router, gossip to keep them alive
|
||||
spawn_bridge_tasks(sender, receiver, bridge.clone(), endpoint, router, gossip);
|
||||
|
||||
Ok(bridge)
|
||||
}
|
||||
|
||||
/// Spawn tokio tasks to forward messages between iroh-gossip and GossipBridge
|
||||
///
|
||||
/// This function spawns two concurrent tokio tasks that run for the lifetime of the application:
|
||||
///
|
||||
/// 1. **Outgoing Task**: Polls GossipBridge for outgoing messages and broadcasts them via gossip
|
||||
/// 2. **Incoming Task**: Receives messages from gossip and pushes them into GossipBridge
|
||||
///
|
||||
/// # Lifetime Management
|
||||
///
|
||||
/// The iroh resources (endpoint, router, gossip) are moved into the first task to keep them
|
||||
/// alive for the application lifetime. Without this, they would be dropped immediately and
|
||||
/// the gossip connection would close.
|
||||
///
|
||||
/// # Platform Support
|
||||
///
|
||||
/// This function is only compiled on non-iOS platforms.
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
fn spawn_bridge_tasks(
|
||||
sender: iroh_gossip::api::GossipSender,
|
||||
mut receiver: iroh_gossip::api::GossipReceiver,
|
||||
bridge: GossipBridge,
|
||||
_endpoint: iroh::Endpoint,
|
||||
_router: iroh::protocol::Router,
|
||||
_gossip: iroh_gossip::net::Gossip,
|
||||
) {
|
||||
use bytes::Bytes;
|
||||
use futures_lite::StreamExt;
|
||||
use lib::networking::VersionedMessage;
|
||||
use std::time::Duration;
|
||||
|
||||
let node_id = bridge.node_id();
|
||||
|
||||
// Task 1: Forward outgoing messages from GossipBridge → iroh-gossip
|
||||
// Keep endpoint, router, gossip alive by moving them into this task
|
||||
let bridge_out = bridge.clone();
|
||||
tokio::spawn(async move {
|
||||
let _endpoint = _endpoint; // Keep alive for app lifetime
|
||||
let _router = _router; // Keep alive for app lifetime
|
||||
let _gossip = _gossip; // Keep alive for app lifetime
|
||||
|
||||
loop {
|
||||
if let Some(msg) = bridge_out.try_recv_outgoing() {
|
||||
if let Ok(bytes) = bincode::serialize(&msg) {
|
||||
if let Err(e) = sender.broadcast(Bytes::from(bytes)).await {
|
||||
error!("[Node {}] Broadcast failed: {}", node_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Task 2: Forward incoming messages from iroh-gossip → GossipBridge
|
||||
let bridge_in = bridge.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_millis(100), receiver.next()).await {
|
||||
Ok(Some(Ok(event))) => {
|
||||
if let iroh_gossip::api::Event::Received(msg) = event {
|
||||
if let Ok(versioned_msg) =
|
||||
bincode::deserialize::<VersionedMessage>(&msg.content)
|
||||
{
|
||||
if let Err(e) = bridge_in.push_incoming(versioned_msg) {
|
||||
error!("[Node {}] Push incoming failed: {}", node_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(e))) => error!("[Node {}] Receiver error: {}", node_id, e),
|
||||
Ok(None) => break,
|
||||
Err(_) => {} // Timeout
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user