fix(save): replace files atomically

Replace native engine saves and engine/MCP exports atomically, preserving private permissions during staging and cleaning up failed writes. Add regressions for long filenames and caught writer unwinding.

Fixes #436.

Co-authored-by: aiongg <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 09:19:36 -06:00
1 parent 3624f48819
commit 5cdcac4ace
13 files changed
+417 -19

No files matched your search

+2 -1
View File
@@ -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.
+359
View File
@@ -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<tempfile::NamedTempFile> {
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<PathBuf> {
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<String> {
let mut v: Vec<String> = 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);
}
}
+3 -3
View File
@@ -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<designcraft_doc::Document> {
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<CommandSpec> {
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})),
+5 -5
View File
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
#[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<()> {
+34 -3
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
}
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<Value> {
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};
+1 -1
View File
@@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
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()})),
+1 -1
View File
@@ -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(())
}
+5 -1
View File
@@ -844,7 +844,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
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)})),
+1 -1
View File
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
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()})),
+2
View File
@@ -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;
+2 -2
View File
@@ -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}))
}
}
+1 -1
View File
@@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
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})))
}
+1
View File
@@ -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).