M14: recover existing macOS PDF associations after open and save (#3558)

* M14: recover existing macOS PDF associations

* M14: address macOS association recovery review

* M14: reuse upstream save metadata preservation
This commit is contained in:
skylar taylor-barrick
2026-10-22 15:49:13 -06:00
committed by GitHub
parent 39844f06b5
commit adea2c908a
10 changed files with 628 additions and 11 deletions
Generated
+26 -1
View File
@@ -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",
]
+2
View File
@@ -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.
+10 -4
View File
@@ -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"
+23 -1
View File
@@ -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<dyn FnOnce(&mut Self) + Send>) -> 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 [
+93 -2
View File
@@ -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<Self> {
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<Arc<Vec<u8>>>) -> 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.
+4
View File
@@ -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);
+440
View File
@@ -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<Vec<u8>>,
quarantine: Option<Vec<u8>>,
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<Self> {
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<Option<Vec<u8>>> {
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<usize>) -> io::Result<Option<Vec<u8>>> {
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<String>,
}
let Ok(binding) = plist::from_bytes::<Binding>(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<Option<RepairQueue>> = OnceLock::new();
let Some(queue) = QUEUE.get_or_init(|| {
let (tx, rx) = mpsc::sync_channel::<RepairJob>(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<RepairJob>,
pending: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
}
/// 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<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
}
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<u8> {
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::<plist::Value>(&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<u8> =
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::<plist::Value>(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());
}
}
+2
View File
@@ -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();
+25
View File
@@ -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);
}
+3 -3
View File
@@ -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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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<br>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. |