//! 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). pub mod backend; pub mod commands; pub mod controller; pub mod derive; pub mod devices; pub mod events; 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; pub fn run() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into()), ) .init(); tauri::Builder::default() .plugin(tauri_plugin_dialog::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, // 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, // controller commands::controller_status, commands::connect_controller, commands::disconnect_controller, commands::swap_controller_pods, ]) .setup(|app| { let handle = app.handle().clone(); // 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) } _ => {} } }); }