Autosave (SAVETIME, SAVEFILEPATH) and Drawing Recovery after a crash (fixes #406) (#416)

Squash of PR #416 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:18:49 -04:00
1 parent b4777fa8b3
commit 64eed455e5
12 files changed
+1024 -7

No files matched your search

+12 -1
View File
@@ -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));
+410
View File
@@ -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));
}
}
}
}
+68 -4
View File
@@ -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)
}
+1
View File
@@ -10,6 +10,7 @@
pub mod assoc;
pub mod audit;
pub mod autosave;
pub mod cmd;
mod finite;
pub mod grips;
+9
View File
@@ -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 {
+229
View File
@@ -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);
}
+174
View File
@@ -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");
}
}
+1
View File
@@ -996,3 +996,4 @@
Import... Імпорт...
Export... Експорт...
Batch Publish... Пакетна публікація...
Drawing Recovery Manager... Диспетчер відновлення креслень...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
996
997
998
999
+9
View File
@@ -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;
}
+68
View File
@@ -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
View File
@@ -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 |