Loading a GPX on Android failed for every file in the picker. The dialog
plugin fires ACTION_GET_CONTENT, which returns a `content://` URI, and
`load_profile_from_path` handed that straight to `std::fs::read_to_string`
— "no such file or directory" for a file the rider is looking at. Picking
from Nextcloud makes it plainer: a document provider backed by a server
may have no local file at all until the resolver opens the stream, so
there was never a path to find.
So the command now takes a `FilePath` and reads through tauri-plugin-fs,
which opens a path directly and a URI via the resolver. The plugin is
here for `FsExt` alone; nothing in ui/ calls its commands, so the
capabilities are unchanged.
Two things that were derived from the filename can no longer be:
- GPX is detected by content. A document id need not contain a name,
let alone an extension. No YAML profile begins with `<`.
- The route name falls back to the GPX's own <name>. Providers over
real storage encode the filename in the last segment, but an opaque
row id would have made a wretched route name.
save_fit had the same bug on the export side — PathBuf::from on a
save-dialog URI — and now writes down a resolver descriptor when handed
one.
Note for anyone rebuilding locally: gen/android/tauri.settings.gradle is
autogenerated and lists each plugin's Android project, so the new
plugin's Kotlin only reaches the APK after `cargo tauri android init` and
scripts/sync-android-sources.sh. CI already runs both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
798 lines
29 KiB
Rust
798 lines
29 KiB
Rust
//! Every intent the rider can express, as a Tauri command.
|
||
//!
|
||
//! Commands are *intents*, not state changes the frontend has already made:
|
||
//! they mutate Rust-side state and the resulting truth comes back on the event
|
||
//! channel. The UI never assumes a command took effect (§4.3).
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||
use bikecontrol_core::profile::Profile;
|
||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||
use tauri::{AppHandle, State};
|
||
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||
|
||
use bikecontrol_ble::PodId;
|
||
|
||
use crate::controller::{ControllerStatus, Pod};
|
||
use crate::devices::DeviceInfo;
|
||
use crate::events::{DeviceList, LapSummary, Notice, RideState, RideStatus};
|
||
use crate::profile_view::{self, ProfileView};
|
||
use crate::recording::{self, Recovered, RideRecordingSetup, RideSummary};
|
||
use crate::state::{ack, emit_devices, emit_ride_state, notify, AppState};
|
||
|
||
type Cmd<T> = Result<T, String>;
|
||
|
||
/// Ride time now, for journal entries that need a timestamp. Zero before the
|
||
/// first tick, which is the correct answer rather than a missing one.
|
||
fn elapsed_ms(state: &AppState) -> u64 {
|
||
state.lock().last_snapshot.map_or(0, |s| s.elapsed_ms)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Ride state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tauri::command]
|
||
pub fn ride_state(state: State<'_, AppState>) -> RideState {
|
||
state.lock().ride_state()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn start_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
begin_ride(&app, &state);
|
||
ack(&app, "start", None);
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// Put the ride into `Running`, opening a journal if this is a fresh start.
|
||
///
|
||
/// Every route into a running ride goes through here — the Start button, the
|
||
/// space bar via [`toggle_pause`], and Click face button B. Any new path that
|
||
/// set `Running` on its own would ride with no recorder attached, and the rider
|
||
/// would not find out until the summary said nothing had been saved.
|
||
fn begin_ride(app: &AppHandle, state: &AppState) {
|
||
let setup = {
|
||
let mut inner = state.lock();
|
||
// A fresh ride, as opposed to resuming a paused one. Only a fresh ride
|
||
// opens a new journal; resuming must keep writing to the current one.
|
||
let fresh = matches!(inner.inputs.status, RideStatus::Finished | RideStatus::Idle);
|
||
if fresh {
|
||
inner.reset_ride();
|
||
}
|
||
inner.inputs.status = RideStatus::Running;
|
||
fresh.then(|| RideRecordingSetup {
|
||
stamp: recording::stamp_now(),
|
||
rider_kg: inner.inputs.rider.rider_kg,
|
||
has_profile: inner.inputs.profile.is_some(),
|
||
})
|
||
};
|
||
// Started outside the lock: creating the journal touches the disk.
|
||
let Some(setup) = setup else {
|
||
// Resuming, not starting: the journal is already open and only needs
|
||
// its timer restarted.
|
||
state.recorder().resume(elapsed_ms(state));
|
||
return;
|
||
};
|
||
let started = recording::rides_dir(app).and_then(|dir| state.recorder().start(&dir, setup));
|
||
if let Err(e) = started {
|
||
// The ride still starts. Refusing to ride because a file could not be
|
||
// opened would be the wrong trade — but the rider has to be told this
|
||
// one will not be saved.
|
||
tracing::error!(%e, "recording did not start");
|
||
notify(
|
||
app,
|
||
Notice::error(format!("{e} — this ride will not be saved")),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn pause_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
state.lock().inputs.status = RideStatus::Paused;
|
||
state.recorder().pause(elapsed_ms(&state));
|
||
ack(&app, "pause", None);
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn resume_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
// Not a bare status assignment: resuming from `Finished` is a *new* ride and
|
||
// must open a journal rather than run on unrecorded.
|
||
begin_ride(&app, &state);
|
||
ack(&app, "resume", None);
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// Pause or resume, whichever is the opposite of now. This is the one bound to
|
||
/// the space bar and to Click face button B, and it is also how a ride is
|
||
/// *started* from the launch screen — hence the trip through [`begin_ride`]
|
||
/// rather than a status assignment.
|
||
#[tauri::command]
|
||
pub fn toggle_pause(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
let running = state.lock().inputs.status == RideStatus::Running;
|
||
if running {
|
||
state.lock().inputs.status = RideStatus::Paused;
|
||
state.recorder().pause(elapsed_ms(&state));
|
||
} else {
|
||
begin_ride(&app, &state);
|
||
}
|
||
let status = state.lock().inputs.status;
|
||
ack(&app, "toggle-pause", Some(format!("{status:?}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// End the ride. SAF-2: the trainer is returned to 0% / minimum resistance
|
||
/// before the session closes.
|
||
///
|
||
/// The activity is written automatically, before anything is shown and before
|
||
/// the rider is asked anything (FR-8.2). Saving to a location they choose is a
|
||
/// copy made afterwards (FR-9.14, [`save_fit`]) — a rider who cancels that
|
||
/// dialog, or closes the window, still has their ride.
|
||
#[tauri::command]
|
||
pub fn stop_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
state.lock().inputs.status = RideStatus::Finished;
|
||
// The trainer comes first. Whatever happens to the file, the rider must not
|
||
// be left on a loaded trainer while we talk to the disk.
|
||
crate::state::release_trainer(&app);
|
||
ack(&app, "stop", None);
|
||
notify(&app, Notice::info("Ride ended — trainer released to 0%"));
|
||
|
||
match state.recorder().finish() {
|
||
Ok(Some((summary, fit_path))) => {
|
||
let summary = RideSummary::new(&summary, &fit_path);
|
||
state.lock().last_summary = Some(summary.clone());
|
||
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_SUMMARY, summary);
|
||
recording::prune(&app, KEEP_RECORDINGS);
|
||
}
|
||
// Nothing was recording — a ride that never started, or a recorder that
|
||
// failed to open at the start and already said so.
|
||
Ok(None) => {}
|
||
Err(e) => {
|
||
tracing::error!(%e, "could not finalise the activity");
|
||
notify(&app, Notice::error(e));
|
||
}
|
||
}
|
||
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// How many finished rides stay in the app's data directory.
|
||
///
|
||
/// §5.8 puts ride *history* out of scope for v1: the FIT the rider saved is the
|
||
/// artifact, and this directory is the safety net behind it. Unbounded it would
|
||
/// grow forever somewhere nobody looks.
|
||
pub const KEEP_RECORDINGS: usize = 20;
|
||
|
||
/// Rides rebuilt from an interrupted session at startup (FR-8.4).
|
||
///
|
||
/// Draining rather than reading: this is reported to the rider once, and a
|
||
/// webview reload should not re-announce a recovery they have already seen.
|
||
#[tauri::command]
|
||
pub fn recovered_rides(state: State<'_, AppState>) -> Vec<Recovered> {
|
||
std::mem::take(&mut state.lock().recovered)
|
||
}
|
||
|
||
/// The most recently finished ride, if the summary screen is reloaded.
|
||
#[tauri::command]
|
||
pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
|
||
state.lock().last_summary.clone()
|
||
}
|
||
|
||
/// Save the finished activity where the rider asked (FR-9.14).
|
||
///
|
||
/// Returns the path actually written, so the UI can confirm it rather than
|
||
/// claiming success against a path it merely proposed.
|
||
#[tauri::command]
|
||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||
let source = {
|
||
let inner = state.lock();
|
||
let summary = inner
|
||
.last_summary
|
||
.as_ref()
|
||
.ok_or("There is no finished ride to save")?;
|
||
PathBuf::from(&summary.fit_path)
|
||
};
|
||
|
||
match &path {
|
||
FilePath::Path(dest) => recording::save_copy(&source, dest)?,
|
||
// Android: the save dialog returns a `content://` URI for a document
|
||
// the provider has already created. There is no directory to make and
|
||
// no path to copy to — the bytes go down a descriptor the resolver
|
||
// opens, which is the same reason `read_picked_file` exists.
|
||
FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
|
||
}
|
||
let written = path.to_string();
|
||
if let Some(summary) = state.lock().last_summary.as_mut() {
|
||
summary.saved_path = Some(written.clone());
|
||
}
|
||
ack(&app, "save-fit", Some(written.clone()));
|
||
notify(&app, Notice::info(format!("Ride saved to {written}")));
|
||
Ok(written)
|
||
}
|
||
|
||
/// Copy the activity into a document the rider chose from an Android picker.
|
||
///
|
||
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
|
||
/// filesystem path on the other end, only a URI the content resolver can turn
|
||
/// into a writable descriptor. Still a copy, never a move, for the reason
|
||
/// `save_copy` documents — the automatic file in the rides directory has to
|
||
/// survive a failed export.
|
||
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
|
||
let mut from = std::fs::File::open(source)
|
||
.map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
|
||
let mut to = app
|
||
.fs()
|
||
.open(
|
||
dest.clone(),
|
||
OpenOptions::new().write(true).truncate(true).clone(),
|
||
)
|
||
.map_err(|e| format!("could not save to {dest}: {e}"))?;
|
||
std::io::copy(&mut from, &mut to)
|
||
.map(|_| ())
|
||
.map_err(|e| format!("could not save to {dest}: {e}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
state.lock().reset_ride();
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Control modes and targets (§5.4)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Mode cycle order, matching the on-screen control and Click face button A.
|
||
const MODE_CYCLE: [ControlMode; 4] = [
|
||
ControlMode::ManualGrade,
|
||
ControlMode::Profile,
|
||
ControlMode::Resistance,
|
||
ControlMode::Erg,
|
||
];
|
||
|
||
#[tauri::command]
|
||
pub fn set_control_mode(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
mode: ControlMode,
|
||
) -> Cmd<RideState> {
|
||
{
|
||
let mut inner = state.lock();
|
||
if mode == ControlMode::Profile && inner.inputs.profile.is_none() {
|
||
return Err("No profile loaded — load a GPX or YAML profile first".into());
|
||
}
|
||
inner.inputs.mode = mode;
|
||
}
|
||
ack(&app, "mode", Some(format!("{mode:?}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn cycle_control_mode(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
let mode = {
|
||
let mut inner = state.lock();
|
||
let has_profile = inner.inputs.profile.is_some();
|
||
let current = inner.inputs.mode;
|
||
let start = MODE_CYCLE.iter().position(|m| *m == current).unwrap_or(0);
|
||
let mut chosen = current;
|
||
for step in 1..=MODE_CYCLE.len() {
|
||
let candidate = MODE_CYCLE[(start + step) % MODE_CYCLE.len()];
|
||
if candidate == ControlMode::Profile && !has_profile {
|
||
continue;
|
||
}
|
||
chosen = candidate;
|
||
break;
|
||
}
|
||
inner.inputs.mode = chosen;
|
||
chosen
|
||
};
|
||
ack(&app, "mode", Some(format!("{mode:?}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// Shift the virtual gear by `delta` (FR-4.1).
|
||
///
|
||
/// Clamps at both ends rather than wrapping: going from top gear straight to
|
||
/// bottom mid-climb would be violent, and a rider holding the paddle down
|
||
/// expects to arrive at the end of the cassette and stay there.
|
||
///
|
||
/// This is the one place a shift happens. The controller loop and the keyboard
|
||
/// both route here, so the pod and the keys cannot drift apart, and neither can
|
||
/// also nudge the gradient on the way past — a shift changes how hard the
|
||
/// pedals are, not what the road is doing.
|
||
#[tauri::command]
|
||
pub fn shift_gear(app: AppHandle, state: State<'_, AppState>, delta: i32) -> Cmd<RideState> {
|
||
let (gear, count) = {
|
||
let mut inner = state.lock();
|
||
inner.inputs.shift_gear(delta);
|
||
(inner.inputs.gear, inner.inputs.gear_count())
|
||
};
|
||
ack(&app, "gear", Some(format!("{gear}/{count}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// Select a gear directly, one-based (FR-4.1). Out-of-range values clamp.
|
||
#[tauri::command]
|
||
pub fn set_gear(app: AppHandle, state: State<'_, AppState>, gear: usize) -> Cmd<RideState> {
|
||
let (gear, count) = {
|
||
let mut inner = state.lock();
|
||
inner.inputs.set_gear(gear);
|
||
(inner.inputs.gear, inner.inputs.gear_count())
|
||
};
|
||
ack(&app, "gear", Some(format!("{gear}/{count}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
/// FR-4.2 / SAF-5 — one configured increment per event, never more.
|
||
#[tauri::command]
|
||
pub fn nudge_gradient(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
delta_pct: f32,
|
||
) -> Cmd<RideState> {
|
||
let step = delta_pct.clamp(-2.0, 2.0);
|
||
{
|
||
let mut inner = state.lock();
|
||
match inner.inputs.mode {
|
||
ControlMode::ManualGrade => inner.inputs.manual_gradient_pct += step,
|
||
// In profile mode the nudge trims on top of the profile's gradient.
|
||
_ => inner.inputs.gradient_offset_pct += step,
|
||
}
|
||
let limits = inner.inputs.limits;
|
||
inner.inputs.manual_gradient_pct = inner
|
||
.inputs
|
||
.manual_gradient_pct
|
||
.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
|
||
inner.inputs.gradient_offset_pct = inner.inputs.gradient_offset_pct.clamp(-10.0, 10.0);
|
||
}
|
||
ack(&app, "gradient", Some(format!("{step:+.1}%")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_gradient(app: AppHandle, state: State<'_, AppState>, percent: f32) -> Cmd<RideState> {
|
||
{
|
||
let mut inner = state.lock();
|
||
let limits = inner.inputs.limits;
|
||
inner.inputs.manual_gradient_pct =
|
||
percent.clamp(limits.min_gradient_pct, limits.max_gradient_pct);
|
||
}
|
||
ack(&app, "gradient", Some(format!("{percent:.1}%")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn reset_gradient(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
{
|
||
let mut inner = state.lock();
|
||
inner.inputs.gradient_offset_pct = 0.0;
|
||
inner.inputs.manual_gradient_pct = 0.0;
|
||
}
|
||
ack(&app, "gradient-reset", None);
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_target_resistance(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
level: i16,
|
||
) -> Cmd<RideState> {
|
||
{
|
||
let mut inner = state.lock();
|
||
let limits = inner.inputs.limits;
|
||
inner.inputs.resistance_level = level.clamp(limits.min_resistance, limits.max_resistance);
|
||
}
|
||
ack(&app, "resistance", Some(format!("{level}")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_target_power(app: AppHandle, state: State<'_, AppState>, watts: u16) -> Cmd<RideState> {
|
||
{
|
||
let mut inner = state.lock();
|
||
let limits = inner.inputs.limits;
|
||
inner.inputs.power_target_w = watts.clamp(limits.min_power_w, limits.max_power_w);
|
||
}
|
||
ack(&app, "power", Some(format!("{watts} W")));
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn mark_lap(app: AppHandle, state: State<'_, AppState>) -> Cmd<LapSummary> {
|
||
let lap = state.lock().mark_lap();
|
||
// The journal takes the ride time the lap closed at, not the lap's own
|
||
// duration — the two differ from the second lap onwards.
|
||
state.recorder().mark_lap(elapsed_ms(&state), false);
|
||
let _ = tauri::Emitter::emit(&app, crate::events::RIDE_LAP, lap);
|
||
ack(&app, "lap", Some(format!("Lap {}", lap.index)));
|
||
emit_ride_state(&app);
|
||
Ok(lap)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rider and safety configuration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tauri::command]
|
||
pub fn rider_config(state: State<'_, AppState>) -> RiderConfig {
|
||
state.lock().inputs.rider
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_rider_config(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
config: RiderConfig,
|
||
) -> Cmd<RiderConfig> {
|
||
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 {
|
||
return Err("Rider and bike mass must be positive and realistic".into());
|
||
}
|
||
state.lock().inputs.rider = config;
|
||
emit_ride_state(&app);
|
||
Ok(config)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn safety_limits(state: State<'_, AppState>) -> SafetyLimits {
|
||
state.lock().inputs.limits
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_safety_limits(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
limits: SafetyLimits,
|
||
) -> Cmd<SafetyLimits> {
|
||
if limits.min_gradient_pct >= limits.max_gradient_pct {
|
||
return Err("Gradient limits are inverted".into());
|
||
}
|
||
state.lock().inputs.limits = limits;
|
||
emit_ride_state(&app);
|
||
Ok(limits)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Profiles (§5.5, §5.6)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String> {
|
||
if is_gpx {
|
||
// FR-5.2/5.3: core smooths the elevation before differentiating and
|
||
// clamps the result. The defaults are the spec's defaults.
|
||
gpx::import(text, name, &SmoothingConfig::default()).map_err(|e| e.to_string())
|
||
} else {
|
||
Profile::from_yaml(text).map_err(|e| e.to_string())
|
||
}
|
||
}
|
||
|
||
/// Read a file the rider picked, wherever it actually lives.
|
||
///
|
||
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
|
||
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
|
||
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
|
||
/// or directory" — an error the rider gets for a file they are looking at in
|
||
/// the picker. And for a provider backed by a server rather than storage
|
||
/// (Nextcloud, Drive) there may be no local file at all until the resolver
|
||
/// opens the stream, so no amount of path-guessing could have found one.
|
||
///
|
||
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
|
||
/// opened directly, a URI goes through the Android content resolver for a file
|
||
/// descriptor. On desktop it is `std::fs` with extra steps.
|
||
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
|
||
app.fs()
|
||
.read_to_string(path.clone())
|
||
.map_err(|e| format!("{path}: {e}"))
|
||
}
|
||
|
||
/// Whether a picked file is GPX, decided by content rather than by name.
|
||
///
|
||
/// The extension is not always there to read: a `content://` URI carries a
|
||
/// document id, which for some providers contains no filename at all. XML is
|
||
/// unmistakable next to the YAML profile format — no YAML document begins with
|
||
/// `<` — so the first non-space character is the reliable test and the
|
||
/// extension is only a fast path.
|
||
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
|
||
path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
|
||
}
|
||
|
||
/// The filename behind a picked file, if there is one to be had.
|
||
///
|
||
/// A `FilePath::Path` always has a stem. A URI might: providers over real
|
||
/// storage encode the path in the last segment, percent-escaped but with the
|
||
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
|
||
/// ids, which would make a terrible route name — so "does it end in an
|
||
/// extension we know" is the test, and anything else gets `None` and falls back
|
||
/// to the name inside the GPX.
|
||
fn picked_file_stem(path: &FilePath) -> Option<String> {
|
||
match path {
|
||
FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
|
||
FilePath::Url(url) => {
|
||
let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
|
||
if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
|
||
return None;
|
||
}
|
||
// The escaped separators are all that stands between the document
|
||
// id and the name inside it.
|
||
let decoded = stem
|
||
.replace("%2F", "/")
|
||
.replace("%2f", "/")
|
||
.replace("%3A", ":")
|
||
.replace("%3a", ":");
|
||
let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
|
||
(!name.is_empty()).then(|| name.to_string())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
|
||
///
|
||
/// The fallback when the picker gave us no filename to use. Matches on local
|
||
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
|
||
/// same reason `core::gpx` does.
|
||
fn gpx_name(xml: &str) -> Option<String> {
|
||
let doc = roxmltree::Document::parse(xml).ok()?;
|
||
let named = |parent: &str| {
|
||
doc.descendants()
|
||
.find(|n| n.is_element() && n.tag_name().name() == parent)?
|
||
.children()
|
||
.find(|c| c.is_element() && c.tag_name().name() == "name")?
|
||
.text()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
.map(str::to_string)
|
||
};
|
||
named("metadata").or_else(|| named("trk"))
|
||
}
|
||
|
||
/// Load a profile the rider picked: a path on desktop, a `content://` URI on
|
||
/// Android. GPX is detected by content, everything else is treated as the YAML
|
||
/// profile format.
|
||
#[tauri::command]
|
||
pub fn load_profile_from_path(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
path: FilePath,
|
||
) -> Cmd<ProfileView> {
|
||
let text = read_picked_file(&app, &path)?;
|
||
let is_gpx = looks_like_gpx(&path, &text);
|
||
let name = picked_file_stem(&path)
|
||
.or_else(|| gpx_name(&text))
|
||
.unwrap_or_else(|| "Profile".into());
|
||
let profile = parse_profile(&text, &name, is_gpx)?;
|
||
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||
state.lock().set_profile(profile, view.clone(), geom);
|
||
emit_ride_state(&app);
|
||
notify(
|
||
&app,
|
||
Notice::info(format!("Loaded profile “{}”", view.name)),
|
||
);
|
||
Ok(view)
|
||
}
|
||
|
||
/// Load from text the frontend already has — used by the drop target, the
|
||
/// built-in samples and the profile editor.
|
||
#[tauri::command]
|
||
pub fn load_profile_from_text(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
name: String,
|
||
text: String,
|
||
is_gpx: bool,
|
||
) -> Cmd<ProfileView> {
|
||
let profile = parse_profile(&text, &name, is_gpx)?;
|
||
let (view, geom) = profile_view::build(&profile, name);
|
||
state.lock().set_profile(profile, view.clone(), geom);
|
||
emit_ride_state(&app);
|
||
notify(
|
||
&app,
|
||
Notice::info(format!("Loaded profile “{}”", view.name)),
|
||
);
|
||
Ok(view)
|
||
}
|
||
|
||
/// Parse and preview without loading — the editor calls this on every keystroke
|
||
/// so errors surface as you type rather than when you press Ride.
|
||
#[tauri::command]
|
||
pub fn preview_profile_yaml(yaml: String) -> Cmd<ProfileView> {
|
||
let profile = Profile::from_yaml(&yaml).map_err(|e| e.to_string())?;
|
||
Ok(profile_view::build(&profile, "editor").0)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn clear_profile(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||
state.lock().clear_profile();
|
||
emit_ride_state(&app);
|
||
Ok(state.lock().ride_state())
|
||
}
|
||
|
||
#[derive(serde::Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct SampleProfile {
|
||
pub name: String,
|
||
pub summary: String,
|
||
/// YAML profile source, or GPX XML when `is_gpx`.
|
||
pub text: String,
|
||
pub is_gpx: bool,
|
||
}
|
||
|
||
/// Profiles shipped with the app, so there is always something to ride.
|
||
#[tauri::command]
|
||
pub fn sample_profiles() -> Vec<SampleProfile> {
|
||
crate::samples::all()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Devices (FR-1, FR-9.1–9.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tauri::command]
|
||
pub fn device_list(state: State<'_, AppState>) -> DeviceList {
|
||
let inner = state.lock();
|
||
DeviceList {
|
||
scanning: inner.devices.scanning,
|
||
devices: inner.devices.list(),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn start_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||
state.lock().devices.start_scan();
|
||
emit_devices(&app);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn stop_scan(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||
state.lock().devices.stop_scan();
|
||
emit_devices(&app);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn connect_device(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
device_id: String,
|
||
) -> Cmd<DeviceInfo> {
|
||
let info = state.lock().devices.connect(&device_id)?;
|
||
emit_devices(&app);
|
||
Ok(info)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn disconnect_device(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
device_id: String,
|
||
) -> Cmd<DeviceInfo> {
|
||
let info = state.lock().devices.disconnect(&device_id)?;
|
||
emit_devices(&app);
|
||
notify(&app, Notice::info(format!("Disconnected {}", info.name)));
|
||
Ok(info)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn forget_device(app: AppHandle, state: State<'_, AppState>, device_id: String) -> Cmd<()> {
|
||
state.lock().devices.forget(&device_id)?;
|
||
emit_devices(&app);
|
||
Ok(())
|
||
}
|
||
|
||
/// True once a trainer has FTMS control. This is what gates the ride screen:
|
||
/// connected is not controllable, and a ride nothing is driving is not a ride
|
||
/// (FR-9.3).
|
||
#[tauri::command]
|
||
pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
|
||
state.lock().devices.trainer_controllable()
|
||
}
|
||
|
||
/// Ask the platform to switch the Bluetooth radio on.
|
||
///
|
||
/// Only Android can answer this: there, a disabled radio is a normal state the
|
||
/// rider reaches by accident and can fix from inside the app. On desktop the
|
||
/// remedy is the system's business, so this is a no-op and the connection screen
|
||
/// keeps showing the adapter error.
|
||
///
|
||
/// Returns nothing on purpose. The rider may decline, and some OEM dialogs claim
|
||
/// success before the radio is up, so the only trustworthy answer is the one
|
||
/// that arrives on the device list a moment later (§4.3).
|
||
#[tauri::command]
|
||
pub fn request_bluetooth_enable() {
|
||
#[cfg(target_os = "android")]
|
||
crate::android::request_bluetooth_enable();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Controller (Zwift Click)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[tauri::command]
|
||
pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
|
||
state.controller().status()
|
||
}
|
||
|
||
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
|
||
///
|
||
/// `device_id` is an address, for a specific pod the scanner has already
|
||
/// listed. Without one the supervisor looks the pod up by the type byte in its
|
||
/// advertisement — never by name, because both pods of a pair advertise the
|
||
/// same one and the app used to get whichever answered first.
|
||
///
|
||
/// Fire-and-forget: the supervisor owns the radio and the result arrives on
|
||
/// `controller://status`. A Click sleeps within seconds and only advertises
|
||
/// after a button press (A-4), so this routinely takes a few attempts — which
|
||
/// is why it must not block the UI thread waiting for one.
|
||
#[tauri::command]
|
||
pub fn connect_controller(
|
||
state: State<'_, AppState>,
|
||
pod: Option<Pod>,
|
||
device_id: Option<String>,
|
||
) -> Cmd<()> {
|
||
let controller = state.controller();
|
||
let address = device_id.filter(|id| !id.trim().is_empty());
|
||
match pod {
|
||
Some(pod) => controller.connect(pod.into(), address),
|
||
None => {
|
||
if address.is_some() {
|
||
return Err("An address names one pod, so say which pod it is".into());
|
||
}
|
||
// Both, each on its own schedule: a pod that is awake connects now
|
||
// rather than queueing behind its sleeping twin.
|
||
let known = state.lock().devices.click_pod_addresses();
|
||
for id in PodId::BOTH {
|
||
controller.connect(id, known.get(&id).cloned());
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Disconnect one pod, or both when `pod` is omitted.
|
||
#[tauri::command]
|
||
pub fn disconnect_controller(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
pod: Option<Pod>,
|
||
) -> Cmd<()> {
|
||
state.controller().disconnect(pod.map(PodId::from));
|
||
notify(
|
||
&app,
|
||
Notice::info(match pod {
|
||
Some(Pod::Plus) => "+ pod disconnected",
|
||
Some(Pod::Minus) => "− pod disconnected",
|
||
None => "Both Click pods disconnected",
|
||
}),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Exchange the two pods, for when they answer to the other name.
|
||
///
|
||
/// §2.3.1 confirms one manufacturer-data type byte per pod but not which byte
|
||
/// belongs to which, so the app starts from a documented guess. Pressing a
|
||
/// paddle shows the rider whether the guess was right; this is how they fix it
|
||
/// if it was not.
|
||
#[tauri::command]
|
||
pub fn swap_controller_pods(app: AppHandle, state: State<'_, AppState>) -> Cmd<()> {
|
||
state.controller().swap();
|
||
notify(&app, Notice::info("Swapped the + and − pods"));
|
||
Ok(())
|
||
}
|