mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
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:
Generated
+26
-1
@@ -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,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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user