Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions GEOLIBRE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_).
Expand Down
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/rustgis-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
99 changes: 82 additions & 17 deletions apps/rustgis/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/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<Marker>, bool) {
let Some(dir) = dir else { return (None, false) };
/// Takes `<dir>/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<Marker, String>, 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
Expand All @@ -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();
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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}");
}
}
Expand Down Expand Up @@ -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<std::path::PathBuf>) -> Result<Box<dyn rustgis_ui_egui::RecoveryStore>, 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<String> {
Expand Down Expand Up @@ -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() }) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion crates/data/src/dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub fn config_dir() -> Option<PathBuf> {
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<PathBuf> {
resolve(Kind::AppData, OS, &env)
}
Expand Down
18 changes: 17 additions & 1 deletion crates/engine/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, String> {
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();
Expand Down
17 changes: 17 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
41 changes: 41 additions & 0 deletions crates/engine/tests/recovery_paths.rs
Original file line number Diff line number Diff line change
@@ -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());
}
6 changes: 6 additions & 0 deletions crates/ui-egui/examples/ui_shot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.expect(...) in non-test code goes against the "never crash" rule in AGENTS.md. Examples may be more lenient, but a bad recovery path in a script panics here. Consider matching the result and printing an error. Confidence: low.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is on purpose. ui_shot is a developer tool, and it already stops with expect on its other setup failures: an unreadable script line (expect("json line")) and no GPU device (expect("render state")). A recovery folder that can't be opened is the same kind of setup error, and the panic message names it. The never-crash rule covers the app's code, where input comes from users and files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: .expect("recovery folder") panics on a bad path. AGENTS.md bans expect in non-test code. Examples are borderline, and the rest of this file may already allow it. Otherwise print the error and exit. Confidence: low.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered on #896 (comment): ui_shot is a developer tool that already stops with expect on its other setup failures (expect("json line"), expect("render state")), so this follows the file's existing style. The never-crash rule covers the app's own code. Leaving it as is.

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 = {
Expand Down
Loading
Loading