//! BikeControl desktop shell. //! //! This crate is *only* wiring: it owns the ride loop, exposes intents as Tauri //! commands, and pushes state to the webview as events. The ride logic proper //! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` โ€” the //! webview reaches neither directly (ยง4.3). #[cfg(target_os = "android")] pub mod android; pub mod backend; pub mod commands; pub mod controller; pub mod derive; pub mod devices; pub mod events; pub mod heart_rate; pub mod known; pub mod settings; pub mod profile_view; pub mod recording; pub mod samples; pub mod session_backend; pub mod state; pub mod trainer; pub mod wakelock; use tauri::{Manager, RunEvent, WindowEvent}; use crate::state::AppState; /// Set up `tracing` for whatever this platform calls "somewhere I can read it". fn init_tracing() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() // `bikecontrol_ble` belongs here as much as the shell does: it owns every // BLE conversation, so leaving it at `info` silences exactly the frames // NFR-8 says must be loggable โ€” subscribe failures, bad button frames, // the lot. That is survivable on desktop, where RUST_LOG can override // it, and not on Android, where there is no environment to set. .unwrap_or_else(|_| "info,bikecontrol_app_lib=debug,bikecontrol_ble=debug".into()); // Android has no stdout: the default writer would drop every line. See // `android::Logcat`. #[cfg(target_os = "android")] tracing_subscriber::fmt() .with_env_filter(filter) .with_ansi(false) .with_writer(android::Logcat) .init(); #[cfg(not(target_os = "android"))] tracing_subscriber::fmt().with_env_filter(filter).init(); } /// The entry point, on every platform. /// /// `main.rs` calls this on desktop. On Android there is no `main`: the /// attribute below generates the `start_app` symbol that Tauri's generated /// Kotlin invokes, which is why the whole app lives in a lib crate. #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { init_tracing(); // Before anything spawns: every BLE call is made from a Tauri task, and on // Android a task running on a thread the JVM has never seen cannot reach // droidplug at all. See `android::install_async_runtime`. #[cfg(target_os = "android")] android::install_async_runtime(); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .manage(AppState::new()) .invoke_handler(tauri::generate_handler![ // ride lifecycle commands::ride_state, commands::start_ride, commands::pause_ride, commands::resume_ride, commands::toggle_pause, commands::stop_ride, commands::reset_ride, // recording and export commands::ride_summary, commands::save_fit, commands::recovered_rides, // control modes and targets commands::set_control_mode, commands::cycle_control_mode, commands::shift_gear, commands::set_gear, commands::nudge_gradient, commands::set_gradient, commands::reset_gradient, commands::set_target_resistance, commands::set_target_power, commands::mark_lap, // configuration commands::rider_config, commands::set_rider_config, commands::safety_limits, commands::set_safety_limits, commands::preferences, commands::set_preferences, // profiles commands::load_profile_from_path, commands::load_profile_from_text, commands::preview_profile_yaml, commands::clear_profile, commands::sample_profiles, // devices commands::device_list, commands::start_scan, commands::stop_scan, commands::connect_device, commands::disconnect_device, commands::forget_device, commands::trainer_controllable, commands::request_bluetooth_enable, // controller commands::controller_status, commands::connect_controller, commands::disconnect_controller, commands::swap_controller_pods, ]) .setup(|app| { let handle = app.handle().clone(); // FR-1.5: the hardware the rider paired with last time, before the // first scan pass so the very first thing the scanner sees can be // reconnected rather than merely listed. A missing data directory // costs auto-connect and nothing else, so it is a warning, not a // failed launch. match known::store_path(&handle) { Ok(path) => handle.state::().lock().devices.attach_store(path), Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"), } // FR-7.4: the rider's mass, bike and drag, before the first tick // reads them. Restored ahead of the ride loop starting so no // snapshot is ever computed against the 105 kg default the struct // falls back to. match settings::store_path(&handle) { Ok(path) => { let state = handle.state::(); let mut inner = state.lock(); let inner = &mut *inner; let (rider, limits) = (&mut inner.inputs.rider, &mut inner.inputs.limits); inner.settings.attach(path, rider, limits); } Err(e) => tracing::warn!(error = %e, "rider settings unavailable"), } // NFR-7: scanning starts immediately, not on a user click. handle.state::().lock().devices.start_scan(); state::spawn_ride_loop(handle.clone()); state::spawn_device_loop(handle.clone()); state::spawn_controller_loop(handle.clone()); // FR-8.4: a journal with no activity beside it is a ride the app // died during. Rebuilding it is the same code path a clean stop // uses, so the rider gets the same file they would have had. // // The result is stashed rather than emitted: nothing is listening // on the event channel yet, and a recovered ride is exactly the // thing that must not be announced to an empty room. The webview // collects it via `recovered_rides` when it starts. let recovered = recording::recover_orphans(&handle); handle.state::().lock().recovered = recovered; recording::prune(&handle, commands::KEEP_RECORDINGS); state::emit_devices(&handle); state::emit_ride_state(&handle); Ok(()) }) .build(tauri::generate_context!()) .expect("failed to start BikeControl") .run(|app, event| { // SAF-2 / SAF-9 โ€” on any exit path, hand the trainer back at zero // load and close every link the app owns. `shutdown_devices` blocks // until the reset sequence has actually been written; a // fire-and-forget send would race the process teardown and leave the // rider on a loaded trainer. It is idempotent, which matters because // one quit delivers several of these events. match &event { RunEvent::ExitRequested { .. } | RunEvent::Exit => { // NFR-11: hand the screensaver back too. The inhibitor would // lapse with the process anyway, but not before a slow // shutdown, and a released lock is one fewer thing to explain. crate::wakelock::set(false); state::shutdown_devices(app) } RunEvent::WindowEvent { event: WindowEvent::Destroyed, .. } => { crate::wakelock::set(false); state::shutdown_devices(app) } _ => {} } }); }