diff --git a/ROADMAP.md b/ROADMAP.md index d33353c..6045a00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (atomic engine saves and exports) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs new file mode 100644 index 0000000..7eed2f0 --- /dev/null +++ b/crates/engine/src/atomic.rs @@ -0,0 +1,359 @@ +//! Crash-safe file replacement: how documents, exports and other user files reach the disk. +//! +//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary +//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then +//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename +//! fails, the temporary file is removed and the destination keeps its old bytes. +//! +//! - A replaced file keeps its permissions (Unix mode). +//! - A symbolic link keeps pointing at its file: the file it names is replaced. +//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the +//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff. +//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no +//! fallback to writing in place. +//! - Flushing the parent folder after replacement is best effort. A successful save confirms +//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if +//! the file system rejects that folder flush. +//! +//! The web has no file system, so this module is not built for `wasm32`. + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +/// How many times a replacing rename is tried before its error is returned. +const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 }; +/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`]. +const RENAME_FIRST_DELAY_MS: u64 = 10; +const RENAME_MAX_DELAY_MS: u64 = 200; +/// Symbolic links followed before giving up (a loop, or an absurd chain). +const MAX_LINKS: usize = 40; + +/// Atomically replace (or create) `path` with `bytes`. See the module docs. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + write_atomic_with(path, |f| f.write_all(bytes)) +} + +/// [`write_atomic`] for a command's `path` parameter: the error names the path. +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { + write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) +} + +/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When +/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and +/// the error is returned. +pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let target = resolve_links(path)?; + target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; + let dir = match target.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + }; + let existing = std::fs::metadata(&target).ok(); + if existing.is_some() { + check_writable(&target)?; + } + let mut tmp = create_temp(&dir, existing.as_ref())?; + if let Some(existing) = existing { + // Restore the exact mode before any document bytes are written, and flush it along with + // the content. The caller's umask may have restricted the initial temporary-file mode. + keep_permissions(tmp.as_file(), &existing)?; + } + fill(tmp.as_file_mut())?; + tmp.as_file().sync_all()?; + // Closing the file before renaming also allows replacement on Windows. TempPath removes the + // staging file on every error; NamedTempFile does the same if the writer unwinds. + let tmp = tmp.into_temp_path(); + retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?; + // The new bytes are in place. A failed folder flush can't be undone and must not report the + // save as failed. + let _ = sync_dir(&dir); + Ok(()) +} + +/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one +/// volume). A short randomized name supports long destination names and skips existing entries. +fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-save-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Private replacements are private from creation. New files retain fs::write's normal + // 0666 mode restricted by the caller's umask. + let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode()); + builder.permissions(std::fs::Permissions::from_mode(mode)); + } + #[cfg(not(unix))] + let _ = existing; + builder.tempfile_in(dir) +} + +/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling +/// link resolves to the missing file it names, which is then created. +fn resolve_links(path: &Path) -> io::Result { + let mut p = path.to_path_buf(); + for _ in 0..MAX_LINKS { + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_symlink() => { + let to = std::fs::read_link(&p)?; + p = match p.parent() { + Some(dir) if to.is_relative() => dir.join(to), + _ => to, + }; + } + _ => return Ok(p), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) +} + +/// A rename replaces a file the user can't write (Unix only needs the folder to be writable); +/// refuse, as writing it in place would. Opening for writing without truncating changes nothing. +#[cfg(unix)] +fn check_writable(target: &Path) -> io::Result<()> { + OpenOptions::new().write(true).open(target).map(drop) +} + +/// Windows refuses to rename over a read-only file by itself. +#[cfg(not(unix))] +fn check_writable(_: &Path) -> io::Result<()> { + Ok(()) +} + +/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag, +/// and a read-only file can't be replaced). +#[cfg(unix)] +fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> { + tmp.set_permissions(replaced.permissions()) +} + +#[cfg(not(unix))] +fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> { + Ok(()) +} + +/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with +/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once. +fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { + let mut delay = RENAME_FIRST_DELAY_MS; + let mut left = tries.max(1); + loop { + left = left.saturating_sub(1); + match rename() { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => { + std::thread::sleep(std::time::Duration::from_millis(delay)); + delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS); + } + r => return r, + } + } +} + +/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows; +/// this does not establish power-loss durability for its directory entries. +#[cfg(unix)] +fn sync_dir(dir: &Path) -> io::Result<()> { + File::open(dir)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_dir(_: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// The names in `d`, sorted. + fn names(d: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v + } + + #[test] + fn replaces_and_creates_without_leftovers() { + let d = dir("replace"); + let path = d.join("doc.designcraft"); + write_atomic(&path, b"first").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + write_atomic(&path, b"second, longer").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second, longer"); + write_atomic(&path, b"3").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"3"); + assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_original_and_no_temporary_file() { + let d = dir("fail"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + let r = write_atomic_with(&path, |f| { + f.write_all(b"half a docu")?; + Err(io::Error::other("disk full")) + }); + assert_eq!(r.unwrap_err().to_string(), "disk full"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed"); + // A folder that doesn't exist: an error, nothing written. + assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft"]); + // A folder in the way: an error, the folder stays and nothing is left beside it. + std::fs::create_dir(d.join("folder")).unwrap(); + assert!(write_atomic(&d.join("folder"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft", "folder"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_long_destination_name_does_not_lengthen_the_temporary_name() { + let d = dir("long-name"); + let name = format!("{}.designcraft", "a".repeat(235)); + let path = d.join(&name); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), [name]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn private_permissions_are_preserved_while_the_file_is_written() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("private-staging"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic_with(&path, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + file.write_all(b"private document")?; + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"private document"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn an_unwinding_writer_removes_its_partial_temporary_file() { + let d = dir("unwinding-writer"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + let result = std::panic::catch_unwind(|| { + write_atomic_with(&path, |file| { + file.write_all(b"partial private document")?; + panic!("synthetic writer panic"); + }) + }); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_rename_is_retried_only_while_access_is_denied() { + let mut calls = 0; + let r = retry_rename(3, || { + calls += 1; + if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) } + }); + assert!(r.is_ok() && calls == 3); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied)); + assert_eq!(calls, 3, "gives up after the last try"); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::CrossesDevices)) + }); + assert!(r.is_err() && calls == 1, "other errors are not retried"); + } + + #[cfg(unix)] + #[test] + fn permissions_are_kept_and_unwritable_files_are_not_replaced() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("perms"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + // A read-only file stays as it is (root may write it anyway). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); + if OpenOptions::new().write(true).open(&path).is_err() { + assert!(write_atomic(&path, b"newer").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), ["private.designcraft"]); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let _ = std::fs::remove_dir_all(&d); + } + + /// A folder the user can't write: the save fails and the original stays. + #[cfg(unix)] + #[test] + fn an_unwritable_folder_fails_without_touching_the_file() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("ro-dir"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes anyway; the check only means something for an ordinary user. + let probe = d.join("probe"); + let unwritable = std::fs::write(&probe, b"").is_err(); + if unwritable { + assert!(write_atomic(&path, b"new").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + } + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_file(&probe); + assert_eq!(names(&d), ["doc.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn a_symbolic_link_keeps_pointing_at_the_replaced_file() { + let d = dir("link"); + std::fs::create_dir(d.join("real")).unwrap(); + let target = d.join("real").join("doc.designcraft"); + std::fs::write(&target, b"old").unwrap(); + let link = d.join("link.designcraft"); + std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(names(&d), ["link.designcraft", "real"]); + assert_eq!(names(&d.join("real")), ["doc.designcraft"]); + // A loop of links is an error, not a hang. + std::os::unix::fs::symlink("b", d.join("a")).unwrap(); + std::os::unix::fs::symlink("a", d.join("b")).unwrap(); + assert!(write_atomic(&d.join("a"), b"x").is_err()); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 9631dac..1204029 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path))) + crate::atomic::write_file(&b.path, &bytes) } #[cfg(target_arch = "wasm32")] @@ -52,7 +52,7 @@ fn load(path: &str) -> Result { fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] - return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return crate::atomic::write_file(path, &super::file::to_bytes(d)); #[cfg(target_arch = "wasm32")] { let _ = (path, d); @@ -210,7 +210,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f478638..8e0f04d 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &r.bytes)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::atomic::write_file(path, bytes) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ed126ee..b5887bb 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); let bytes = to_bytes(&s.doc()?.doc); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), @@ -247,7 +247,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result { } let bytes = to_bytes(&st.doc); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; st.path = Some(path.clone()); st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); @@ -518,7 +518,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -735,6 +735,37 @@ mod layout_place_tests { } } +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod document_fonts_tests { use std::path::{Path, PathBuf}; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a0af09f..96498e8 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f0e77af..00855dd 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?; + crate::atomic::write_file(p, &bytes)?; } Ok(()) } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b6..4bf69ec 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -844,7 +844,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, &bytes); + written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 32252a9..6dab205 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b..1647193 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +pub mod atomic; pub mod cmd; pub mod dtd; pub mod guard; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index aea3e87..eea81dd 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -201,7 +201,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -221,7 +221,7 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 802f615..c51bac8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9..6c033d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -288,6 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). +- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).