diff --git a/Cargo.lock b/Cargo.lock index 5f3a12f..a20808b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2595,6 +2595,27 @@ dependencies = [ "crypto-common 0.2.0-rc.4", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -4499,6 +4520,7 @@ dependencies = [ "crdts", "criterion", "crossbeam-channel", + "dirs", "egui", "encase 0.10.0", "futures-lite", @@ -5527,6 +5549,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.49" @@ -6201,6 +6229,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.2" diff --git a/crates/app/src/input/desktop_bridge.rs b/crates/app/src/input/desktop_bridge.rs deleted file mode 100644 index c4ac3aa..0000000 --- a/crates/app/src/input/desktop_bridge.rs +++ /dev/null @@ -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 { - // 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::() - .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) { - buffer.events.clear(); -} - -/// Collect mouse button events -fn collect_mouse_buttons( - mut buffer: ResMut, - mut mouse_button_events: MessageReader, - 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, - mut cursor_moved: MessageReader, - mouse_buttons: Res>, -) { - 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, - mut wheel_events: MessageReader, - 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, - mut keyboard_events: MessageReader, - keys: Res>, -) { - 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, - }); - } - } -} diff --git a/crates/app/src/input/mod.rs b/crates/app/src/input/mod.rs index 59029e0..47d7686 100644 --- a/crates/app/src/input/mod.rs +++ b/crates/app/src/input/mod.rs @@ -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; diff --git a/crates/app/src/input/mouse.rs b/crates/app/src/input/mouse.rs deleted file mode 100644 index b0d5b9d..0000000 --- a/crates/app/src/input/mouse.rs +++ /dev/null @@ -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>, - mut mouse_motion: MessageReader, - mut mouse_wheel: MessageReader, - mut mouse_state: Local>, - lock_registry: Res, - node_clock: Res, - mut cube_query: Query<(&NetworkedEntity, &mut Transform), With>, -) { - // 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 - } - } -} diff --git a/crates/app/src/input/pencil.rs b/crates/app/src/input/pencil.rs deleted file mode 100644 index dc5a932..0000000 --- a/crates/app/src/input/pencil.rs +++ /dev/null @@ -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, - 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>) { - 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>) { - 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); - } - } -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 28fb0d6..e9d10bc 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -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"); } diff --git a/crates/libmarathon/Cargo.toml b/crates/libmarathon/Cargo.toml index b4b4b82..936e7bf 100644 --- a/crates/libmarathon/Cargo.toml +++ b/crates/libmarathon/Cargo.toml @@ -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 diff --git a/crates/libmarathon/build.rs b/crates/libmarathon/build.rs new file mode 100644 index 0000000..9999785 --- /dev/null +++ b/crates/libmarathon/build.rs @@ -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()); +} diff --git a/crates/libmarathon/src/engine/persistence.rs b/crates/libmarathon/src/engine/persistence.rs index cd5f756..27d10ab 100644 --- a/crates/libmarathon/src/engine/persistence.rs +++ b/crates/libmarathon/src/engine/persistence.rs @@ -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) diff --git a/crates/libmarathon/src/lib.rs b/crates/libmarathon/src/lib.rs index 880213b..8cc5172 100644 --- a/crates/libmarathon/src/lib.rs +++ b/crates/libmarathon/src/lib.rs @@ -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, 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, 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, db_path: impl Into, 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(), } diff --git a/crates/libmarathon/src/platform/ios/PencilBridge.h b/crates/libmarathon/src/platform/ios/PencilBridge.h new file mode 100644 index 0000000..86919aa --- /dev/null +++ b/crates/libmarathon/src/platform/ios/PencilBridge.h @@ -0,0 +1,24 @@ +#ifndef PENCIL_BRIDGE_H +#define PENCIL_BRIDGE_H + +#include + +// 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 diff --git a/crates/libmarathon/src/platform/ios/PencilCapture.swift b/crates/libmarathon/src/platform/ios/PencilCapture.swift new file mode 100644 index 0000000..046af52 --- /dev/null +++ b/crates/libmarathon/src/platform/ios/PencilCapture.swift @@ -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, with event: UIEvent?) { + for touch in touches { + if touch.type == .pencil || touch.type == .stylus { + handlePencilTouch(touch, phase: 0) // began + } + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + for touch in touches { + if touch.type == .pencil || touch.type == .stylus { + handlePencilTouch(touch, phase: 1) // moved + } + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + for touch in touches { + if touch.type == .pencil || touch.type == .stylus { + handlePencilTouch(touch, phase: 2) // ended + } + } + } + + override func touchesCancelled(_ touches: Set, 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.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.fromOpaque(uiView).takeUnretainedValue() as UIView? else { + return + } + + if let recognizer = pencilGestureRecognizer { + view.removeGestureRecognizer(recognizer) + pencilGestureRecognizer = nil + print("✏️ Apple Pencil capture detached") + } +} diff --git a/crates/libmarathon/src/platform/ios/executor.rs b/crates/libmarathon/src/platform/ios/executor.rs new file mode 100644 index 0000000..7125a98 --- /dev/null +++ b/crates/libmarathon/src/platform/ios/executor.rs @@ -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 }, + Running { + window: Arc, + 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::>() + .write(WindowCreated { window }); +} + +fn send_window_resized( + app: &mut App, + window: Entity, + physical_size: winit::dpi::PhysicalSize, + scale_factor: f64, +) { + app.world_mut() + .resource_mut::>() + .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::>() + .write(WindowScaleFactorChanged { + window, + scale_factor, + }); +} + +fn send_window_closing(app: &mut App, window: Entity) { + app.world_mut() + .resource_mut::>() + .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::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + + // Initialize input resources that Bevy UI and picking expect + bevy_app.init_resource::>(); + bevy_app.init_resource::>(); + bevy_app.init_resource::(); + bevy_app.init_resource::>(); + + // 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::(*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::(); + 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::(*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> { + 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(()) +} diff --git a/crates/libmarathon/src/platform/ios/mod.rs b/crates/libmarathon/src/platform/ios/mod.rs index 0c205d4..e1fba1d 100644 --- a/crates/libmarathon/src/platform/ios/mod.rs +++ b/crates/libmarathon/src/platform/ios/mod.rs @@ -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, }; diff --git a/crates/libmarathon/src/platform/ios/pencil_bridge.rs b/crates/libmarathon/src/platform/ios/pencil_bridge.rs index d54bfe7..8f1a904 100644 --- a/crates/libmarathon/src/platform/ios/pencil_bridge.rs +++ b/crates/libmarathon/src/platform/ios/pencil_bridge.rs @@ -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> = 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 +} diff --git a/crates/libmarathon/src/platform/mod.rs b/crates/libmarathon/src/platform/mod.rs index c55e994..0b2ac9d 100644 --- a/crates/libmarathon/src/platform/mod.rs +++ b/crates/libmarathon/src/platform/mod.rs @@ -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(".") + } +} diff --git a/docs/ios-deployment.md b/docs/ios-deployment.md new file mode 100644 index 0000000..defa109 --- /dev/null +++ b/docs/ios-deployment.md @@ -0,0 +1,278 @@ +# iOS Deployment Guide + +This guide covers building and deploying Aspen (built on Marathon engine) to iOS devices and simulators. + +## Prerequisites + +### Required Tools + +1. **Xcode Command Line Tools** (not full Xcode IDE) + ```bash + xcode-select --install + ``` + +2. **Rust iOS targets** + ```bash + # For simulator (M1/M2/M3 Macs) + rustup target add aarch64-apple-ios-sim + + # For device + rustup target add aarch64-apple-ios + ``` + +3. **iOS Simulator Runtime** (one-time download, ~8GB) + ```bash + xcodebuild -downloadPlatform iOS + ``` + +### Verify Installation + +```bash +# Check Swift compiler +swiftc --version + +# Check iOS SDK +xcrun --sdk iphoneos --show-sdk-path + +# List available simulators +xcrun simctl list devices +``` + +## Building for iOS Simulator + +### Quick Start + +Run all steps in one go: + +```bash +# From project root +./scripts/ios/build-simulator.sh +./scripts/ios/package-app.sh +./scripts/ios/deploy-simulator.sh +``` + +### Step-by-Step + +#### 1. Build the Binary + +```bash +cargo build \ + --package app \ + --target aarch64-apple-ios-sim \ + --release +``` + +**Output:** `target/aarch64-apple-ios-sim/release/app` + +#### 2. Package as .app Bundle + +iOS apps must be in a specific `.app` bundle structure: + +``` +Aspen.app/ +├── app # The executable binary +├── Info.plist # App metadata +└── PkgInfo # Legacy file (APPL????) +``` + +Run the packaging script: + +```bash +./scripts/ios/package-app.sh +``` + +**Output:** `target/aarch64-apple-ios-sim/release/Aspen.app/` + +#### 3. Deploy to Simulator + +```bash +./scripts/ios/deploy-simulator.sh +``` + +This will: +1. Find and boot the iPad Pro 13-inch simulator +2. Open Simulator.app +3. Uninstall any previous version +4. Install the new .app bundle +5. Launch the app with console output + +**To use a different simulator:** + +```bash +./scripts/ios/deploy-simulator.sh "iPad Air 13-inch (M2)" +``` + +## Building for Physical Device + +### Prerequisites + +1. **Apple Developer Account** (free account works for local testing) +2. **Device connected via USB** +3. **Code signing setup** + +### Build Steps + +```bash +# Build for device +cargo build \ + --package app \ + --target aarch64-apple-ios \ + --release + +# Package (similar to simulator) +# TODO: Create device packaging script with code signing +``` + +### Code Signing + +iOS requires apps to be signed before running on device: + +```bash +# Create signing identity (first time only) +security find-identity -v -p codesigning + +# Sign the app bundle +codesign --force --sign "Apple Development: your@email.com" \ + target/aarch64-apple-ios/release/Aspen.app +``` + +### Deploy to Device + +```bash +# Using devicectl (modern, requires macOS 13+) +xcrun devicectl device install app \ + --device \ + target/aarch64-apple-ios/release/Aspen.app + +# Launch +xcrun devicectl device process launch \ + --device \ + io.r3t.aspen +``` + +## Project Structure + +``` +lonni/ +├── crates/ +│ ├── app/ # Aspen application +│ └── libmarathon/ # Marathon engine +│ ├── src/platform/ +│ │ ├── ios/ +│ │ │ ├── executor.rs # iOS winit executor +│ │ │ ├── pencil_bridge.rs # Rust FFI +│ │ │ ├── PencilBridge.h # C header +│ │ │ └── PencilCapture.swift # Swift capture +│ │ └── ... +│ └── build.rs # Swift compilation +├── scripts/ +│ └── ios/ +│ ├── Info.plist # App metadata template +│ ├── build-simulator.sh # Build binary +│ ├── package-app.sh # Create .app bundle +│ └── deploy-simulator.sh # Install & launch +└── docs/ + └── ios-deployment.md # This file +``` + +## App Bundle Configuration + +### Info.plist + +Key fields in `scripts/ios/Info.plist`: + +```xml +CFBundleIdentifier +io.r3t.aspen + +CFBundleName +Aspen + +MinimumOSVersion +14.0 +``` + +To customize, edit `scripts/ios/Info.plist` before packaging. + +## Apple Pencil Support + +Aspen includes native Apple Pencil support via the Marathon engine: + +- **Pressure sensitivity** (0.0 - 1.0) +- **Tilt angles** (altitude & azimuth) +- **Touch phases** (began/moved/ended/cancelled) +- **Timestamps** for precision input + +The Swift → Rust bridge is compiled automatically during `cargo build` for iOS targets. + +## Troubleshooting + +### "runtime profile not found" + +The iOS runtime isn't installed: + +```bash +xcodebuild -downloadPlatform iOS +``` + +### "No signing identity found" + +For simulator, signing is not required. For device: + +```bash +# Check signing identities +security find-identity -v -p codesigning + +# If none exist, create one in Xcode or via command line +``` + +### "Could not launch app" + +Check simulator logs: + +```bash +xcrun simctl spawn booted log stream --predicate 'processImagePath contains "Aspen"' +``` + +### Swift compilation fails + +Ensure the iOS SDK is installed: + +```bash +xcrun --sdk iphoneos --show-sdk-path +``` + +Should output a path like: +``` +/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.2.sdk +``` + +## Performance Notes + +### Simulator vs Device + +- **Simulator**: Runs on your Mac's CPU (fast for development) +- **Device**: Runs on iPad's chip (true performance testing) + +### Build Times + +- **Simulator build**: ~30s (incremental), ~5min (clean) +- **Device build**: Similar, but includes Swift compilation + +### App Size + +- **Debug build**: ~200MB (includes debug symbols) +- **Release build**: ~50MB (optimized, stripped) + +## Next Steps + +- [ ] Add device deployment script with code signing +- [ ] Create GitHub Actions workflow for CI builds +- [ ] Document TestFlight deployment +- [ ] Add crash reporting integration + +## References + +- [Apple Developer Documentation](https://developer.apple.com/documentation/) +- [Rust iOS Guide](https://github.com/rust-mobile/rust-ios-android) +- [Winit iOS Platform](https://docs.rs/winit/latest/winit/platform/ios/) diff --git a/scripts/ios/Info.plist b/scripts/ios/Info.plist new file mode 100644 index 0000000..c12717c --- /dev/null +++ b/scripts/ios/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + app + CFBundleIdentifier + io.r3t.aspen + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Aspen + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + MinimumOSVersion + 14.0 + UILaunchStoryboardName + LaunchScreen + + diff --git a/scripts/ios/build-simulator.sh b/scripts/ios/build-simulator.sh new file mode 100755 index 0000000..a545e9b --- /dev/null +++ b/scripts/ios/build-simulator.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# iOS Simulator Build Script +# Builds the Marathon app for iOS simulator (aarch64-apple-ios-sim) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "🔨 Building for iOS Simulator..." +echo "Project root: $PROJECT_ROOT" + +# Build for simulator target +cd "$PROJECT_ROOT" +cargo build \ + --package app \ + --target aarch64-apple-ios-sim \ + --release + +echo "✅ Build complete!" +echo "Binary location: $PROJECT_ROOT/target/aarch64-apple-ios-sim/release/app" diff --git a/scripts/ios/deploy-simulator.sh b/scripts/ios/deploy-simulator.sh new file mode 100755 index 0000000..44882f6 --- /dev/null +++ b/scripts/ios/deploy-simulator.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +# iOS Simulator Deploy Script +# Boots simulator, installs app, and launches it + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +APP_NAME="Aspen" +BUNDLE_ID="io.r3t.aspen" +TARGET="aarch64-apple-ios-sim" +BUILD_MODE="release" + +APP_BUNDLE="$PROJECT_ROOT/target/$TARGET/$BUILD_MODE/$APP_NAME.app" + +# Default to iPad Pro 12.9-inch M2 (matches user's physical device) +DEVICE_NAME="${1:-iPad Pro 12.9-inch M2}" + +echo "📱 Deploying to iOS Simulator..." +echo "Device: $DEVICE_NAME" +echo "Bundle: $APP_BUNDLE" + +# Check if bundle exists +if [ ! -d "$APP_BUNDLE" ]; then + echo "❌ App bundle not found! Run package-app.sh first." + exit 1 +fi + +# Get device UUID +echo "🔍 Finding device UUID..." +DEVICE_UUID=$(xcrun simctl list devices | grep "$DEVICE_NAME" | grep -v "unavailable" | head -1 | sed -E 's/.*\(([0-9A-F-]+)\).*/\1/') + +if [ -z "$DEVICE_UUID" ]; then + echo "❌ Device '$DEVICE_NAME' not found or unavailable." + echo "" + echo "Available devices:" + xcrun simctl list devices | grep -i ipad | grep -v unavailable + exit 1 +fi + +echo "✅ Found device: $DEVICE_UUID" + +# Boot device if not already booted +echo "🚀 Booting simulator..." +xcrun simctl boot "$DEVICE_UUID" 2>/dev/null || true + +# Wait a moment for boot +sleep 2 + +# Open Simulator.app +echo "📱 Opening Simulator.app..." +open -a Simulator + +# Uninstall old version if it exists +echo "🗑️ Uninstalling old version..." +xcrun simctl uninstall "$DEVICE_UUID" "$BUNDLE_ID" 2>/dev/null || true + +# Install the app +echo "📲 Installing app..." +xcrun simctl install "$DEVICE_UUID" "$APP_BUNDLE" + +# Launch the app +echo "🚀 Launching app..." +xcrun simctl launch --console "$DEVICE_UUID" "$BUNDLE_ID" + +echo "✅ App deployed and launched!" +echo "" +echo "To view logs:" +echo " xcrun simctl spawn $DEVICE_UUID log stream --predicate 'processImagePath contains \"$APP_NAME\"'" diff --git a/scripts/ios/package-app.sh b/scripts/ios/package-app.sh new file mode 100755 index 0000000..4a06388 --- /dev/null +++ b/scripts/ios/package-app.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +# iOS App Bundle Packaging Script +# Creates a proper .app bundle for iOS simulator deployment + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +APP_NAME="Aspen" +BUNDLE_ID="io.r3t.aspen" +TARGET="aarch64-apple-ios-sim" +BUILD_MODE="release" + +BINARY_PATH="$PROJECT_ROOT/target/$TARGET/$BUILD_MODE/app" +APP_BUNDLE="$PROJECT_ROOT/target/$TARGET/$BUILD_MODE/$APP_NAME.app" + +echo "📦 Packaging iOS app bundle..." +echo "Binary: $BINARY_PATH" +echo "Bundle: $APP_BUNDLE" + +# Check if binary exists +if [ ! -f "$BINARY_PATH" ]; then + echo "❌ Binary not found! Run build-simulator.sh first." + exit 1 +fi + +# Remove old bundle if it exists +if [ -d "$APP_BUNDLE" ]; then + echo "🗑️ Removing old bundle..." + rm -rf "$APP_BUNDLE" +fi + +# Create .app bundle structure +echo "📁 Creating bundle structure..." +mkdir -p "$APP_BUNDLE" + +# Copy binary +echo "📋 Copying binary..." +cp "$BINARY_PATH" "$APP_BUNDLE/app" + +# Copy Info.plist +echo "📋 Copying Info.plist..." +cp "$SCRIPT_DIR/Info.plist" "$APP_BUNDLE/Info.plist" + +# Create PkgInfo +echo "📋 Creating PkgInfo..." +echo -n "APPL????" > "$APP_BUNDLE/PkgInfo" + +# Set executable permissions +chmod +x "$APP_BUNDLE/app" + +echo "✅ App bundle created successfully!" +echo "Bundle location: $APP_BUNDLE" +echo "" +echo "Bundle contents:" +ls -lh "$APP_BUNDLE"