diff --git a/GEOLIBRE_PARITY.md b/GEOLIBRE_PARITY.md
index 313e2a1d7..15a803d0d 100644
--- a/GEOLIBRE_PARITY.md
+++ b/GEOLIBRE_PARITY.md
@@ -214,8 +214,9 @@ storymaps.md_
`postMessage` (`apps/rustgis-web/src/embed.rs`); no npm client.
- [ ] **Import QGIS (`.qgs/.qgz`) and ArcGIS Pro (`.aprx/.mapx`) projects**. Missing
(_Exploratory_).
-- [ ] **Autosave, project history, crash recovery, snapshot compare**. Partial: undo and per-layer
- archive restore (_Roadmap: solid ground_ for autosave and crash recovery).
+- [ ] **Autosave, project history, crash recovery, snapshot compare**. Partial: undo, per-layer
+ archive restore, and autosave with crash recovery in the desktop app
+ ([#873](https://github.com/opengeos/RustGIS/issues/873)); the browser has neither yet.
- [ ] **Startup preferences and shareable interface settings**. Partial: start-up preferences,
Pane Sets and ribbon/QAT customization.
- [ ] **Layer metadata with STAC Item export**. Missing (_Roadmap: data at the source_).
diff --git a/ROADMAP.md b/ROADMAP.md
index 429fb9cdd..cb8ce54f3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -54,7 +54,8 @@ Correct, robust and recoverable, with the foundations other themes stand on.
GDA94↔GDA2020 and OSGB36, and a notice whenever a layer is read without its shift; GDA94
([#730](https://github.com/opengeos/RustGIS/issues/730)) is one case. _Coordinate first._
- Reloading files changed on disk.
-- Autosave and crash recovery: reopen unsaved work after a crash.
+- Autosave and crash recovery in the browser: reopen unsaved work after a crash. The desktop app
+ has them ([#873](https://github.com/opengeos/RustGIS/issues/873)).
- Signed Windows builds (no SmartScreen warning), then in-place updates; settings an
administrator can fix for a managed install (no AI Assistant, plugins or web services).
- Extend the OS keychain secret store to service connections (database passwords, S3 keys,
@@ -173,7 +174,8 @@ Open questions rather than plans; discuss before building.
- Layered workspace, command registry with undo, project files (`.rgis`), `rustgis-cli`, the
web app (WebAssembly, installable with offline start, opened with a project or data by its
web address), 18 interface languages; a ribbon laid out in the order of the work (Home, Data,
- Style, Edit, Analyze, View, Publish), start-up preferences, Check for Updates.
+ Style, Edit, Analyze, View, Publish), start-up preferences, Check for Updates, autosave and
+ crash recovery in the desktop app.
- Data: files by their web address; GeoJSON, CSV/TSV (x/y in any EPSG system, or WKT), Excel
and OpenDocument spreadsheets, GeoRSS, LandXML, encoded polylines, Shapefile (zipped too,
with the coordinate system in its `.prj`), GeoPackage, GeoParquet, FlatGeobuf, KML/KMZ
diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs
index add1f603d..84129246a 100644
--- a/apps/rustgis-web/src/web.rs
+++ b/apps/rustgis-web/src/web.rs
@@ -833,6 +833,8 @@ fn services(inbox: Inbox, font_inbox: Inbox, notes: Notes, ctx: egui::Context, e
save_pref: Some(save_pref),
// No keychain in the browser: script on the page could read anything stored (#754).
secrets: None,
+ // No autosave in the browser yet: localStorage is too small for a project (#873).
+ recovery: None,
fetch_font: Some(Box::new(move |url: &str| {
let (url, inbox, ctx) = (url.to_string(), font_sink.clone(), font_ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
diff --git a/apps/rustgis/src/main.rs b/apps/rustgis/src/main.rs
index 36b9430c7..ef6dd9a66 100644
--- a/apps/rustgis/src/main.rs
+++ b/apps/rustgis/src/main.rs
@@ -158,26 +158,30 @@ mod running {
use std::path::Path;
/// The marker, held while this run lasts.
- pub struct Marker(File);
+ pub struct Marker {
+ file: File,
+ /// This run has it to itself: no other copy can take it while this one runs.
+ locked: bool,
+ }
- /// Takes `
/running` for this run: the marker (none when another copy holds it or the
- /// folder can't be written) and whether the previous run didn't close cleanly.
- pub fn start(dir: Option<&Path>) -> (Option, bool) {
- let Some(dir) = dir else { return (None, false) };
+ /// Takes `/running` for this run: the marker, or why there is none (another copy holds
+ /// it, or the folder can't be written), and whether the previous run didn't close cleanly.
+ pub fn start(dir: Option<&Path>) -> (Result, bool) {
+ let Some(dir) = dir else { return (Err("this computer has no settings folder for RustGIS".into()), false) };
let file = std::fs::create_dir_all(dir)
.and_then(|_| OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join("running")));
let mut file = match file {
Ok(f) => f,
Err(e) => {
log::warn!("no run marker in {} ({e}): an unclean exit won't be noticed", dir.display());
- return (None, false);
+ return (Err(format!("the settings folder {} can't be written ({e})", dir.display())), false);
}
};
- match file.try_lock() {
- Ok(()) => {}
+ let locked = match file.try_lock() {
+ Ok(()) => true,
Err(std::fs::TryLockError::WouldBlock) => {
log::info!("the run marker is held: another copy of RustGIS is running");
- return (None, false);
+ return (Err("another copy of RustGIS is running, and only the first keeps a recovery copy".into()), false);
}
// The folder can't lock files (some network or FUSE folders): keep the marker unlocked,
// so unclean exits are still noticed. Without a lock, copies running side by side share
@@ -187,8 +191,9 @@ mod running {
// and a marker nobody clears would make every later launch look unclean.
Err(std::fs::TryLockError::Error(e)) => {
log::warn!("could not lock the run marker ({e}): a second copy running alongside will look like an unclean exit");
+ false
}
- }
+ };
// A clean exit leaves the marker empty: anything in it (whatever it reads as) means the
// last run didn't close, and so does a marker that can't be read.
let mut bytes = Vec::new();
@@ -199,17 +204,27 @@ mod running {
true
}
};
- let marked = file.set_len(0).and_then(|_| file.rewind()).and_then(|_| file.write_all(b"running")).and_then(|_| file.flush());
+ // Written over what is there, then cut to its length: a write that fails leaves the old
+ // content, so a marker that was set never reads as a clean exit.
+ const RUNNING: &[u8] = b"running";
+ let marked =
+ file.rewind().and_then(|_| file.write_all(RUNNING)).and_then(|_| file.set_len(RUNNING.len() as u64)).and_then(|_| file.flush());
if let Err(e) = marked {
log::warn!("could not mark this run: {e}");
}
- (Some(Marker(file)), unclean)
+ (Ok(Marker { file, locked }), unclean)
}
impl Marker {
+ /// Whether no other copy can hold the marker while this run lasts: only then is the
+ /// recovery folder this run's alone.
+ pub fn locked(&self) -> bool {
+ self.locked
+ }
+
/// A clean exit: the next launch starts as usual. Dropping the file releases the lock.
pub fn close(self) {
- if let Err(e) = self.0.set_len(0) {
+ if let Err(e) = self.file.set_len(0) {
log::warn!("could not clear the run marker: {e}");
}
}
@@ -253,10 +268,19 @@ impl eframe::App for App {
/// Every way eframe closes the app comes here, Cmd+Q on macOS included, which may not
/// return from `run_native`: the run ended cleanly.
fn on_exit(&mut self) {
+ self.0.end_autosave();
running::close(&self.1);
}
}
+/// This run's recovery folder, `recovery` in the app-data folder `data` (see
+/// `docs/installation.md`), or why there is none: the app then starts without autosave and says so.
+fn recovery_store(data: Option) -> Result, String> {
+ let dir = data.ok_or("this computer has no folder for RustGIS's app data")?.join("recovery");
+ let store = rustgis_ui_egui::FolderStore::open(&dir)?;
+ Ok(Box::new(store))
+}
+
/// Opens the files named on the command line: a project or a project package as the project, any
/// other file as data. Returns what didn't open, one message per file.
fn open_files(app: &mut RustGisApp, files: &[&String]) -> Vec {
@@ -323,7 +347,23 @@ fn main() -> eframe::Result {
} else {
RustGisApp::new(Session::new(Project::default()))
};
- let mut app = app.with_services(prefs::services());
+ let mut services = prefs::services();
+ // Autosave, in a recovery folder only the copy holding the run marker uses: a second copy
+ // running alongside would offer, or overwrite, the first one's unsaved work.
+ // Without the marker to itself, a run can't tell whether another copy uses the folder: it
+ // doesn't autosave, and says why.
+ let no_autosave = match &marker {
+ Ok(m) if m.locked() => match recovery_store(rustgis_engine::data::dirs::app_data_dir()) {
+ Ok(store) => {
+ services.recovery = Some(store);
+ None
+ }
+ Err(e) => Some(e),
+ },
+ Ok(_) => Some("the settings folder can't lock files, so a second copy of RustGIS couldn't be told apart".into()),
+ Err(why) => Some(why.clone()),
+ };
+ let mut app = app.with_services(services);
// Settings › Start-up; `--sample` still opens the sample.
let blank = if args.iter().any(|a| a == "--sample") { Some(false) } else { (!files.is_empty()).then_some(true) };
if let Err(e) = app.start(&rustgis_ui_egui::Start { blank, after_crash: unclean, ..Default::default() }) {
@@ -338,6 +378,12 @@ fn main() -> eframe::Result {
);
app.session.notify(text, true);
}
+ if let Some(e) = no_autosave {
+ log::warn!("no autosave: {e}");
+ rustgis_ui_egui::i18n::set_current(app.chosen_lang());
+ let text = rustgis_ui_egui::i18n::fmt("Autosave is off: {error}", &[("error", &e)]);
+ app.session.notify(text, true);
+ }
for e in plugin_errors {
log::warn!("{e}");
app.session.notify(e, true);
@@ -366,7 +412,7 @@ fn main() -> eframe::Result {
setup.instance_descriptor.backends = backends(backend, env.as_deref(), cfg!(windows));
}
let options = eframe::NativeOptions { viewport, wgpu_options, ..Default::default() };
- let marker: running::Shared = std::sync::Arc::new(std::sync::Mutex::new(marker));
+ let marker: running::Shared = std::sync::Arc::new(std::sync::Mutex::new(marker.ok()));
let held = marker.clone();
let result = eframe::run_native("RustGIS", options, Box::new(move |_cc| Ok(Box::new(App(app, held)))));
running::close(&marker);
@@ -406,10 +452,13 @@ mod tests {
// Removed however the test ends (declared first, so dropped after the markers).
let _cleanup = TempDir(dir.clone());
let (first, unclean) = running::start(Some(&dir));
- assert!(first.is_some() && !unclean, "a first run");
+ assert!(first.is_ok() && !unclean, "a first run");
+ // Held alone: autosave may use the recovery folder.
+ assert!(first.as_ref().is_ok_and(running::Marker::locked));
// Another copy running alongside can't take it, and doesn't count as a crash.
let (second, unclean) = running::start(Some(&dir));
- assert!(second.is_none() && !unclean);
+ // It says why, which the app shows as it starts without autosave.
+ assert!(second.as_ref().err().is_some_and(|why| why.contains("another copy")) && !unclean);
// This run ends without closing (a crash): the next one notices, once it closes cleanly.
drop(first);
let (after_crash, unclean) = running::start(Some(&dir));
@@ -446,6 +495,22 @@ mod tests {
assert!(app.session.path.is_none(), "a package opens unsaved, so Save asks where to write a project");
}
+ /// A recovery folder that can't be made (a file in its way) or no app-data folder at all gives
+ /// a reason, which the app shows as it starts without autosave; a usable one
+ /// gives the store.
+ #[test]
+ fn a_recovery_folder_that_does_not_open_says_why() {
+ let dir = std::env::temp_dir().join(format!("rustgis-recovery-store-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let _cleanup = TempDir(dir.clone());
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("recovery"), "a file, not a folder").unwrap();
+ let blocked = super::recovery_store(Some(dir.clone())).err().unwrap();
+ assert!(blocked.contains("recovery"), "{blocked}");
+ assert!(super::recovery_store(None).err().unwrap().contains("app data"));
+ assert!(super::recovery_store(Some(dir.join("fresh"))).is_ok());
+ }
+
#[test]
fn crash_times_are_utc_dates() {
assert_eq!(crash::utc(0), "1970-01-01 00:00:00");
diff --git a/crates/data/src/dirs.rs b/crates/data/src/dirs.rs
index 2142a3392..cd26faaee 100644
--- a/crates/data/src/dirs.rs
+++ b/crates/data/src/dirs.rs
@@ -48,7 +48,7 @@ pub fn config_dir() -> Option {
resolve(Kind::Config, OS, &env)
}
-/// App data kept on this computer: installed plugins and programs.
+/// App data kept on this computer: installed plugins and programs, recovery copies of unsaved work.
pub fn app_data_dir() -> Option {
resolve(Kind::AppData, OS, &env)
}
diff --git a/crates/engine/src/project.rs b/crates/engine/src/project.rs
index e8d75c11d..073aa2d67 100644
--- a/crates/engine/src/project.rs
+++ b/crates/engine/src/project.rs
@@ -39,7 +39,7 @@ pub fn open_command(path: &str) -> &'static str {
/// Format version written to files.
pub const FORMAT: u32 = 1;
/// Largest project file read (512 MiB).
-const MAX_BYTES: u64 = 512 << 20;
+pub const MAX_BYTES: u64 = 512 << 20;
/// The address a saved file keeps. Only web addresses can contain layer credentials. A tile
/// template (`pmtiles:…@archive`, `cog:…@archive`) is judged by its archive, and an address read
@@ -763,6 +763,22 @@ impl Project {
text.map(|text| (text, n))
}
+ /// The project as an autosaved recovery copy keeps it: its text without passwords, keys, tokens
+ /// or signatures in layer addresses, from a copy of the project (autosave writes it off the
+ /// interface thread). Unlike [`Self::saved_text`], local file paths stay as the open project
+ /// holds them, absolute: the copy lives in the app's recovery folder and is opened from there,
+ /// not from the project's folder. Nor is it remembered as a file this process wrote, so
+ /// frequent copies can't push the user's own saves out of the snapshots that restore secrets.
+ /// Text too large to open again is refused.
+ pub fn recovery_text(mut self) -> Result {
+ strip_addresses(self.addresses_mut());
+ let text = serde_json::to_string(&self).map_err(|e| e.to_string())?;
+ if text.len() as u64 > MAX_BYTES {
+ return Err("the project is too large to keep a recovery copy".into());
+ }
+ Ok(text)
+ }
+
fn restore_addresses(&mut self, text: &str) {
let id = identity(text);
let kept = kept();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 677432ba3..9b234dcf6 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -11747,6 +11747,23 @@ fn database_layers_wait_for_their_connection_login() {
assert_eq!(result["failed"], 2);
}
+/// An autosaved recovery copy (#873) leaves out layer secrets as a save does, from a copy of the
+/// project (the open one keeps them), and isn't registered as a file this process wrote: frequent
+/// copies don't evict saved snapshots, and opening a copy's text restores no secret.
+#[test]
+fn recovery_copies_keep_no_layer_tokens_and_restore_none() {
+ let mut s = world();
+ let url = "https://gis.example/arcgis/rest/services/a/MapServer/tile/{z}/{y}/{x}?token=s3cret";
+ s.run("map.addTiles", &json!({"url":url,"name":"Secured"})).unwrap();
+ let text = s.project.clone().recovery_text().unwrap();
+ assert!(!text.contains("s3cret") && text.contains("MapServer/tile/{z}/{y}/{x}"));
+ assert!(s.map().tiles.iter().any(|t| t.url == url), "the open project keeps its token");
+ let mut t = Session::new(Project::default());
+ t.run("project.openText", &json!({"text":text})).unwrap();
+ assert!(t.map().tiles.iter().any(|t| t.url.contains("MapServer/tile")));
+ assert!(!serde_json::to_string(&t.project).unwrap().contains("s3cret"));
+}
+
#[test]
fn saved_projects_keep_no_layer_tokens() {
let mut s = world();
diff --git a/crates/engine/tests/recovery_paths.rs b/crates/engine/tests/recovery_paths.rs
new file mode 100644
index 000000000..6443c805a
--- /dev/null
+++ b/crates/engine/tests/recovery_paths.rs
@@ -0,0 +1,41 @@
+//! Recovery copies (#873) and portable projects (#886): a project saved with paths relative to its
+//! folder, then reopened, gives a recovery copy whose linked files still resolve when the copy is
+//! opened from the app's recovery folder.
+#[path = "portable_projects/common.rs"]
+#[allow(dead_code)]
+mod common;
+
+use common::{Fixture, add_stream, sample_file, session};
+use serde_json::json;
+
+/// `path` as it is spelled inside project JSON.
+fn in_json(path: &std::path::Path) -> String {
+ let quoted = serde_json::to_string(&path.display().to_string()).unwrap();
+ quoted.trim_matches('"').to_string()
+}
+
+#[test]
+fn a_recovery_copy_keeps_linked_files_absolute() {
+ let f = Fixture::new("recovery");
+ let data = f.file("data/places.geojson");
+ sample_file(&data, "Cities");
+ let project = f.file("maps/atlas.rgis");
+ let mut s = session();
+ add_stream(&mut s, &data, None);
+ s.run("project.saveAs", &json!({"path": project})).unwrap();
+ // The saved file is portable: the data's path is relative to the project's folder.
+ let saved = std::fs::read_to_string(&project).unwrap();
+ assert!(!saved.contains(&in_json(&data)), "saved relative");
+
+ // Reopened, then copied for recovery: the copy names the data by its full path...
+ let mut opened = session();
+ opened.run("project.open", &json!({"path": project})).unwrap();
+ let copy = opened.project.clone().recovery_text().unwrap();
+ assert!(copy.contains(&in_json(&data)), "the copy keeps the data's full path");
+ // ...so opened from elsewhere, as recovery does, it reads the same file.
+ let mut recovered = session();
+ recovered.run("project.openText", &json!({"text": copy})).unwrap();
+ let live = serde_json::to_string(&recovered.project).unwrap();
+ assert!(live.contains(&in_json(&data)));
+ assert_eq!(recovered.map().layers.len(), opened.map().layers.len());
+}
diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs
index 10236b8a4..33bd23e54 100644
--- a/crates/ui-egui/examples/ui_shot.rs
+++ b/crates/ui-egui/examples/ui_shot.rs
@@ -23,6 +23,8 @@
//! `{"width": w, "height": h}` on the first line sets the size (default
//! 1480×900 at 2×; `"dt": seconds` there sets each frame's length, default 0.25, and a
//! short one such as 0.016 catches the map mid-motion). The window opens the World Atlas sample.
+//! `"recovery": "/abs/dir"` on the first line gives the run that recovery folder, as the desktop
+//! app has one (Settings › Autosave; a copy left there is offered by the recovery dialog).
//!
//! Add `"ribbonSplit": true` to the first line for a compact reusable split-button showcase
//! instead of the app. Its "Disable arrows", "Disable enclosing UI", and "Long two-line labels"
@@ -130,6 +132,10 @@ fn main() {
let ribbon_split = lines.first().and_then(|l| l.get("ribbonSplit")).and_then(Value::as_bool).unwrap_or(false);
let mut split_showcase = RibbonSplitShowcase::default();
let mut app = RustGisApp::with_sample();
+ if let Some(dir) = lines.first().and_then(|l| l.get("recovery")).and_then(Value::as_str) {
+ let store = rustgis_ui_egui::FolderStore::open(std::path::Path::new(dir)).expect("recovery folder");
+ app = app.with_services(rustgis_ui_egui::Services { recovery: Some(Box::new(store)), ..Default::default() });
+ }
// One shared wgpu render state: the app's GPU map backend and the harness's renderer must
// live on the same device for the map's paint callback to be valid.
let rs = {
diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs
new file mode 100644
index 000000000..2c9522323
--- /dev/null
+++ b/crates/ui-egui/src/autosave.rs
@@ -0,0 +1,966 @@
+//! Autosave and crash recovery (#873). While the project has unsaved changes, a recovery copy of
+//! it is kept through [`Services::recovery`](crate::Services), rewritten at most once per
+//! interval and only when the project changed since the last copy. Saving, starting another
+//! project and a clean exit remove it, so a copy left over at start means the last run ended
+//! without closing (a crash, a kill, a power cut): the app offers it then ([`offer`]).
+//!
+//! The desktop app keeps the copy in a folder ([`FolderStore`]); a shell without a store (the
+//! browser, for now) has no autosave.
+
+use egui::vec2;
+use rustgis_engine::Project;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use crate::RustGisApp;
+use crate::theme::{Tokens, semibold};
+
+/// The preference the interval is kept under, in minutes (`0` is off).
+pub(crate) const MINUTES_PREF: &str = "autosave_minutes";
+/// The interval until the user picks one.
+pub(crate) const DEFAULT_MINUTES: u32 = 5;
+/// The longest interval `ui.autosave` takes.
+const MAX_MINUTES: u64 = 60;
+/// The intervals Settings › Autosave offers (`0` is off).
+pub(crate) const CHOICES: [u32; 7] = [0, 1, 2, 5, 10, 15, 30];
+
+/// What a recovery copy holds beside the project: what the recovery dialog says about it and
+/// where saving the recovered work writes.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Recovery {
+ /// The project's name.
+ #[serde(default)]
+ pub name: String,
+ /// The file the project was opened from or saved to, if any.
+ #[serde(default)]
+ pub path: Option,
+ /// When the copy was made, in seconds since 1970 (UTC).
+ #[serde(default)]
+ pub time: u64,
+}
+
+/// Where a shell keeps the recovery copy. Writing and clearing may happen in the background, but
+/// in the order they were asked for: a copy cleared after a save is never written again by an
+/// earlier, slower write.
+pub trait RecoveryStore {
+ /// Replaces this run's copy with `project` (a copy of the open one) and `about` it.
+ fn write(&self, project: Project, about: Recovery);
+ /// Removes this run's copy (the work was saved, or replaced, or the app closes cleanly).
+ fn clear(&self);
+ /// Waits, for a bounded time, until what was asked for has been done: before the app exits.
+ fn flush(&self);
+ /// The copy an earlier run left that is offered now: the newest of those waiting to be
+ /// recovered or discarded (runs that crashed again after Later each leave one).
+ fn pending(&self) -> Option;
+ /// How many copies are waiting, the offered one included.
+ fn waiting(&self) -> usize;
+ /// The project text of the offered copy.
+ fn read_pending(&self) -> Result;
+ /// Forgets the offered copy; the next one waiting is offered.
+ fn discard_pending(&self);
+ /// Makes the offered copy this run's copy, once it was recovered: it is kept, unchanged,
+ /// until the recovered work is saved or changes again.
+ fn adopt_pending(&self);
+ /// Why the last copy couldn't be written, once.
+ fn take_error(&self) -> Option;
+ /// Gives the store a way to wake the interface when a copy fails in the background, so the
+ /// failure is told on an idle window too. A store that fails only when asked needs none.
+ fn set_wake(&self, _wake: Box) {}
+}
+
+/// The app's autosave state.
+pub struct AutosaveState {
+ /// Minutes between copies; `0` is off.
+ pub(crate) minutes: u32,
+ /// The session revision the last copy was made at, while one is kept.
+ written: Option,
+ /// The last copy failed in the background: due again at the next interval, unchanged or not.
+ /// Kept apart from `written`, so a save still removes the earlier copy the failure left.
+ failed: bool,
+ /// When the last copy was made.
+ last: Option,
+ /// The copy an earlier run left, while the user hasn't recovered or discarded it.
+ offer: Option,
+ /// How many more copies wait behind the offered one.
+ more: usize,
+ /// Whether the offered copy's file changed on disk after the copy was made.
+ changed_since: bool,
+ /// The dialog was put off until the next start (Later, or other copies after a recovery).
+ later: bool,
+ /// Why recovering the offered copy failed, shown in the dialog.
+ offer_error: Option,
+ /// The last write error told, so a failing folder isn't reported at every interval.
+ told: Option,
+ /// The store was given the interface's wake.
+ wake_set: bool,
+}
+
+impl Default for AutosaveState {
+ /// Every few minutes ([`DEFAULT_MINUTES`]), nothing written yet and nothing offered.
+ fn default() -> Self {
+ AutosaveState {
+ minutes: DEFAULT_MINUTES,
+ written: None,
+ failed: false,
+ last: None,
+ offer: None,
+ more: 0,
+ changed_since: false,
+ later: false,
+ offer_error: None,
+ told: None,
+ wake_set: false,
+ }
+ }
+}
+
+/// Seconds since 1970, now.
+fn now_secs() -> u64 {
+ SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
+}
+
+/// The open project's description for its recovery copy.
+/// A file name that isn't Unicode (legal on Unix) can't be kept exactly in the copy's description:
+/// the project is described as untitled then, so Save asks where to write rather than writing to
+/// a lossy spelling of the name.
+fn about(app: &RustGisApp) -> Recovery {
+ let path = app.session.path.as_ref().and_then(|p| std::path::absolute(p).unwrap_or_else(|_| p.clone()).to_str().map(str::to_string));
+ Recovery { name: app.session.project.name.clone(), path, time: now_secs() }
+}
+
+/// Reads the interval preference and the copy an earlier run left (from [`RustGisApp::with_services`]).
+pub(crate) fn load(app: &mut RustGisApp) {
+ let minutes = app.services.load_pref.as_ref().and_then(|load| load(MINUTES_PREF)).and_then(|v| v.trim().parse::().ok());
+ // A stored value out of range (edited by hand) means the default.
+ app.autosave.minutes = minutes.filter(|m| u64::from(*m) <= MAX_MINUTES).unwrap_or(DEFAULT_MINUTES);
+ next_offer(app);
+}
+
+/// How many copies wait to be offered, the one offered now included.
+fn waiting(app: &RustGisApp) -> usize {
+ app.autosave.offer.as_ref().map_or(0, |_| app.autosave.more + 1)
+}
+
+/// Offers the next copy waiting in the store, if any.
+fn next_offer(app: &mut RustGisApp) {
+ let store = app.services.recovery.as_ref();
+ let pending = store.and_then(|s| s.pending());
+ app.autosave.more = store.map_or(0, |s| s.waiting()).saturating_sub(1);
+ app.autosave.changed_since = pending.as_ref().is_some_and(changed_since);
+ app.autosave.offer = pending;
+ app.autosave.offer_error = None;
+}
+
+/// Whether the copy's file was changed on disk after the copy was made.
+fn changed_since(r: &Recovery) -> bool {
+ let Some(path) = &r.path else { return false };
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok();
+ modified.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).is_some_and(|d| d.as_secs() > r.time)
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = path;
+ false
+ }
+}
+
+/// Each frame: keeps the recovery copy in step with the project. A copy is made when the project
+/// has unsaved changes it doesn't hold yet and the interval has passed since the last one; it is
+/// removed once the project has none (saved, or another one started).
+pub(crate) fn tick(app: &mut RustGisApp, ctx: &egui::Context) {
+ let Some(store) = &app.services.recovery else { return };
+ if !app.autosave.wake_set {
+ let ctx = ctx.clone();
+ store.set_wake(Box::new(move || ctx.request_repaint()));
+ app.autosave.wake_set = true;
+ }
+ if let Some(e) = store.take_error() {
+ // The work is due again, at the next interval.
+ app.autosave.failed = true;
+ if app.autosave.told.as_ref() != Some(&e) {
+ app.autosave.told = Some(e.clone());
+ let text = crate::i18n::fmt("Autosave couldn't keep a recovery copy: {error}", &[("error", &e)]);
+ app.session.notify(text, true);
+ }
+ }
+ if app.autosave.minutes == 0 || !app.session.dirty {
+ if app.autosave.written.take().is_some() {
+ store.clear();
+ }
+ app.autosave.failed = false;
+ return;
+ }
+ if app.autosave.written == Some(app.session.revision) && !app.autosave.failed {
+ return;
+ }
+ let interval = Duration::from_secs(u64::from(app.autosave.minutes) * 60);
+ let now = Instant::now();
+ if let Some(since) = app.autosave.last.map(|l| now.saturating_duration_since(l))
+ && since < interval
+ {
+ ctx.request_repaint_after(interval - since);
+ return;
+ }
+ // Cheap: layers' features (`Features`), raster pixels and embedded bytes are `Arc`s shared
+ // with the copy, as they are with the undo snapshot every edit already takes; only the
+ // project's own settings are copied here. Serializing and writing happen on the store's thread.
+ store.write(app.session.project.clone(), about(app));
+ app.autosave.written = Some(app.session.revision);
+ app.autosave.failed = false;
+ app.autosave.last = Some(now);
+}
+
+/// A clean exit: this run's copy goes, and what was asked of the store is done before the
+/// process ends. The copy an earlier run left stays until it is recovered or discarded.
+pub(crate) fn end(app: &mut RustGisApp) {
+ if let Some(store) = &app.services.recovery {
+ store.clear();
+ store.flush();
+ }
+ app.autosave.written = None;
+}
+
+/// `ui.autosave`: sets the interval (`minutes`, 0 to 60; `0` is off) and says what autosave
+/// does: the interval, and the copy waiting to be recovered.
+pub(crate) fn command(app: &mut RustGisApp, p: &Value) -> Result {
+ if let Some(m) = p.get("minutes") {
+ if app.services.recovery.is_none() {
+ return Err("autosave needs the desktop app".into());
+ }
+ let m = m.as_u64().filter(|m| *m <= MAX_MINUTES).ok_or("minutes is a whole number from 0 (off) to 60")?;
+ app.autosave.minutes = u32::try_from(m).unwrap_or(DEFAULT_MINUTES);
+ if let Some(save) = &app.services.save_pref {
+ save(MINUTES_PREF, &m.to_string());
+ }
+ }
+ Ok(json!({
+ "available": app.services.recovery.is_some(),
+ "minutes": app.autosave.minutes,
+ "pending": app.autosave.offer.as_ref().map(|r| json!({"name": r.name, "path": r.path, "time": r.time})),
+ }))
+}
+
+/// `ui.recover`: opens the copy an earlier run left as the project, unsaved, with its original
+/// file (so Save writes there); `{"discard": true}` deletes it instead.
+pub(crate) fn recover(app: &mut RustGisApp, p: &Value) -> Result {
+ let discard = match p.get("discard") {
+ None | Some(Value::Null) => false,
+ Some(Value::Bool(b)) => *b,
+ Some(_) => return Err("discard is true or false".into()),
+ };
+ let none = "there is no unsaved work to recover";
+ let (Some(store), Some(offered)) = (&app.services.recovery, app.autosave.offer.clone()) else { return Err(none.into()) };
+ if discard {
+ store.discard_pending();
+ next_offer(app);
+ return Ok(json!({"discarded": true, "waiting": waiting(app)}));
+ }
+ // Opening it replaces the open project: never one with work of its own not saved (after
+ // Later), which it would lose with its recovery copy.
+ if app.session.dirty {
+ return Err("the open project has unsaved changes: save them before recovering other work".into());
+ }
+ let text = store.read_pending()?;
+ app.run("project.openText", json!({"text": text}))?;
+ app.session.path = offered.path.as_ref().map(std::path::PathBuf::from);
+ app.session.dirty = true;
+ // The copy becomes this run's as it is: a crash now loses nothing, and nothing is written.
+ if let Some(store) = &app.services.recovery {
+ store.adopt_pending();
+ }
+ app.autosave.written = Some(app.session.revision);
+ app.autosave.last = Some(Instant::now());
+ next_offer(app);
+ let text = crate::i18n::fmt("Recovered unsaved work in {name}. Save to keep it.", &[("name", &app.session.project.name)]);
+ app.session.notify(text, false);
+ // Other copies wait for the next start: recovering one now would replace this work.
+ let waiting = waiting(app);
+ if waiting > 0 {
+ app.autosave.later = true;
+ let text =
+ crate::i18n::fmt("Other copies of unsaved work will be offered at the next start ({n}).", &[("n", &waiting.to_string())]);
+ app.session.notify(text, false);
+ }
+ Ok(json!({"name": app.session.project.name, "path": offered.path, "waiting": waiting}))
+}
+
+/// The dialog's first line: what was kept, and how long ago (`then`, seconds since 1970). Whole
+/// sentences, one per unit, so each translates as a sentence; only data is filled in.
+fn kept_line(name: &str, then: u64) -> String {
+ let secs = now_secs().saturating_sub(then);
+ let (template, n) = match secs {
+ 0..60 => ("RustGIS didn't close properly last time. A copy of {name} with unsaved changes was kept less than a minute ago.", 0),
+ 60..120 => ("RustGIS didn't close properly last time. A copy of {name} with unsaved changes was kept a minute ago.", 1),
+ 120..7_200 => {
+ ("RustGIS didn't close properly last time. A copy of {name} with unsaved changes was kept {n} minutes ago.", secs / 60)
+ }
+ 7_200..172_800 => {
+ ("RustGIS didn't close properly last time. A copy of {name} with unsaved changes was kept {n} hours ago.", secs / 3_600)
+ }
+ _ => ("RustGIS didn't close properly last time. A copy of {name} with unsaved changes was kept {n} days ago.", secs / 86_400),
+ };
+ crate::i18n::fmt(template, &[("name", name), ("n", &n.to_string())])
+}
+
+/// The recovery dialog, at start while a copy an earlier run left is waiting: Recover, Discard,
+/// or Later (it is offered again at the next start).
+pub(crate) fn offer(app: &mut RustGisApp, ctx: &egui::Context) {
+ if app.autosave.later {
+ return;
+ }
+ let Some(r) = app.autosave.offer.clone() else { return };
+ let t = Tokens::get(ctx);
+ let name = if r.name.trim().is_empty() { tl_raw!("Untitled").to_string() } else { r.name.clone() };
+ let file = r.path.as_deref().map(|p| std::path::Path::new(p).file_name().map_or(p.to_string(), |n| n.to_string_lossy().into_owned()));
+ let mut decision = None;
+ egui::Modal::new(egui::Id::unique("autosave_recover")).show(ctx, |ui| {
+ ui.set_width(420.0);
+ ui.label(egui::RichText::new(tl!("Recover unsaved work?")).font(semibold(15.0)));
+ ui.add_space(4.0);
+ ui.label(kept_line(&name, r.time));
+ ui.add_space(4.0);
+ match &file {
+ Some(f) => {
+ ui.label(crate::i18n::fmt("It opens unsaved; saving replaces {file}.", &[("file", f)]));
+ if app.autosave.changed_since {
+ ui.label(
+ egui::RichText::new(crate::i18n::fmt("{file} has changed since this copy was made.", &[("file", f)])).color(t.red),
+ );
+ }
+ }
+ None => {
+ ui.label(tl!("It was never saved: it opens unsaved, and Save asks where to write it."));
+ }
+ }
+ if app.autosave.more > 0 {
+ ui.label(crate::i18n::fmt("Older copies waiting: {n}.", &[("n", &app.autosave.more.to_string())]));
+ }
+ // Recovering replaces the open project: not one with unsaved work of its own (an edit
+ // made before answering). Recover comes back once that work is saved.
+ let blocked = app.session.dirty;
+ if blocked {
+ ui.add_space(4.0);
+ ui.label(
+ egui::RichText::new(tl!("The open project has unsaved changes: save them, then recover this copy.")).color(t.text_dim),
+ );
+ }
+ if let Some(e) = &app.autosave.offer_error {
+ ui.add_space(4.0);
+ ui.label(egui::RichText::new(crate::i18n::fmt("It didn't open: {error}", &[("error", e)])).color(t.red));
+ }
+ ui.add_space(10.0);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
+ // A copy that didn't open isn't offered again; Discard and Later remain.
+ if app.autosave.offer_error.is_none()
+ && !blocked
+ && crate::widgets::dialog_button(ui, tl!("Recover"), Some(t.accent), vec2(84.0, 28.0)).clicked()
+ {
+ decision = Some("recover");
+ }
+ if crate::widgets::dialog_button(ui, tl!("Discard"), None, vec2(84.0, 28.0)).clicked() {
+ decision = Some("discard");
+ }
+ if crate::widgets::dialog_button(ui, tl!("Later"), None, vec2(84.0, 28.0)).clicked()
+ || ui.input(|i| i.key_pressed(egui::Key::Escape))
+ {
+ decision = Some("later");
+ }
+ });
+ });
+ match decision {
+ Some("recover") => {
+ if let Err(e) = app.run("ui.recover", Value::Null) {
+ app.autosave.offer_error = Some(e);
+ }
+ }
+ Some("discard") => {
+ let _ = app.run("ui.recover", json!({"discard": true}));
+ }
+ Some(_) => app.autosave.later = true,
+ None => {}
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+pub use folder::FolderStore;
+
+/// The desktop's store: one file for this run's copy and one for each copy earlier runs left,
+/// in a folder of their own, written by a background thread.
+#[cfg(not(target_arch = "wasm32"))]
+mod folder {
+ use std::io::{BufRead, Read};
+ use std::path::{Path, PathBuf};
+ use std::sync::mpsc::{Receiver, Sender, channel};
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ use rustgis_engine::Project;
+
+ use super::{Recovery, RecoveryStore};
+
+ /// This run's copy.
+ const LIVE: &str = "autosave.recovery";
+ /// Copies earlier runs left are `recovered-.recovery`.
+ const PENDING_PREFIX: &str = "recovered-";
+ const EXTENSION: &str = ".recovery";
+ /// The most copies kept waiting: the oldest beyond these go.
+ const MAX_WAITING: usize = 5;
+ /// The first line of a copy, describing it, is at most this long.
+ const MAX_HEADER: u64 = 64 * 1024;
+ /// How long a clean exit waits for a copy still being written.
+ const FLUSH_WAIT: Duration = Duration::from_secs(30);
+
+ /// What the background thread is asked to do, in order.
+ enum Job {
+ Write(Box, Recovery),
+ Clear,
+ Remove(PathBuf),
+ Adopt(PathBuf),
+ Flush(Sender<()>),
+ }
+
+ /// A recovery folder. A copy is one file: a line of JSON describing it ([`Recovery`]), then the
+ /// project's saved text. One file written atomically means the description always belongs to
+ /// the project after it, whenever the app stops.
+ pub struct FolderStore {
+ jobs: Mutex>,
+ /// The copies earlier runs left, newest first.
+ waiting: Mutex>,
+ error: Arc>>,
+ /// Called by the background thread after a failure: the interface's repaint.
+ wake: Wake,
+ }
+
+ /// The interface's wake, shared with the background thread; none until the app sets it.
+ type Wake = Arc>>>;
+
+ impl FolderStore {
+ /// Takes `dir` as the recovery folder for this run, which must be the only one using it
+ /// (the desktop app holds the run marker). A copy the last run left there joins those
+ /// waiting to be offered; none is ever replaced by another, and only the oldest beyond
+ /// [`MAX_WAITING`] are removed.
+ pub fn open(dir: &Path) -> Result {
+ std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
+ // The copies hold the project's data: this user's only.
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
+ log::warn!("autosave: could not make {} private: {e}", dir.display());
+ }
+ }
+ let live = dir.join(LIVE);
+ if live.is_file() {
+ let set_aside = unused_pending_name(dir);
+ std::fs::rename(&live, &set_aside).map_err(|e| format!("{}: {e}", live.display()))?;
+ }
+ let mut waiting: Vec<(PathBuf, Recovery)> = std::fs::read_dir(dir)
+ .map_err(|e| format!("{}: {e}", dir.display()))?
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .filter(|p| p.is_file() && is_pending(p))
+ .map(|p| {
+ let about = describe(&p);
+ (p, about)
+ })
+ .collect();
+ // Newest first; copies of the same second by name, the later set aside first.
+ waiting.sort_by(|a, b| b.1.time.cmp(&a.1.time).then_with(|| b.0.cmp(&a.0)));
+ for (old, _) in waiting.drain(MAX_WAITING.min(waiting.len())..) {
+ log::warn!("autosave: removing {}, the oldest of more than {MAX_WAITING} copies", old.display());
+ remove(&old);
+ }
+ let (jobs, rx) = channel();
+ let error: Arc>> = Arc::default();
+ let wake: Wake = Arc::default();
+ let (worker_dir, worker_error, worker_wake) = (dir.to_path_buf(), error.clone(), wake.clone());
+ std::thread::Builder::new()
+ .name("autosave".into())
+ .spawn(move || work(&worker_dir, &rx, &worker_error, &worker_wake))
+ .map_err(|e| format!("could not start autosave: {e}"))?;
+ Ok(FolderStore { jobs: Mutex::new(jobs), waiting: Mutex::new(waiting), error, wake })
+ }
+
+ /// Queues `job` for the background thread; one that stopped (it can't, short of a panic)
+ /// is logged, and the copy is then not kept.
+ fn send(&self, job: Job) {
+ let jobs = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
+ if jobs.send(job).is_err() {
+ log::warn!("autosave stopped: its thread is gone");
+ }
+ }
+
+ /// The copies waiting, locked.
+ fn list(&self) -> std::sync::MutexGuard<'_, Vec<(PathBuf, Recovery)>> {
+ self.waiting.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ /// Takes the offered copy off the list, for `job` to do with its file.
+ fn take_offered(&self, job: fn(PathBuf) -> Job) {
+ let mut waiting = self.list();
+ if waiting.is_empty() {
+ return;
+ }
+ let (path, _) = waiting.remove(0);
+ drop(waiting);
+ self.send(job(path));
+ }
+ }
+
+ /// Whether `path` is a copy an earlier run left.
+ fn is_pending(path: &Path) -> bool {
+ path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(PENDING_PREFIX) && n.ends_with(EXTENSION))
+ }
+
+ /// A name for a copy set aside that no other copy has: `recovered-.recovery`, one past the
+ /// highest number there.
+ fn unused_pending_name(dir: &Path) -> PathBuf {
+ let highest = std::fs::read_dir(dir)
+ .into_iter()
+ .flatten()
+ .filter_map(Result::ok)
+ .filter_map(|e| {
+ let name = e.file_name().to_str()?.to_string();
+ name.strip_prefix(PENDING_PREFIX)?.strip_suffix(EXTENSION)?.parse::().ok()
+ })
+ .max();
+ let n = highest.map_or(1, |h| h.saturating_add(1));
+ dir.join(format!("{PENDING_PREFIX}{n}{EXTENSION}"))
+ }
+
+ /// A copy's description, from its first line; a copy whose line doesn't read (cut short,
+ /// garbled) is still offered, as an untitled project of its file's time.
+ fn describe(path: &Path) -> Recovery {
+ let mut line = String::new();
+ let read = std::fs::File::open(path).and_then(|f| std::io::BufReader::new(f.take(MAX_HEADER)).read_line(&mut line));
+ if read.is_ok()
+ && let Ok(r) = serde_json::from_str::(line.trim_end())
+ {
+ return r;
+ }
+ let time = std::fs::metadata(path)
+ .and_then(|m| m.modified())
+ .ok()
+ .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
+ .map_or(0, |d| d.as_secs());
+ Recovery { time, ..Recovery::default() }
+ }
+
+ /// How the background thread writes a copy (a test can make one fail).
+ type WriteCopy = fn(&Path, Project, &Recovery) -> Result<(), String>;
+
+ /// The background thread: does what it is asked in order, writing copies with [`write`].
+ fn work(dir: &Path, rx: &Receiver, error: &Mutex