Files
vectorcraft/crates/engine/src/cmd/recovery.rs
T

1606 lines
73 KiB
Rust

//! Data Recovery: copies of modified documents kept in a recovery store, so a crash loses at most
//! the last few minutes of work.
//!
//! - `file.recovery.save` writes a copy of every modified document changed since its last copy
//! (the app runs it every Preferences → File Handling → Data Recovery interval, in the
//! background like Background Save: [`jobs`] snapshots, [`RecoveryJob::write`] encodes and
//! stores, [`RecoveryJob::finish`] records the copy).
//! - A copy is removed when its document is saved, reverted or closed ([`forget`]), so copies
//! left in the store after the app has quit are what a crash left behind:
//! `file.recovery.list` lists them, `file.recovery.restore` opens them as
//! "<name> [Recovered]" (modified, never saved, the original path remembered for Save As) and
//! `file.recovery.discard` deletes them.
//!
//! Every running app (each browser tab) keeps its copies in an area of its own, `<area>/…`, and
//! holds it while it runs: a lock on the area for its lifetime where the store has locks (folders:
//! [`FolderStore`]), else a heartbeat it refreshes ([`heartbeat`]; browser storage) plus, where the
//! store can, an announcement that lasts as long as the app ([`RecoveryStore::announce`]: the
//! browser's Web Locks). Only areas nobody holds any more (their app is gone) are offered, and
//! while their copies are restored or discarded the area is held, so two apps launched together
//! never both take it. An area's lock file or folder can be removed while its app runs, so each
//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or
//! takes a new area. The copies it had in the old area stay there until it has written them again
//! in the new one, and an entry `<new area>/keeps-<old area>` ([`mark_left`]) tells other apps
//! that those copies belong to a running app, so none of them offers or discards them meanwhile.
//!
//! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so
//! when unsure an area counts as held, and its app repairs what another app did meanwhile: each
//! heartbeat and each recovery save write again the copies missing from its area ([`repair`]), and
//! a heartbeat newer than the one another app wrote when taking the area over marks it running
//! again.
//!
//! Each copy is two entries: `<area>/<file>.vectorcraft` (the native document, written as a save
//! writes it, without its images) and `<area>/<file>.json` (its title, original path and format,
//! when it was written, and its images). The images are entries of their own, `<area>/img-…blob`,
//! named by their content and shared by the area's copies: a copy written again writes only the
//! images that changed, and the images no copy names any more go. Copies written before images
//! were kept apart (the images in the document entry) still restore. The store is a folder on the desktop ([`FolderStore`]: the `recoveryFolder`
//! preference, else the folder the app sets), browser storage on the web, or any
//! [`RecoveryStore`] the host installs (tests use [`MemoryStore`]).
use std::collections::{BTreeMap, BTreeSet};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, Weak};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob};
use super::fileio::{self, SAVE_FORMATS, SaveJob, SaveMode, SavePlan};
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, str_param};
use crate::DocState;
/// Documents with more objects than this are complex: with Preferences → Turn off Data Recovery
/// for complex documents, they get no recovery copy.
pub const COMPLEX_OBJECTS: usize = 20_000;
/// The extension of a copy's document entry.
const DOC_EXT: &str = ".vectorcraft";
/// The extension of a copy's details entry.
const META_EXT: &str = ".json";
/// The extension of an image entry (see [`blob_name`]).
const BLOB_EXT: &str = ".blob";
/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
/// seconds, as text).
const BEAT: &str = "heartbeat";
/// The start of an entry `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies
/// it left in `<left area>` ([`mark_left`]).
const KEEPS: &str = "keeps-";
/// The least time without a heartbeat after which an area's app counts as gone (seconds); else
/// three Data Recovery intervals.
const MIN_STALE: i64 = 180;
/// How often a running app refreshes its heartbeat (seconds).
pub const HEARTBEAT_EVERY: f64 = 60.0;
/// Holds an area while it lives (dropping it releases the lock).
pub type Hold = Box<dyn std::any::Any + Send + Sync>;
/// What [`RecoveryStore::lock`] got.
pub enum Lock {
/// The area is ours until the hold is dropped.
Held(Hold),
/// A running app holds it.
Busy,
/// The store has no locks: areas are held with heartbeats.
Unsupported,
}
/// Where recovery copies are kept: named entries of bytes, `<area>/<name>`. Called from the UI
/// thread and the background worker.
pub trait RecoveryStore: Send + Sync {
/// The names of the entries (`<area>/<name>`).
fn list(&self) -> std::result::Result<Vec<String>, String>;
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String>;
/// Create or replace an entry (a failed write must not leave a damaged entry behind).
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String>;
/// Delete an entry (one that isn't there is no error).
fn remove(&self, name: &str) -> std::result::Result<(), String>;
/// Where the entries are, as the user knows it (a folder, "browser storage").
fn location(&self) -> String;
/// Is [`RecoveryStore::location`] a folder on this computer (default: no)?
fn in_folder(&self) -> bool {
false
}
/// The areas in the store (default: those with entries).
fn areas(&self) -> std::result::Result<Vec<String>, String> {
let names = self.list()?;
let set: BTreeSet<String> = names.iter().filter_map(|n| n.split_once('/')).map(|(a, _)| a.to_string()).collect();
Ok(set.into_iter().collect())
}
/// Hold area `area` exclusively, if the store has locks (default: it hasn't).
fn lock(&self, _area: &str) -> std::result::Result<Lock, String> {
Ok(Lock::Unsupported)
}
/// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a
/// file or folder that was removed or replaced since holds nothing; when that can't be told now,
/// it does (default: it does).
fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool {
true
}
/// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as
/// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped.
fn announce(&self, _area: &str) -> Option<Hold> {
None
}
/// Stores without locks: does a running app hold area `area` by [`RecoveryStore::announce`]?
/// `None`: the store can't tell (heartbeats alone decide). A store that can tell but isn't sure
/// right now says `Some(true)`: a copy kept loses nothing, one deleted may.
fn announced(&self, _area: &str) -> Option<bool> {
None
}
/// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds
/// the area until this returns.
fn remove_area(&self, _area: &str) {}
/// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the
/// caller let the area go. On some file systems a file that is open is removed only when it is
/// closed, so `remove_area` leaves the folder there while the area is held.
fn remove_empty_area(&self, _area: &str) {}
/// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone).
fn now(&self) -> Option<i64> {
vectorcraft_doc::metadata::now_unix()
}
}
/// A store in memory (tests, and hosts without storage). Areas are locked in memory, so sessions
/// sharing one store behave like apps sharing a folder; [`MemoryStore::without_locks`] uses
/// heartbeats like browser storage, judged by a clock tests set ([`MemoryStore::set_now`]), and
/// [`MemoryStore::announcing`] adds announcements like the browser's Web Locks.
pub struct MemoryStore {
entries: Mutex<BTreeMap<String, Vec<u8>>>,
held: Arc<Mutex<BTreeSet<String>>>,
locks: bool,
announces: bool,
now: Mutex<Option<i64>>,
}
impl Default for MemoryStore {
fn default() -> Self {
Self { entries: Default::default(), held: Default::default(), locks: true, announces: false, now: Mutex::new(Some(1_000_000)) }
}
}
impl MemoryStore {
/// A store without locks: areas are held with heartbeats.
pub fn without_locks() -> Self {
Self { locks: false, ..Self::default() }
}
/// A store without locks whose apps also announce the areas they hold for as long as they run
/// (as the browser's Web Locks do).
pub fn announcing() -> Self {
Self { announces: true, ..Self::without_locks() }
}
fn hold(&self, area: &str) -> Option<Hold> {
if !self.held.lock().unwrap_or_else(|e| e.into_inner()).insert(area.to_string()) {
return None;
}
Some(Box::new(MemoryHold { held: self.held.clone(), area: area.to_string() }))
}
/// Set the clock heartbeats are judged by.
pub fn set_now(&self, t: i64) {
*self.now.lock().unwrap_or_else(|e| e.into_inner()) = Some(t);
}
fn entries(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Vec<u8>>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// An area of a [`MemoryStore`] held until dropped.
struct MemoryHold {
held: Arc<Mutex<BTreeSet<String>>>,
area: String,
}
impl Drop for MemoryHold {
fn drop(&mut self) {
self.held.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.area);
}
}
impl RecoveryStore for MemoryStore {
fn list(&self) -> std::result::Result<Vec<String>, String> {
Ok(self.entries().keys().cloned().collect())
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> {
self.entries().get(name).cloned().ok_or_else(|| format!("{name}: no such recovery entry"))
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
self.entries().insert(name.to_string(), bytes.to_vec());
Ok(())
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
self.entries().remove(name);
Ok(())
}
fn location(&self) -> String {
"memory".into()
}
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
if !self.locks {
return Ok(Lock::Unsupported);
}
Ok(self.hold(area).map_or(Lock::Busy, Lock::Held))
}
fn announce(&self, area: &str) -> Option<Hold> {
if !self.announces {
return None;
}
self.hold(area)
}
fn announced(&self, area: &str) -> Option<bool> {
self.announces.then(|| self.held.lock().unwrap_or_else(|e| e.into_inner()).contains(area))
}
fn now(&self) -> Option<i64> {
*self.now.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// A folder of files (desktop): an area is a sub-folder whose `.lock` file its app keeps locked
/// while it runs. Created when the first copy is written.
#[cfg(not(target_arch = "wasm32"))]
pub struct FolderStore(std::path::PathBuf);
/// The lock file of a [`FolderStore`] area.
#[cfg(not(target_arch = "wasm32"))]
const LOCK_FILE: &str = ".lock";
#[cfg(not(target_arch = "wasm32"))]
impl FolderStore {
pub fn new(folder: impl Into<std::path::PathBuf>) -> Self {
Self(folder.into())
}
/// Is `part` a plain file or folder name (no separators, not hidden)?
fn plain(part: &str) -> bool {
!part.starts_with('.') && std::path::Path::new(part).file_name().is_some_and(|f| f == part)
}
/// The folder of area `area`.
fn area(&self, area: &str) -> std::result::Result<std::path::PathBuf, String> {
if !Self::plain(area) {
return Err(format!("`{area}` is no recovery area name"));
}
Ok(self.0.join(area))
}
/// The file of entry `name` (`<area>/<file>`: anything else is refused).
fn file(&self, name: &str) -> std::result::Result<std::path::PathBuf, String> {
match name.split_once('/') {
Some((area, file)) if Self::plain(file) => Ok(self.area(area)?.join(file)),
_ => Err(format!("`{name}` is no recovery entry name")),
}
}
/// Create area folder `dir`, and the store's folder when it is missing. On Unix every folder
/// made here is owner-only (`0o700`): the copies are unsaved documents, and with the default
/// mode other accounts on the machine could read them. A store folder that already exists
/// keeps its mode, since it can be one the user picked in Preferences.
fn create_area(dir: &std::path::Path) -> std::result::Result<(), String> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true))
}
/// The first folder on the way to `dir` (`dir` included), from the top down, that is not a
/// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of
/// them can't be checked.
fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> {
let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect();
way.reverse();
for p in way {
match std::fs::metadata(p) {
Ok(m) if m.is_dir() => {}
Ok(_) => return Some((p, true)),
Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)),
Err(_) => return None,
}
}
None
}
/// The message for error `e` on folder `dir`, which was being made (`making`) or read. It
/// names the first folder on the way to `dir` that is not a folder, or is missing (when
/// `making`, the one that couldn't be made), and otherwise `dir`.
fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String {
match Self::in_the_way(dir) {
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()),
Some((p, false)) if p != dir => format!("{} is missing", p.display()),
_ => format!("{}: {e}", dir.display()),
}
}
/// The message for error `e` on file `f`. It names the first folder on the way to `f` that is
/// not a folder or is missing, and otherwise `f`.
fn file_error(f: &std::path::Path, e: &std::io::Error) -> String {
match f.parent().and_then(Self::in_the_way) {
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
Some((p, false)) => format!("{} is missing", p.display()),
None => format!("{}: {e}", f.display()),
}
}
/// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a
/// file is on the way to it: Windows reports that as a missing file, and it is named as for a
/// listing.
pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> {
match r {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) {
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
_ => Ok(()),
},
Err(e) => Err(Self::file_error(f, &e)),
Ok(()) => Ok(()),
}
}
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
/// belongs to this account. Listing areas leaves links out, so a name another account had
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
/// wrote the copies through the link.
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?;
if !m.file_type().is_dir() {
return Err(format!("{}: not a folder of its own", dir.display()));
}
#[cfg(unix)]
if std::os::unix::fs::MetadataExt::uid(&m) != rustix::process::geteuid().as_raw() {
return Err(format!("{}: belongs to another account", dir.display()));
}
Ok(())
}
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
/// A file on the way to `dir` is an error: Windows reports such a folder as missing.
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return match Self::in_the_way(dir) {
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
_ => Ok(vec![]),
};
}
Err(e) => return Err(Self::folder_error(dir, &e, false)),
};
Ok(entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| if dirs { t.is_dir() } else { t.is_file() }))
.filter_map(|e| e.file_name().to_str().map(str::to_string))
// Lock files, and files left by an interrupted atomic write.
.filter(|n| Self::plain(n))
.collect())
}
/// Lock area folder `dir`, which was just made. Another app tidies an empty area away
/// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder
/// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer
/// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one.
pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result<Lock, String> {
if let Err(e) = Self::own_area(dir) {
let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound);
return if gone { Ok(Lock::Busy) } else { Err(e) };
}
let path = dir.join(LOCK_FILE);
match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
Ok(file) => Self::take(file, &path),
Err(e) => Self::open_failed(e, &path),
}
}
/// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the
/// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some
/// file systems keep a removed file that another app still has open until that app closes it,
/// and refuse to open it meanwhile. A lock file that can't be made is an error.
pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result<Lock, String> {
match e.kind() {
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
_ => Err(Self::file_error(path, &e)),
}
}
/// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still
/// names that file.
pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result<Lock, String> {
match file.try_lock() {
Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))),
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
// The lock file was removed meanwhile, and locking it failed instead.
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)),
}
}
/// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on
/// Windows they must be the same file (volume serial number and file index); elsewhere `path`
/// must exist. When either file can't be read, it does not.
fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
match (file.metadata(), std::fs::symlink_metadata(path)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(windows)]
{
let opened = file.try_clone().and_then(same_file::Handle::from_file);
match (opened, same_file::Handle::from_path(path)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
std::fs::symlink_metadata(path).is_ok()
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl RecoveryStore for FolderStore {
fn list(&self) -> std::result::Result<Vec<String>, String> {
let mut out = vec![];
for area in self.areas()? {
out.extend(Self::names(&self.0.join(&area), false)?.into_iter().map(|f| format!("{area}/{f}")));
}
Ok(out)
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> {
let f = self.file(name)?;
// Entry names come from the copy's metadata: refuse a link (or a folder another account
// owns) that would read a file from outside the store.
if let Some(dir) = f.parent() {
Self::own_area(dir)?;
}
if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() {
return Err(format!("{}: not a plain file", f.display()));
}
std::fs::read(&f).map_err(|e| Self::file_error(&f, &e))
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
let f = self.file(name)?;
if let Some(dir) = f.parent() {
Self::create_area(dir)?;
Self::own_area(dir)?;
}
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e))
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
let f = self.file(name)?;
Self::removed(&f, std::fs::remove_file(&f))
}
fn location(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn in_folder(&self) -> bool {
true
}
fn areas(&self) -> std::result::Result<Vec<String>, String> {
Self::names(&self.0, true)
}
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
let dir = self.area(area)?;
Self::create_area(&dir)?;
Self::lock_area(&dir)
}
fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool {
let (Some(file), Ok(dir)) = (hold.downcast_ref::<std::fs::File>(), self.area(area)) else { return true };
let path = dir.join(LOCK_FILE);
// Only a lock file or folder that is gone, or another one in its place, ends the hold. One
// that can't be read now (a network folder that doesn't answer) still counts as held.
match std::fs::symlink_metadata(&path) {
Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path),
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
}
}
fn remove_area(&self, area: &str) {
// Best effort: whatever is left is tidied at a later launch. A folder that isn't empty
// (copies came back meanwhile) stays. The caller still holds the lock here.
if let Ok(dir) = self.area(area) {
let _ = std::fs::remove_file(dir.join(LOCK_FILE));
let _ = std::fs::remove_dir(&dir);
}
}
fn remove_empty_area(&self, area: &str) {
// Best effort, as above. An app that made the folder again meanwhile and has not locked it
// yet finds it gone and takes another area (`lock_area`); one that has keeps it.
if let Ok(dir) = self.area(area) {
let _ = std::fs::remove_dir(&dir);
}
}
}
/// The area this session keeps its copies in, while it holds it.
struct Own {
/// The store it is in (copies are removed from there even after the preference moves on).
store: Arc<dyn RecoveryStore>,
area: String,
/// What holds the area for the session's lifetime: its lock, else its announcement (stores
/// without locks that have them).
hold: Option<Hold>,
/// Held with a heartbeat (stores without locks).
beats: bool,
}
/// The session's recovery store settings and its own area ([`crate::Session::recovery`]).
#[derive(Default)]
pub struct Recovery {
/// A store the host installed (the web's browser storage, tests): wins over folders.
store: Option<Arc<dyn RecoveryStore>>,
/// The folder the desktop app keeps copies in when the `recoveryFolder` preference is empty.
default_folder: Option<String>,
own: Option<Own>,
/// Gone apps' areas this session took over by heartbeat (stores without locks), with the
/// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their
/// app was only paused and came back).
adopted: BTreeMap<String, i64>,
/// Copies in an area this session had to leave (`keep_hold`), by document uid, with their
/// store: each stays until its document has a copy in the new area, or needs none. They are
/// still this session's: never offered or discarded as a crash's here.
left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>,
/// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]).
marked: BTreeSet<String>,
}
impl Recovery {
/// Keep copies in `store` (instead of a folder).
pub fn set_store(&mut self, store: Arc<dyn RecoveryStore>) {
self.store = Some(store);
}
/// The folder copies go to while the `recoveryFolder` preference is empty (desktop; `None`:
/// none, so Data Recovery is off until the preference names one).
pub fn set_default_folder(&mut self, folder: Option<String>) {
self.default_folder = folder;
}
/// This session's area, once it has written a copy.
pub fn own_area(&self) -> Option<&str> {
self.own.as_ref().map(|o| o.area.as_str())
}
}
/// The store copies go to now (see [`Recovery`]), if any.
pub fn store(s: &Session) -> Option<Arc<dyn RecoveryStore>> {
if let Some(st) = &s.recovery.store {
return Some(st.clone());
}
#[cfg(not(target_arch = "wasm32"))]
{
folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
}
#[cfg(target_arch = "wasm32")]
None
}
/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is
/// the folder the app sets). `None` when they go to a store the host installed (browser storage)
/// or nowhere.
pub fn folder(s: &Session) -> Option<(String, bool)> {
if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") {
return None;
}
match s.prefs.recovery_folder.trim() {
"" => s.recovery.default_folder.clone().map(|f| (f, false)),
set => Some((set.to_string(), true)),
}
}
fn has_store(s: &Session) -> std::result::Result<(), String> {
match store(s) {
Some(_) => Ok(()),
None => Err("Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)".into()),
}
}
fn need_store(s: &Session, cmd: &str) -> Result<Arc<dyn RecoveryStore>> {
store(s).ok_or_else(|| bad(cmd, has_store(s).err().unwrap_or_default()))
}
/// Seconds without a heartbeat after which an area's app counts as gone: three Data Recovery
/// intervals, at least [`MIN_STALE`].
fn stale_after(s: &Session) -> i64 {
(i64::from(s.prefs.autosave_interval.max(1)) * 180).max(MIN_STALE)
}
/// Record that `area`'s app runs (stores without locks) → the heartbeat written.
fn beat(store: &dyn RecoveryStore, area: &str) -> std::result::Result<i64, String> {
let now = store.now().ok_or("no clock to keep a heartbeat by")?;
store.write(&format!("{area}/{BEAT}"), now.to_string().as_bytes())?;
Ok(now)
}
/// Has `area`'s app said it runs within `stale` seconds (stores without locks)? `mine`: the
/// heartbeat this session wrote there itself when taking the area over, which doesn't count.
/// Without a clock every app counts as running.
fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>) -> bool {
let Some(now) = store.now() else { return true };
let at = store.read(&format!("{area}/{BEAT}")).ok().and_then(|b| String::from_utf8(b).ok()).and_then(|t| t.trim().parse::<i64>().ok());
at.is_some_and(|t| now.saturating_sub(t) <= stale && Some(t) != mine)
}
/// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds,
/// so also on its first frame after its timers were paused): its heartbeat in a store without
/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now),
/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed
/// beat only lets another app offer the copies once three intervals have passed.
pub fn heartbeat(s: &mut Session) -> Vec<u64> {
let mut lost = keep_hold(s);
// An entry that couldn't be written before, or that was removed since (by the user, a
// cleaner), is written now.
if let Some(own) = &s.recovery.own
&& !s.recovery.marked.is_empty()
&& let Ok(names) = own.store.list()
{
s.recovery.marked.retain(|n| names.contains(n));
}
mark_left(s);
if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) {
let _ = beat(own.store.as_ref(), &own.area);
}
// A copy that was being written when the session left its area finished there. A document
// that gets no copy now (a complex one) keeps the copy it has.
lost.extend(
s.recovery
.left
.keys()
.filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none()))
.copied(),
);
lost.extend(repair(s));
lost.sort_unstable();
lost.dedup();
lost
}
/// Check that this session's lock still holds its area (stores with locks). The area's lock file,
/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program),
/// and then another app can lock the area and offer or delete this session's copies as a crash's.
/// The area is locked again, made again first if it went (its copies are then written again).
/// When another app holds it now, the session takes a new area, and each copy left in the old one
/// stays until its document has a copy in the new one. → the documents whose copies must be
/// written again.
fn keep_hold(s: &mut Session) -> Vec<u64> {
drop_left_done(s);
let Some(own) = &mut s.recovery.own else { return vec![] };
if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) {
return vec![];
}
let (store, area) = (own.store.clone(), own.area.clone());
// The lock held is on a file the area no longer has, so it holds nothing: it goes first (a
// removed file this app still has open can keep a new lock file from being made).
own.hold = None;
if let Ok(Lock::Held(h)) = store.lock(&area) {
own.hold = Some(h);
return repair(s);
}
let mut lost = vec![];
for st in &mut s.docs {
if let Some(c) = st.recovery.take() {
s.recovery.left.insert(st.uid, (store.clone(), c.file));
lost.push(st.uid);
}
}
s.recovery.own = None;
// Best effort: when no area can be taken now, the next recovery save says why.
let _ = claim(s, &store);
lost
}
/// The copies left in an area this session left whose documents were saved, reverted or closed
/// since go.
fn drop_left_done(s: &mut Session) {
let done: Vec<u64> = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect();
for uid in done {
drop_left(s, uid);
}
}
/// Document `uid`'s copy in an area this session left goes. A copy that can't be removed now stays
/// this session's, and a later heartbeat removes it.
fn drop_left(s: &mut Session, uid: u64) {
let Some((store, file)) = s.recovery.left.get(&uid) else { return };
let doc = store.remove(&format!("{file}{DOC_EXT}"));
let meta = store.remove(&format!("{file}{META_EXT}"));
if doc.is_ok() && meta.is_ok() {
s.recovery.left.remove(&uid);
mark_left(s);
}
}
/// Record in this session's area each area it left that still holds copies of its documents: an
/// entry `<own area>/keeps-<left area>`, so another app leaves those copies alone while this one
/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort:
/// an entry not written or removed now is written or removed at the next heartbeat.
fn mark_left(s: &mut Session) {
let Some(own) = &s.recovery.own else { return };
let (store, area) = (own.store.clone(), own.area.clone());
let want: BTreeSet<String> = s
.recovery
.left
.values()
.filter(|(st, _)| st.location() == store.location())
.filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a))
.filter(|a| *a != area)
.map(|a| format!("{area}/{KEEPS}{a}"))
.collect();
let gone: Vec<String> = s.recovery.marked.difference(&want).cloned().collect();
for n in gone {
if store.remove(&n).is_ok() {
s.recovery.marked.remove(&n);
}
}
for n in want {
if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() {
s.recovery.marked.insert(n);
}
}
}
/// Forget this session's copies that are missing from its area, so the next recovery save writes
/// them again → their documents' uids. Another app removes them only when it took this one for
/// gone (its heartbeat stopped while its timers were paused, as browsers do to background tabs); a
/// copy it restored is its own now, so both are kept. A store that can't be listed changes nothing.
pub fn repair(s: &mut Session) -> Vec<u64> {
let Some(names) = s.recovery.own.as_ref().and_then(|o| o.store.list().ok()) else { return vec![] };
let mut lost = vec![];
for st in &mut s.docs {
if st.recovery.as_ref().is_some_and(|c| !names.iter().any(|n| n.strip_suffix(DOC_EXT) == Some(c.file.as_str()))) {
st.recovery = None;
lost.push(st.uid);
}
}
lost
}
/// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the
/// session's lifetime (locked, or with a heartbeat and an announcement where the store has them).
pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> {
if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) {
// Documents whose copies went are left without one, so the caller writes them again.
keep_hold(s);
if let Some(own) = &s.recovery.own {
return Ok(own.area.clone());
}
} else if s.recovery.own.is_some() {
// The store moved (the preference names another folder): copies start again there.
release(s);
}
let taken = store.areas().map_err(EngineError::Other)?;
let base = store.now().unwrap_or(0);
for n in 1..=1000 {
let area = format!("{base}-{n}");
if taken.contains(&area) {
continue;
}
let (hold, beats) = match store.lock(&area).map_err(EngineError::Other)? {
Lock::Held(h) => (Some(h), false),
Lock::Busy => continue,
Lock::Unsupported => {
beat(store.as_ref(), &area).map_err(EngineError::Other)?;
(store.announce(&area), true)
}
};
s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats });
// The entries written before are in the area this session had (released or left).
s.recovery.marked.clear();
mark_left(s);
return Ok(area);
}
Err(EngineError::Other("no free recovery area".into()))
}
/// Give up this session's area: its copies, heartbeat and lock go (quitting with nothing unsaved).
pub fn release(s: &mut Session) {
for st in &mut s.docs {
st.recovery = None;
}
// Copies left in an area this session had to leave go too.
let left: Vec<u64> = s.recovery.left.keys().copied().collect();
for uid in left {
drop_left(s, uid);
}
let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return };
// Its `keeps-` entries go with the area's other entries.
s.recovery.marked.clear();
let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area);
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
let _ = store.remove(name);
}
// Held until it is gone, as in `tidy`.
store.remove_area(&area);
drop(hold);
store.remove_empty_area(&area);
}
/// Let go of this session's area but leave its copies, as a crash would, so the next app started
/// offers them at once instead of waiting for the heartbeat to go stale: for an app that can't go
/// on (the web page lost its graphics and has to be reloaded). Its documents keep no copies.
pub fn leave(s: &mut Session) {
for st in &mut s.docs {
st.recovery = None;
}
let Some(Own { store, area, hold, beats }) = s.recovery.own.take() else { return };
if beats {
// Best effort: if the heartbeat stays, the copies are offered once it is stale.
let _ = store.remove(&format!("{area}/{BEAT}"));
}
// The lock or announcement goes too.
drop(hold);
}
/// A document's recovery copy: its entry and the document it holds.
#[derive(Clone, Debug)]
pub struct RecoveryCopy {
/// The copy's name in the store (`<area>/<name>`, without extension).
pub file: String,
/// The document as last copied (unchanged since: no new copy needed).
pub doc: Arc<Document>,
}
/// What a copy's details entry says.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
struct Meta {
title: String,
/// The document's file when it was copied, if it had one.
path: Option<String>,
/// The format Save writes for it ([`SAVE_FORMATS`]).
format: String,
/// A document's own native PDF choice before the recovery copy omits its PDF. None keeps
/// inheritance from the current application preference. Older copies did not record a choice.
#[serde(skip_serializing_if = "Option::is_none")]
native_pdf_compatible: Option<bool>,
/// When the copy was written (Unix seconds; none without a clock).
saved: Option<i64>,
/// The file it was read from when that import left things out of the document, and what
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
imported_from: Option<String>,
import_losses: Vec<String>,
/// The file the links' relative paths name places from, for a copy without `path` (see
/// [`crate::DocState::links_from`]).
#[serde(skip_serializing_if = "Option::is_none")]
links_from: Option<String>,
/// The document's images by key, in entries of the area (none in copies written before
/// images were kept apart: the document entry holds them).
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
images: BTreeMap<String, ImageRef>,
}
/// One of a copy's images: its entry in the copy's area.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
struct ImageRef {
mime: String,
/// The entry's name in the area ([`blob_name`]).
blob: String,
/// A linked image's preview, as a save writes it ([`ImageBlob::proxy`]).
#[serde(skip_serializing_if = "std::ops::Not::not")]
proxy: bool,
}
/// Writes of image entries and their sweeping ([`sweep`]) take turns, in this app (the background
/// worker and the UI thread), so an image is never swept between being written and named.
static WRITES: Mutex<()> = Mutex::new(());
/// Image bytes and the name of their entry ([`blob_name`]).
type Named = (Weak<Vec<u8>>, String);
/// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once,
/// not at every interval, keyed by the bytes' address. Bytes behind a live `Weak` never change
/// (`Arc::make_mut` copies them).
static NAMES: Mutex<Names> = Mutex::new(Names { by_address: None, live_after_sweep: 0 });
struct Names {
by_address: Option<std::collections::HashMap<usize, Named>>,
/// How many entries were live at the last sweep of dead ones: the next sweep waits until there
/// are twice as many, so sweeping costs a constant per name, not a pass over all of them.
live_after_sweep: usize,
}
/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so
/// identical images share one entry and one written before isn't written again.
fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
let mut guard = NAMES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Names { by_address, live_after_sweep } = &mut *guard;
let names = by_address.get_or_insert_with(Default::default);
let address = Arc::as_ptr(bytes) as usize;
// A dead entry at this address named other bytes that were freed there before.
if let Some((w, name)) = names.get(&address)
&& w.strong_count() > 0
&& std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))
{
return name.clone();
}
if names.len() >= (*live_after_sweep * 2).max(64) {
names.retain(|_, (w, _)| w.strong_count() > 0);
*live_after_sweep = names.len();
}
let hash = |seed: u8| {
let mut h = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut h);
bytes.as_slice().hash(&mut h);
h.finish()
};
let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len());
names.insert(address, (Arc::downgrade(bytes), name.clone()));
name
}
/// The MIME type of a linked image's preview (JPEG or PNG), as a save names it.
fn proxy_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
}
/// Remove the image entries of `area` no copy's details name any more. Best effort: an entry left
/// behind goes at the next sweep, or with the area.
fn sweep(store: &dyn RecoveryStore, area: &str) {
let Ok(names) = store.list() else { return };
let in_area = |n: &&String| n.split_once('/').is_some_and(|(a, _)| a == area);
let mut named = BTreeSet::new();
for meta in names.iter().filter(in_area).filter_map(|n| n.strip_suffix(META_EXT)) {
named.extend(read_meta(store, meta).images.into_values().map(|r| r.blob));
}
for n in names.iter().filter(in_area).filter(|n| n.ends_with(BLOB_EXT)) {
if n.split_once('/').is_some_and(|(_, b)| !named.contains(b)) {
let _ = store.remove(n);
}
}
}
/// The document's file `path` as a reply names it: under automation read roots, only inside them.
fn shown_path(path: Option<String>) -> Option<String> {
path.filter(|p| crate::file_access::check_read(p).is_ok())
}
/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation
/// read roots is `null`.
fn shown_location(store: &dyn RecoveryStore) -> Value {
let location = store.location();
if store.in_folder() && crate::file_access::check_read(&location).is_err() {
return Value::Null;
}
json!(location)
}
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
/// A copy of one document, snapshotted and ready to encode and store anywhere (the background
/// worker too): [`jobs`], then [`RecoveryJob::write`], then [`RecoveryJob::finish`].
#[derive(Clone)]
pub struct RecoveryJob {
/// The document's [`DocState::uid`].
pub uid: u64,
/// The copy's name in the store (`<area>/<name>`).
pub file: String,
/// The document's title (status messages).
pub title: String,
meta: Meta,
save: SaveJob,
/// The store the copy goes to (the preference can name another folder before it is finished).
store: Arc<dyn RecoveryStore>,
}
impl RecoveryJob {
/// Encode the snapshot and store it with its details and the images the area doesn't have
/// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes
/// (images written now)}`.
pub fn write(&self) -> std::result::Result<Value, String> {
let store = self.store.as_ref();
let doc = self.save.doc();
// A linked image keeps its preview, as a save writes it.
let linked = doc.linked_only_images();
let mut lean = Document::clone(doc);
let blobs = std::mem::take(&mut lean.images);
let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?;
let bytes = enc.files.first().map(|f| f.1.as_slice()).ok_or("nothing encoded")?;
let mut meta = self.meta.clone();
let mut images = vec![];
for (key, b) in &blobs {
let (mime, data, proxy) = match b.proxy.as_ref().filter(|_| linked.contains(key)) {
Some(p) => (proxy_mime(p), p, true),
None => (b.mime.as_str(), &b.bytes, false),
};
let blob = blob_name(data);
meta.images.insert(key.clone(), ImageRef { mime: mime.into(), blob: blob.clone(), proxy });
images.push((blob, data));
}
let meta_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?;
let area = self.file.split_once('/').map_or("", |(a, _)| a);
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
let mut have: BTreeSet<String> = if images.is_empty() { BTreeSet::new() } else { store.list()?.into_iter().collect() };
let mut image_bytes = 0;
for (blob, data) in images {
let name = format!("{area}/{blob}");
// Images of several keys can share one entry: it is written once.
if have.insert(name.clone()) {
store.write(&name, data)?;
image_bytes += data.len();
}
}
let meta_name = format!("{}{META_EXT}", self.file);
// The details name the images, so a crash between writing the document and its details
// would pair the new document with the old copy's images: when the new document has
// images the old details don't name, they name both until the new document is in.
let old = read_meta(store, &self.file).images;
if meta.images.iter().any(|(k, r)| old.get(k).is_none_or(|o| o.blob != r.blob)) {
let mut both = meta.clone();
for (k, r) in old {
both.images.entry(k).or_insert(r);
}
store.write(&meta_name, &serde_json::to_vec_pretty(&both).map_err(|e| e.to_string())?)?;
}
store.write(&format!("{}{DOC_EXT}", self.file), bytes)?;
store.write(&meta_name, &meta_bytes)?;
// Images this copy no longer shows, nor any other.
sweep(store, area);
Ok(json!({ "file": self.file, "title": self.title, "bytes": bytes.len(), "imageBytes": image_bytes }))
}
/// Once stored: the document (if still open) remembers the copy, so it isn't written again
/// while unchanged and goes when the document is saved or closed. A document saved or closed
/// meanwhile has nothing left to recover: its copy goes at once. So does a copy written in an
/// area the session left meanwhile (`keep_hold`), and its document gets a new one.
pub fn finish(self, s: &mut Session) {
let snapshot = self.save.snapshot().clone();
let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot };
let area = self.file.split_once('/').map(|(a, _)| a);
let current = s.recovery.own.as_ref().is_some_and(|o| o.store.location() == self.store.location() && Some(o.area.as_str()) == area);
match s.document_mut(self.uid).filter(|st| st.is_dirty()) {
Some(st) if current => {
st.recovery = Some(copy);
drop_left(s, self.uid);
}
// Written in an area left meanwhile: kept, in place of the copy left there before,
// until the document has a copy in the new area.
Some(st) if st.recovery.is_none() => {
let before = s.recovery.left.insert(self.uid, (self.store.clone(), self.file.clone()));
// The copy left there before, unless this one was written over it. Best effort,
// as in `remove_copy`.
if let Some((store, file)) = before.filter(|(old, f)| old.location() != self.store.location() || *f != self.file) {
let _ = store.remove(&format!("{file}{DOC_EXT}"));
let _ = store.remove(&format!("{file}{META_EXT}"));
}
mark_left(s);
}
// Best effort, as in `remove_copy`.
_ => {
let _ = self.store.remove(&format!("{}{DOC_EXT}", self.file));
let _ = self.store.remove(&format!("{}{META_EXT}", self.file));
drop_left(s, self.uid);
}
}
}
}
/// How many objects `doc` has (its complexity).
fn object_count(doc: &Document) -> usize {
doc.layers.iter().map(|l| l.count()).sum()
}
/// Why document `st` gets no recovery copy now, if it doesn't: unchanged since its last copy (or
/// never changed), or complex while the preference turns Data Recovery off for those.
pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
if !st.is_dirty() {
return Some("no unsaved changes");
}
if st.recovery.as_ref().is_some_and(|c| st.same_content(&c.doc)) {
return Some("unchanged since its last recovery copy");
}
if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
return Some(COMPLEX_SKIP);
}
None
}
/// Why a complex document with unsaved changes gets no copy.
const COMPLEX_SKIP: &str = "complex document (Data Recovery is off for complex documents)";
/// A file name part from a title: letters, digits, `-`, `_` and spaces (others as `-`).
fn safe_stem(title: &str) -> String {
let stem: String = fileio::file_stem(title).chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ') { c } else { '-' }).collect();
let stem = stem.trim().chars().take(60).collect::<String>();
if stem.is_empty() { "Untitled".into() } else { stem }
}
/// The copies among store entries `names`, each once, as `(area, "<area>/<name>")`.
fn copies_in(names: &[String]) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> =
names.iter().filter_map(|n| n.strip_suffix(DOC_EXT)).filter_map(|f| f.split_once('/').map(|(a, _)| (a.to_string(), f.to_string()))).collect();
v.sort();
v.dedup();
v
}
/// A copy name `<area>/<stem>-<n>` none of `taken` has.
fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String {
(1..=10_000).map(|n| format!("{area}/{stem}-{n}")).find(|f| !taken.contains(f)).unwrap_or_else(|| format!("{area}/{stem}-x"))
}
/// The jobs for every document that needs a copy now, in this session's area of `store` (taken
/// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified
/// documents that get none).
pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<RecoveryJob>, Vec<Value>)> {
// A lost lock is taken again first: copies that go with it are written again, even of
// documents unchanged since.
if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) {
keep_hold(s);
}
// Copies another app removed are written again.
repair(s);
let skipped: Vec<Value> = s
.documents()
.iter()
.filter(|st| st.is_dirty())
.filter_map(|st| skip_reason(s, st).map(|why| json!({ "title": st.title(), "reason": why })))
.collect();
if s.documents().iter().all(|st| skip_reason(s, st).is_some()) {
return Ok((vec![], skipped));
}
let area = claim(s, store)?;
let mut taken: Vec<String> =
copies_in(&store.list().map_err(EngineError::Other)?).into_iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
taken.extend(s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()));
let native = fileio::format("vectorcraft").ok_or_else(|| EngineError::Other("no native format".into()))?;
let saved = store.now();
let mut out = vec![];
for st in s.documents().iter().filter(|st| skip_reason(s, st).is_none()) {
let title = st.title();
let file = match &st.recovery {
Some(c) => c.file.clone(),
None => {
let f = fresh_name(&area, &safe_stem(&title), &taken);
taken.push(f.clone());
f
}
};
// Written as a save writes it, links relative to the document's own file.
let plan = SavePlan {
mode: SaveMode::Copy,
path: st.path.clone(),
format: native,
options: serde_json::Map::from_iter([("pdfCompatible".into(), json!(false))]),
name: format!("{file}{DOC_EXT}"),
folder: None,
modified: None,
notes: Vec::new(),
lossy_source: None,
kept: serde_json::Map::new(),
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta {
title: title.clone(),
path: st.path.clone(),
format: st.format.to_string(),
native_pdf_compatible: if matches!(st.format, "vectorcraft" | "template") {
st.save_options.get("pdfCompatible").and_then(Value::as_bool)
} else {
None
},
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
links_from: st.links_from.clone().filter(|_| st.path.is_none()),
// Filled in as the copy is written.
images: BTreeMap::new(),
};
out.push(RecoveryJob { uid: st.uid, file, title, meta, save, store: store.clone() });
}
Ok((out, skipped))
}
/// Remove copy `file` from this session's area. Best effort: a copy that can't be removed is
/// offered again after the next launch, which loses nothing.
fn remove_copy(s: &Session, file: &str) {
if let Some(own) = &s.recovery.own {
let _ = own.store.remove(&format!("{file}{DOC_EXT}"));
let _ = own.store.remove(&format!("{file}{META_EXT}"));
}
}
/// Closed document `st`'s recovery copy goes.
pub(crate) fn forget_closed(s: &Session, st: &crate::DocState) {
if let Some(copy) = &st.recovery {
remove_copy(s, &copy.file);
}
}
/// Writes a copy of every document that needs one now. An error when a document with unsaved
/// changes would be left without a current copy.
pub(crate) fn save_copies(s: &mut Session) -> Result<()> {
let store = need_store(s, "script.run")?;
let (jobs, _) = jobs(s, &store)?;
let uncopied = s.documents().iter().any(|st| skip_reason(s, st) == Some(COMPLEX_SKIP));
if uncopied {
return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into()));
}
for j in jobs {
j.write().map_err(EngineError::Other)?;
j.finish(s);
}
Ok(())
}
/// Document `uid` was saved, reverted or closed: its recovery copy goes. While a script runs the
/// run's commit settles that.
pub fn forget(s: &mut Session, uid: u64) {
if s.script_in_run {
return;
}
// So does a copy of it left in an area this session left.
drop_left(s, uid);
let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return };
remove_copy(s, &copy.file);
}
/// Quitting with nothing unsaved: the copies of every open document go, with this session's area.
pub fn forget_all(s: &mut Session) {
release(s);
}
/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and
/// the copies left in an area this session left of documents closed since.
pub fn forget_clean(s: &mut Session) {
let uids: Vec<u64> = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect();
for uid in uids {
forget(s, uid);
}
if !s.script_in_run {
drop_left_done(s);
}
}
fn save(s: &mut Session, _: &Value) -> Result<Value> {
let store = need_store(s, "file.recovery.save")?;
let (jobs, skipped) = jobs(s, &store)?;
let mut saved = vec![];
for j in jobs {
saved.push(j.write().map_err(EngineError::Other)?);
j.finish(s);
}
forget_clean(s);
heartbeat(s);
Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) }))
}
/// Who an area of the store belongs to.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Owner {
/// This session.
Mine,
/// Another app that is running.
Running,
/// An app that is gone (crashed): its copies are offered.
Gone,
}
/// Whose `area` of `store` is. A gone app's area is held by the caller (its lock in `hold` until
/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so
/// another app leaves it alone meanwhile. In a store without locks an area is gone only when
/// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three
/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the
/// caller already holds.
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool, held: &[&str]) -> Owner {
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
return Owner::Mine;
}
match store.lock(area) {
Ok(Lock::Held(h)) => {
// Copies an app left here when it had to leave the area stay its own while it runs.
if let Some(who) = keeper(s, store, area, held) {
return who;
}
hold.push(h);
Owner::Gone
}
Ok(Lock::Busy) | Err(_) => Owner::Running,
Ok(Lock::Unsupported) => {
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
return Owner::Running;
}
if let Some(who) = keeper(s, store, area, held) {
return who;
}
// Best effort: without it another app might offer the copies too, which loses nothing.
if take && let Ok(t) = beat(store, area) {
s.recovery.adopted.insert(area.to_string(), t);
}
Owner::Gone
}
}
}
/// Who keeps the copies left in `area` by an app that had to leave it, from the entries
/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`:
/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds
/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now
/// keeps them (a copy kept loses nothing).
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option<Owner> {
let Ok(names) = store.list() else { return Some(Owner::Running) };
let mark = format!("/{KEEPS}{area}");
let mut who = None;
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) {
match holder(s, store, k) {
Owner::Mine => return Some(Owner::Mine),
Owner::Running => who = Some(Owner::Running),
Owner::Gone => {}
}
}
who
}
/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a
/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this
/// account, a lock file that can't be opened) is nobody's.
fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner {
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
return Owner::Mine;
}
match store.lock(area) {
Ok(Lock::Held(_)) | Err(_) => Owner::Gone,
Ok(Lock::Busy) => Owner::Running,
Ok(Lock::Unsupported) => {
let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied());
if runs { Owner::Running } else { Owner::Gone }
}
}
}
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The
/// area is removed while it is still held, so an app taking a new area of that name meanwhile
/// finds it held or gone and takes the next name.
fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec<Hold>) {
let names = store.list().unwrap_or_default();
let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect();
if mine.iter().any(|n| n.ends_with(DOC_EXT)) {
return;
}
for n in mine {
let _ = store.remove(n);
}
store.remove_area(area);
drop(hold);
store.remove_empty_area(area);
}
/// The copies of documents open now in this session.
fn open_copies(s: &Session) -> Vec<String> {
s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect()
}
/// The copies of documents open now in this session that are in an area it left, in `store`.
fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec<String> {
s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect()
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let store = need_store(s, "file.recovery.list")?;
let open = open_copies(s);
let left = left_copies(s, store.as_ref());
let copies = copies_in(&store.list().map_err(EngineError::Other)?);
let mut rows = vec![];
for area in store.areas().map_err(EngineError::Other)? {
let mut hold = vec![];
let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]);
let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
for f in &files {
let meta = read_meta(store.as_ref(), f);
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
rows.push(json!({
"file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved,
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
}));
}
// A gone app's empty area is tidied away.
if who == Owner::Gone && files.is_empty() {
tidy(store.as_ref(), &area, hold);
}
}
Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) }))
}
/// A gone app's area to act on: its name, the copies to act on, and its lock.
type Target = (String, Vec<String>, Vec<Hold>);
/// The copies a command acts on, by gone app's area, each area held until its hold is dropped:
/// `file` (a copy a crash left behind), else every one.
fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> Result<Vec<Target>> {
let copies = copies_in(&store.list().map_err(EngineError::Other)?);
let file = str_param(p, "file");
if let Some(f) = file
&& !copies.iter().any(|(_, c)| c == f)
{
return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)")));
}
// A copy this session left in an area it had to leave is still its own.
let left = left_copies(s, store);
if let Some(f) = file
&& left.iter().any(|c| c == f)
{
return Err(bad(cmd, format!("`{f}` is the copy of a document open now")));
}
let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c);
let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
let mut out: Vec<Target> = vec![];
for area in areas {
let mut hold = vec![];
let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect();
match (owner(s, store, area, &mut hold, true, &held), file) {
(Owner::Gone, _) => {
let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect();
out.push((area.to_string(), files, hold));
}
(Owner::Mine, Some(f)) => return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))),
(Owner::Running, Some(f)) => return Err(bad(cmd, format!("`{f}` belongs to a VectorCraft that is running"))),
_ => {}
}
}
Ok(out)
}
/// Open copy `file` as a new document: "<name> [Recovered]", modified, with its original path
/// (for Save As) and format. The copy moves into this session's area (another crash keeps it).
fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> Result<Value> {
let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
// A frame whose first paragraph continues from the frame before draws it without the
// paragraph's first-line indent and list marker.
super::threads::mark_continued(&mut doc);
// The images, from the entries of the copy's area.
let from = file.split_once('/').map_or("", |(a, _)| a);
let mut blobs: BTreeMap<String, Arc<Vec<u8>>> = BTreeMap::new();
for (key, r) in &meta.images {
// Keys sharing an entry share its bytes: each entry is read (and written again) once.
let read = match blobs.get(&r.blob) {
Some(data) => Ok(data.clone()),
None => store.read(&format!("{from}/{}", r.blob)).map(Arc::new),
};
match read {
Ok(data) => {
let mut blob = ImageBlob { mime: r.mime.clone(), bytes: data, proxy: None };
// Until the linked file is read, the preview stands in for it.
if r.proxy {
blob.proxy = Some(blob.bytes.clone());
}
blobs.insert(r.blob.clone(), blob.bytes.clone());
doc.images.insert(key.clone(), blob);
}
Err(e) => warnings.push(format!("the image “{key}” of the recovered document couldn't be read: {e}")),
}
}
let area = claim(s, store)?;
let mut taken: Vec<String> = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect();
taken.extend(open_copies(s));
let stem = file.rsplit('/').next().unwrap_or(file);
let mine = match format!("{area}/{stem}") {
f if taken.contains(&f) => fresh_name(&area, stem, &taken),
f => f,
};
{
let _turn = WRITES.lock().unwrap_or_else(|e| e.into_inner());
for (blob, data) in &blobs {
store.write(&format!("{area}/{blob}"), data).map_err(EngineError::Other)?;
}
store.write(&format!("{mine}{DOC_EXT}"), &bytes).map_err(EngineError::Other)?;
store.write(&format!("{mine}{META_EXT}"), &meta_bytes).map_err(EngineError::Other)?;
}
// The copy's images in its old area go with the area, once no copy is left there.
store.remove(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?;
store.remove(&format!("{file}{META_EXT}")).map_err(EngineError::Other)?;
if doc.title.is_empty() {
doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() };
}
// Linked files are looked for from the document's own folder, else from the file it was read from.
let links_from = meta.path.clone().or_else(|| meta.links_from.clone());
let base = super::links::LinkBase::file(links_from.as_deref());
let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
let index = s.add_document(doc, meta.path.clone());
let st = s.doc_mut()?;
st.recovered = true;
st.links_from = links_from;
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
if let Some(pdf) = meta.native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
st.mark_unsaved();
// The copy stays until the document is saved or closed.
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
let title = st.title();
Ok(super::fileio::merge(
json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }),
links.to_json(),
))
}
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.recovery.restore";
let store = need_store(s, C)?;
let one = str_param(p, "file").is_some();
let (mut restored, mut failed) = (vec![], vec![]);
for (area, files, hold) in targets(s, store.as_ref(), C, p)? {
for f in files {
match restore_one(s, &store, &f) {
Ok(r) => restored.push(r),
Err(e) if one => return Err(e),
// A damaged copy doesn't hold the others back.
Err(e) => failed.push(json!({ "file": f, "error": e.to_string() })),
}
}
tidy(store.as_ref(), &area, hold);
}
Ok(json!({ "restored": restored, "failed": failed }))
}
fn discard(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.recovery.discard";
let store = need_store(s, C)?;
let mut discarded = vec![];
for (area, files, hold) in targets(s, store.as_ref(), C, p)? {
for f in files {
store.remove(&format!("{f}{DOC_EXT}")).map_err(EngineError::Other)?;
store.remove(&format!("{f}{META_EXT}")).map_err(EngineError::Other)?;
discarded.push(f);
}
tidy(store.as_ref(), &area, hold);
}
Ok(json!({ "discarded": discarded }))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.recovery.save",
"Save Recovery Data",
[],
None,
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)",
has_store,
save
),
cmd!(
query "file.recovery.list",
"Recovery Data",
[],
None,
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is",
has_store,
list
),
cmd!(
"file.recovery.restore",
"Restore Recovered Documents",
[],
None,
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed",
has_store,
restore
),
cmd!(
"file.recovery.discard",
"Discard Recovered Documents",
[],
None,
"{file?} delete a copy left behind by a crash (default: every one; copies of open documents and of running VectorCraft apps stay) → {discarded: [file…]}",
has_store,
discard
),
]
}