mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #416 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4777fa8b3
commit
64eed455e5
12 files changed
+1024
-7
No files matched your search
@@ -73,12 +73,16 @@ impl eframe::App for App {
|
||||
self.cad.ui(ui);
|
||||
self.persist_workspace(false);
|
||||
}
|
||||
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
|
||||
/// crash leaves them for recovery).
|
||||
fn on_exit(&mut self) {
|
||||
self.persist_workspace(true);
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
|
||||
storage.set_string(cadcraft_ui_egui::autosave::PREFS_KEY, cadcraft_ui_egui::autosave::prefs_json());
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
@@ -124,7 +128,7 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] {
|
||||
filters
|
||||
}
|
||||
|
||||
/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else
|
||||
/// The per-user settings directory (it holds `logs/` and `autosave/`): `CADCRAFT_CONFIG_DIR` if set, else
|
||||
/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs,
|
||||
/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
@@ -258,6 +262,13 @@ fn main() -> eframe::Result {
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::autosave::PREFS_KEY)) {
|
||||
cadcraft_ui_egui::autosave::load_prefs(&prefs);
|
||||
}
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = config_dir() {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
|
||||
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Autosave (SAVETIME, SAVEFILEPATH) and the files Drawing Recovery offers after a crash.
|
||||
//!
|
||||
//! Every SAVETIME minutes after a change, each drawing with unsaved changes made since its last
|
||||
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
|
||||
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
|
||||
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
|
||||
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
|
||||
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
|
||||
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
|
||||
//!
|
||||
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
|
||||
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
|
||||
//! are profile settings: process-wide, not saved in the drawing.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The autosave file extension.
|
||||
pub const EXT: &str = "sv$";
|
||||
/// SAVETIME's default, in minutes.
|
||||
pub const DEFAULT_SAVETIME: u32 = 10;
|
||||
/// SAVETIME's largest value, in minutes.
|
||||
pub const MAX_SAVETIME: u32 = 600;
|
||||
/// A lock file not refreshed for this long belongs to an app that is no longer running.
|
||||
pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
/// How often a running app refreshes its lock file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
|
||||
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
|
||||
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
|
||||
pub fn savetime() -> u32 {
|
||||
SAVETIME.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Set SAVETIME (clamped to 0–600 minutes).
|
||||
pub fn set_savetime(minutes: u32) {
|
||||
SAVETIME.store(minutes.min(MAX_SAVETIME), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// SAVEFILEPATH: the folder autosave files go to (the host sets a per-user default at start).
|
||||
pub fn savefilepath() -> String {
|
||||
SAVEFILEPATH.lock().map(|p| p.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_savefilepath(path: &str) {
|
||||
if let Ok(mut p) = SAVEFILEPATH.lock() {
|
||||
*p = path.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
|
||||
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
|
||||
let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect();
|
||||
let stem = stem.trim().trim_matches('.');
|
||||
let stem = if stem.is_empty() { "Drawing" } else { stem };
|
||||
format!("{stem}_{instance}_{uid}.{EXT}")
|
||||
}
|
||||
|
||||
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
|
||||
/// `instance_id`). Only file names built from one are treated as CADCraft's own, so files of
|
||||
/// other programs in the autosave folder are never offered for recovery or deleted.
|
||||
pub fn is_instance_id(s: &str) -> bool {
|
||||
let Some((pid, time)) = s.split_once('-') else { return false };
|
||||
(1..=10).contains(&pid.len())
|
||||
&& pid.bytes().all(|b| b.is_ascii_digit())
|
||||
&& (1..=32).contains(&time.len())
|
||||
&& time.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// An autosave file name's parts: the drawing's title, the instance and the drawing uid.
|
||||
pub fn parse_file_name(name: &str) -> Option<(&str, &str, u64)> {
|
||||
let base = name.strip_suffix(&format!(".{EXT}"))?;
|
||||
let (rest, uid) = base.rsplit_once('_')?;
|
||||
let (title, instance) = rest.rsplit_once('_')?;
|
||||
let uid_ok = !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit());
|
||||
(!title.is_empty() && is_instance_id(instance) && uid_ok).then_some(())?;
|
||||
Some((title, instance, uid.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The instance whose lock file is named `name` (`<instance>.lock`), if it is one of ours.
|
||||
pub fn parse_lock_name(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".lock").filter(|i| is_instance_id(i))
|
||||
}
|
||||
|
||||
/// What to do with the file system.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Job {
|
||||
/// Write `doc` (drawing `uid`) to `path`.
|
||||
Write {
|
||||
uid: u64,
|
||||
path: String,
|
||||
doc: Arc<Drawing>,
|
||||
},
|
||||
Delete {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Tracked {
|
||||
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
|
||||
written: Option<Weak<Drawing>>,
|
||||
/// Its autosave file, once written (or adopted).
|
||||
file: Option<String>,
|
||||
/// When a change not yet autosaved was first seen (seconds).
|
||||
since: Option<f64>,
|
||||
}
|
||||
|
||||
/// Autosave state for one running app.
|
||||
pub struct Autosaver {
|
||||
/// This app instance's id (`<process id>-<start time>`), part of every file name.
|
||||
pub instance: String,
|
||||
docs: HashMap<u64, Tracked>,
|
||||
/// Drawings whose autosave is being written right now.
|
||||
busy: HashSet<u64>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec<(u64, std::thread::JoinHandle<()>)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: Option<f64>,
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
pub fn new(instance: impl Into<String>) -> Self {
|
||||
Autosaver {
|
||||
instance: instance.into(),
|
||||
docs: HashMap::new(),
|
||||
busy: HashSet::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant::now(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
last_beat: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Take over an existing autosave file for drawing `uid` (a drawing opened from Drawing
|
||||
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
|
||||
/// once the drawing is saved or closed.
|
||||
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
|
||||
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
|
||||
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
|
||||
}
|
||||
|
||||
/// The autosave files this app has written or adopted.
|
||||
pub fn files(&self) -> Vec<String> {
|
||||
self.docs.values().filter_map(|t| t.file.clone()).collect()
|
||||
}
|
||||
|
||||
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
|
||||
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
|
||||
/// files of drawings that were saved (no unsaved changes left) or closed.
|
||||
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
|
||||
let mut jobs = Vec::new();
|
||||
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let busy = &self.busy;
|
||||
self.docs.retain(|uid, t| {
|
||||
// A file being written is deleted once the write is done.
|
||||
if live.contains(uid) || busy.contains(uid) {
|
||||
return true;
|
||||
}
|
||||
if let Some(path) = t.file.take() {
|
||||
jobs.push(Job::Delete { path });
|
||||
}
|
||||
false
|
||||
});
|
||||
for st in &s.docs {
|
||||
if self.busy.contains(&st.uid) {
|
||||
continue;
|
||||
}
|
||||
let t = self.docs.entry(st.uid).or_default();
|
||||
if !st.is_dirty() {
|
||||
if let Some(path) = t.file.take() {
|
||||
jobs.push(Job::Delete { path });
|
||||
}
|
||||
*t = Tracked::default();
|
||||
continue;
|
||||
}
|
||||
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
|
||||
if !changed {
|
||||
t.since = None;
|
||||
continue;
|
||||
}
|
||||
let since = *t.since.get_or_insert(now);
|
||||
if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 {
|
||||
continue;
|
||||
}
|
||||
let path = t
|
||||
.file
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
|
||||
t.file = Some(path.clone());
|
||||
t.written = Some(Arc::downgrade(&st.doc));
|
||||
t.since = None;
|
||||
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// Seconds from `now` until the next autosave is due, if one is waiting.
|
||||
pub fn next_due(&self, now: f64, minutes: u32) -> Option<f64> {
|
||||
if minutes == 0 {
|
||||
return None;
|
||||
}
|
||||
self.docs.values().filter_map(|t| t.since).map(|since| (since + f64::from(minutes) * 60.0 - now).max(0.0)).reduce(f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file Drawing Recovery offers: an autosave left behind by an app that is no longer running.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Recoverable {
|
||||
pub path: String,
|
||||
/// The drawing's title when it was autosaved.
|
||||
pub drawing: String,
|
||||
/// When it was written, in seconds since 1970.
|
||||
pub modified: u64,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A new instance id: the process id and the start time.
|
||||
pub fn instance_id() -> String {
|
||||
let t = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
|
||||
format!("{}-{t:x}", std::process::id())
|
||||
}
|
||||
|
||||
fn lock_path(dir: &Path, instance: &str) -> std::path::PathBuf {
|
||||
dir.join(format!("{instance}.lock"))
|
||||
}
|
||||
|
||||
/// Whether the app instance that owns a lock file modified `age` ago (`None`: no lock file)
|
||||
/// may still be running.
|
||||
pub fn instance_alive(age: Option<Duration>) -> bool {
|
||||
age.is_some_and(|a| a < Duration::from_secs(STALE_LOCK_SECS))
|
||||
}
|
||||
|
||||
fn lock_age(dir: &Path, instance: &str) -> Option<Duration> {
|
||||
let m = std::fs::metadata(lock_path(dir, instance)).and_then(|m| m.modified()).ok()?;
|
||||
// A time in the future (clock changes) counts as fresh.
|
||||
Some(SystemTime::now().duration_since(m).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
|
||||
/// `own`, this app's instance), oldest first.
|
||||
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut alive: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
for e in entries.flatten().take(10_000) {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
|
||||
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let modified = meta.modified().ok().and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0);
|
||||
out.push(Recoverable { path: e.path().to_string_lossy().to_string(), drawing: title.to_string(), modified, bytes: meta.len() });
|
||||
}
|
||||
out.sort_by(|a, b| a.modified.cmp(&b.modified).then_with(|| a.path.cmp(&b.path)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete a recovery file the user discarded (or that was recovered and then saved), and lock
|
||||
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
|
||||
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
|
||||
pub fn discard(path: &str) {
|
||||
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
|
||||
if !ours {
|
||||
log::warn!("autosave: not deleting {path}: not an autosave file");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::remove_file(path)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("autosave: can't delete {path}: {e}");
|
||||
}
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
prune_locks(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the lock files of apps that are no longer running and left no autosave files. Only
|
||||
/// names of the form `<instance id>.lock` are ours ([`parse_lock_name`]): SAVEFILEPATH may
|
||||
/// point at a folder shared with other programs, whose `Cargo.lock` or `foo.lock` stay.
|
||||
pub fn prune_locks(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
for n in &names {
|
||||
let Some(instance) = parse_lock_name(n) else { continue };
|
||||
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
|
||||
if !has_files && !instance_alive(lock_age(dir, instance)) {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
|
||||
let bytes = write(doc, "autosave.dxf")?;
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
/// Autosave what is due (writing in the background) and delete files no longer needed.
|
||||
/// Returns how long until the next autosave is due, so the host can wake up for it.
|
||||
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
|
||||
let now = self.start.elapsed().as_secs_f64();
|
||||
let busy = &mut self.busy;
|
||||
self.running.retain(|(uid, h)| {
|
||||
let running = !h.is_finished();
|
||||
if !running {
|
||||
busy.remove(uid);
|
||||
}
|
||||
running
|
||||
});
|
||||
let dir = savefilepath();
|
||||
let minutes = savetime();
|
||||
let jobs = self.plan(s, now, minutes, &dir);
|
||||
self.perform(jobs);
|
||||
self.heartbeat(&dir, now);
|
||||
self.next_due(now, minutes).map(Duration::from_secs_f64)
|
||||
}
|
||||
|
||||
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
|
||||
pub fn perform(&mut self, jobs: Vec<Job>) {
|
||||
for job in jobs {
|
||||
match job {
|
||||
Job::Delete { path } => discard(&path),
|
||||
Job::Write { uid, path, doc } => {
|
||||
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
|
||||
log::warn!("autosave: file formats are not available in this build");
|
||||
continue;
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
|
||||
Ok(()) => log::info!("autosaved {path}"),
|
||||
Err(e) => log::warn!("autosave {path}: {e}"),
|
||||
});
|
||||
match spawned {
|
||||
Ok(h) => {
|
||||
self.busy.insert(uid);
|
||||
self.running.push((uid, h));
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep this app's lock file fresh while it has autosave files.
|
||||
fn heartbeat(&mut self, dir: &str, now: f64) {
|
||||
if dir.is_empty() || self.docs.values().all(|t| t.file.is_none()) || self.last_beat.is_some_and(|b| now - b < HEARTBEAT_SECS) {
|
||||
return;
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
if let Err(e) = r {
|
||||
log::warn!("autosave: lock file in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// The app is closing normally: finish writes in progress, then delete this app's autosave
|
||||
/// files and its lock file.
|
||||
pub fn shutdown(&mut self) {
|
||||
for (_, h) in self.running.drain(..) {
|
||||
let _ = h.join();
|
||||
}
|
||||
self.busy.clear();
|
||||
for path in self.files() {
|
||||
discard(&path);
|
||||
}
|
||||
self.docs.clear();
|
||||
let dir = savefilepath();
|
||||
if !dir.is_empty() && is_instance_id(&self.instance) {
|
||||
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,65 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(Audit))),
|
||||
CommandSpec::new("recover", "Recover...", run_recover)
|
||||
.menu(&["File", "Drawing Utilities", "Recover..."])
|
||||
.params("{path} | {data: base64, name} → {index, errors, fixed, issues, message}")
|
||||
.params("{path, copy?: bool (an unsaved copy without a file name), title?} | {data: base64, name} → {index, uid, errors, fixed, issues, message}")
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(Recover))),
|
||||
CommandSpec::new("drawingrecovery", "Drawing Recovery Manager...", run_drawingrecovery)
|
||||
.menu(&["File", "Drawing Utilities", "Drawing Recovery Manager..."])
|
||||
.params("{action?: list|open|discard (default list), path? (one of the listed files)} → {files: [{path, drawing, modified, bytes}]} | (open: as RECOVER)")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Drawing Recovery: list the autosave files that apps which stopped left in SAVEFILEPATH, open
|
||||
/// one as an unsaved copy, or delete one. Only listed files can be opened or deleted.
|
||||
fn run_drawingrecovery(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase();
|
||||
let files = recoverable_files();
|
||||
let pick = || {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("drawingrecovery", "`path` is required"))?;
|
||||
files.iter().find(|f| f.path == path).cloned().ok_or_else(|| bad("drawingrecovery", format!("{path} is not a recoverable autosave file")))
|
||||
};
|
||||
match action.as_str() {
|
||||
"list" => Ok(json!({ "files": files })),
|
||||
"open" => {
|
||||
let f = pick()?;
|
||||
run_recover(s, &json!({ "path": f.path, "copy": true, "title": f.drawing }))
|
||||
}
|
||||
"discard" => {
|
||||
let f = pick()?;
|
||||
discard_file(&f.path);
|
||||
Ok(json!({ "files": recoverable_files() }))
|
||||
}
|
||||
other => Err(bad("drawingrecovery", format!("unknown action `{other}` (list, open or discard)"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn recoverable_files() -> Vec<crate::autosave::Recoverable> {
|
||||
let dir = crate::autosave::savefilepath();
|
||||
if dir.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// This app's own files are never listed: its lock file is fresh.
|
||||
crate::autosave::recoverable(std::path::Path::new(&dir), "")
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn recoverable_files() -> Vec<crate::autosave::Recoverable> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn discard_file(path: &str) {
|
||||
crate::autosave::discard(path);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn discard_file(_: &str) {}
|
||||
|
||||
/// Check the current drawing; with `fix`, repair it (one undo step).
|
||||
fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = bool_or(p, "fix", false);
|
||||
@@ -36,7 +88,9 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
|
||||
/// drawing keeps its file name; when anything was fixed it opens with unsaved changes.
|
||||
/// drawing keeps its file name; when anything was fixed it opens with unsaved changes. With
|
||||
/// `copy` it opens as an unsaved copy without a file name (Drawing Recovery opens autosave files
|
||||
/// this way), titled `title`.
|
||||
fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, path) = match (str_param(p, "path"), str_param(p, "data")) {
|
||||
(Some(path), _) => (read_path("recover", path)?, path.to_string(), Some(path.to_string())),
|
||||
@@ -48,16 +102,26 @@ fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let hooks = file::io().ok_or_else(|| bad("recover", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(&bytes, &name).map_err(|e| bad("recover", format!("{name} can't be read: {e}")))?;
|
||||
let title = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone());
|
||||
let i = s.open_drawing(d, &title, path);
|
||||
let title = match str_param(p, "title") {
|
||||
Some(t) => t.to_string(),
|
||||
None => std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.clone()),
|
||||
};
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let i = s.open_drawing(d, &title, if copy { None } else { path });
|
||||
let st = s.state_mut()?;
|
||||
let report = crate::audit::fix(Arc::make_mut(&mut st.doc));
|
||||
if report.issues.is_empty() {
|
||||
// Nothing to fix: the drawing opens unchanged, like OPEN.
|
||||
st.doc = st.saved.clone();
|
||||
}
|
||||
if copy && !st.is_dirty() {
|
||||
// An unsaved copy (Drawing Recovery): closing it asks to save it under a name.
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
}
|
||||
let uid = st.uid;
|
||||
let mut r = report.to_json();
|
||||
r["index"] = json!(i);
|
||||
r["uid"] = json!(uid);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod assoc;
|
||||
pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod cmd;
|
||||
mod finite;
|
||||
pub mod grips;
|
||||
|
||||
@@ -32,6 +32,8 @@ const SESSION_VARS: &[&str] = &[
|
||||
"LASTPOINT",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
"SAVETIME",
|
||||
"SAVEFILEPATH",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -69,6 +71,9 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
// Process-wide font substitution (profile settings, not saved in the drawing).
|
||||
"FONTALT" => json!(cadcraft_fonts::ttf::font_alt()),
|
||||
"FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()),
|
||||
// Autosave (profile settings too).
|
||||
"SAVETIME" => json!(crate::autosave::savetime()),
|
||||
"SAVEFILEPATH" => json!(crate::autosave::savefilepath()),
|
||||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
@@ -172,6 +177,10 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
|
||||
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
|
||||
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
|
||||
"SAVETIME" => crate::autosave::set_savetime(
|
||||
as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32,
|
||||
),
|
||||
"SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?),
|
||||
// A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line).
|
||||
"ANGBASE" => {
|
||||
let a = match v {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Autosave: SAVETIME timing, which drawings are written, files deleted on save and close, and
|
||||
//! the files Drawing Recovery offers after a crash (#406, docs/gaps.md F4).
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::autosave::{self, Autosaver, Job};
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use serde_json::json;
|
||||
|
||||
fn writes(jobs: &[Job]) -> Vec<String> {
|
||||
jobs.iter()
|
||||
.filter_map(|j| match j {
|
||||
Job::Write { path, .. } => Some(path.clone()),
|
||||
Job::Delete { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn deletes(jobs: &[Job]) -> Vec<String> {
|
||||
jobs.iter()
|
||||
.filter_map(|j| match j {
|
||||
Job::Delete { path } => Some(path.clone()),
|
||||
Job::Write { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw(s: &mut Session) {
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_drawing_is_written_savetime_minutes_after_the_change() {
|
||||
let mut s = Session::new();
|
||||
let uid = s.state().unwrap().uid;
|
||||
let mut a = Autosaver::new("77-a");
|
||||
assert!(a.plan(&s, 0.0, 10, "/auto").is_empty(), "nothing to save in a clean drawing");
|
||||
|
||||
draw(&mut s);
|
||||
assert!(a.plan(&s, 30.0, 10, "/auto").is_empty(), "the change starts the clock");
|
||||
assert_eq!(a.next_due(100.0, 10), Some(530.0));
|
||||
assert!(a.plan(&s, 629.0, 10, "/auto").is_empty());
|
||||
let jobs = a.plan(&s, 630.0, 10, "/auto");
|
||||
let path = std::path::Path::new("/auto").join(format!("Drawing1_77-a_{uid}.sv$")).to_string_lossy().to_string();
|
||||
assert_eq!(writes(&jobs), vec![path.clone()]);
|
||||
assert!(matches!(&jobs[0], Job::Write { doc, .. } if std::sync::Arc::ptr_eq(doc, &s.state().unwrap().doc)));
|
||||
assert!(a.plan(&s, 5000.0, 10, "/auto").is_empty(), "no change since the autosave");
|
||||
assert_eq!(a.next_due(5000.0, 10), None);
|
||||
|
||||
// The next change restarts the clock; the same file is written again.
|
||||
draw(&mut s);
|
||||
assert!(a.plan(&s, 6000.0, 10, "/auto").is_empty());
|
||||
assert_eq!(writes(&a.plan(&s, 6600.0, 10, "/auto")), vec![path.clone()]);
|
||||
assert_eq!(a.files(), vec![path.clone()]);
|
||||
|
||||
// Saving (nothing unsaved left) deletes the file.
|
||||
let st = s.state_mut().unwrap();
|
||||
st.saved = st.doc.clone();
|
||||
assert_eq!(deletes(&a.plan(&s, 6601.0, 10, "/auto")), vec![path]);
|
||||
assert!(a.files().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_deletes_and_savetime_zero_or_no_folder_writes_nothing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("new", &json!({})).unwrap();
|
||||
let mut a = Autosaver::new("5-b");
|
||||
draw(&mut s);
|
||||
a.plan(&s, 0.0, 1, "/auto");
|
||||
assert!(a.plan(&s, 1e6, 0, "/auto").is_empty(), "SAVETIME 0 is off");
|
||||
assert!(a.plan(&s, 1e6, 1, " ").is_empty(), "no folder, no autosave");
|
||||
let path = writes(&a.plan(&s, 1e6, 1, "/auto")).pop().unwrap();
|
||||
assert!(path.contains("Drawing2_5-b_"), "{path}");
|
||||
|
||||
// Undo back to the saved state: no unsaved changes, no file.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(deletes(&a.plan(&s, 1e6 + 1.0, 1, "/auto")), vec![path.clone()]);
|
||||
|
||||
draw(&mut s);
|
||||
a.plan(&s, 0.0, 1, "/auto");
|
||||
assert_eq!(writes(&a.plan(&s, 60.0, 1, "/auto")).len(), 1);
|
||||
s.execute("close", &json!({})).unwrap();
|
||||
assert_eq!(deletes(&a.plan(&s, 61.0, 1, "/auto")).len(), 1, "closing deletes the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_round_trip() {
|
||||
let n = autosave::file_name("My plan_v2.dwg", "123-abc", 7);
|
||||
assert_eq!(n, "My plan_v2.dwg_123-abc_7.sv$");
|
||||
assert_eq!(autosave::parse_file_name(&n), Some(("My plan_v2.dwg", "123-abc", 7)));
|
||||
assert_eq!(autosave::file_name("a/b:c*", "1-2", 3), "a-b-c-_1-2_3.sv$");
|
||||
assert_eq!(autosave::file_name("..", "1-2", 3), "Drawing_1-2_3.sv$");
|
||||
for bad in
|
||||
["plan.dxf", "x_1.sv$", "_1-2_3.sv$", "a_1-2_x.sv$", "x.sv$", "notes_foo_3.sv$", "a_1-xyz_3.sv$", "a_-1_3.sv$", "a_1-2_+3.sv$", "a_1-2_.sv$"]
|
||||
{
|
||||
assert_eq!(autosave::parse_file_name(bad), None, "{bad}");
|
||||
}
|
||||
assert!(autosave::is_instance_id(&autosave::instance_id()));
|
||||
assert_eq!(autosave::parse_lock_name("123-18f3a2b4c5d.lock"), Some("123-18f3a2b4c5d"));
|
||||
for bad in ["Cargo.lock", "foo.lock", "package-lock.json", "yarn-lock.lock", "1-2.lock.bak", ".lock", "-1.lock", "1-.lock", "1-2-3.lock"] {
|
||||
assert_eq!(autosave::parse_lock_name(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_of_other_programs_in_the_autosave_folder_are_never_deleted() {
|
||||
// SAVEFILEPATH may point at a project folder: only CADCraft's own lock and autosave names
|
||||
// (built from an instance id) are ever pruned or discarded.
|
||||
let dir = temp_dir("foreign");
|
||||
let old = SystemTime::now() - Duration::from_secs(3600);
|
||||
let foreign = ["Cargo.lock", "package-lock.json", "foo.lock", "yarn-lock.lock", "x.sv$", "notes_foo_3.sv$", "a_b-c_1.sv$"];
|
||||
for name in foreign.iter().chain(&["1-a.lock"]) {
|
||||
let f = std::fs::File::create(dir.join(name)).unwrap();
|
||||
f.set_modified(old).unwrap();
|
||||
}
|
||||
|
||||
assert!(autosave::recoverable(&dir, "9-f").is_empty(), "foreign files are not offered");
|
||||
autosave::prune_locks(&dir);
|
||||
assert!(!dir.join("1-a.lock").exists(), "a stopped app's own lock is pruned");
|
||||
for name in foreign {
|
||||
autosave::discard(&dir.join(name).to_string_lossy());
|
||||
}
|
||||
autosave::prune_locks(&dir);
|
||||
for name in foreign {
|
||||
assert!(dir.join(name).exists(), "{name} must survive");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("cadcraft-autosave-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_offers_files_of_apps_that_stopped() {
|
||||
let dir = temp_dir("scan");
|
||||
let touch = |name: &str| std::fs::write(dir.join(name), b"0\nEOF\n").unwrap();
|
||||
touch("Crashed.dxf_1-a_3.sv$"); // no lock: the app went down
|
||||
touch("Live.dxf_2-b_4.sv$"); // fresh lock: still running
|
||||
std::fs::write(dir.join("2-b.lock"), b"2").unwrap();
|
||||
touch("Stale.dxf_3-c_5.sv$"); // lock not refreshed for 10 minutes
|
||||
let lock = std::fs::File::create(dir.join("3-c.lock")).unwrap();
|
||||
lock.set_modified(SystemTime::now() - Duration::from_secs(600)).unwrap();
|
||||
drop(lock);
|
||||
touch("Mine.dxf_9-f_1.sv$"); // this app's own
|
||||
touch("notes.txt");
|
||||
std::fs::write(dir.join("Half.dxf_4-d_6.sv$.tmp"), b"").unwrap();
|
||||
|
||||
let found = autosave::recoverable(&dir, "9-f");
|
||||
let names: Vec<&str> = found.iter().map(|r| r.drawing.as_str()).collect();
|
||||
assert_eq!(names.len(), 2, "{found:?}");
|
||||
assert!(names.contains(&"Crashed.dxf") && names.contains(&"Stale.dxf"));
|
||||
assert!(found.iter().all(|r| r.bytes == 6 && r.modified > 0));
|
||||
|
||||
// Discarding deletes the file and the dead app's lock once it has no files left.
|
||||
let stale = found.iter().find(|r| r.drawing == "Stale.dxf").unwrap();
|
||||
autosave::discard(&stale.path);
|
||||
assert!(!std::path::Path::new(&stale.path).exists());
|
||||
assert!(!dir.join("3-c.lock").exists());
|
||||
assert!(dir.join("2-b.lock").exists(), "a running app's lock stays");
|
||||
assert_eq!(autosave::recoverable(&dir, "9-f").len(), 1);
|
||||
assert!(autosave::recoverable(&dir.join("missing"), "9-f").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
|
||||
hooks();
|
||||
let dir = temp_dir("write");
|
||||
let mut s = Session::new();
|
||||
draw(&mut s);
|
||||
let mut a = Autosaver::new(autosave::instance_id());
|
||||
a.plan(&s, 0.0, 1, &dir.to_string_lossy());
|
||||
let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy());
|
||||
let path = writes(&jobs).pop().unwrap();
|
||||
a.perform(jobs);
|
||||
for _ in 0..500 {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
|
||||
// While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH
|
||||
// are system variables (process-wide, so set in this test only).
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10)));
|
||||
s.execute("setvar", &json!({"name": "SAVETIME", "value": 3})).unwrap();
|
||||
assert_eq!(autosave::savetime(), 3);
|
||||
assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": -1})).is_err());
|
||||
assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": 601})).is_err());
|
||||
s.execute("setvar", &json!({"name": "SAVEFILEPATH", "value": format!(" {} ", dir.display())})).unwrap();
|
||||
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVEFILEPATH"), Some(json!(dir.to_string_lossy())));
|
||||
a.tick(&s);
|
||||
assert!(dir.join(format!("{}.lock", a.instance)).exists());
|
||||
assert!(autosave::recoverable(&dir, "someone-else").is_empty());
|
||||
|
||||
a.shutdown();
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
assert!(!dir.join(format!("{}.lock", a.instance)).exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_as_copy_opens_an_unsaved_untitled_drawing() {
|
||||
hooks();
|
||||
let mut s = Session::new();
|
||||
let dir = temp_dir("copy");
|
||||
let file = dir.join("Plan.dxf_1-a_2.sv$");
|
||||
std::fs::write(&file, b"0\nEOF\n").unwrap();
|
||||
let r = s.execute("recover", &json!({"path": file.to_string_lossy(), "copy": true, "title": "Plan.dxf"})).unwrap();
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!((st.title.as_str(), st.path.as_deref()), ("Plan.dxf", None));
|
||||
assert!(st.is_dirty(), "a recovered copy has unsaved changes");
|
||||
assert_eq!(r["uid"], st.uid);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// File hooks for every test in this file (the first call installs them).
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |_, _| Ok(cadcraft_engine::doc::Drawing::new_metric()),
|
||||
write: |_, _| Ok(b"0\nSECTION\n0\nENDSEC\n0\nEOF\n".to_vec()),
|
||||
plot: None,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! DRAWINGRECOVERY: list, open and delete the autosave files a stopped app left behind, and never
|
||||
//! touch a file that isn't one of them. Its own test binary: SAVEFILEPATH is process-wide.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::autosave;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn lists_and_discards_only_recoverable_autosave_files() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-drawingrecovery-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
autosave::set_savefilepath(&dir.to_string_lossy());
|
||||
|
||||
// A file from an app that stopped (no lock file), and one that isn't an autosave file.
|
||||
let left = dir.join(autosave::file_name("Bracket", "123-456", 7));
|
||||
std::fs::write(&left, b"0\nEOF\n").unwrap();
|
||||
let other = dir.join("notes.txt");
|
||||
std::fs::write(&other, b"keep me").unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("drawingrecovery", &json!({})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0]["drawing"], "Bracket");
|
||||
|
||||
// Unlisted paths are refused, whatever they are.
|
||||
for path in [other.to_string_lossy().to_string(), "/etc/passwd".to_string()] {
|
||||
assert!(s.execute("drawingrecovery", &json!({ "action": "discard", "path": path })).is_err());
|
||||
assert!(s.execute("drawingrecovery", &json!({ "action": "open", "path": path })).is_err());
|
||||
}
|
||||
assert!(other.exists());
|
||||
assert!(s.execute("drawingrecovery", &json!({ "action": "frobnicate" })).is_err());
|
||||
|
||||
let r = s.execute("drawingrecovery", &json!({ "action": "discard", "path": left.to_string_lossy() })).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(0));
|
||||
assert!(!left.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Autosave and Drawing Recovery in the app. The engine decides what to write and writes it
|
||||
//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME and SAVEFILEPATH with
|
||||
//! the preferences, and shows the Drawing Recovery list after a crash: each autosaved drawing can
|
||||
//! be opened (as an unsaved copy) or discarded. On the web there is no autosave yet.
|
||||
|
||||
use cadcraft_engine::autosave::{self, Recoverable};
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The host storage key for [`prefs_json`].
|
||||
pub const PREFS_KEY: &str = "cadcraft.autosave";
|
||||
|
||||
/// SAVETIME and SAVEFILEPATH as JSON for the host's storage.
|
||||
pub fn prefs_json() -> String {
|
||||
json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath() }).to_string()
|
||||
}
|
||||
|
||||
/// Restore what [`prefs_json`] saved; malformed values are ignored.
|
||||
pub fn load_prefs(json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<Value>(json) else { return };
|
||||
if let Some(m) = v.get("savetime").and_then(Value::as_u64) {
|
||||
autosave::set_savetime(u32::try_from(m).unwrap_or(autosave::MAX_SAVETIME));
|
||||
}
|
||||
if let Some(p) = v.get("saveFilePath").and_then(Value::as_str) {
|
||||
autosave::set_savefilepath(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing Recovery: autosave files left behind by an app that stopped, listed once at start.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Recovery {
|
||||
pub items: Vec<Recoverable>,
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
/// Turn autosave on (desktop). Files go to SAVEFILEPATH, or to `default_dir` when it isn't
|
||||
/// set; files a crashed app left there are listed for Drawing Recovery.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn enable_autosave(&mut self, default_dir: &std::path::Path) {
|
||||
if autosave::savefilepath().is_empty() {
|
||||
autosave::set_savefilepath(&default_dir.to_string_lossy());
|
||||
}
|
||||
let saver = autosave::Autosaver::new(autosave::instance_id());
|
||||
let dir = std::path::PathBuf::from(autosave::savefilepath());
|
||||
autosave::prune_locks(&dir);
|
||||
self.recovery.items = autosave::recoverable(&dir, &saver.instance);
|
||||
self.autosave = Some(saver);
|
||||
}
|
||||
|
||||
/// Open recovery item `i` as an unsaved copy. Its autosave file stays until that drawing is
|
||||
/// saved or closed, so a second crash doesn't lose it.
|
||||
pub fn recover_item(&mut self, i: usize) -> Result<(), String> {
|
||||
let item = self.recovery.items.get(i).cloned().ok_or("no such recovery file")?;
|
||||
let r = self.run("recover", json!({ "path": item.path, "copy": true, "title": item.drawing }))?;
|
||||
self.recovery.items.remove(i);
|
||||
if let (Some(saver), Some(uid)) = (&mut self.autosave, r.get("uid").and_then(Value::as_u64)) {
|
||||
saver.adopt(&self.session, uid, &item.path);
|
||||
}
|
||||
self.ui.start_tab = false;
|
||||
self.canvas.zoom_pending = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete recovery item `i`'s file.
|
||||
pub fn discard_item(&mut self, i: usize) {
|
||||
if i < self.recovery.items.len() {
|
||||
let _item = self.recovery.items.remove(i);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
autosave::discard(&_item.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every frame: autosave what is due, and wake up when the next one is.
|
||||
pub fn autosave_tick(&mut self, _ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) {
|
||||
_ctx.request_repaint_after(wait);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app closes normally: this app's autosave files are not needed any more.
|
||||
pub fn shutdown_autosave(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(s) = &mut self.autosave {
|
||||
s.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC).
|
||||
fn utc(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
||||
}
|
||||
|
||||
/// The Drawing Recovery window, while autosave files from a crash are waiting.
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if app.recovery.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = None;
|
||||
let mut discard = None;
|
||||
let mut later = false;
|
||||
egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.label(RichText::new("Drawing Recovery").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label("CADCraft did not close normally. These drawings were autosaved:");
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("cc_recovery_list").num_columns(4).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (i, item) in app.recovery.items.iter().enumerate() {
|
||||
ui.label(&item.drawing);
|
||||
ui.label(RichText::new(format!("{} UTC · {} KB", utc(item.modified), item.bytes.div_ceil(1024))).color(t.text_dim));
|
||||
if ui.button("Open").clicked() {
|
||||
open = Some(i);
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
discard = Some(i);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("Opened drawings have no file name: save them to keep them.").color(t.text_dim));
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Later").on_hover_text("Keep the files and ask again next time").clicked() {
|
||||
later = true;
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
later = true;
|
||||
}
|
||||
});
|
||||
if let Some(i) = open
|
||||
&& let Err(e) = app.recover_item(i)
|
||||
{
|
||||
app.set_status(e);
|
||||
}
|
||||
if let Some(i) = discard {
|
||||
app.discard_item(i);
|
||||
}
|
||||
if later {
|
||||
app.recovery.items.clear();
|
||||
}
|
||||
if open.is_some() || discard.is_some() || later {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn utc_dates() {
|
||||
assert_eq!(super::utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01");
|
||||
assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00");
|
||||
}
|
||||
}
|
||||
@@ -996,3 +996,4 @@
|
||||
Import... Імпорт...
|
||||
Export... Експорт...
|
||||
Batch Publish... Пакетна публікація...
|
||||
Drawing Recovery Manager... Диспетчер відновлення креслень...
|
||||
|
@@ -8,6 +8,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod autosave;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod clipboard;
|
||||
@@ -184,6 +185,10 @@ pub struct CadApp {
|
||||
pub quit_confirmed: bool,
|
||||
/// A close request waiting for "Save changes?" answers ([`closing`]).
|
||||
pub closing: Option<closing::Closing>,
|
||||
/// Autosave (desktop; `None` until [`Self::enable_autosave`]).
|
||||
pub autosave: Option<cadcraft_engine::autosave::Autosaver>,
|
||||
/// Drawing Recovery: autosave files a crashed app left behind.
|
||||
pub recovery: autosave::Recovery,
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
@@ -211,6 +216,8 @@ impl CadApp {
|
||||
quit_requested: false,
|
||||
quit_confirmed: false,
|
||||
closing: None,
|
||||
autosave: None,
|
||||
recovery: autosave::Recovery::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +390,7 @@ impl CadApp {
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
self.autosave_tick(ctx);
|
||||
menus::shortcuts(self, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
@@ -442,6 +450,7 @@ impl CadApp {
|
||||
});
|
||||
dialogs::show(self, ui.ctx());
|
||||
closing::prompt(self, ui.ctx());
|
||||
autosave::show(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Drawing Recovery after a crash (#406): autosave files a stopped app left behind are listed;
|
||||
//! Open makes an unsaved copy whose file stays until it is saved, Discard deletes the file.
|
||||
//! SAVETIME and SAVEFILEPATH are process-wide, so everything runs in one test.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::autosave;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
#[test]
|
||||
fn recovery_list_opens_copies_and_discards_files() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-recovery-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let plan = dir.join("Plan.dxf_1-a_3.sv$");
|
||||
let site = dir.join("Site.dwg_1-a_4.sv$");
|
||||
for f in [&plan, &site] {
|
||||
std::fs::write(f, b"0\nEOF\n").unwrap();
|
||||
}
|
||||
// A copy of the app that is still running keeps its lock fresh: its files are not offered.
|
||||
std::fs::write(dir.join("Other.dxf_2-b_1.sv$"), b"0\nEOF\n").unwrap();
|
||||
std::fs::write(dir.join("2-b.lock"), b"2").unwrap();
|
||||
|
||||
let mut app = CadApp::new(Session::empty(), Services::default());
|
||||
app.enable_autosave(&dir);
|
||||
assert_eq!(autosave::savefilepath(), dir.to_string_lossy());
|
||||
let names: Vec<_> = app.recovery.items.iter().map(|r| r.drawing.clone()).collect();
|
||||
assert_eq!(names.len(), 2, "{names:?}");
|
||||
|
||||
let i = names.iter().position(|n| n == "Plan.dxf").unwrap();
|
||||
app.recover_item(i).unwrap();
|
||||
assert_eq!(app.recovery.items.len(), 1);
|
||||
let st = app.session.state().unwrap();
|
||||
assert_eq!((st.title.as_str(), st.path.as_deref()), ("Plan.dxf", None));
|
||||
assert!(st.is_dirty(), "a recovered drawing is an unsaved copy");
|
||||
assert!(plan.exists(), "the file stays until the recovered drawing is saved");
|
||||
assert!(app.autosave.as_ref().unwrap().files().contains(&plan.to_string_lossy().to_string()));
|
||||
|
||||
app.discard_item(0);
|
||||
assert!(app.recovery.items.is_empty());
|
||||
assert!(!site.exists(), "Discard deletes the file");
|
||||
assert!(app.recover_item(0).is_err());
|
||||
|
||||
// Saving the recovered drawing (no unsaved changes left) deletes its file.
|
||||
let st = app.session.state_mut().unwrap();
|
||||
st.saved = st.doc.clone();
|
||||
app.autosave.as_mut().unwrap().tick(&app.session);
|
||||
assert!(!plan.exists());
|
||||
|
||||
// Exiting normally removes this app's files; the running copy's file stays.
|
||||
app.shutdown_autosave();
|
||||
assert!(dir.join("Other.dxf_2-b_1.sv$").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// SAVETIME and SAVEFILEPATH survive restarts (process-wide, so checked in this one test).
|
||||
cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": 25, "saveFilePath": "/tmp/cc-autosave-prefs"}"#);
|
||||
assert_eq!(autosave::savetime(), 25);
|
||||
let saved = cadcraft_ui_egui::autosave::prefs_json();
|
||||
cadcraft_ui_egui::autosave::load_prefs("not json");
|
||||
cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": "often"}"#);
|
||||
assert_eq!(autosave::savetime(), 25, "bad values are ignored");
|
||||
cadcraft_ui_egui::autosave::load_prefs(r#"{"savetime": 0}"#);
|
||||
assert_eq!(autosave::savetime(), 0);
|
||||
cadcraft_ui_egui::autosave::load_prefs(&saved);
|
||||
assert_eq!((autosave::savetime(), autosave::savefilepath().as_str()), (25, "/tmp/cc-autosave-prefs"));
|
||||
}
|
||||
+2
-2
@@ -20,7 +20,7 @@ do them first.
|
||||
| 5 | **[alpha blocker]** **Stubbed prompt options** left in PEDIT Edit vertex, SPLINEDIT and MTEXT columns | Scripts and muscle memory hit "not available yet" (the most-used commands are done) | 3–6 | [features](#features) |
|
||||
| 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
|
||||
| 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) |
|
||||
| 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) |
|
||||
| 8 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) |
|
||||
| 9 | **Dynamic input lacks grip input; no TK; grip options ignored** (FROM, M2P/MTP, point filters, `*` and `<a` landed, #392) | Precision entry at the cursor is how AutoCAD is taught today | 12–20 | [ui](ui-parity.md) |
|
||||
| 10 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) |
|
||||
|
||||
@@ -31,7 +31,7 @@ do them first.
|
||||
| F1 | **[alpha blocker]** Prompt options print "not available yet": PEDIT Edit vertex Break/Regen/Tangent, some SPLINEDIT options; MTEXT Dynamic/Static columns (no column model). SPLINE, MTEXT and MLEADER options done (#408): closed splines keep but ignore a fit tolerance, SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `modify2.rs:594,783,806`, `cmd/mtext_opts.rs` | Medium: the most-used commands are done | 3–6 |
|
||||
| F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 |
|
||||
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
|
||||
| F4 | No autosave (SAVETIME), crash recovery files or drawing recovery manager (AUDIT and RECOVER landed with #406; AUDITCTL `.adt` logs and a RECOVER mode in the file readers are not there) | no SAVETIME anywhere | High: lost work | 6–10 |
|
||||
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 |
|
||||
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |
|
||||
| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 |
|
||||
|
||||
Reference in new issue
Block a user