diff --git a/Cargo.lock b/Cargo.lock index 4d95a6b..3e3e4eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-fs", "thiserror 2.0.19", "tokio", "tracing", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 07e0b9c..b53fec7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,11 @@ bikecontrol-fit = { workspace = true } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" +# Not for the frontend — nothing in `ui/` calls the fs commands. This is here +# for `FsExt::read_to_string`, the one API that can read what the *picker* +# returns on Android: a `content://` URI rather than a path. See +# `read_picked_file` in commands.rs. +tauri-plugin-fs = "2" uuid = { workspace = true } chrono = { workspace = true } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3dea3ec..8377f04 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,12 +4,13 @@ //! 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::PathBuf; +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; @@ -188,8 +189,7 @@ pub fn ride_summary(state: State<'_, AppState>) -> Option { /// 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: String) -> Cmd { - let dest = PathBuf::from(&path); +pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd { let source = { let inner = state.lock(); let summary = inner @@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd PathBuf::from(&summary.fit_path) }; - recording::save_copy(&source, &dest)?; - let written = dest.display().to_string(); + 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()); } @@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd 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 { state.lock().reset_ride(); @@ -453,22 +482,101 @@ fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result Result { + 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 { + 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: ``, else the first ``. +/// +/// The fallback when the picker gave us no filename to use. Matches on local +/// names so a namespaced document (``) is not silently skipped, for the +/// same reason `core::gpx` does. +fn gpx_name(xml: &str) -> Option { + 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: String, + path: FilePath, ) -> Cmd { - let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; - let stem = std::path::Path::new(&path) - .file_stem() - .map(|s| s.to_string_lossy().to_string()) + 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 is_gpx = path.to_ascii_lowercase().ends_with(".gpx"); - let profile = parse_profile(&text, &stem, is_gpx)?; - let (view, geom) = profile_view::build(&profile, path); + 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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6410eab..20c8fb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) .manage(AppState::new()) .invoke_handler(tauri::generate_handler![ // ride lifecycle