diff --git a/Cargo.lock b/Cargo.lock index 651843001..877f803a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4368,6 +4368,7 @@ version = "0.12.0" dependencies = [ "arboard", "base64 0.22.1", + "block2 0.6.2", "bytemuck", "chrono", "craft-quit", @@ -4391,6 +4392,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", + "plist", "png 0.18.1", "pollster 1.0.1", "rfd", @@ -4405,6 +4407,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "winsafe", + "xattr", ] [[package]] @@ -4662,6 +4665,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -4889,6 +4905,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -7261,7 +7286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c..27c8e1b6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -189,6 +190,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. - **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8b091945a..39eaff607 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -53,11 +53,13 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] -# Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit -# calls only. All three are already in the tree through arboard and rfd. +# Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. +# The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen", "NSWorkspace", "block2"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry", "NSBundle", "NSURL", "NSString", "NSError"] } +block2 = "0.6" +plist = "1.10" # A plain fsync where a volume refuses the full flush `sync_all` asks for (SMB, FUSE); already in # the tree through pdfcraft-cli. rustix = { version = "1", features = ["fs"] } @@ -86,3 +88,7 @@ skrifa = "0.44" [build-dependencies] serde_json = { workspace = true } toml = "1" + +[target.'cfg(target_os = "macos")'.dev-dependencies] +# Only synthetic attribute fixtures use xattr; product reads/writes use rustix. +xattr = "1.6" diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 0cde38611..5b821b638 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -709,7 +709,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); - if own && self.watch.disk_differs(id, dest) { + if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) { self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); self.dialog = Some(crate::Dialog::OverwriteChanged); return false; @@ -785,6 +785,8 @@ impl PdfCraftApp { view.disk_changed = false; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); + #[cfg(target_os = "macos")] + crate::macos_association::repair(std::path::Path::new(dest)); true } Err(e) => { @@ -1336,6 +1338,26 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(target_os = "macos")] + #[test] + fn a_save_preserves_opening_settings_changed_while_staging() { + let dir = staging_dir("opening-metadata-changed"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"old handler").unwrap(); + xattr::set(&target, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); + let suffixes = std::iter::once_with(|| { + xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"new handler").unwrap(); + xattr::set(&target, "com.apple.quarantine", b"0082;5678;another app;").unwrap(); + 7 + }); + write_atomically_with(&target.to_string_lossy(), b"NEW", suffixes).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(xattr::get(&target, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"new handler".to_vec())); + assert_eq!(xattr::get(&target, "com.apple.quarantine").unwrap(), Some(b"0082;5678;another app;".to_vec())); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn outlook_attachment_copies_are_recognised_on_either_separator_and_in_any_case() { for p in [ diff --git a/crates/ui-egui/src/file_watch.rs b/crates/ui-egui/src/file_watch.rs index 533842d7e..57de3428a 100644 --- a/crates/ui-egui/src/file_watch.rs +++ b/crates/ui-egui/src/file_watch.rs @@ -55,6 +55,19 @@ impl Stamp { Self { len: meta.len(), modified: meta.modified().ok(), extra } } + /// Only Unix change-time differs: metadata may have changed without a content edit. + fn metadata_only_candidate(self, other: Self) -> bool { + #[cfg(unix)] + { + self.len == other.len && self.modified == other.modified && self.extra.get(..2) == other.extra.get(..2) + } + #[cfg(not(unix))] + { + let _ = other; + false + } + } + /// The file at `path` now; `None` when it is missing, not a file or can't be read. pub fn at(path: &str) -> Option { std::fs::metadata(path).ok().filter(Metadata::is_file).map(|m| Self::of(&m)) @@ -158,6 +171,24 @@ impl Watcher { } } +/// Verify a metadata-only candidate without allocating a second PDF. The stamp must stay +/// fixed throughout; changed bytes, a replacement or any read failure still count as a change. +fn contents_match(path: &str, stamp: Stamp, expected: &[u8]) -> bool { + let Ok(mut file) = File::open(path) else { return false }; + if !file.metadata().is_ok_and(|m| Stamp::of(&m) == stamp) { + return false; + } + let mut buffer = [0u8; 8192]; + for chunk in expected.chunks(buffer.len()) { + let Some(out) = buffer.get_mut(..chunk.len()) else { return false }; + if file.read_exact(out).is_err() || out != chunk { + return false; + } + } + let mut tail = [0]; + matches!(file.read(&mut tail), Ok(0)) && file.metadata().is_ok_and(|m| Stamp::of(&m) == stamp) && Stamp::at(path) == Some(stamp) +} + /// What a changed file turned out to be. enum Version { /// A whole PDF, read. @@ -279,9 +310,11 @@ impl FileWatch { /// else wrote it since the document was opened, reloaded or last saved (#2166). A file /// that is missing or can't be looked at, or a document whose version was never recorded, /// is not a difference. - pub fn disk_differs(&self, doc: DocId, path: &str) -> bool { + pub fn disk_differs(&self, doc: DocId, path: &str, saved: Option>>) -> bool { match (Stamp::at(path), self.basis.get(&doc)) { - (Some(now), Some(basis)) => now != *basis, + (Some(now), Some(basis)) => { + now != *basis && !(now.metadata_only_candidate(*basis) && saved.is_some_and(|bytes| contents_match(path, now, &bytes))) + } _ => false, } } @@ -378,6 +411,12 @@ impl PdfCraftApp { if self.watch.known.get(&c.doc) == Some(&c.stamp) { return; } + // LaunchServices recovery (and Finder metadata edits) changes ctime. The worker + // already read the stable file: identical saved bytes are not a document change. + if self.session.get(c.doc).is_some_and(|d| d.saved_bytes().as_slice() == c.bytes.as_slice()) { + self.watch.known_as(c.doc, Some(c.stamp)); + return; + } self.watch.known.insert(c.doc, c.stamp); if self.has_unsaved_work(i) { if let Some(view) = self.views.get_mut(i) { @@ -517,6 +556,58 @@ mod tests { app.session.get(app.views[0].id).unwrap() } + /// Association recovery changes ctime without replacing PDF contents. It must neither + /// discard undo history nor make the next Save ask to overwrite our own metadata change. + #[cfg(target_os = "macos")] + #[test] + fn association_metadata_does_not_reload_or_block_save() { + let (_dir, path) = TempDir::new("association-metadata", &fixture(2)); + let mut h = harness(&path); + let app = h.state_mut(); + app.views[0].select_pages(&[0]); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + xattr::set(&path, "com.apple.quarantine", b"2082;1234;Preview;").unwrap(); + // Exercise Save before the worker has consumed the metadata change. + assert!(h.state_mut().save_active(SaveTarget::InPlace), "ctime alone must not cause an overwrite prompt"); + xattr::set(&path, "com.apple.quarantine", b"2082;5678;Preview;").unwrap(); + h.run_steps(4); + assert!(doc(&h).can_undo().is_some(), "metadata-only events must not reload and clear history"); + assert!(!h.state().views[0].disk_changed); + } + + /// The repair parser's 64 KiB cap must not override the shared writer's larger limit. + #[cfg(target_os = "macos")] + #[test] + fn a_large_binding_does_not_block_a_document_save() { + let (_dir, path) = TempDir::new("large-binding-save", &fixture(2)); + xattr::set(&path, "com.apple.LaunchServices.OpenWith", &vec![b'x'; 65537]).unwrap(); + xattr::set(&path, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); + let mut h = harness(&path); + let app = h.state_mut(); + app.views[0].select_pages(&[0]); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + assert!(app.save_active(SaveTarget::InPlace), "the document save uses upstream preservation limits"); + assert_eq!(xattr::get(&path, "com.apple.LaunchServices.OpenWith").unwrap().unwrap().len(), 65537); + assert_eq!(xattr::get(&path, "com.apple.quarantine").unwrap(), Some(b"0082;1234;Preview;".to_vec())); + } + + /// Retaining mtime and length must not hide an actual external edit behind metadata noise. + #[cfg(unix)] + #[test] + fn same_length_and_mtime_content_edits_still_require_overwrite_confirmation() { + let (_dir, path) = TempDir::new("ctime-content", &fixture(2)); + let mut h = harness(&path); + let old = std::fs::metadata(&path).unwrap().modified().unwrap(); + let changed = String::from_utf8(fixture(2)).unwrap().replace("200 300", "201 300"); + std::fs::write(&path, changed).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(old).unwrap(); + let app = h.state_mut(); + app.views[0].select_pages(&[0]); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + assert!(!app.save_active(SaveTarget::InPlace)); + assert!(matches!(app.dialog, Some(crate::Dialog::OverwriteChanged))); + } + /// #1802: the watcher found version B of the file, then the user saved version C over it /// before B was applied. B is older than the document now: it is not applied, the undo history /// stays, and C stays on disk. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64b22ff18..af9912f45 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,6 +57,8 @@ mod crop; mod drag_pointer; mod export_ui; mod js_ui; +#[cfg(target_os = "macos")] +mod macos_association; mod marks_ui; mod measure_ui; mod ocr_ui; @@ -1431,6 +1433,8 @@ impl PdfCraftApp { self.left_open = left_open; } if let Some(p) = path { + #[cfg(target_os = "macos")] + macos_association::repair(std::path::Path::new(&p)); self.recent.retain(|r| r.path != p); self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); self.recent.truncate(12); diff --git a/crates/ui-egui/src/macos_association.rs b/crates/ui-egui/src/macos_association.rs new file mode 100644 index 000000000..e07d6496a --- /dev/null +++ b/crates/ui-egui/src/macos_association.rs @@ -0,0 +1,440 @@ +//! Best-effort recovery of an existing per-file PDFCraft binding after a successful open/save. +//! Quarantined documents with redirected bindings can be refused by LaunchServices before the +//! app receives an open event (Apple feedback FB19468486). In-app Open remains reachable. +//! Never remove quarantine, set type-wide defaults, or create a new preference on an open. + +use std::{io, path::Path}; + +const OPEN_WITH: &str = "com.apple.LaunchServices.OpenWith"; +const QUARANTINE: &str = "com.apple.quarantine"; +const BUNDLE_ID: &str = "ai.storyteller.pdfcraft"; +const MAX_ATTRIBUTE: usize = 64 * 1024; + +/// A bounded snapshot used only to decide whether native association recovery is eligible. +#[derive(Default, PartialEq, Eq)] +struct OpeningMetadata { + association: Option>, + quarantine: Option>, + identity: Option<(u64, u64)>, +} + +impl OpeningMetadata { + /// Capture one regular file identity and only read a binding when quarantine exists. + fn read(path: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt as _; + let identity = match path.symlink_metadata() { + Ok(meta) if meta.is_file() => (meta.dev(), meta.ino()), + Ok(_) => return Ok(Self::default()), + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(e), + }; + let quarantine = read_attribute(path, QUARANTINE)?; + let association = if quarantine.is_none() { None } else { read_attribute(path, OPEN_WITH)? }; + let snapshot = Self { association, quarantine, identity: Some(identity) }; + let current = path.symlink_metadata()?; + if !current.is_file() || (current.dev(), current.ino()) != identity { + return Err(io::Error::other("the file changed while reading its opening settings")); + } + Ok(snapshot) + } +} + +/// Read a last-component-symlink-safe attribute with a strict allocation cap. +fn read_attribute(path: &Path, name: &str) -> io::Result>> { + read_attribute_with(|buffer| rustix::fs::lgetxattr(path, name, buffer)) +} + +/// Probe the length before allocating. A concurrently grown value fails safely; unreadable +/// attributes or values beyond the parser cap skip recovery without affecting open/save. +fn read_attribute_with(mut get: impl FnMut(&mut [u8]) -> rustix::io::Result) -> io::Result>> { + use rustix::io::Errno; + let unreadable = |error| match error { + Errno::RANGE => Err(io::Error::new(io::ErrorKind::InvalidData, "opening metadata exceeds 64 KiB or changed while reading")), + _ => Ok(None), + }; + let len = match get(&mut []) { + Ok(len) if len <= MAX_ATTRIBUTE => len, + Ok(_) => return unreadable(Errno::RANGE), + Err(e) => return unreadable(e), + }; + let mut bytes = vec![0; len]; + match get(bytes.as_mut_slice()) { + Ok(len) if len <= bytes.len() => { + bytes.truncate(len); + Ok(Some(bytes.into_boxed_slice().into_vec())) + } + Ok(_) => unreadable(Errno::RANGE), + Err(e) => unreadable(e), + } +} + +/// Accept only the supported native schema identifying this exact running bundle. +fn points_to_this_app(data: &[u8], app: &Path) -> bool { + if data.len() > MAX_ATTRIBUTE { + return false; + } + // Deserialize just the small native schema rather than retaining an arbitrary plist tree. + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Binding { + version: u32, + path: String, + // Apple's API sometimes writes only version/path (e.g. an unregistered local bundle). + // The exact path still has to match the running bundle whose identity we checked. + bundleidentifier: Option, + } + let Ok(binding) = plist::from_bytes::(data) else { return false }; + if binding.version != 0 || binding.bundleidentifier.as_deref().is_some_and(|id| id != BUNDLE_ID) || !Path::new(&binding.path).is_absolute() { + return false; + } + // Exact native spelling is intentionally conservative. Never resolve an untrusted path: + // canonicalize could contact a stale network mount and block the main thread. + Path::new(&binding.path) == app +} + +/// Queue bounded, deduplicated recovery work without filesystem I/O on the UI thread. +/// The framework's safe NSWorkspace binding is callable on a worker; its completion remains +/// asynchronous. A stalled volume consumes at most one worker and a bounded queue. +pub(crate) fn repair(path: &Path) { + use objc2_foundation::NSBundle; + use std::collections::HashSet; + use std::sync::{Arc, Mutex, OnceLock, PoisonError, mpsc}; + + if !objc2::available!(macos = 12.0) + || objc2::MainThreadMarker::new().is_none() + || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf")) + { + return; + } + let bundle = NSBundle::mainBundle(); + if bundle.bundleIdentifier().is_none_or(|id| id.to_string() != BUNDLE_ID) { + return; + } + let Some(app) = bundle.bundleURL().to_file_path() else { return }; + static QUEUE: OnceLock> = OnceLock::new(); + let Some(queue) = QUEUE.get_or_init(|| { + let (tx, rx) = mpsc::sync_channel::(32); + let pending = Arc::new(Mutex::new(HashSet::new())); + std::thread::Builder::new() + .name("pdfcraft-association".into()) + .spawn(move || { + for job in rx { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| objc2::rc::autoreleasepool(|_| repair_job(job)))).is_err() { + log::warn!("PDF association recovery skipped after a worker error"); + } + } + }) + .ok()?; + Some(RepairQueue { tx, pending }) + }) else { + return; + }; + let path = path.to_path_buf(); + { + let mut pending = queue.pending.lock().unwrap_or_else(PoisonError::into_inner); + if pending.len() >= 32 || !pending.insert(path.clone()) { + return; + } + } + let job = RepairJob { app, pending: Pending { path, set: queue.pending.clone() } }; + let _ = queue.tx.try_send(job); // A refused send drops the job and releases its pending entry. +} + +/// One process-wide worker and the paths queued or awaiting native completion. +struct RepairQueue { + tx: std::sync::mpsc::SyncSender, + pending: std::sync::Arc>>, +} + +/// The exact running bundle captured on the main thread and its bounded pending entry. +struct RepairJob { + app: std::path::PathBuf, + pending: Pending, +} + +/// Release deduplication on every exit, including errors and native callback destruction. +struct Pending { + path: std::path::PathBuf, + set: std::sync::Arc>>, +} + +impl Drop for Pending { + fn drop(&mut self) { + self.set.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&self.path); + } +} + +/// Read one identity-checked snapshot immediately before dispatch. No second xattr pass: +/// the native API has no compare-and-set, so a post-dispatch external change can still race it. +fn repair_job(job: RepairJob) { + use block2::RcBlock; + use objc2_app_kit::NSWorkspace; + use objc2_foundation::{NSError, NSURL}; + let metadata = match OpeningMetadata::read(&job.pending.path) { + Ok(metadata) => metadata, + Err(_) => { + log::warn!("PDF association recovery skipped: could not read opening metadata"); + return; + } + }; + if metadata.quarantine.is_none() || !metadata.association.as_deref().is_some_and(|data| points_to_this_app(data, &job.app)) { + return; + } + let (Some(app_url), Some(file_url)) = (NSURL::from_file_path(&job.app), NSURL::from_file_path(&job.pending.path)) else { return }; + let pending = job.pending; + let completion = RcBlock::new(move |error: *mut NSError| { + let _ = &pending; + if error.is_null() { + log::info!("PDF association recovery completed"); + } else { + log::warn!("PDF association recovery failed; document open/save remains successful"); + } + }); + NSWorkspace::sharedWorkspace().setDefaultApplicationAtURL_toOpenFileAtURL_completionHandler(&app_url, &file_url, Some(&completion)); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(0); + + /// Create an isolated scratch directory for parallel metadata tests. + fn directory() -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("pdfcraft-association-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&p).unwrap(); + p + } + + /// Build either native plist encoding with controlled identity fields. + fn binding(app: &Path, id: &str, version: u32, binary: bool) -> Vec { + let value = plist::Value::Dictionary( + [ + ("version".to_string(), plist::Value::Integer(version.into())), + ("path".to_string(), plist::Value::String(app.to_string_lossy().into_owned())), + ("bundleidentifier".to_string(), plist::Value::String(id.into())), + ] + .into_iter() + .collect(), + ); + let mut bytes = Vec::new(); + if binary { + value.to_writer_binary(&mut bytes).unwrap(); + } else { + value.to_writer_xml(&mut bytes).unwrap(); + } + bytes + } + + /// Reject foreign apps, unsupported schema versions, malformed data and relative paths. + #[test] + fn requires_matching_identifier_path_and_native_schema() { + let app = directory(); + let other = directory(); + for binary in [false, true] { + assert!(points_to_this_app(&binding(&app, BUNDLE_ID, 0, binary), &app)); + assert!(!points_to_this_app(&binding(&app, "com.apple.Preview", 0, binary), &app)); + assert!(!points_to_this_app(&binding(&other, BUNDLE_ID, 0, binary), &app)); + assert!(!points_to_this_app(&binding(&app, BUNDLE_ID, 1, binary), &app)); + } + let mut native = plist::from_bytes::(&binding(&app, BUNDLE_ID, 0, true)).unwrap(); + native.as_dictionary_mut().unwrap().remove("bundleidentifier"); + let mut bytes = Vec::new(); + native.to_writer_binary(&mut bytes).unwrap(); + assert!(points_to_this_app(&bytes, &app), "Apple's path-only binding still identifies this exact bundle"); + assert!(!points_to_this_app(&bytes, &other)); + assert!(!points_to_this_app(b"not a plist", &app)); + assert!(!points_to_this_app(&vec![0; MAX_ATTRIBUTE + 1], &app)); + assert!(!points_to_this_app(&binding(Path::new("relative.app"), BUNDLE_ID, 0, true), &app)); + std::fs::remove_dir_all(app).unwrap(); + std::fs::remove_dir_all(other).unwrap(); + } + + /// Preserve destination values verbatim without interpreting them as new preferences. + #[test] + fn atomic_save_preserves_own_other_and_malformed_bindings_and_quarantine() { + let dir = directory(); + let file = dir.join("file.pdf"); + for data in [binding(&dir, BUNDLE_ID, 0, true), binding(&dir, "com.apple.Preview", 0, true), b"malformed".to_vec()] { + std::fs::write(&file, b"before").unwrap(); + xattr::set(&file, OPEN_WITH, &data).unwrap(); + xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap(); + crate::editing::write_atomically(file.to_str().unwrap(), b"after").unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"after"); + assert_eq!(xattr::get(&file, OPEN_WITH).unwrap(), Some(data)); + assert_eq!(xattr::get(&file, QUARANTINE).unwrap(), Some(b"0082;1234;Preview;".to_vec())); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A new Save As destination must not inherit a source association or quarantine. + #[test] + fn new_destination_does_not_acquire_an_association() { + let dir = directory(); + let file = dir.join("new.pdf"); + crate::editing::write_atomically(file.to_str().unwrap(), b"pdf bytes").unwrap(); + assert!(xattr::get(&file, OPEN_WITH).unwrap().is_none()); + assert!(xattr::get(&file, QUARANTINE).unwrap().is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// The repair parser cap must not narrow upstream's larger preservation limits. + #[test] + fn oversized_binding_skips_repair_but_is_preserved_by_the_shared_writer() { + let dir = directory(); + let file = dir.join("out.pdf"); + std::fs::write(&file, b"original PDF").unwrap(); + xattr::set(&file, OPEN_WITH, &vec![b'x'; MAX_ATTRIBUTE + 1]).unwrap(); + xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap(); + assert!(OpeningMetadata::read(&file).is_err()); + crate::editing::write_atomically(file.to_str().unwrap(), b"new PDF").unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"new PDF"); + assert_eq!(xattr::get(&file, OPEN_WITH).unwrap().unwrap().len(), MAX_ATTRIBUTE + 1); + assert_eq!(xattr::get(&file, QUARANTINE).unwrap(), Some(b"0082;1234;Preview;".to_vec())); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Snapshots distinguish replaced inodes even with identical attribute bytes. + #[test] + fn preflight_refuses_a_replaced_file_even_with_identical_attributes() { + let dir = directory(); + let file = dir.join("out.pdf"); + let replacement = dir.join("replacement.pdf"); + let prepare = |path: &Path| { + std::fs::write(path, b"PDF").unwrap(); + xattr::set(path, OPEN_WITH, &binding(&dir, BUNDLE_ID, 0, true)).unwrap(); + xattr::set(path, QUARANTINE, b"0082;1234;Preview;").unwrap(); + }; + prepare(&file); + let metadata = OpeningMetadata::read(&file).unwrap(); + assert!(OpeningMetadata::read(&file).unwrap() == metadata); + prepare(&replacement); + std::fs::rename(replacement, &file).unwrap(); + assert!(OpeningMetadata::read(&file).unwrap() != metadata, "the file successfully opened is no longer at this path"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Snapshots distinguish changed quarantine and ignore last-component symlinks. + #[test] + fn preflight_refuses_changed_quarantine_and_symbolic_links() { + let dir = directory(); + let file = dir.join("out.pdf"); + std::fs::write(&file, b"PDF").unwrap(); + xattr::set(&file, OPEN_WITH, &binding(&dir, BUNDLE_ID, 0, true)).unwrap(); + xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap(); + let metadata = OpeningMetadata::read(&file).unwrap(); + xattr::set(&file, QUARANTINE, b"0082;5678;another app;").unwrap(); + assert!(OpeningMetadata::read(&file).unwrap() != metadata); + let link = dir.join("link.pdf"); + std::os::unix::fs::symlink(&file, &link).unwrap(); + let linked = OpeningMetadata::read(&link).unwrap(); + assert!(linked.association.is_none() && linked.quarantine.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Do not resolve aliases or mount paths supplied in untrusted binding metadata. + #[test] + fn untrusted_bundle_aliases_are_skipped_without_resolving_them() { + let dir = directory(); + let app = dir.join("PdfCraft.app"); + std::fs::create_dir(&app).unwrap(); + let alias = dir.join("alias.app"); + std::os::unix::fs::symlink(&app, &alias).unwrap(); + assert!(!points_to_this_app(&binding(&alias, BUNDLE_ID, 0, true), &app)); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Check a real captured native binding and the packaged application identity. + #[test] + fn native_binding_and_packaging_identity_match() { + // Captured via Apple's API on a synthetic PDF on macOS 27.0.1. Contains only the + // public installed bundle path, identifier and schema version; no document data. + let hex = "62706c6973743030d30102030405065776657273696f6e54706174685f101062756e646c656964656e74696669657210005f101a2f4170706c69636174696f6e732f50646643726166742e6170705f101761692e73746f727974656c6c65722e7064666372616674080f171c2f314e0000000000000101000000000000000700000000000000000000000000000068"; + let captured: Vec = + hex.as_bytes().as_chunks::<2>().0.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()).collect(); + assert!(points_to_this_app(&captured, Path::new("/Applications/PdfCraft.app"))); + let package = plist::from_bytes::(include_bytes!("../../../packaging/macos/Info.plist.in")).unwrap(); + assert_eq!(package.as_dictionary().unwrap().get("CFBundleIdentifier").unwrap().as_string(), Some(BUNDLE_ID)); + } + + /// Exercise arbitrary and damaged metadata through the strict native parser. + #[test] + fn malformed_plists_and_truncated_native_schemas_do_not_panic() { + let app = Path::new("/Applications/PdfCraft.app"); + for binary in [false, true] { + let native = binding(app, BUNDLE_ID, 0, binary); + for end in 0..native.len() { + let _ = points_to_this_app(native.get(..end).unwrap(), app); + } + for index in 0..native.len() { + for byte in [0, 0xff, b'<', b'&'] { + let mut mutated = native.clone(); + *mutated.get_mut(index).unwrap() = byte; + let _ = points_to_this_app(&mutated, app); + } + } + } + let mut state = 0x125a_9430_8c97_ae13u64; + for len in 0..1024 { + let data: Vec<_> = (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state as u8 + }) + .collect(); + let _ = points_to_this_app(&data, app); + } + } + + /// Length probing allocates only the value, rejects oversize before a second call, + /// and skips recovery if the value grows between the probe and read. + #[test] + fn attribute_probes_bound_allocation_and_detect_growth() { + let mut calls = 0; + let small = read_attribute_with(|out| { + calls += 1; + if calls == 1 { + assert!(out.is_empty()); + return Ok(3); + } + assert_eq!(out.len(), 3); + out.copy_from_slice(b"abc"); + Ok(3) + }) + .unwrap() + .unwrap(); + assert_eq!(small, b"abc"); + assert_eq!(small.capacity(), 3); + assert!(read_attribute_with(|_| Ok(MAX_ATTRIBUTE + 1)).is_err()); + let mut calls = 0; + assert!( + read_attribute_with(|_| { + calls += 1; + if calls == 1 { Ok(3) } else { Err(rustix::io::Errno::RANGE) } + }) + .is_err() + ); + } + + /// Volumes may expose no readable attributes with errors beyond ENOTSUP. + #[test] + fn unreadable_attributes_are_best_effort() { + use rustix::io::Errno; + for errno in [Errno::NOSYS, Errno::INVAL, Errno::PERM, Errno::ACCESS, Errno::IO] { + assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None); + } + } + + /// Skip unavailable attributes while retaining the hard limit on known oversized values. + #[test] + fn unsupported_attribute_volumes_are_distinct_from_read_failures() { + use rustix::io::Errno; + for errno in [Errno::NOATTR, Errno::NOTSUP, Errno::OPNOTSUPP] { + assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None, "{errno}"); + } + assert!(read_attribute_with(|_| Err(Errno::RANGE)).is_err()); + } +} diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index fe784904f..8b8c34a61 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -463,6 +463,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] app.watch.known_now(doc_id, &path.to_string_lossy()); app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?; + #[cfg(target_os = "macos")] + crate::macos_association::repair(&path); app.sign_graphic = choice; if let Some(view) = app.views.iter_mut().find(|v| v.id == doc_id) { view.invalidate_content(); diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index c7fd4c42f..0050f82b1 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -661,3 +661,28 @@ fn an_imported_digital_id_can_be_removed() { assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure)); h.get_by_label("Configure a Digital ID for Signing"); } + +/// Sign-and-save must use the same destination metadata policy as Save As. +#[cfg(target_os = "macos")] +#[test] +fn signing_preserves_destination_opening_attributes() { + let dir = dir(); + let dest = dir.join("contract_signed.pdf"); + std::fs::write(&dest, FIXTURE).unwrap(); + xattr::set(&dest, "com.apple.LaunchServices.OpenWith", b"existing binding").unwrap(); + xattr::set(&dest, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); + let mut h = harness(dir.clone()); + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + let cert = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap().certificate; + h.state_mut().start_signing(0, None, None, None); + let entry = h.state_mut().add_digital_id(p12, &cert); + let d = h.state_mut().sign_draft.as_mut().unwrap(); + (d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into()); + h.run_steps(2); + h.get_by_label("Sign").click(); + h.run_steps(6); + assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed)); + assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec())); + assert_eq!(xattr::get(&dest, "com.apple.quarantine").unwrap(), Some(b"0082;1234;Preview;".to_vec())); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index f2972ab10..92d91503b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -188,10 +189,8 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | ## Upcoming milestones, ranked @@ -239,6 +238,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | | 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |