mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge PR #793
This commit is contained in:
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user