From e198eb5d1dfb7fef562808820e20473b6c00f081 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 12:04:39 -0600 Subject: [PATCH] fix(export): replace desktop and CLI outputs atomically Route desktop writes and CLI raster exports through the engine atomic writer. Add actual-caller regressions for reader visibility, private modes, symlinks and valid long filenames. Follow-up to #485. PR #614. --- ROADMAP.md | 3 ++- apps/designcraft-cli/src/main.rs | 2 +- apps/designcraft-cli/tests/cli.rs | 32 +++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 36 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8260a78..31985c0 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-12 · **Change:** minor (failed writes preserve live save and collection state) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (atomic desktop and CLI 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** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b1819c3..c3808f2 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -281,7 +281,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }) .ok_or("no such page")?; let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs); Ok(()) } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 1a06d13..c2804be 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -122,3 +122,35 @@ fn run_export_options_come_before_their_export() { assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "CLI export changed bytes under an existing reader"); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 35a5686..e3ff86c 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -186,7 +186,7 @@ fn services() -> Services { })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), ..Default::default() } } @@ -353,3 +353,37 @@ mod tests { assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); } } + +#[cfg(all(test, unix))] +mod atomic_writer_tests { + #[test] + fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let mut write = super::services().write.unwrap(); + write(alias.to_str().unwrap(), b"complete new export").unwrap(); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "desktop export changed bytes under an existing reader"); + assert_eq!(std::fs::read(&target).unwrap(), b"complete new export"); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bbf1796..18188e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -288,7 +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. +- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544).