This commit is contained in:
vcoppe committed 2026-10-14 19:15:02 +02:00
1 parent 2e1ca00afa
commit 1ba5436af3
29 files changed
+3348 -190

No files matched your search

+6 -1
View File
@@ -2,7 +2,7 @@
name = "gpx-studio-desktop"
version = "0.1.0"
description = "Desktop shell for gpx.studio"
edition = "2021"
edition = "2024"
[lib]
name = "gpx_studio_desktop_lib"
@@ -20,4 +20,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["serde"] }
gpx-engine = { path = "../gpx-rs" }
pollster = "1.0.1"
notify = "8.2.0"
notify-debouncer-mini = "0.7.0"
tauri-plugin-dialog = "2.8.2"
tauri-plugin-single-instance = "2.5.3"
+139
View File
@@ -0,0 +1,139 @@
//! The files of the engine and the file system: picking files and folders, saving, and what the
//! frontend shows of it (see `workspace`).
use std::collections::BTreeMap;
use std::path::PathBuf;
use gpx_engine::api;
use tauri::{AppHandle, Runtime, State};
use tauri_plugin_dialog::{DialogExt, FilePath};
use uuid::Uuid;
use crate::state::AppState;
use crate::workspace::{Unsaved, WorkspaceState};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked
.into_iter()
.filter_map(|path| path.into_path().ok())
.collect()
}
#[tauri::command(async)]
pub fn workspace_state(state: State<AppState>) -> WorkspaceState {
state.core(|core| core.workspace_state())
}
/// Opens the files that the user picks. Nothing happens if they pick none.
#[tauri::command(async)]
pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api::Outcome {
let picked = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.blocking_pick_files();
let Some(picked) = picked else {
return api::Outcome::failed_none();
};
let files = paths(picked);
state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &files, true);
core.keep_workspace();
core.persist();
outcome
})
}
/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder
/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits.
#[tauri::command(async)]
pub fn open_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
let Some(folder) = app
.dialog()
.file()
.blocking_pick_folder()
.and_then(|folder| folder.into_path().ok())
else {
return Ok(api::Outcome::failed_none());
};
state.core(move |core| {
let outcome = core
.workspace
.open_folder(&mut core.engine, &folder, unsaved);
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
})
}
/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to
/// do with the ones that have unsaved edits (they stay without it).
#[tauri::command(async)]
pub fn close_folder(
state: State<AppState>,
unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core.workspace.close_folder(&mut core.engine, unsaved);
core.keep_workspace();
core.persist();
core.watch();
core.announce(None);
outcome
})
}
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
/// the open folder, or where the user says if there is none.
#[tauri::command(async)]
pub fn save_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
) -> Result<(), String> {
let (ids, unlinked, folder) = {
let ids = file_ids.clone();
state.core(move |core| {
(
ids.clone(),
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
})
};
let mut chosen = BTreeMap::new();
// with a folder open, a new file goes in it: nobody is asked
let to_ask = if folder.is_some() { vec![] } else { unlinked };
for (id, name) in to_ask {
let mut dialog = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(format!("{name}.gpx"));
if let Some(folder) = &folder {
dialog = dialog.set_directory(folder);
}
match dialog
.blocking_save_file()
.and_then(|path| path.into_path().ok())
{
Some(path) => {
chosen.insert(id, path);
}
// cancelled: the others are not saved either
None => return Ok(()),
}
}
state.core(move |core| {
let result = core.workspace.save(&core.engine, &ids, &chosen);
core.keep_workspace();
core.announce(None);
result
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning...
use gpx_engine::{api, CleanType, MergeType, SplitType};
use gpx_engine::{CleanType, MergeType, SplitType, api};
use tauri::State;
use uuid::Uuid;
+1 -1
View File
@@ -1,8 +1,8 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use gpx_engine::api;
use tauri::ipc::{InvokeBody, Request};
use tauri::State;
use tauri::ipc::{InvokeBody, Request};
use uuid::Uuid;
use crate::state::AppState;
+1
View File
@@ -5,6 +5,7 @@
//! The commands that edit the files or change the selection return an `Outcome`.
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
pub mod disk;
pub mod edit;
pub mod files;
pub mod queries;
+1 -1
View File
@@ -1,7 +1,7 @@
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
use gpx_engine::{api, ExportOptions};
use gpx_engine::{ExportOptions, api};
use tauri::State;
use uuid::Uuid;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Changing what is selected.
use gpx_engine::{api, SelectMode};
use gpx_engine::{SelectMode, api};
use tauri::State;
use uuid::Uuid;
+17 -15
View File
@@ -1,46 +1,48 @@
//! Keeping the files and the settings.
//!
//! For now nothing is written: the files only live as long as the app, and so do the settings.
//! The file system storage will go here.
//! What the frontend knows of the storage, which is kept by the engine thread (see `state`).
use std::collections::BTreeMap;
use gpx_engine::api;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use gpx_engine::api::Outcome;
/// What opening the storage found.
#[derive(Serialize)]
pub struct StorageOpened {
settings: std::collections::BTreeMap<String, String>,
outcome: Outcome,
settings: BTreeMap<String, String>,
/// All the files of the engine as added: the frontend starts with none.
outcome: api::Outcome,
}
/// The files were restored when the app started: the frontend is told about all of them.
#[tauri::command(async)]
pub fn open_storage(state: State<AppState>) -> StorageOpened {
StorageOpened {
settings: state.settings().clone(),
outcome: Outcome::failed_none(),
settings: state.storage().settings(),
outcome: state.read(api::Outcome::everything),
}
}
/// Resolves when what was changed has been written to the storage.
/// Resolves when what was changed has been written to the storage, which is the case when a command
/// returns.
#[tauri::command(async)]
pub fn flush_storage() {}
/// Reads the files again if they were written by someone else.
/// There is no other writer.
#[tauri::command(async)]
pub fn sync_storage() -> Outcome {
Outcome::failed_none()
pub fn sync_storage() -> api::Outcome {
api::Outcome::failed_none()
}
/// Saves a setting, `value` being its JSON.
#[tauri::command(async)]
pub fn set_setting(state: State<AppState>, key: String, value: String) {
state.settings().insert(key, value);
state.put_setting(&key, &value);
}
#[tauri::command(async)]
pub fn delete_setting(state: State<AppState>, key: String) {
state.settings().remove(&key);
state.delete_setting(&key);
}
+173 -108
View File
@@ -4,121 +4,186 @@
mod bytes;
mod commands;
mod state;
mod store;
mod workspace;
use commands::*;
use tauri::{Emitter, Manager};
/// The GPX files among what the system gave to the app (the arguments of the command line, when a
/// file is opened with it).
fn gpx_paths(arguments: impl IntoIterator<Item = String>) -> Vec<std::path::PathBuf> {
arguments
.into_iter()
.map(std::path::PathBuf::from)
.filter(|path| {
path.is_file()
&& path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("gpx"))
})
.collect()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
with_engine(tauri::Builder::default())
.run(tauri::generate_context!())
.expect("error while running gpx.studio");
let builder = tauri::Builder::default();
// a single instance of the app: starting it again shows the one that runs (it has to be the
// first plugin)
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(
|app, arguments, _directory| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
// a file that was opened with the app, while it runs (Windows, Linux)
if let Some(state) = app.try_state::<state::AppState>() {
state.open_external(gpx_paths(arguments.into_iter().skip(1)));
}
},
));
register_commands(builder)
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let handle = app.handle().clone();
let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
let _ = handle.emit(event, payload);
});
// what the engine holds is kept in the data directory of the app
let directory = app.path().app_data_dir()?.join("store");
let state = state::AppState::open(directory, emit)?;
// a file that was opened with the app, which starts (Windows, Linux)
state.open_external(gpx_paths(std::env::args().skip(1)));
app.manage(state);
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building gpx.studio")
.run(|_app, _event| {
// a file that was opened with the app (macOS)
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = _event
&& let Some(state) = _app.try_state::<state::AppState>()
{
let paths = urls.iter().filter_map(|url| url.to_file_path().ok());
state.open_external(gpx_paths(
paths.map(|path| path.to_string_lossy().into_owned()),
));
}
});
}
/// Gives the builder the engine and the commands that reach it.
fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder
.manage(state::AppState::default())
.invoke_handler(tauri::generate_handler![
// selection
selection::select,
selection::select_all,
selection::arrow_select,
selection::select_tracks,
selection::select_segments,
selection::select_waypoint_group,
selection::select_waypoints,
// files
files::new_file,
files::load_files,
files::duplicate,
files::delete,
files::delete_all,
files::reorder,
files::copy,
files::cut,
files::paste,
files::move_elements,
files::undo,
files::redo,
// edits and tools
edit::metadata,
edit::style,
edit::new_track,
edit::new_track_segment,
edit::new_waypoint,
edit::move_waypoint,
edit::delete_waypoint,
edit::update_waypoint,
edit::reverse,
edit::round_trip,
edit::crop,
edit::reduce,
edit::change_timestamps,
edit::create_artificial_timestamps,
edit::merge,
edit::extract,
edit::elevation,
edit::clean,
edit::route,
edit::insert_anchor,
edit::change_loop_start,
edit::split,
// queries
queries::file_order,
queries::file_structure,
queries::file_statistics,
queries::selection,
queries::clipboard,
queries::can_undo,
queries::can_redo,
queries::can_paste,
queries::waypoint,
queries::trackpoint,
queries::export_file,
queries::exportable_data,
queries::reduction_distances,
queries::segment_coordinates,
queries::waypoint_coordinates,
// statistics
statistics::total_distance,
statistics::slope,
statistics::slope_segment_slope,
statistics::slope_segment_distance,
statistics::lng,
statistics::lat,
statistics::ele,
statistics::speed,
statistics::hr,
statistics::cad,
statistics::atemp,
statistics::power,
statistics::timestamps,
statistics::selection_statistics,
statistics::slice_statistics,
statistics::surface_starts,
statistics::surface_values,
statistics::highway_starts,
statistics::highway_values,
statistics::sac_scale_starts,
statistics::sac_scale_values,
statistics::mtb_scale_starts,
statistics::mtb_scale_values,
statistics::surfaces,
statistics::highways,
statistics::sac_scales,
statistics::mtb_scales,
statistics::anchor_indices,
statistics::anchor_zooms,
statistics::segment_starts,
statistics::segment_ids,
statistics::routing_revision,
// storage
storage::open_storage,
storage::flush_storage,
storage::sync_storage,
storage::set_setting,
storage::delete_setting,
])
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder.invoke_handler(tauri::generate_handler![
// selection
selection::select,
selection::select_all,
selection::arrow_select,
selection::select_tracks,
selection::select_segments,
selection::select_waypoint_group,
selection::select_waypoints,
// files
files::new_file,
files::load_files,
files::duplicate,
files::delete,
files::delete_all,
files::reorder,
files::copy,
files::cut,
files::paste,
files::move_elements,
files::undo,
files::redo,
// edits and tools
edit::metadata,
edit::style,
edit::new_track,
edit::new_track_segment,
edit::new_waypoint,
edit::move_waypoint,
edit::delete_waypoint,
edit::update_waypoint,
edit::reverse,
edit::round_trip,
edit::crop,
edit::reduce,
edit::change_timestamps,
edit::create_artificial_timestamps,
edit::merge,
edit::extract,
edit::elevation,
edit::clean,
edit::route,
edit::insert_anchor,
edit::change_loop_start,
edit::split,
// queries
queries::file_order,
queries::file_structure,
queries::file_statistics,
queries::selection,
queries::clipboard,
queries::can_undo,
queries::can_redo,
queries::can_paste,
queries::waypoint,
queries::trackpoint,
queries::export_file,
queries::exportable_data,
queries::reduction_distances,
queries::segment_coordinates,
queries::waypoint_coordinates,
// statistics
statistics::total_distance,
statistics::slope,
statistics::slope_segment_slope,
statistics::slope_segment_distance,
statistics::lng,
statistics::lat,
statistics::ele,
statistics::speed,
statistics::hr,
statistics::cad,
statistics::atemp,
statistics::power,
statistics::timestamps,
statistics::selection_statistics,
statistics::slice_statistics,
statistics::surface_starts,
statistics::surface_values,
statistics::highway_starts,
statistics::highway_values,
statistics::sac_scale_starts,
statistics::sac_scale_values,
statistics::mtb_scale_starts,
statistics::mtb_scale_values,
statistics::surfaces,
statistics::highways,
statistics::sac_scales,
statistics::mtb_scales,
statistics::anchor_indices,
statistics::anchor_zooms,
statistics::segment_starts,
statistics::segment_ids,
statistics::routing_revision,
// storage
storage::open_storage,
storage::flush_storage,
storage::sync_storage,
storage::set_setting,
storage::delete_setting,
// the file system
disk::workspace_state,
disk::open_files,
disk::open_folder,
disk::close_folder,
disk::save_files,
])
}
#[cfg(test)]
+206 -34
View File
@@ -1,48 +1,196 @@
//! The state the commands share: the engine, and the settings of the frontend.
//! The state the commands share: the engine, and what goes with it.
//!
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own and
//! the commands send it jobs: they are done one after the other, in the order they were sent.
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own with
//! everything that works on it (its storage, the link between its files and the file system, the
//! watcher of the open folder), and the commands send that thread jobs: they are done one after the
//! other, in the order they were sent.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::sync::{Mutex, MutexGuard};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use gpx_engine::Engine;
use gpx_engine::{Engine, Persister, Storage, api};
use notify::RecursiveMode;
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
use serde::Serialize;
type Job = Box<dyn FnOnce(&mut Engine) + Send>;
use crate::store::FileStorage;
use crate::workspace::{Workspace, WorkspaceState};
pub struct AppState {
jobs: Mutex<Sender<Job>>,
/// The settings of the frontend, as JSON strings by key.
settings: Mutex<BTreeMap<String, String>>,
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
type Job = Box<dyn FnOnce(&mut Core) + Send>;
/// Everything that lives on the thread of the engine.
pub struct Core {
pub engine: Engine,
pub workspace: Workspace,
storage: Arc<FileStorage>,
persister: Persister,
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
jobs: Sender<Job>,
emit: Emit,
}
impl Default for AppState {
fn default() -> Self {
let (jobs, receiver) = mpsc::channel::<Job>();
thread::Builder::new()
.name("engine".into())
.spawn(move || {
let mut engine = Engine::default();
while let Ok(job) = receiver.recv() {
job(&mut engine);
#[derive(Clone)]
pub struct AppState {
jobs: Arc<Mutex<Sender<Job>>>,
storage: Arc<FileStorage>,
}
impl Core {
fn start(storage: Arc<FileStorage>, jobs: Sender<Job>, emit: Emit) -> Self {
let mut core = Self {
engine: Engine::default(),
workspace: Workspace::load(storage.root()),
persister: Persister::default(),
storage,
watcher: None,
jobs,
emit,
};
match pollster::block_on(core.persister.restore(&*core.storage)) {
Ok(restored) => {
core.engine.restore(restored);
}
// what is stored comes from a newer version, which is left alone
Err(gpx_engine::StorageError::Newer) => {}
Err(error) => eprintln!("the files could not be read: {error}"),
}
core.workspace.prune(&core.engine);
core.workspace.verify(&core.engine);
// what happened on the disk while the app was closed
core.workspace.reconcile(&mut core.engine, None);
core.workspace.write(core.storage.root());
core.watch();
core.persist();
core
}
/// Writes what changed in the engine.
pub fn persist(&mut self) {
match pollster::block_on(self.persister.save(&*self.storage, self.engine.snapshot())) {
Ok(()) | Err(gpx_engine::CommitError::Failed(gpx_engine::StorageError::Newer)) => {}
Err(error) => eprintln!("the files could not be kept: {error}"),
}
}
/// Opens files that the system gave to the app (open with..., double click, the command line),
/// which get selected. The frontend is told, if it already runs: when it starts it reads what
/// the engine has.
pub fn open_external(&mut self, paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
let outcome = self.workspace.open_files(&mut self.engine, paths, true);
self.keep_workspace();
self.persist();
(self.emit)("files-opened", to_json(&outcome));
self.announce(None);
}
pub fn keep_workspace(&self) {
self.workspace.write(self.storage.root());
}
pub fn workspace_state(&self) -> WorkspaceState {
self.workspace.state(&self.engine)
}
/// Tells the frontend what changed without it asking, and where the files are.
pub fn announce(&self, outcome: Option<&api::Outcome>) {
if let Some(outcome) = outcome {
(self.emit)("engine-changed", to_json(outcome));
}
(self.emit)("workspace-changed", to_json(&self.workspace_state()));
}
/// Watches the open folder (and only it).
pub fn watch(&mut self) {
self.watcher = None;
let Some(folder) = self.workspace.folder.clone() else {
return;
};
if !Path::new(&folder).is_dir() {
return;
}
let jobs = self.jobs.clone();
let debouncer = new_debouncer(
Duration::from_millis(400),
move |result: DebounceEventResult| {
let Ok(events) = result else { return };
let paths: Vec<PathBuf> = events.into_iter().map(|event| event.path).collect();
let _ = jobs.send(Box::new(move |core: &mut Core| {
core.on_disk_changed(&paths)
}));
},
);
match debouncer {
Ok(mut debouncer) => {
match debouncer.watcher().watch(&folder, RecursiveMode::Recursive) {
Ok(()) => self.watcher = Some(debouncer),
Err(error) => eprintln!("the folder cannot be watched: {error}"),
}
})
.expect("the engine thread could not be started");
Self {
jobs: Mutex::new(jobs),
settings: Mutex::default(),
}
Err(error) => eprintln!("the folder cannot be watched: {error}"),
}
}
fn on_disk_changed(&mut self, paths: &[PathBuf]) {
let outcome = self.workspace.reconcile(&mut self.engine, Some(paths));
if let Some(outcome) = outcome {
self.workspace.write(self.storage.root());
self.persist();
self.announce(Some(&outcome));
}
}
}
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
serde_json::to_value(value).unwrap_or_default()
}
impl AppState {
/// Runs `f` on the engine and waits for its result.
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
/// Starts the engine, which restores what was kept in `directory`.
pub fn open(directory: impl Into<PathBuf>, emit: Emit) -> Result<Self, String> {
let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?);
let (jobs, receiver) = mpsc::channel::<Job>();
let (thread_storage, thread_jobs) = (storage.clone(), jobs.clone());
thread::Builder::new()
.name("engine".into())
.spawn(move || {
let mut core = Core::start(thread_storage, thread_jobs, emit);
while let Ok(job) = receiver.recv() {
job(&mut core);
}
})
.map_err(|error| error.to_string())?;
Ok(Self {
jobs: Arc::new(Mutex::new(jobs)),
storage,
})
}
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
#[cfg(test)]
pub fn temporary() -> Self {
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened")
}
/// Opens files that the system gave to the app, see `Core::open_external`.
pub fn open_external(&self, paths: Vec<PathBuf>) {
self.core(move |core| core.open_external(&paths));
}
/// Runs `f` on the thread of the engine and waits for its result.
pub fn core<T: Send + 'static>(&self, f: impl FnOnce(&mut Core) -> T + Send + 'static) -> T {
let (reply, result) = mpsc::channel();
let job: Job = Box::new(move |engine| {
let _ = reply.send(f(engine));
let job: Job = Box::new(move |core| {
let _ = reply.send(f(core));
});
self.jobs
.lock()
@@ -52,12 +200,36 @@ impl AppState {
result.recv().expect("the engine thread stopped")
}
/// Reads from the engine.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
self.run(move |engine| f(engine))
/// Runs `f` on the engine, which can change: what changed is kept.
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
self.core(move |core| {
let result = f(&mut core.engine);
core.persist();
result
})
}
pub fn settings(&self) -> MutexGuard<'_, BTreeMap<String, String>> {
self.settings.lock().unwrap()
/// Reads from the engine.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
self.core(move |core| f(&core.engine))
}
pub fn storage(&self) -> &FileStorage {
&self.storage
}
}
// the settings only are read and written by the commands, from any thread
impl AppState {
pub fn put_setting(&self, key: &str, value: &str) {
if let Err(error) = pollster::block_on(self.storage.put_setting(key, value)) {
eprintln!("the setting could not be kept: {error}");
}
}
pub fn delete_setting(&self, key: &str) {
if let Err(error) = pollster::block_on(self.storage.delete_setting(key)) {
eprintln!("the setting could not be kept: {error}");
}
}
}
+284
View File
@@ -0,0 +1,284 @@
//! The storage of the engine on the file system: what the engine holds is kept between sessions,
//! in a directory of the app (see `gpx_engine::Storage`).
//!
//! ```text
//! head the revision of the last commit
//! order the order of the files
//! files/<id> the record of each file
//! trackpoints/<id> the chunks of trackpoints
//! waypoints/<id> the chunks of waypoints
//! settings.json the settings of the frontend
//! ```
//!
//! A commit writes the chunks, the records and the order first, then the head, then deletes what
//! is not used anymore: the head moves once all of the new data is there, and nothing that the old
//! data refers to is gone before that.
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use gpx_engine::{
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
};
use uuid::Uuid;
pub struct FileStorage {
root: PathBuf,
settings: Mutex<BTreeMap<String, String>>,
}
fn failed(error: impl std::fmt::Display) -> StorageError {
StorageError::Failed(error.to_string())
}
/// Writes a file as a whole or not at all.
pub fn write_atomically(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temporary = path.with_extension("tmp");
fs::write(&temporary, bytes)?;
fs::rename(&temporary, path)
}
fn read_optional(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn remove(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
}
impl FileStorage {
/// Opens the storage in `root`, which is created if it does not exist.
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StorageError> {
let root = root.into();
fs::create_dir_all(&root).map_err(failed)?;
let settings = match read_optional(&root.join("settings.json")).map_err(failed)? {
Some(bytes) => serde_json::from_slice(&bytes).map_err(failed)?,
None => BTreeMap::new(),
};
Ok(Self {
root,
settings: Mutex::new(settings),
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn settings(&self) -> BTreeMap<String, String> {
self.settings.lock().unwrap().clone()
}
fn chunk_path(&self, key: &ChunkKey) -> PathBuf {
let directory = match key.kind {
ChunkKind::Trackpoints => "trackpoints",
ChunkKind::Waypoints => "waypoints",
};
self.root.join(directory).join(key.id.to_string())
}
fn file_path(&self, id: &FileId) -> PathBuf {
self.root.join("files").join(id.0.to_string())
}
/// The ids and contents of the files of a directory, named by UUIDs.
fn read_directory(&self, name: &str) -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
let directory = self.root.join(name);
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(error) => return Err(failed(error)),
};
let mut items = vec![];
for entry in entries {
let path = entry.map_err(failed)?.path();
let Some(id) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| Uuid::parse_str(name).ok())
else {
// temporary files of a write that did not finish
continue;
};
items.push((id, fs::read(&path).map_err(failed)?));
}
Ok(items)
}
fn save_settings(&self, settings: &BTreeMap<String, String>) -> Result<(), StorageError> {
let json = serde_json::to_vec(settings).map_err(failed)?;
write_atomically(&self.root.join("settings.json"), &json).map_err(failed)
}
fn read_head(&self) -> Result<Revision, StorageError> {
match read_optional(&self.root.join("head")).map_err(failed)? {
Some(bytes) => String::from_utf8_lossy(&bytes)
.trim()
.parse()
.map(Revision)
.map_err(failed),
None => Ok(Revision::default()),
}
}
fn write_batch(&self, batch: &Batch, head: Revision) -> io::Result<()> {
for (key, bytes) in &batch.put_chunks {
write_atomically(&self.chunk_path(key), bytes)?;
}
for (id, bytes) in &batch.put_files {
write_atomically(&self.file_path(id), bytes)?;
}
if let Some(order) = &batch.order {
write_atomically(&self.root.join("order"), order)?;
}
write_atomically(&self.root.join("head"), head.0.to_string().as_bytes())?;
for id in &batch.delete_files {
remove(&self.file_path(id))?;
}
for key in &batch.delete_chunks {
remove(&self.chunk_path(key))?;
}
Ok(())
}
}
impl Storage for FileStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let chunks = |kind: ChunkKind, name: &str| -> Result<Vec<_>, StorageError> {
Ok(self
.read_directory(name)?
.into_iter()
.map(|(id, bytes)| (ChunkKey { kind, id }, bytes))
.collect())
};
let mut all_chunks = chunks(ChunkKind::Trackpoints, "trackpoints")?;
all_chunks.extend(chunks(ChunkKind::Waypoints, "waypoints")?);
Ok(StoredData {
files: self
.read_directory("files")?
.into_iter()
.map(|(id, bytes)| (FileId(id), bytes))
.collect(),
chunks: all_chunks,
order: read_optional(&self.root.join("order")).map_err(failed)?,
head: self.read_head()?,
})
}
async fn head(&self) -> Result<Revision, StorageError> {
self.read_head()
}
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
let head = self.read_head()?;
if head != batch.expect_head {
return Err(CommitError::Stale { head });
}
let next = head.next();
self.write_batch(batch, next)
.map_err(|error| CommitError::Failed(failed(error)))?;
Ok(next)
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
Ok(self
.settings
.lock()
.unwrap()
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
let mut settings = self.settings.lock().unwrap();
settings.insert(key.to_owned(), value.to_owned());
self.save_settings(&settings)
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
let mut settings = self.settings.lock().unwrap();
if settings.remove(key).is_some() {
self.save_settings(&settings)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use gpx_engine::{Action, Command, Engine, Load, LoadFiles, Persister};
use super::*;
fn directory() -> PathBuf {
std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4()))
}
fn engine_with_a_file() -> Engine {
let mut engine = Engine::default();
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
engine
.execute(Action::Edit(Command::LoadFiles(LoadFiles {
files: vec![Load {
data: &data,
name: "simple",
}],
})))
.unwrap();
engine
}
#[test]
fn keeps_the_files_and_the_settings_between_sessions() {
let root = directory();
let engine = engine_with_a_file();
let order = engine.order().to_vec();
let storage = FileStorage::open(&root).unwrap();
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap();
// another session
let storage = FileStorage::open(&root).unwrap();
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
let mut other = Engine::default();
other.restore(restored);
assert_eq!(other.order(), order);
assert_eq!(
other.file_statistics(&order[0]).unwrap().total_distance,
engine.file_statistics(&order[0]).unwrap().total_distance
);
assert_eq!(storage.settings()["units"], "\"metric\"");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn refuses_a_commit_that_is_not_based_on_the_head() {
let root = directory();
let storage = FileStorage::open(&root).unwrap();
let batch = Batch {
order: Some(vec![]),
..Default::default()
};
assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1)));
assert_eq!(
pollster::block_on(storage.commit(&batch)),
Err(CommitError::Stale { head: Revision(1) })
);
fs::remove_dir_all(root).unwrap();
}
}
+93 -9
View File
@@ -1,18 +1,19 @@
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
//! files of the engine.
use serde_json::{json, Value};
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
use serde_json::{Value, json};
use tauri::test::{INVOKE_KEY, get_ipc_response, mock_builder, mock_context, noop_assets};
use tauri::webview::InvokeRequest;
use tauri::{ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody, WebviewWindow};
use tauri::{WebviewWindow, ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody};
struct App(WebviewWindow<tauri::test::MockRuntime>);
impl App {
fn new() -> Self {
let app = crate::with_engine(mock_builder())
.build(mock_context(noop_assets()))
.unwrap();
let app =
crate::register_commands(mock_builder().manage(crate::state::AppState::temporary()))
.build(mock_context(noop_assets()))
.unwrap();
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
.build()
.unwrap();
@@ -242,9 +243,11 @@ fn waypoints_exports_and_routing_revisions() {
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
);
assert_eq!(exported[0], 1);
assert!(String::from_utf8(exported[1..].to_vec())
.unwrap()
.contains("<wpt"));
assert!(
String::from_utf8(exported[1..].to_vec())
.unwrap()
.contains("<wpt")
);
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
let stale = app.call(
@@ -274,3 +277,84 @@ fn keeps_settings_for_the_session() {
app.call("delete_setting", json!({ "key": "units" }));
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
}
#[test]
fn the_files_are_still_there_in_the_next_session() {
use crate::state::AppState;
use gpx_engine::api;
let directory =
std::env::temp_dir().join(format!("gpx-studio-session-{}", uuid::Uuid::new_v4()));
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
let first = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
let lengths = [data.len()];
let names = ["simple".to_string()];
first.run(move |e| api::load_files(e, &data, &lengths, &names));
let order = first.read(api::file_order);
let second = AppState::open(&directory, std::sync::Arc::new(|_, _| {})).unwrap();
assert_eq!(second.read(api::file_order), order);
let outcome = second.read(api::Outcome::everything);
assert_eq!(
serde_json::to_value(&outcome).unwrap()["added"],
serde_json::json!(order)
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn only_gpx_files_that_exist_are_taken_from_what_the_system_gives() {
let directory = std::env::temp_dir().join(format!("gpx-studio-args-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&directory).unwrap();
let track = directory.join("track.GPX");
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
let text = directory.join("notes.txt");
std::fs::write(&text, "x").unwrap();
let arguments = [
"--flag".to_string(),
track.to_string_lossy().into_owned(),
text.to_string_lossy().into_owned(),
directory.join("missing.gpx").to_string_lossy().into_owned(),
];
assert_eq!(crate::gpx_paths(arguments), [track]);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
use crate::state::AppState;
use gpx_engine::api;
use std::sync::{Arc, Mutex};
let directory = std::env::temp_dir().join(format!("gpx-studio-open-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&directory).unwrap();
let track = directory.join("track.gpx");
std::fs::copy("../gpx-rs/data/simple.gpx", &track).unwrap();
let events = Arc::new(Mutex::new(vec![]));
let sink = events.clone();
let state = AppState::open(
directory.join("store"),
Arc::new(move |name, payload| sink.lock().unwrap().push((name.to_string(), payload))),
)
.unwrap();
state.open_external(vec![track.clone()]);
// opening it again is a selection, not another file
state.open_external(vec![track]);
assert_eq!(state.read(api::file_order).len(), 1);
assert!(matches!(
state.read(api::selection),
api::Selection::File { .. }
));
let events = events.lock().unwrap();
let opened: Vec<_> = events
.iter()
.filter(|(name, _)| name == "files-opened")
.collect();
assert_eq!(opened.len(), 2);
assert_eq!(opened[0].1["added"].as_array().unwrap().len(), 1);
assert!(opened[1].1["added"].as_array().unwrap().is_empty());
assert!(events.iter().any(|(name, _)| name == "workspace-changed"));
std::fs::remove_dir_all(directory).unwrap();
}
File diff suppressed because it is too large. Load diff
+11 -2
View File
@@ -5,7 +5,7 @@
"identifier": "gpx.studio",
"build": {
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
"devUrl": "http://localhost:4283/app",
"devUrl": "http://localhost:4283",
"beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build",
"frontendDist": "../website/build"
},
@@ -14,7 +14,7 @@
{
"label": "main",
"title": "gpx.studio",
"url": "app.html",
"url": "app",
"width": 1400,
"height": 900,
"minWidth": 800,
@@ -26,6 +26,15 @@
"bundle": {
"active": true,
"targets": "all",
"fileAssociations": [
{
"ext": ["gpx"],
"name": "GPX file",
"description": "GPS Exchange Format",
"mimeType": "application/gpx+xml",
"role": "Editor"
}
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",