diff --git a/ROADMAP.md b/ROADMAP.md index b6c78946..c4bcac4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `.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. diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 33b9a2a3..ac1d8d34 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -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> { } /// 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 { 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::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 { + #[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 { // 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 { - 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 { )); } } - 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 { 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 { 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> { fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result> { #[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)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3c1a505c..3ecad79f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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).