mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.37: Data Recovery keeps copies of modified documents and offers them after a crash
- Every Preferences > File Handling > Data Recovery interval, modified documents
changed since their last copy are written to the recovery store in the
background (the same snapshot and encoder as Background Save; the status bar
shows it). Complex documents (over 20,000 objects) are skipped while Turn off
Data Recovery for complex documents is on.
- A copy goes when its document is saved, reverted, closed or undone back to its
saved state, and when the app quits with nothing unsaved.
- Each running app keeps its copies in an area of its own: a sub-folder whose
.lock file it keeps locked for its lifetime (File::try_lock), or on the web a
heartbeat refreshed every minute. Only areas nobody holds (lock free, or no
heartbeat for three intervals, at least three minutes) are offered, and an
area is held while it is restored or discarded, so a second running instance
never offers or deletes a live instance's copies. Empty leftover areas are
tidied away.
- The next launch after a crash asks with the Recover Documents dialog (Restore /
Discard / Cancel): restored documents open as "<name> [Recovered]", unsaved, and
Save asks where to save them, suggesting their original file; the copy moves
into the new app's area.
- Store: the recoveryFolder preference, else Data Recovery beside the desktop
app's preferences; browser storage on the web (Services::recovery_store);
tests use an in-memory store (with in-memory locks, or heartbeats).
- Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?},
file.recovery.discard {file?}; DocState.recovered and DocState.recovery.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d8d1d472cf
commit
975067b44a
21 files changed
+1777
-24
No files matched your search
@@ -16,6 +16,7 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -26,4 +27,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] }
|
||||
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::RecoveryStore;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
@@ -172,10 +173,52 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
recovery_store: Some(Arc::new(BrowserStore)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
|
||||
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
|
||||
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock.
|
||||
struct BrowserStore;
|
||||
|
||||
const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
|
||||
|
||||
fn js_err(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
|
||||
fn local_storage() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
|
||||
}
|
||||
|
||||
impl RecoveryStore for BrowserStore {
|
||||
fn list(&self) -> Result<Vec<String>, String> {
|
||||
let s = local_storage()?;
|
||||
let n = s.length().map_err(js_err)?;
|
||||
Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
|
||||
}
|
||||
fn read(&self, name: &str) -> Result<Vec<u8>, String> {
|
||||
let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
|
||||
text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
local_storage()?
|
||||
.set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
|
||||
.map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
|
||||
}
|
||||
fn location(&self) -> String {
|
||||
"browser storage".into()
|
||||
}
|
||||
fn now(&self) -> Option<i64> {
|
||||
Some((js_sys::Date::now() / 1000.0) as i64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
|
||||
Reference in new issue
Block a user