This commit is contained in:
Brandon Thomas
2026-10-14 22:05:11 -04:00
3 changed files with 176 additions and 41 deletions
+1
View File
@@ -166,6 +166,7 @@ Newest first.
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change.
- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog may go partly off-screen, but its title row stays reachable; each dialog opens centred, and again after the window is resized. No readiness percentage change.
- **2026-10-13** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). No readiness percentage change.
- **2026-10-13** · An explicit recovery folder (`file.recovery.save` with `dir`) never overwrites or deletes another running app's checkpoints. Document uids start at 1 in every process, so two apps saving recovery to one chosen folder replaced or removed each other's `<uid>.designcraft`. A run replaces or removes only the entries it wrote or recovered there, refuses an occupied uid, and refuses a folder whose run lock another app holds. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change.
+174 -40
View File
@@ -11,6 +11,10 @@
//! An app that starts moves into its own folder what apps that have ended left in theirs, and
//! leaves the folder of one still running alone.
//!
//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries
//! under the same uids. This run writes there only entries it finds free, replaces and removes
//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held.
//!
//! Each file is replaced atomically, but the pair is not a transaction. The document is published
//! and made durable before its metadata so a crash can leave metadata missing or stale, never a
//! listed metadata record without a complete primary document. Listing and opening recovery data
@@ -192,10 +196,11 @@ fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> {
}
/// A run's folder that was removed while it runs is made again by the next save: so is its lock.
/// Returns whether `dir` is this run's folder.
#[cfg(not(target_arch = "wasm32"))]
fn relock(dir: &Path, created: bool) -> Result<()> {
fn relock(dir: &Path, created: bool) -> Result<bool> {
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) };
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) };
validate_recovery_dir(&run.root)?;
#[cfg(unix)]
let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
@@ -219,7 +224,7 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display())));
}
match std::fs::symlink_metadata(dir.join(LOCK)) {
Ok(_) => return validate_lock(dir, &run.lock),
Ok(_) => return validate_lock(dir, &run.lock).map(|()| true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
}
@@ -242,7 +247,93 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
{
run.directory = replacement_directory;
}
Ok(())
Ok(true)
}
/// Entries this process wrote or recovered in folders that are not its run's own: (canonical
/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under
/// one of this run's uids may be another run's.
static WRITTEN: std::sync::Mutex<std::collections::BTreeSet<(PathBuf, u64)>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
/// What this run may do in a recovery folder.
struct Access {
/// This run's own folder (`run_dir`): every entry in it is this run's.
own: bool,
key: PathBuf,
}
impl Access {
fn owns(&self, uid: u64) -> bool {
self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid))
}
fn record(&self, uid: u64) {
if !self.own {
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid));
}
}
fn forget(&self, uid: u64) {
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid));
}
/// Refuse an entry of another run before anything is written: this run never replaces it.
fn check_free(&self, dir: &Path, uid: u64) -> Result<()> {
if self.owns(uid) {
return Ok(());
}
let (doc, meta) = files(dir, uid);
for path in [doc, meta] {
match std::fs::symlink_metadata(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
_ => {
return Err(EngineError::Other(format!(
"{}: recovery data this run did not write is there; recover it or choose another recovery folder",
path.display()
)));
}
}
}
Ok(())
}
}
/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder
/// of another running app, whose lock that app holds: this run leaves it alone.
fn access(dir: &Path, created: bool) -> Result<Access> {
#[cfg(not(target_arch = "wasm32"))]
let own = relock(dir, created)?;
#[cfg(not(target_arch = "wasm32"))]
if !own {
check_unheld(dir)?;
}
#[cfg(target_arch = "wasm32")]
let own = {
let _ = created;
false
};
Ok(Access { own, key: directory_key(dir)? })
}
#[cfg(not(target_arch = "wasm32"))]
fn check_unheld(dir: &Path) -> Result<()> {
let path = dir.join(LOCK);
let in_use =
|| EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display()));
match std::fs::symlink_metadata(&path) {
Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))),
}
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
match lock.try_lock() {
// Free: the run that held it has ended. Dropping the handle releases it again.
Ok(()) => Ok(()),
Err(std::fs::TryLockError::WouldBlock) => Err(in_use()),
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))),
}
}
/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs
@@ -329,15 +420,21 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
}
/// Remove a document's recovery entry (it was saved or closed).
/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this
/// run's own, only an entry this run wrote or recovered is removed.
pub fn discard(dir: &Path, uid: u64) {
#[cfg(not(target_arch = "wasm32"))]
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
if validate_recovery_dir(dir).is_err() {
return;
}
let Ok(access) = access(dir, false) else { return };
if !access.owns(uid) {
return;
}
let (d, m) = files(dir, uid);
let _ = std::fs::remove_file(d);
let _ = std::fs::remove_file(m);
access.forget(uid);
}
/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory
@@ -385,29 +482,29 @@ fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> {
Ok(())
}
fn prepare_save(s: &Session, dir: &Path) -> Result<()> {
fn prepare_save(s: &Session, dir: &Path) -> Result<Access> {
// Check before any write or deletion: a failed republish can retain a source whose uid
// coincides with an unrelated live document in a manually configured recovery directory.
protect_retained_sources(s, dir)?;
let created = secure_recovery_dir(dir)?;
#[cfg(not(target_arch = "wasm32"))]
relock(dir, created)?;
#[cfg(target_arch = "wasm32")]
let _ = created;
Ok(())
let access = access(dir, created)?;
for document in s.docs.iter().filter(|d| d.is_dirty()) {
access.check_free(dir, document.uid)?;
}
Ok(access)
}
/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many
/// were written. Command callers use the tracked variant without configuring automatic recovery.
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
prepare_save(s, dir)?;
let access = prepare_save(s, dir)?;
let mut n = 0;
for d in &s.docs {
if !d.is_dirty() {
discard(dir, d.uid);
continue;
}
write_entry(dir, d)?;
write_entry(&access, dir, d)?;
n += 1;
}
Ok(n)
@@ -424,7 +521,7 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
));
}
}
prepare_save(s, dir)?;
let access = prepare_save(s, dir)?;
let mut n = 0;
for index in 0..s.docs.len() {
if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
@@ -435,25 +532,30 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
let (doc, meta) = files(dir, document.uid);
let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
.map_err(|e| EngineError::Other(e.to_string()))?;
write_recovery_file(&doc, &bytes)?;
// An entry this run did not write is never replaced, even one made since it checked.
let replace = access.owns(document.uid);
publish_recovery_file(&doc, &bytes, replace)?;
access.record(document.uid);
// The primary is useful even if optional metadata fails next. Track it immediately.
if !has_checkpoint(document, dir) {
document.recovery_entries.push((dir.to_path_buf(), document.uid));
}
let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
write_recovery_file(&meta, metadata.to_string().as_bytes())?;
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
n += 1;
}
Ok(n)
}
/// Publish a document's recovery entry before its optional metadata.
fn write_entry(dir: &Path, d: &DocState) -> Result<()> {
fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> {
let (doc, meta) = files(dir, d.uid);
let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
write_recovery_file(&doc, &bytes)?;
let replace = access.owns(d.uid);
publish_recovery_file(&doc, &bytes, replace)?;
access.record(d.uid);
let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
write_recovery_file(&meta, metadata.to_string().as_bytes())
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)
}
#[cfg(not(target_arch = "wasm32"))]
@@ -499,11 +601,6 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
Err(EngineError::Other("recovery files are unavailable on the web".into()))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
publish_recovery_file(path, bytes, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
let Some(dir) = path.parent() else {
@@ -581,11 +678,6 @@ fn sync_recovery_dir(dir: &Path) -> Result<()> {
Ok(())
}
#[cfg(target_arch = "wasm32")]
fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other("recovery files are unavailable on the web".into()))
}
#[cfg(target_arch = "wasm32")]
fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> {
Err(EngineError::Other("recovery files are unavailable on the web".into()))
@@ -661,18 +753,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> {
#[cfg(not(target_arch = "wasm32"))]
{
validate_recovery_dir(dir)?;
relock(dir, false)?;
}
validate_recovery_dir(dir)?;
let source = access(dir, false)?;
let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf());
let created = secure_recovery_dir(&destination)?;
#[cfg(not(target_arch = "wasm32"))]
relock(&destination, created)?;
#[cfg(target_arch = "wasm32")]
let _ = created;
let source_key = directory_key(dir)?;
let destination_key = directory_key(&destination)?;
let target = access(&destination, created)?;
let source_key = source.key.clone();
let destination_key = target.key.clone();
let mut opened = Vec::new();
// For this run's own folder, first bring in what runs that ended left in theirs.
#[cfg(not(target_arch = "wasm32"))]
@@ -705,6 +792,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
let mut st = DocState::new(d, path);
reserve_uid(&mut st, &mut occupied)?;
st.recovery_entries.push((dir.to_path_buf(), uid));
// Recovered, the entry is this run's: Save and Close of its document may remove it.
source.record(uid);
st.fonts = fonts;
// Unsaved: the copy on disk (if any) is older than what was recovered.
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
@@ -721,6 +810,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
.and_then(|bytes| publish(&document, &bytes));
match published {
Ok(()) => {
target.record(state.uid);
state.recovery_entries.push((destination.clone(), state.uid));
let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()});
match publish(&metadata, meta.to_string().as_bytes()) {
@@ -1477,6 +1567,50 @@ mod tests {
assert!(!left.join(LOCK).exists());
assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock");
}
/// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered
/// 7 too: document uids start at 1 in every process.
fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) {
let root = tempfile::tempdir().unwrap();
let (doc, meta) = files(root.path(), 7);
std::fs::write(&doc, b"another run's document").unwrap();
std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap();
(root, doc, meta)
}
#[test]
fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() {
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.docs[0].uid = 7;
assert!(!s.docs[0].is_dirty());
s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap();
s.execute("file.close", &json!({})).unwrap();
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
}
#[test]
fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() {
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
let mut s = unsaved("this run's work");
s.docs[0].uid = 7;
assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err());
assert!(save(&s, root.path()).is_err());
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
}
/// A chosen folder that is a running app's run folder is that app's.
#[test]
fn a_folder_another_running_app_holds_is_refused() {
let root = tempfile::tempdir().unwrap();
let held = new_lock(root.path()).unwrap();
held.try_lock().unwrap();
assert!(save(&dirty_session(), root.path()).is_err());
assert!(list(root.path()).is_empty());
}
}
#[cfg(test)]
+1 -1
View File
@@ -290,7 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- 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, 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. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. 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`.
- 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. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. A folder given to `file.recovery.save` keeps other runs' entries: a run replaces or removes only the entries it wrote or recovered there, and refuses a folder whose run lock another app holds. 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, application/menu Quit and macOS Dock 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. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate.
- 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).