feat(ipad): first successful ipad build

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-14 22:50:35 +00:00
parent 8f829e9537
commit 5cafe89e4f
21 changed files with 1224 additions and 465 deletions

View File

@@ -1,217 +0,0 @@
//! Bridge Bevy's input to the engine's InputEvent system
//!
//! This temporarily reads Bevy's input and converts to InputEvents.
//! Later, we'll replace this with direct winit ownership.
use bevy::prelude::*;
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::{MouseButtonInput, MouseWheel};
use bevy::window::CursorMoved;
use libmarathon::platform::input::{InputEvent, InputEventBuffer, KeyCode as EngineKeyCode, MouseButton as EngineMouseButton, TouchPhase, Modifiers};
/// Convert Bevy's Vec2 to glam::Vec2
///
/// Bevy re-exports glam types, so they're the same layout.
/// We just construct a new one to be safe.
#[inline]
fn to_glam_vec2(v: bevy::math::Vec2) -> glam::Vec2 {
glam::Vec2::new(v.x, v.y)
}
/// Convert Bevy's KeyCode to engine's KeyCode (winit::keyboard::KeyCode)
///
/// Bevy re-exports winit's KeyCode but wraps it, so we need to extract it.
/// For now, we'll just match the common keys. TODO: Complete mapping.
fn bevy_to_engine_keycode(bevy_key: KeyCode) -> Option<EngineKeyCode> {
// In Bevy 0.17, KeyCode variants match winit directly
// We can use format matching as a temporary solution
use EngineKeyCode as E;
Some(match bevy_key {
KeyCode::KeyA => E::KeyA,
KeyCode::KeyB => E::KeyB,
KeyCode::KeyC => E::KeyC,
KeyCode::KeyD => E::KeyD,
KeyCode::KeyE => E::KeyE,
KeyCode::KeyF => E::KeyF,
KeyCode::KeyG => E::KeyG,
KeyCode::KeyH => E::KeyH,
KeyCode::KeyI => E::KeyI,
KeyCode::KeyJ => E::KeyJ,
KeyCode::KeyK => E::KeyK,
KeyCode::KeyL => E::KeyL,
KeyCode::KeyM => E::KeyM,
KeyCode::KeyN => E::KeyN,
KeyCode::KeyO => E::KeyO,
KeyCode::KeyP => E::KeyP,
KeyCode::KeyQ => E::KeyQ,
KeyCode::KeyR => E::KeyR,
KeyCode::KeyS => E::KeyS,
KeyCode::KeyT => E::KeyT,
KeyCode::KeyU => E::KeyU,
KeyCode::KeyV => E::KeyV,
KeyCode::KeyW => E::KeyW,
KeyCode::KeyX => E::KeyX,
KeyCode::KeyY => E::KeyY,
KeyCode::KeyZ => E::KeyZ,
KeyCode::Digit1 => E::Digit1,
KeyCode::Digit2 => E::Digit2,
KeyCode::Digit3 => E::Digit3,
KeyCode::Digit4 => E::Digit4,
KeyCode::Digit5 => E::Digit5,
KeyCode::Digit6 => E::Digit6,
KeyCode::Digit7 => E::Digit7,
KeyCode::Digit8 => E::Digit8,
KeyCode::Digit9 => E::Digit9,
KeyCode::Digit0 => E::Digit0,
KeyCode::Space => E::Space,
KeyCode::Enter => E::Enter,
KeyCode::Escape => E::Escape,
KeyCode::Backspace => E::Backspace,
KeyCode::Tab => E::Tab,
KeyCode::ShiftLeft => E::ShiftLeft,
KeyCode::ShiftRight => E::ShiftRight,
KeyCode::ControlLeft => E::ControlLeft,
KeyCode::ControlRight => E::ControlRight,
KeyCode::AltLeft => E::AltLeft,
KeyCode::AltRight => E::AltRight,
KeyCode::SuperLeft => E::SuperLeft,
KeyCode::SuperRight => E::SuperRight,
KeyCode::ArrowUp => E::ArrowUp,
KeyCode::ArrowDown => E::ArrowDown,
KeyCode::ArrowLeft => E::ArrowLeft,
KeyCode::ArrowRight => E::ArrowRight,
_ => return None, // Unmapped keys
})
}
pub struct DesktopInputBridgePlugin;
impl Plugin for DesktopInputBridgePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<InputEventBuffer>()
.add_systems(PreUpdate, (
clear_buffer,
collect_mouse_buttons,
collect_mouse_motion,
collect_mouse_wheel,
collect_keyboard,
).chain());
}
}
/// Clear the buffer at the start of each frame
fn clear_buffer(mut buffer: ResMut<InputEventBuffer>) {
buffer.events.clear();
}
/// Collect mouse button events
fn collect_mouse_buttons(
mut buffer: ResMut<InputEventBuffer>,
mut mouse_button_events: MessageReader<MouseButtonInput>,
windows: Query<&Window>,
) {
let cursor_pos = windows
.single()
.ok()
.and_then(|w| w.cursor_position())
.unwrap_or(Vec2::ZERO);
for event in mouse_button_events.read() {
let button = match event.button {
MouseButton::Left => EngineMouseButton::Left,
MouseButton::Right => EngineMouseButton::Right,
MouseButton::Middle => EngineMouseButton::Middle,
_ => continue,
};
let phase = if event.state.is_pressed() {
TouchPhase::Started
} else {
TouchPhase::Ended
};
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button,
phase,
});
}
}
/// Collect mouse motion events (for hover and drag tracking)
fn collect_mouse_motion(
mut buffer: ResMut<InputEventBuffer>,
mut cursor_moved: MessageReader<CursorMoved>,
mouse_buttons: Res<ButtonInput<MouseButton>>,
) {
for event in cursor_moved.read() {
let cursor_pos = event.position;
// ALWAYS send MouseMove for cursor tracking (hover, tooltips, etc.)
buffer.events.push(InputEvent::MouseMove {
pos: to_glam_vec2(cursor_pos),
});
// ALSO generate drag events for currently pressed buttons
if mouse_buttons.pressed(MouseButton::Left) {
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button: EngineMouseButton::Left,
phase: TouchPhase::Moved,
});
}
if mouse_buttons.pressed(MouseButton::Right) {
buffer.events.push(InputEvent::Mouse {
pos: to_glam_vec2(cursor_pos),
button: EngineMouseButton::Right,
phase: TouchPhase::Moved,
});
}
}
}
/// Collect mouse wheel events
fn collect_mouse_wheel(
mut buffer: ResMut<InputEventBuffer>,
mut wheel_events: MessageReader<MouseWheel>,
windows: Query<&Window>,
) {
let cursor_pos = windows
.single()
.ok()
.and_then(|w| w.cursor_position())
.unwrap_or(Vec2::ZERO);
for event in wheel_events.read() {
buffer.events.push(InputEvent::MouseWheel {
delta: to_glam_vec2(Vec2::new(event.x, event.y)),
pos: to_glam_vec2(cursor_pos),
});
}
}
/// Collect keyboard events
fn collect_keyboard(
mut buffer: ResMut<InputEventBuffer>,
mut keyboard_events: MessageReader<KeyboardInput>,
keys: Res<ButtonInput<KeyCode>>,
) {
for event in keyboard_events.read() {
let modifiers = Modifiers {
shift: keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]),
ctrl: keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]),
alt: keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]),
meta: keys.any_pressed([KeyCode::SuperLeft, KeyCode::SuperRight]),
};
// Convert Bevy's KeyCode to engine's KeyCode
if let Some(engine_key) = bevy_to_engine_keycode(event.key_code) {
buffer.events.push(InputEvent::Keyboard {
key: engine_key,
pressed: event.state.is_pressed(),
modifiers,
});
}
}
}

View File

@@ -1,28 +1,13 @@
//! Input handling modules
//! Input handling for Aspen
//!
//! This module contains platform-specific input adapters that bridge
//! native input (Bevy/winit, iOS pencil) to libmarathon's InputEvent system.
//! Input flow:
//! 1. Platform executor (desktop/iOS) captures native input
//! 2. Platform layer converts to InputEvents and populates InputEventBuffer
//! 3. InputHandler reads buffer and converts to GameActions
//! 4. GameActions are applied to entities
pub mod event_buffer;
pub mod input_handler;
#[cfg(target_os = "ios")]
pub mod pencil;
#[cfg(not(target_os = "ios"))]
pub mod desktop_bridge;
#[cfg(not(target_os = "ios"))]
pub mod mouse;
pub use event_buffer::InputEventBuffer;
pub use input_handler::InputHandlerPlugin;
#[cfg(target_os = "ios")]
pub use pencil::PencilInputPlugin;
#[cfg(not(target_os = "ios"))]
pub use desktop_bridge::DesktopInputBridgePlugin;
#[cfg(not(target_os = "ios"))]
pub use mouse::MouseInputPlugin;

View File

@@ -1,97 +0,0 @@
//! Mouse input handling for macOS
use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel};
use libmarathon::networking::{EntityLockRegistry, NetworkedEntity, NodeVectorClock};
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 cubes that are locked by us
fn handle_mouse_input(
mouse_buttons: Res<ButtonInput<MouseButton>>,
mut mouse_motion: MessageReader<MouseMotion>,
mut mouse_wheel: MessageReader<MouseWheel>,
mut mouse_state: Local<Option<MouseState>>,
lock_registry: Res<EntityLockRegistry>,
node_clock: Res<NodeVectorClock>,
mut cube_query: Query<(&NetworkedEntity, &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);
let node_id = node_clock.node_id;
// 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 - only for cubes locked by us
if total_delta != Vec2::ZERO {
for (networked, mut transform) in cube_query.iter_mut() {
// Only move cubes that we have locked
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
continue;
}
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
// Change detection will trigger clock tick automatically
} 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;
// Change detection will trigger clock tick automatically
}
}
}
// Process mouse wheel for Z-axis movement - only for cubes locked by us
let mut total_scroll = 0.0;
for wheel in mouse_wheel.read() {
total_scroll += wheel.y;
}
if total_scroll != 0.0 {
for (networked, mut transform) in cube_query.iter_mut() {
// Only move cubes that we have locked
if !lock_registry.is_locked_by(networked.network_id, node_id, node_id) {
continue;
}
// Scroll: Move in Z axis
let sensitivity = 0.1;
transform.translation.z += total_scroll * sensitivity;
// Change detection will trigger clock tick automatically
}
}
}

View File

@@ -1,69 +0,0 @@
//! Apple Pencil input system for iOS
//!
//! This module integrates the platform-agnostic pencil bridge with Bevy.
use bevy::prelude::*;
use libmarathon::{platform::input::InputEvent, platform::ios};
pub struct PencilInputPlugin;
impl Plugin for PencilInputPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, attach_pencil_capture)
.add_systems(PreUpdate, poll_pencil_input);
}
}
/// Resource to track the latest pencil state
#[derive(Resource, Default)]
pub struct PencilState {
pub latest: Option<InputEvent>,
pub points_this_frame: usize,
}
/// Attach the Swift pencil capture to Bevy's window
#[cfg(target_os = "ios")]
fn attach_pencil_capture(windows: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let Ok(handle) = windows.get_single() else {
warn!("No primary window for pencil capture");
return;
};
unsafe {
if let Ok(raw) = handle.window_handle() {
if let RawWindowHandle::UiKit(h) = raw.as_ref() {
ios::swift_attach_pencil_capture(h.ui_view.as_ptr() as *mut _);
info!("✏️ Apple Pencil capture attached");
}
}
}
}
#[cfg(not(target_os = "ios"))]
fn attach_pencil_capture() {
// No-op on non-iOS platforms
}
/// Poll pencil input from the platform layer and update PencilState
fn poll_pencil_input(mut commands: Commands, state: Option<ResMut<PencilState>>) {
let events = ios::drain_as_input_events();
if events.is_empty() {
return;
}
// Insert resource if it doesn't exist
if state.is_none() {
commands.insert_resource(PencilState::default());
return;
}
if let Some(mut state) = state {
state.points_this_frame = events.len();
if let Some(latest) = events.last() {
state.latest = Some(*latest);
}
}
}

View File

@@ -41,9 +41,13 @@ fn main() {
)
.init();
// Database path
let db_path = PathBuf::from("cube_demo.db");
// Application configuration
const APP_NAME: &str = "Aspen";
// Get platform-appropriate database path
let db_path = libmarathon::platform::get_database_path(APP_NAME);
let db_path_str = db_path.to_str().unwrap().to_string();
info!("Database path: {}", db_path_str);
// Create EngineBridge (for communication between Bevy and EngineCore)
let (engine_bridge, engine_handle) = EngineBridge::new();
@@ -79,7 +83,7 @@ fn main() {
// Marathon core plugins (networking, debug UI, persistence)
app.add_plugins(libmarathon::MarathonPlugin::new(
db_path,
APP_NAME,
PersistenceConfig {
flush_interval_secs: 2,
checkpoint_interval_secs: 30,
@@ -99,6 +103,5 @@ fn main() {
app.add_plugins(SessionUiPlugin);
app.add_systems(Startup, initialize_offline_resources);
// Run with our executor (unbounded event loop)
libmarathon::platform::desktop::run_executor(app).expect("Failed to run executor");
libmarathon::platform::run_executor(app).expect("Failed to run executor");
}

View File

@@ -4,38 +4,39 @@ version = "0.1.0"
edition.workspace = true
[dependencies]
rusqlite = { version = "0.37.0", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json.workspace = true
crdts.workspace = true
anyhow.workspace = true
sync-macros = { path = "../sync-macros" }
uuid = { version = "1.0", features = ["v4", "serde"] }
toml.workspace = true
tracing.workspace = true
arboard = "3.4"
bevy.workspace = true
glam = "0.29"
winit = "0.30"
raw-window-handle = "0.6"
bincode = "1.3"
bytes = "1.0"
futures-lite = "2.0"
sha2 = "0.10"
blake3 = "1.5"
rand = "0.8"
tokio.workspace = true
blocking = "1.6"
bytemuck = { version = "1.14", features = ["derive"] }
bytes = "1.0"
chrono = { version = "0.4", features = ["serde"] }
crdts.workspace = true
crossbeam-channel = "0.5"
dirs = "5.0"
egui = { version = "0.33", default-features = false, features = ["bytemuck", "default_fonts"] }
encase = { version = "0.10", features = ["glam"] }
futures-lite = "2.0"
glam = "0.29"
iroh = { workspace = true, features = ["discovery-local-network"] }
iroh-gossip.workspace = true
egui = { version = "0.33", default-features = false, features = ["bytemuck", "default_fonts"] }
arboard = "3.4"
bytemuck = { version = "1.14", features = ["derive"] }
encase = { version = "0.10", features = ["glam"] }
wgpu-types = "26.0"
itertools = "0.14"
rand = "0.8"
raw-window-handle = "0.6"
rusqlite = { version = "0.37.0", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json.workspace = true
sha2 = "0.10"
sync-macros = { path = "../sync-macros" }
thiserror = "2.0"
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
uuid = { version = "1.0", features = ["v4", "serde"] }
wgpu-types = "26.0"
winit = "0.30"
[dev-dependencies]
tokio.workspace = true

View File

@@ -0,0 +1,85 @@
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
// Only compile Swift code when building for iOS
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "ios" {
println!("cargo:warning=Not building for iOS, skipping Swift compilation");
return;
}
println!("cargo:warning=Building Swift bridge for iOS");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let swift_src = manifest_dir.join("src/platform/ios/PencilCapture.swift");
let header = manifest_dir.join("src/platform/ios/PencilBridge.h");
let object_file = out_dir.join("PencilCapture.o");
// Get the iOS SDK path
let sdk_output = Command::new("xcrun")
.args(&["--sdk", "iphoneos", "--show-sdk-path"])
.output()
.expect("Failed to get iOS SDK path");
let sdk_path = String::from_utf8_lossy(&sdk_output.stdout).trim().to_string();
println!("cargo:warning=Using iOS SDK: {}", sdk_path);
// Compile Swift to object file
let status = Command::new("swiftc")
.args(&[
"-sdk", &sdk_path,
"-target", "arm64-apple-ios14.0", // Minimum iOS 14
"-import-objc-header", header.to_str().unwrap(),
"-parse-as-library",
"-c", swift_src.to_str().unwrap(),
"-o", object_file.to_str().unwrap(),
])
.status()
.expect("Failed to compile Swift");
if !status.success() {
panic!("Swift compilation failed");
}
println!("cargo:warning=Swift compilation succeeded");
// Create static library from object file
let lib_file = out_dir.join("libPencilCapture.a");
let ar_status = Command::new("ar")
.args(&[
"rcs",
lib_file.to_str().unwrap(),
object_file.to_str().unwrap(),
])
.status()
.expect("Failed to create static library");
if !ar_status.success() {
panic!("Failed to create static library");
}
println!("cargo:warning=Created static library: {}", lib_file.display());
// Tell Cargo to link the static library
println!("cargo:rustc-link-search=native={}", out_dir.display());
println!("cargo:rustc-link-lib=static=PencilCapture");
// Link Swift standard library
println!("cargo:rustc-link-lib=dylib=swiftCore");
println!("cargo:rustc-link-lib=dylib=swiftFoundation");
println!("cargo:rustc-link-lib=dylib=swiftUIKit");
// Link iOS frameworks
println!("cargo:rustc-link-lib=framework=UIKit");
println!("cargo:rustc-link-lib=framework=Foundation");
// Rerun if Swift files change
println!("cargo:rerun-if-changed={}", swift_src.display());
println!("cargo:rerun-if-changed={}", header.display());
}

View File

@@ -13,19 +13,8 @@ impl PersistenceManager {
pub fn new(db_path: &str) -> Self {
let conn = Connection::open(db_path).expect("Failed to open database");
// Initialize schema (Phase 1 stub - will load from file in Phase 4)
let schema = "
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL
);
";
if let Err(e) = conn.execute_batch(schema) {
tracing::warn!("Failed to initialize schema: {}", e);
}
// Schema is handled by the migration system in persistence/migrations
// No hardcoded schema creation here
Self {
conn: Arc::new(Mutex::new(conn)),
@@ -35,14 +24,17 @@ impl PersistenceManager {
pub fn save_session(&self, session: &Session) -> anyhow::Result<()> {
let conn = self.conn.lock().unwrap();
// Schema from migration 004: id (BLOB), code, name, created_at, last_active, entity_count, state, secret
conn.execute(
"INSERT OR REPLACE INTO sessions (id, state, created_at, last_active_at)
VALUES (?1, ?2, ?3, ?4)",
"INSERT OR REPLACE INTO sessions (id, code, state, created_at, last_active, entity_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
(
session.id.to_code(),
session.id.as_uuid().as_bytes(), // id is BLOB in migration 004
session.id.to_code(), // code is the text representation
format!("{:?}", session.state),
session.created_at,
session.last_active,
0, // entity_count default
),
)?;
@@ -53,21 +45,22 @@ impl PersistenceManager {
let conn = self.conn.lock().unwrap();
// Query for the most recently active session
// Schema from migration 004: uses last_active (not last_active_at)
let mut stmt = conn.prepare(
"SELECT id, state, created_at, last_active_at
"SELECT code, state, created_at, last_active
FROM sessions
ORDER BY last_active_at DESC
ORDER BY last_active DESC
LIMIT 1"
)?;
let session = stmt.query_row([], |row| {
let id_code: String = row.get(0)?;
let code: String = row.get(0)?;
let _state: String = row.get(1)?;
let _created_at: String = row.get(2)?;
let _last_active_at: String = row.get(3)?;
let _created_at: i64 = row.get(2)?;
let _last_active: i64 = row.get(3)?;
// Parse session ID from code
if let Ok(session_id) = SessionId::from_code(&id_code) {
if let Ok(session_id) = SessionId::from_code(&code) {
Ok(Some(Session::new(session_id)))
} else {
Ok(None)

View File

@@ -40,6 +40,8 @@ pub mod sync;
/// For simple integration, just add this single plugin to your Bevy app.
/// Note: You'll still need to add your app-specific bridge/event handling.
pub struct MarathonPlugin {
/// Application name (used for database filename)
pub app_name: String,
/// Path to the persistence database
pub db_path: std::path::PathBuf,
/// Persistence configuration
@@ -47,9 +49,26 @@ pub struct MarathonPlugin {
}
impl MarathonPlugin {
/// Create a new MarathonPlugin with custom database path and config
pub fn new(db_path: impl Into<std::path::PathBuf>, config: persistence::PersistenceConfig) -> Self {
/// Create a new MarathonPlugin with app name and config
///
/// The database will be created in the platform-appropriate location:
/// - iOS: app's Documents directory
/// - Desktop: current directory
pub fn new(app_name: impl Into<String>, config: persistence::PersistenceConfig) -> Self {
let app_name = app_name.into();
let db_path = platform::get_database_path(&app_name);
Self {
app_name,
db_path,
persistence_config: config,
}
}
/// Create a new MarathonPlugin with custom database path and config
pub fn with_custom_db(app_name: impl Into<String>, db_path: impl Into<std::path::PathBuf>, config: persistence::PersistenceConfig) -> Self {
Self {
app_name: app_name.into(),
db_path: db_path.into(),
persistence_config: config,
}
@@ -58,6 +77,7 @@ impl MarathonPlugin {
/// Create with default settings (database in current directory)
pub fn with_default_db() -> Self {
Self {
app_name: "marathon".to_string(),
db_path: "marathon.db".into(),
persistence_config: Default::default(),
}

View File

@@ -0,0 +1,24 @@
#ifndef PENCIL_BRIDGE_H
#define PENCIL_BRIDGE_H
#include <stdint.h>
// Raw Apple Pencil data point
typedef struct {
float x; // Screen x coordinate
float y; // Screen y coordinate
float force; // Pressure (0.0 - 1.0)
float altitude; // Altitude angle in radians
float azimuth; // Azimuth angle in radians (relative to screen)
double timestamp; // Event timestamp
uint8_t phase; // 0 = began, 1 = moved, 2 = ended, 3 = cancelled
} RawPencilPoint;
// Swift-implemented functions
void swift_attach_pencil_capture(void* ui_view);
void swift_detach_pencil_capture(void* ui_view);
// Rust-implemented function (called from Swift)
void rust_push_pencil_point(RawPencilPoint point);
#endif // PENCIL_BRIDGE_H

View File

@@ -0,0 +1,105 @@
import UIKit
// Import the C bridge header
// This declares rust_push_pencil_point() which we'll call
// Note: In build.rs we'll use -import-objc-header to make this available
/// Custom gesture recognizer that captures Apple Pencil input
class PencilGestureRecognizer: UIGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
if touch.type == .pencil || touch.type == .stylus {
handlePencilTouch(touch, phase: 0) // began
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
if touch.type == .pencil || touch.type == .stylus {
handlePencilTouch(touch, phase: 1) // moved
}
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
if touch.type == .pencil || touch.type == .stylus {
handlePencilTouch(touch, phase: 2) // ended
}
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
for touch in touches {
if touch.type == .pencil || touch.type == .stylus {
handlePencilTouch(touch, phase: 3) // cancelled
}
}
}
private func handlePencilTouch(_ touch: UITouch, phase: UInt8) {
let location = touch.location(in: view)
// Construct the raw pencil point
var point = RawPencilPoint(
x: Float(location.x),
y: Float(location.y),
force: Float(touch.force / touch.maximumPossibleForce),
altitude: Float(touch.altitudeAngle),
azimuth: Float(touch.azimuthAngle(in: view)),
timestamp: touch.timestamp,
phase: phase
)
// Call into Rust FFI
rust_push_pencil_point(point)
}
// Allow simultaneous recognition with other gestures
override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
// Static reference to the gesture recognizer (so we can detach later)
private var pencilGestureRecognizer: PencilGestureRecognizer?
/// Attach pencil capture to a UIView
@_cdecl("swift_attach_pencil_capture")
public func swiftAttachPencilCapture(_ uiView: UnsafeMutableRawPointer) {
guard let view = Unmanaged<UIView>.fromOpaque(uiView).takeUnretainedValue() as UIView? else {
print("⚠️ Failed to get UIView from pointer")
return
}
// Create and attach the gesture recognizer
let recognizer = PencilGestureRecognizer()
recognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.pencil.rawValue)]
recognizer.cancelsTouchesInView = false
recognizer.delaysTouchesBegan = false
recognizer.delaysTouchesEnded = false
view.addGestureRecognizer(recognizer)
pencilGestureRecognizer = recognizer
print("✏️ Apple Pencil capture attached to view")
}
/// Detach pencil capture from a UIView
@_cdecl("swift_detach_pencil_capture")
public func swiftDetachPencilCapture(_ uiView: UnsafeMutableRawPointer) {
guard let view = Unmanaged<UIView>.fromOpaque(uiView).takeUnretainedValue() as UIView? else {
return
}
if let recognizer = pencilGestureRecognizer {
view.removeGestureRecognizer(recognizer)
pencilGestureRecognizer = nil
print("✏️ Apple Pencil capture detached")
}
}

View File

@@ -0,0 +1,339 @@
//! iOS application executor - owns winit and drives Bevy ECS
//!
//! iOS-specific implementation of the executor pattern, adapted for UIKit integration.
//! See platform/desktop/executor.rs for detailed architecture documentation.
use bevy::prelude::*;
use bevy::app::AppExit;
use bevy::input::{
ButtonInput,
mouse::MouseButton as BevyMouseButton,
keyboard::KeyCode as BevyKeyCode,
touch::{Touches, TouchInput},
gestures::*,
keyboard::KeyboardInput,
mouse::{MouseButtonInput, MouseMotion, MouseWheel},
};
use bevy::window::{
PrimaryWindow, WindowCreated, WindowResized, WindowScaleFactorChanged, WindowClosing,
WindowResolution, WindowMode, WindowPosition, WindowEvent as BevyWindowEvent,
RawHandleWrapper, WindowWrapper,
CursorMoved, CursorEntered, CursorLeft,
WindowFocused, WindowOccluded, WindowMoved, WindowThemeChanged, WindowDestroyed,
FileDragAndDrop, Ime, WindowCloseRequested,
};
use bevy::ecs::message::Messages;
use crate::platform::input::{InputEvent, InputEventBuffer};
use std::sync::Arc;
use winit::application::ApplicationHandler;
use winit::event::{Event as WinitEvent, WindowEvent as WinitWindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::window::{Window as WinitWindow, WindowId, WindowAttributes};
/// Application handler state machine
enum AppHandler {
Initializing { app: Option<App> },
Running {
window: Arc<WinitWindow>,
bevy_window_entity: Entity,
bevy_app: App,
},
}
// Helper functions to reduce duplication
fn send_window_created(app: &mut App, window: Entity) {
app.world_mut()
.resource_mut::<Messages<WindowCreated>>()
.write(WindowCreated { window });
}
fn send_window_resized(
app: &mut App,
window: Entity,
physical_size: winit::dpi::PhysicalSize<u32>,
scale_factor: f64,
) {
app.world_mut()
.resource_mut::<Messages<WindowResized>>()
.write(WindowResized {
window,
width: physical_size.width as f32 / scale_factor as f32,
height: physical_size.height as f32 / scale_factor as f32,
});
}
fn send_scale_factor_changed(app: &mut App, window: Entity, scale_factor: f64) {
app.world_mut()
.resource_mut::<Messages<WindowScaleFactorChanged>>()
.write(WindowScaleFactorChanged {
window,
scale_factor,
});
}
fn send_window_closing(app: &mut App, window: Entity) {
app.world_mut()
.resource_mut::<Messages<WindowClosing>>()
.write(WindowClosing { window });
}
impl AppHandler {
/// Initialize the window and transition to Running state.
fn initialize(&mut self, event_loop: &ActiveEventLoop) -> Result<(), String> {
let AppHandler::Initializing { app: app_opt } = self else {
warn!("initialize() called on non-Initializing state, ignoring");
return Ok(());
};
let Some(mut bevy_app) = app_opt.take() else {
warn!("initialize() called twice, ignoring");
return Ok(());
};
// Insert InputEventBuffer resource
bevy_app.insert_resource(InputEventBuffer::default());
// Initialize window message channels
bevy_app.init_resource::<Messages<WindowCreated>>();
bevy_app.init_resource::<Messages<WindowResized>>();
bevy_app.init_resource::<Messages<WindowScaleFactorChanged>>();
bevy_app.init_resource::<Messages<WindowClosing>>();
bevy_app.init_resource::<Messages<BevyWindowEvent>>();
// Initialize input resources that Bevy UI and picking expect
bevy_app.init_resource::<ButtonInput<BevyMouseButton>>();
bevy_app.init_resource::<ButtonInput<BevyKeyCode>>();
bevy_app.init_resource::<Touches>();
bevy_app.init_resource::<Messages<TouchInput>>();
// Create the winit window BEFORE finishing the app
let window_attributes = WindowAttributes::default()
.with_title("Marathon")
.with_inner_size(winit::dpi::LogicalSize::new(1280, 720));
let winit_window = event_loop.create_window(window_attributes)
.map_err(|e| format!("Failed to create window: {}", e))?;
let winit_window = Arc::new(winit_window);
info!("Created iOS window before app.finish()");
let physical_size = winit_window.inner_size();
let scale_factor = winit_window.scale_factor();
// iOS-specific: High DPI screens (Retina)
// iPad Pro has scale factors of 2.0, some models 3.0
info!("iOS scale factor: {}", scale_factor);
// Create window entity with all required components
let mut window = bevy::window::Window {
title: "Marathon".to_string(),
resolution: WindowResolution::new(
physical_size.width / scale_factor as u32,
physical_size.height / scale_factor as u32,
),
mode: WindowMode::Windowed,
position: WindowPosition::Automatic,
focused: true,
..Default::default()
};
window
.resolution
.set_scale_factor_and_apply_to_physical_size(scale_factor as f32);
// Create WindowWrapper and RawHandleWrapper for renderer
let window_wrapper = WindowWrapper::new(winit_window.clone());
let raw_handle_wrapper = RawHandleWrapper::new(&window_wrapper)
.map_err(|e| format!("Failed to create RawHandleWrapper: {}", e))?;
let window_entity = bevy_app.world_mut().spawn((
window,
PrimaryWindow,
raw_handle_wrapper,
)).id();
info!("Created window entity {}", window_entity);
// Send initialization event
send_window_created(&mut bevy_app, window_entity);
// Now finish the app - the renderer will initialize with the window
bevy_app.finish();
bevy_app.cleanup();
info!("iOS app finished and cleaned up");
// Transition to Running state
*self = AppHandler::Running {
window: winit_window,
bevy_window_entity: window_entity,
bevy_app,
};
Ok(())
}
/// Clean shutdown of the app and window
fn shutdown(&mut self, event_loop: &ActiveEventLoop) {
if let AppHandler::Running {
bevy_window_entity,
bevy_app,
..
} = self
{
info!("Shutting down iOS app gracefully");
// Send WindowClosing event
send_window_closing(bevy_app, *bevy_window_entity);
// Run one final update to process close event
bevy_app.update();
}
event_loop.exit();
}
}
impl ApplicationHandler for AppHandler {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
// Initialize on first resumed() call
if let Err(e) = self.initialize(event_loop) {
error!("Failed to initialize iOS app: {}", e);
event_loop.exit();
return;
}
info!("iOS app resumed");
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WinitWindowEvent,
) {
// Only handle events if we're in Running state
let AppHandler::Running {
window,
bevy_window_entity,
bevy_app,
} = self
else {
return;
};
match event {
WinitWindowEvent::CloseRequested => {
self.shutdown(event_loop);
}
WinitWindowEvent::Resized(physical_size) => {
// Update the Bevy Window component's physical resolution
if let Some(mut window_component) = bevy_app.world_mut().get_mut::<Window>(*bevy_window_entity) {
window_component
.resolution
.set_physical_resolution(physical_size.width, physical_size.height);
}
// Notify Bevy systems of window resize
let scale_factor = window.scale_factor();
send_window_resized(bevy_app, *bevy_window_entity, physical_size, scale_factor);
}
WinitWindowEvent::RedrawRequested => {
// iOS-specific: Get pencil input from the bridge
#[cfg(target_os = "ios")]
let pencil_events = super::drain_as_input_events();
#[cfg(not(target_os = "ios"))]
let pencil_events = vec![];
// Reuse buffer capacity instead of replacing (optimization)
{
let mut buffer = bevy_app.world_mut().resource_mut::<InputEventBuffer>();
buffer.events.clear();
buffer.events.extend(pencil_events);
}
// Run one Bevy ECS update (unbounded)
bevy_app.update();
// Check if app should exit
if bevy_app.should_exit().is_some() {
self.shutdown(event_loop);
return;
}
// Request next frame immediately (unbounded loop)
window.request_redraw();
}
WinitWindowEvent::ScaleFactorChanged { scale_factor, .. } => {
// Update the Bevy Window component's scale factor
if let Some(mut window_component) = bevy_app.world_mut().get_mut::<Window>(*bevy_window_entity) {
let prior_factor = window_component.resolution.scale_factor();
window_component
.resolution
.set_scale_factor_and_apply_to_physical_size(scale_factor as f32);
send_scale_factor_changed(bevy_app, *bevy_window_entity, scale_factor);
info!(
"iOS scale factor changed from {} to {} for window {:?}",
prior_factor, scale_factor, bevy_window_entity
);
}
}
_ => {}
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
// On iOS, the app is being backgrounded
info!("iOS app suspended (backgrounded)");
// TODO: Implement AppLifecycle resource to track app state
// iOS-specific considerations:
// 1. Release GPU resources to avoid being killed by iOS
// 2. Stop requesting redraws to save battery
// 3. Save any persistent state
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Ensure we keep rendering even if no window events arrive
if let AppHandler::Running { window, .. } = self {
window.request_redraw();
}
}
}
/// Run the iOS application executor.
///
/// This is the iOS equivalent of desktop::run_executor().
/// See desktop/executor.rs for detailed documentation.
///
/// # iOS-Specific Notes
///
/// - Uses winit's iOS backend (backed by UIKit)
/// - Supports Apple Pencil input via the pencil_bridge
/// - Handles iOS lifecycle (suspended/resumed) for backgrounding
/// - Uses Retina display scale factors (2.0-3.0)
///
/// # Errors
///
/// Returns an error if:
/// - The event loop cannot be created
/// - Window creation fails during initialization
/// - The event loop encounters a fatal error
pub fn run_executor(app: App) -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?;
// Run as fast as possible (unbounded)
event_loop.set_control_flow(ControlFlow::Poll);
info!("Starting iOS executor (unbounded mode)");
// Create handler in Initializing state with the app
let mut handler = AppHandler::Initializing { app: Some(app) };
event_loop.run_app(&mut handler)?;
Ok(())
}

View File

@@ -1,10 +1,16 @@
//! iOS platform support
//!
//! This module contains iOS-specific input capture code.
//! This module contains iOS-specific implementations:
//! - Apple Pencil input capture
//! - iOS executor (winit + UIKit integration)
pub mod executor;
pub mod pencil_bridge;
pub use executor::run_executor;
pub use pencil_bridge::{
drain_as_input_events, drain_raw, pencil_point_received, swift_attach_pencil_capture,
drain_as_input_events, drain_raw,
pencil_point_received, rust_push_pencil_point,
swift_attach_pencil_capture, swift_detach_pencil_capture,
RawPencilPoint,
};

View File

@@ -3,7 +3,7 @@
//! This module captures raw Apple Pencil input via Swift/UIKit and converts
//! it to engine-agnostic InputEvents.
use crate::engine::input_events::{InputEvent, TouchPhase};
use crate::platform::input::{InputEvent, TouchPhase};
use glam::Vec2;
use std::sync::Mutex;
@@ -39,13 +39,19 @@ static BUFFER: Mutex<Vec<RawPencilPoint>> = Mutex::new(Vec::new());
///
/// This is exposed as a C function so Swift can call it.
/// The `#[no_mangle]` prevents Rust from changing the function name.
#[no_mangle]
pub extern "C" fn pencil_point_received(point: RawPencilPoint) {
#[unsafe(no_mangle)]
pub extern "C" fn rust_push_pencil_point(point: RawPencilPoint) {
if let Ok(mut buf) = BUFFER.lock() {
buf.push(point);
}
}
/// Legacy alias for compatibility
#[unsafe(no_mangle)]
pub extern "C" fn pencil_point_received(point: RawPencilPoint) {
rust_push_pencil_point(point);
}
/// Drain all buffered pencil points and convert to InputEvents
///
/// Call this from your Bevy Update system to consume input.
@@ -89,15 +95,20 @@ fn raw_to_input_event(p: RawPencilPoint) -> InputEvent {
}
}
/// Attach the pencil capture system to a UIView
///
/// This is only available on iOS. On other platforms, it's a no-op.
#[cfg(target_os = "ios")]
extern "C" {
unsafe extern "C" {
/// Attach the pencil capture system to a UIView
pub fn swift_attach_pencil_capture(view: *mut std::ffi::c_void);
/// Detach the pencil capture system from a UIView
pub fn swift_detach_pencil_capture(view: *mut std::ffi::c_void);
}
#[cfg(not(target_os = "ios"))]
pub unsafe fn swift_attach_pencil_capture(_: *mut std::ffi::c_void) {
// No-op on non-iOS platforms
}
#[cfg(not(target_os = "ios"))]
pub unsafe fn swift_detach_pencil_capture(_: *mut std::ffi::c_void) {
// No-op on non-iOS platforms
}

View File

@@ -4,6 +4,11 @@
//! - **input**: Abstract input events (keyboard, mouse, touch, gestures)
//! - **desktop**: Concrete winit-based implementation for desktop platforms
//! - **ios**: Concrete UIKit-based implementation for iOS
//!
//! The `run_executor()` function is the main entry point and automatically
//! selects the correct platform-specific executor based on compilation target.
use std::path::PathBuf;
pub mod input;
@@ -12,3 +17,57 @@ pub mod ios;
#[cfg(not(target_os = "ios"))]
pub mod desktop;
// Re-export the appropriate executor based on target platform
#[cfg(target_os = "ios")]
pub use ios::run_executor;
#[cfg(not(target_os = "ios"))]
pub use desktop::run_executor;
/// Sanitize app name for safe filesystem usage
///
/// Removes whitespace and converts to lowercase.
/// Example: "My App" -> "myapp"
pub fn sanitize_app_name(app_name: &str) -> String {
app_name.replace(char::is_whitespace, "").to_lowercase()
}
/// Get the database filename for an application
///
/// Returns sanitized app name with .db extension.
/// Example: "My App" -> "myapp.db"
pub fn get_database_filename(app_name: &str) -> String {
format!("{}.db", sanitize_app_name(app_name))
}
/// Get the full database path for an application
///
/// Combines platform-appropriate directory with sanitized database filename.
/// Example on iOS: "/path/to/Documents/myapp.db"
/// Example on desktop: "./myapp.db"
pub fn get_database_path(app_name: &str) -> PathBuf {
get_data_directory().join(get_database_filename(app_name))
}
/// Get the platform-appropriate data directory
///
/// - iOS: Returns the app's Documents directory (via dirs crate)
/// - Desktop: Returns the current directory
pub fn get_data_directory() -> PathBuf {
#[cfg(target_os = "ios")]
{
// Use dirs crate for proper iOS document directory access
if let Some(doc_dir) = dirs::document_dir() {
doc_dir
} else {
tracing::warn!("Failed to get iOS document directory, using current directory");
PathBuf::from(".")
}
}
#[cfg(not(target_os = "ios"))]
{
PathBuf::from(".")
}
}