* Use craft-heif from craft-libs; remove crates/heif
PhotoCraft's HEIF decoder had drifted from LightCraft's by about 700 lines;
both now use craft-heif (storytold/craft-libs#784), built from LightCraft's
copy. HEIC colours now follow the HEVC VUI's range and matrix and libheif's
chroma upsampling. 8-bit files still open as 8-bit documents; a colour
description the VUI specifies now tags the image as an nclx did.
The codecs -> heif standalone exception ends with the local crate, and the
HEIF nextest step drops -p photocraft-heif, which no longer exists.
* Pin craft-heif to the merge of storytold/craft-libs#794
* heif: count the decoder's 16-bit buffer against max_alloc
* heif: match Apple's chroma upsampling; pin craft-heif to storytold/craft-libs#799
* heif: free the decoder's buffer before the HDR conversion and count that conversion's peak
* heif: hold the canvas the decoder really allocates to max_alloc
## Summary
`photocraft-cli mcp --bridge` sent the control token in clear as its first frame, to whatever had accepted the connection: a `[::1]` listener reached through a `localhost` lookup, or any process holding `127.0.0.1:<port>` while the app was not listening. This PR fixes both cases:
- **Literal loopback only.** `BridgeClient` parses its address into a socket address and connects to it. Only `127.0.0.1:<port>` and `[::1]:<port>` are accepted. `localhost:<port>` means `127.0.0.1:<port>` (what the desktop app binds) and is never looked up.
- **Challenge authentication.** The bridge authenticates with `auth.challenge {clientNonce}` → `{serverNonce, serverProof}` → `auth {proof}`. Both proofs are HMAC-SHA256 keyed by the token over both nonces, each under its own label. The client checks the server's proof before it sends its own and never falls back to sending the token. The token never crosses the wire, so a listener that is not the app gets only a random nonce.
- **One shared server implementation.** `security::ServerHandshake` replaces `authentication_reply`, and both the desktop control server and `photocraft-cli serve` use it. The servers still accept the one-frame `auth {token}`, so existing scripts and older clients keep working. A repeat `auth.challenge` after authenticating gets "already authenticated", like a repeat `auth`.
- HMAC comes from RustCrypto's `hmac` 0.12 with `sha2` 0.10 (`sha2` was already in the lockfile). The proofs are compared without an early exit.
- `docs/control-protocol.md` documents the handshake, the exact HMAC message, and the bridge's address rules. The book's protocol summary is updated too.
Compatibility: a new bridge needs an app from the same release. Against an older app it fails with "refused challenge authentication … the control token was not sent". It does not downgrade.
Known limit: the channel is plain TCP with no TLS, so nothing binds the handshake to the connection. A party sitting between a bridge and a real app could relay the handshake without learning the token. On loopback that needs it to hold the port the bridge dials while the app listens on another, which the literal-address rule above narrows. Older clients that still send `auth {token}` keep their old exposure until they upgrade.
Closes#9777
## Tests
New in `crates/automation/tests/it/bridge_token.rs`:
- `localhost_reaches_the_ipv4_app_not_an_ipv6_listener`: a listener on `[::1]:P` gets no connection, and the app on `127.0.0.1:P` does.
- `a_listener_that_is_not_the_app_never_receives_the_token`: on both loopbacks, a listener that stays silent, refuses, or fakes the challenge receives only the challenge frame, never the token. The error says why.
- `the_bridge_authenticates_to_the_headless_server_by_challenge`: end to end against `rpc::serve_tcp`. A wrong token is refused before its proof is sent.
Unit tests: the handshake in both directions, and that wrong, missing or replayed proofs, the token after a challenge, a second challenge and malformed nonces are all refused (`security.rs`). `loopback_socket` address parsing (`bridge.rs`). The desktop server answering a challenge, then dispatching (`control_server.rs`). The existing bridge and MCP fakes now run the real `ServerHandshake`.
Run locally (Windows 11, targeted tests only):
- **Fail before.** I put upstream's `crates/automation/src/bridge.rs` (the old client) back and ran `cargo test -p photocraft-automation --test it bridge_token`: **3 failed**. The `[::1]` listener received `{"method":"auth","params":{"token":"<the token>"}}`, a `127.0.0.1` listener received the raw token, and the headless test got "authentication required" instead of the refusal. The original audit tests (`audit_nv6_bridge_sends_token_to_ipv6_impostor`, `audit_nv6_bridge_sends_token_to_any_ipv4_holder`) fail the same way on upstream/main.
- **Pass after.**
- `cargo test -p photocraft-automation --lib --test it -- bridge security tcp`: 23 + 21 passed. This covers the new tests, the existing bridge, MCP-bridge, security and serve TCP tests, and the legacy-token tests that still pass unchanged.
- `cargo test -p photocraft control_server`: 6 passed, including the new challenge test. The legacy-token tests still pass.
- `cargo clippy -p photocraft-automation --all-targets -- -D warnings`: clean.
- `cargo fmt`: clean.
- `Cargo.lock` adds only `hmac` 0.12.1, plus `subtle` under `digest` (already in the lock). Checked with `cargo metadata --locked`.
Not run here: the full gate (`cargo xtask ...`, the workspace-wide tests, wasm, clippy on the desktop app crate). That is left to CI.
API note: the workspace-internal `security::authentication_reply` is replaced by `ServerHandshake::reply`, which returns an `AuthStep`.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Security**
* Control connections support challenge-response authentication, verifying possession of the configured token without sending it. Token-in-frame authentication remains available for scripts.
* The MCP bridge accepts literal loopback addresses and maps `localhost` directly to IPv4 loopback. It rejects servers that refuse or cannot complete challenge authentication.
* **Bug Fixes**
* Rejected authentication exchanges close the connection; successful authentication allows requests to proceed.
* **Documentation**
* Control protocol and bridge authentication behavior are documented.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Conflicts with main's --no-maximize-compatibility (#8064) in the CLI
export args, output mapping and dev-dependencies, and in the
file-format parity status line and revision history: both sides kept.
Co-Authored-By: ljkx <ljkx@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cap-std's Windows `rename`, `remove_file` and `create_dir` are path-based:
they append the request to the handle's own path, read back without its
`\\?\` prefix, and call `std::fs` (sunfishcode/cap-std#348). Below a drive
root that still resolves; below a UNC root (`\\wsl.localhost\...`, any
network share) it becomes the relative `UNC\wsl.localhost\...` and fails
with ERROR_PATH_NOT_FOUND. `AuthorizedWorkspace::write` then left only its
`.pcsave.tmp` behind, `export_sequence` could not create a new directory,
and `ExportSequence::cleanup` could not remove its own frames.
Opening files and directories and `stat` are handle-relative and work
below any root, so everything is built on them. Files are removed through
the held directory handle (`remove_file_in`: `Dir::remove_file`, then on
the UNC `NotFound` an open relative to the same handle with
FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT). The two steps
that have no handle-relative form, creating a directory and renaming the
finished temporary file into place, run on the canonical root path
(trusted launch configuration, never the request) below directories that
were each verified a plain directory through the handle of their parent
and are held open for the duration (`held_directory_chain`,
`pinned_directory_chain`). cap-std opens directories without
FILE_SHARE_DELETE, so a held ancestor cannot be renamed away or replaced
by a link while the ambient step runs. The rename is therefore the same
temp-file + rename replace as on every other root: a failed save keeps the
original, and no temporary file is stranded. Drive roots and Unix are
unchanged.
The regression tests use the temp drive's administrative share
(`\\localhost\C$\...`) and skip on a runner that does not expose it; the
pinned-ancestor test shows a held chain refusing the swap with
ERROR_SHARING_VIOLATION. The security model notes document the two
ambient operations.
Fixes#10317
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* MCP headless: waited commands run as jobs, with progress and cancel (#8129)
Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.
A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.
Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: cancel a waited job whose request was dropped before it started
The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: job_poll applies finished jobs before checking the job
A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: a cancel that loses the race returns the job's result
job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: waited commands run as jobs, with progress and cancel (#8129)
Headless command_run and command_batch steps that wait (the default) ran
job-capable commands inline while holding the session lock. Cancelling a
long Gaussian Blur did not stop it: it still added its history step, and
doc_inspect and jobs_list queued behind it. No progress was reported.
A waited job-capable command now starts as a background job
(Headless::command_begin) and the server polls it with the lock released
(Headless::job_poll), so other calls are served meanwhile. With a progress
token it sends notifications/progress (strictly increasing, at most ten a
second; for a batch, finished steps plus the current step's fraction).
notifications/cancelled cancels the job and leaves the document unchanged;
a drop guard does the same when rmcp drops the request future. Headless
keeps the outcome of each waited job as sync_jobs sees it end, since
jobs.list keeps only the last few. Both backends share one batch loop,
which runs no further step once the request is cancelled.
Tool hints: jobs_list is read-only, doc_select is non-destructive and
idempotent, and the scoped exports (which refuse to overwrite) are
non-destructive. doc_save's description lost a run of spaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: cancel a waited job whose request was dropped before it started
The Abandon guard was created only after command_begin returned. A request
dropped while the start still waited for the session lock left the job it
then started running, and its `waited` entry behind. The guard is now armed
inside the locked closure that starts the job, so dropping the unread output
cancels it. Review feedback on #8132.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: waited commands poll the app's job, with progress and cancel (#8130)
A waited command_run or command_batch step of an engine command now starts it
with engine.execute {wait: false} (sent once) and, when the app runs it as a
background job, polls jobs.list until it ends. A job can run past the bridge's
55s reply timeout, its progress is forwarded, and cancelling or dropping the
request sends jobs.cancel. UI command ids keep wait: true. A failed poll or a
job no longer listed is reported with its id rather than guessed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: keep the cancel guard armed until jobs.cancel returns; clarify the docs
The cancel branch disarmed the Abandon guard before awaiting jobs.cancel, so a
request future dropped during that await sent no cancel. The control-protocol
table now says polling applies only to a waited command_run. Review feedback
on #8134.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: job_poll applies finished jobs before checking the job
A caller polling Headless::job_poll directly, without sync_jobs in between,
kept seeing Running after the worker finished, because the session only
drops a finished job from its running list when its jobs are polled.
job_poll now syncs first. The MCP path already synced in headless_op.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP headless: a cancel that loses the race returns the job's result
job_abandon ignored whether Session::cancel_job cancelled anything and
always dropped the stored outcome, so headless_wait answered `Cancelled`
for a job another request had already applied to the document. It now
returns how the job really ended: Cancelled only when it cancelled the
job, the job's result when it had already ended, and an error when the
job cannot be cancelled (printing) and is still running.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: a refused jobs.cancel is not reported as a cancellation
bridge_wait discarded the app's reply to jobs.cancel and always answered
`Cancelled`. The app refuses to cancel a job that is no longer running,
so a job that finished between the jobs.list poll and the cancel was
reported as cancelled although its edit may be applied. A refused cancel
now answers that the job was not cancelled and points to jobs_list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: a cancel during a failed poll still cancels the job
When notifications/cancelled arrived while a jobs.list poll was in
flight, and the poll and its retry then failed, bridge_wait disarmed the
Abandon guard and returned the poll error without checking the waiter,
so no jobs.cancel reached the app and the edit ran on after the caller
cancelled it. That error path now sends jobs.cancel first when the
request was cancelled. A failed poll without a cancel still leaves the
job running and names it.
Reported in review of #8134.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: a cancel doesn't wait for a slow jobs.list poll
bridge_wait checked for a cancel only between polls, so a cancel that
arrived while the app was slow to answer jobs.list waited for the poll,
up to the 55s call timeout, before jobs.cancel was sent. The poll now
races the cancel.
That needs BridgeClient to drop the connection of a call abandoned
before its reply: it kept the one cached connection, so jobs.cancel
queued behind the unanswered poll on the same socket (the app serves a
connection's requests in order), and a half-sent request could share it.
A call now takes the connection out of the cache and returns it only
once answered, as a timeout or lost reply already dropped it.
The poll-error check from the previous commit stays for a cancel that
races a failing poll; its test is replaced by one for the slow poll,
which covers the reviewed case. A BridgeClient test checks that a
dropped call's connection is not reused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(automation): retain cleanup after failed bridge cancellation
* chore: restart CI for PR #8134
* test(automation): classify the smart object replacement MCP tool
* test(automation): synchronize dropped bridge waits and accept reconnections
* Fix CI after merging upstream
* Fix CI after merging upstream
* Deduplicate Arabic closure messages after upstream merge
* fix(engine): bound active and cancelling background workers
* Fix cancellation and draining in bridge merge
* Test release-aware draining cleanup
* Fix draining regression warning
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Khawar Zamman Wani <wanikhawar@Khawars-MacBook-Air.local>
Co-authored-by: FredApps <fredapps@users.noreply.github.com>
Thanks @wanikhawar — merging.
**Summary:** In headless MCP, waited `command_run` / batch steps run job-capable commands as engine jobs (lock released while polling), with progress notifications and cancel/abandon on request drop. Fixes the “cancel does nothing / inspect blocks” behaviour for long filters. Cap of 16 background jobs; docs updated. Stack base for #8134.
Review follow-up on #9975. The help text and the CLI book page said the
XMP and free-form text are kept in every flat export and the ICC profile,
EXIF and resolution "either way". Not every format holds them: JPEG and
WebP have no place for free-form text, TIFF writes only its named text
tags, and an Indexed Color document's palette PNG carries no XMP, text,
ICC or EXIF at all, only its resolution. The texts (and the parity-doc
revision row) now say the XMP and text are kept where the format holds
them and that the option leaves everything else as it is without it.
The test fixture gains a free-form text entry and a Display P3 profile
(from photocraft-cms, now a dev-dependency; no profile file committed),
so it checks the text is gone with `none` and kept by a default PNG, and
that the default export keeps a real ICC profile that `none` matches,
instead of comparing None with None.
Since #733 a flat export from the CLI keeps the document's whole XMP
packet and free-form text, as Save As does; only Export As starts at
Metadata: None. Batch exports of Photoshop masters therefore carry the
full DocumentAncestors list (and the text of every type layer), which
can be many times larger than the image, with no way to leave it out.
`--metadata` is a new export option shared by `convert`, `run --out`
and `batch`. `none` sets ExportOptions::xmp to XmpEmbed::None, the same
path Export As's Metadata: None and Quick Export use, so the XMP packet
and free-form text are dropped while the ICC profile, EXIF and
resolution are written as before; PSD, PSB and .pcraft keep the XMP
either way. `all` is the default, so existing scripts write the same
files. Any other value is a usage error (exit 2) naming the flag,
before anything is read or written.
Tested through the binary for PNG, WebP, JPEG and TIFF from all three
subcommands (packet present by default and with `all`, absent with
`none`; pixels, ICC and resolution unchanged), layered saves, and bad
values. Documented in the CLI book page and file-format parity doc.
A canvas over 2^26 pixels (256 MiB of RGBA8) is saved without composite/full.png. The PNG encoder's buffers are reserved fallibly, so an allocation failure reports an encode error, and a ZIP bundle that cannot be reserved with the composite is written without it.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Integrate lossless export-only JXL and independent decoder regression matrix, preserving current JPEG/TIFF save extensions and 12-bit/arithmetic JPEG decoder dependency.
- The PNG encoder gets a fallibly reserved copy of the composite's pixels instead of a
clone, and a full-size composite that cannot be copied or encoded is left out of the
ZIP or directory bundle and out of its manifest; the save goes on without it.
- The ZIP size limit for keeping the full-size composite counts the central directory
and end record as well as the entries.
- `pcraftFullComposite` that is not true, false or null is a bad parameter of
`file.saveACopy`, the other engine saves and the automation `doc.save`.
- MCP `doc_save` in bridge mode refuses `pcraftFullComposite: false` as well as `true`.
The app's `app.save` does not take the option.
A .pcraft bundle holds the flattened document only as thumb.png (at most
256 px) and composite/preview.png (at most 1024 px). A new option also
writes composite/full.png: the flattened document at its canvas size,
an 8-bit RGBA PNG made like the two previews, with the ICC profile of
its pixels embedded. The option is off by default, and a save without
it writes the same bytes as before.
- Preferences > File Handling > Save Full-Size Composite in .pcraft
Files sets it for the app's Save, Save As and Save a Copy, in the
desktop and web builds.
- The engine's file.saveACopy and its batch and export commands take
"pcraftFullComposite" (unset, the preference), the automation
doc.save and MCP doc_save take
"pcraftFullComposite": true, and photocraft-cli convert, run and
batch take --pcraft-full-composite.
The manifest names the image in a new optional member, full_composite;
the format version stays 1, and bundles without it open as before. A
canvas over 2^30 pixels, or one whose pixels cannot be allocated, is
saved without it, and so is a ZIP bundle that it would take past 4 GiB
or 65,535 entries.
Conflicts: the batch's wheel reads (#5547 Animated Zoom) keep their extra
argument with main's ids (`Id::unique`, `ui.scope_id()`); crop presets keep
#6620's `apply_choice` with main's accessible name; Bevel & Emboss keeps
#9370's per-mode id with main's name argument; the Stroke field keeps
#7203's gradients with main's name; tone.rs keeps #9139's move of the
Histogram panel to histogram_panel.rs (main only renamed ids there);
translation catalogs as unions.
Not flagged by git, the batch's new code follows egui 0.37 too: dropdowns
take an accessible name (Histogram Channel/Source #9139, tool Mode #5504,
Art History Style #1610, New Snapshot From #3567), MouseWheel events carry
`source`, `WidgetType` is `Role` (ColorButton is ColorWell, #7203),
`Id::new`/`Ui::id` are `Id::unique`/`Ui::scope_id`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sign.ps1 downloaded Microsoft.Trusted.Signing.Client from the unversioned
NuGet URL and loaded its dlib into signtool, next to the Azure signing
secrets, with no version or integrity check.
Download a pinned version (1.0.95, which the unversioned URL serves today)
and refuse it unless its SHA-256 matches, so upgrading the client is a
deliberate change in the repository. The release docs say where the pin
lives.
psd_import.rs keeps main's warning for an incomplete merged extra channel
next to the PR's Photoshop spot polarity. people.toml takes main's entries
plus jasallen's own. Docs take both sides' status lines and revision rows;
docs/scorecard.md is regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review round of the #7071 stack, which #9167 merged without
(it took the pre-review heads):
- A bare --help or -h before `--` is help after any parse error, not
only after a missing value: `mcp stray.psd --help` and
`serve --port abc --port 1 -h` print the subcommand's help.
- mcp and serve --help carry #8035's rules: what the automation roots
grant, that bridge roots are refused, and that the token flags need
--bridge or --port (or PHOTOCRAFT_CONTROL_PORT).
- check_server takes the subcommand and whether a transport is set;
the bridge-roots check moves next to the other mcp checks.
- The server tests match clap's "a value is required for" text, and
mcp.md in the book and docs names PHOTOCRAFT_CONTROL_PORT.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Say which packages install them and how to generate and install them
by hand from a checkout. The desktop app has neither.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With the environment fallbacks, clap's conflicts_with on the token pair
made a stale exported PHOTOCRAFT_CONTROL_TOKEN and
PHOTOCRAFT_CONTROL_TOKEN_FILE a usage error for every mcp and serve, though
headless mcp and stdio serve read no token. As on main, the pair is now
refused only by serve on TCP and mcp bridging, still as clap's either/or
usage error naming each half as it was given.
An automation root on the command line with PHOTOCRAFT_CONTROL_PORT set
is a usage error too: the variable would switch mcp to bridging, which
drops the roots, with only a note on stderr.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Say that PHOTOCRAFT_CONTROL_PORT is the fallback for serve --port and
mcp --bridge (with the stderr notice and the empty-is-unset escape),
and list the CLI's variables on the book's CLI page, which also says
the automation root variables stay the desktop app's.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Name what typed values made a usage error: a value out of range
(--quality 0), malformed --new or --params JSON, <FILE> together with
--new, and --control-token together with --control-token-file. A path
that is not valid Unicode now reaches the command: convert takes it,
info, run and batch fail through the automation layer (exit 1), and
droplet, which takes text, refuses it as a usage error.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The CLI page said parsing reports malformed flags and JSON. Name what
the step 0 decisions made a usage error: a single-value flag given twice
(--cmd and --params repeat), how to pass a value starting with '-', and
that `version` is only --version/-V. Say why --quality allows negative
numbers.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Brings split/tiff-extra-samples-codecs at e6fca054, which merged main
1695f323. Conflicts:
- crates/io/src/lib.rs: main moved PSD import into import_psd(); it now
passes PsdImportOptions::default().
- PsdExportOptions (psd_export.rs, tiff_layers.rs): main's new
read_linked_files next to this branch's photoshop_spot_polarity.
- docs: main's newer status lines and rows, with this branch's TIFF
statements re-applied (dated 2026-10-23). docs/scorecard.md is main's
and is regenerated after PR 2 is merged too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges main (8f09d122d). Conflicts: the replay_save doc comment (main added
file.export.exportAs replay) and the status lines and history rows of
docs/file-format-parity.md and docs/gaps.md; both sides kept.
The docs called FILE-215-19 done and said only Save As and Save a Copy ask.
Photoshop's JPEG Options also has Matte, the progressive scan count
(Scans 3-5) and the size preview, none of which is offered, and Baseline
Optimized writes the same file as Baseline for RGB (4:2:0). The row is now
partial with those gaps named, and the docs say File > Save asks too.
docs/scorecard.md is regenerated.
Adds jpeg_options_ui::tests::the_answer_reaches_the_encoder. Every UI test
used a fake exporter, so dropping `opts.encode.jpeg_format` from
ExportSettings::io_options (the desktop and web save path) left them all
green; this test writes a real JPEG through io_options and fails without it.
Co-authored-by: Michael Higgins <55243596+Higgs32584@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/ui-egui/src/lib.rs: replay_save doc comment names both Export As and JPEG; replay_save uses the PR's FormatChoices::from_step, after main's export_dialog::replay branch.
docs/file-format-parity.md: header keeps main's text plus the JPEG Options item (last updated 2026-10-19); TIFF row is main's, JPEG row is the PR's; change-log rows once each, newest first.
docs/gaps.md: header keeps main's text plus the G10 item (last updated 2026-10-19).
crates/format/src/store.rs: kept both field sets in PcraftWriter::default (main's blob_hash_cache/compressed_budget and the PR's lock_wait) and both setters (set_compressed_cache_budget, set_lock_wait).
Flat and layered TIFFs write and read alpha channels, spot channels and a
Quick Mask as extra samples, with Photoshop's channel resources. This is
the io part of #7415 as it stands, on current main; the next commit keeps
PSD output as it is on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com>
Main added DDS saving (#5993): DDS Options, and FormatChoices carrying the
Targa and DDS answers through every save path (this branch's SaveChoices
carried Targa and JPEG). Resolved by keeping main's name and making it
the one struct for all three answers.
- lib.rs: FormatChoices { tga_bits, dds, jpeg } with from_params (strict),
from_step (lenient JPEG read of a recorded step), apply and record;
main's separate block is folded in. Both prompts (dds_options and
jpeg_options) are kept everywhere: save_to asks Targa, then DDS, then
JPEG Options; save_options_open covers all four. save_in_place takes
main's (path, choices) signature and records the plain-Save step with
plain_save_step for JPEG plus the Targa/DDS answers (save_in_place_with
folded into it). journal_as_plain_save relabels a parked Targa, DDS or
JPEG prompt and keeps tgaBits/dds*/quality/jpegFormat when dropping the
path. replay_save reads choices with from_step.
- SAVE_OPTIONS_OPEN names all four prompts; DDS Options uses it too.
- targa_options_ui: main's plain-Save step (no path) through this
branch's write_answered_save. JPEG Options gets main's Enter-on-a-
focused-Cancel fix.
- view_cmds Save a Copy asks Targa, DDS, then JPEG Options; write_copy
takes FormatChoices.
- control.rs: both app.save tests (JPEG and DDS) kept.
- engine file_cmds: SaveOpts keeps jpeg_format and dds; param docs list
both jpegFormat and ddsCompression/ddsMipmaps. CLI: both --jpeg-format
and --dds-compression/--dds-mipmaps. codecs re-exports JpegFormat and
DdsCompression/DdsOptions.
- docs/book: both entries kept (control-protocol app.save lists both).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name missing values alongside empty ones, and say `--help`/`-h` are the
options that take no value (CodeRabbit review on #8035).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--help` or `-h` where a flag's value should be prints usage rather than a
"needs a value" error, in every subcommand. Tests cover the missing-value
rule outside mcp/serve and the `--flag=--value` form, and the stdin test
checks each launch line that used to create a token file. The book's MCP
page documents the launch rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`mcp` and `serve` silently accepted positional arguments, repeated options,
empty values, credential flags for an inactive transport, and (in bridge mode)
root flags the desktop process owns. They are now usage errors (exit 2, stderr
only) before any server starts or token file is created. The shared parser
also refuses another long flag or `-h` as a missing value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.
Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
Save As and Save a Copy to a JPEG now ask for JPEG Options before writing,
as Photoshop does. Re-creates #4330 on current main and folds in the
unique parts of #5580 (#5563).
- codecs: `JpegFormat` (Baseline, Optimized, Progressive) on
`EncodeOptions`. Progressive writes SOF2. Baseline Optimized fits the
Huffman tables only at 4:4:4 (Gray, CMYK) or with Progressive: fitted
tables make the baseline scans non-interleaved, which zune-jpeg 0.5.15
decodes wrongly with 4:2:0 chroma (zune-image#419), as Save for Web
already works around.
- engine: a `jpegFormat` save param (file.saveACopy, Batch, Image
Processor, Layers to Files and every SaveOpts user; anything but the
three names is BadParams before writing) and a shared 0–12 -> 1–100
`jpeg_quality()` that maps NaN/inf to 100.
- ui-egui: `jpeg_options_ui` (Quality 0–12 with Low/Medium/High/Maximum
presets, Format Options), parked like Targa Options. It starts at the
opened JPEG's own quality (`file_encoding`, #4032), else the Export
preference; left there, OK writes that quality exactly and the step
records no `quality`. `SaveChoices` (Targa bits + JPEG) replaces the
bare `tga_bits` through file.save / file.saveAs / app.save, replay and
Save a Copy; automation never prompts. `write_answered_save` is shared
with Targa Options. `park_at` + `Start` let any save seed the prompt.
A File › Save that confirms the document's own flat JPEG is still
journalled as a plain `file.save` (#3531).
- CLI: `--jpeg-format`.
- i18n: five new strings in all 20 catalogs (#4330's duplicate de/es/zh
`Low`/`High` rows dropped: main has them since #4008).
- Docs: book raster-formats JPEG bullet, control protocol, file-format
parity, gaps G10, target-app parity; scorecard FILE-215-19 done.
Co-authored-by: SckraT <70698629+SckraT@users.noreply.github.com>
Co-authored-by: PierrunoYT <95778421+PierrunoYT@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve current upstream DDS, DPX, PCX, XPM and related export changes
while restoring JPEG XL format registration, dependencies, documentation,
Save As/Export As integration and independent lossless round-trip tests.
Retain the 16-bit alpha header correction.
A directory save lists the existing objects, writes its own, publishes its manifest and removes what that manifest doesn't reference. Two writers (two stores, or two processes) saving the same bundle could interleave, and one writer's cleanup could remove an object the other's winning manifest needs. Each save now holds an OS advisory lock on save.lock for its whole transaction; a held lock is waited for (10 s by default, PcraftWriter::set_lock_wait), then the save fails without writing. A crash releases the lock; a file system without file locks saves unlocked as before. Fixes#4957.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CMYK document with several layers, masks or effects is flattened through the
RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the
document's mode and ICC profile. The composite is now converted back through the
document's CMYK profile (relative colorimetric with black point compensation)
inside each parallel worker, written as four ink channels plus straight alpha at
the document depth, and tagged with that profile (the built-in coated CMYK
profile when the document is untagged). Invalid, non-CMYK or unusable profiles
are rejected. RGB-only formats keep the direct sRGB composite, and a single
plain raster layer keeps its native samples.
This fixes the output mode and tagging only: the RGB roundtrip does not
preserve original ink separations, black generation or CMYK-native blending.
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Retain FITS support while integrating JPEG XL export. Expand GrayA
samples to RGBA at their original depth so the independent decoder
oracle can verify alpha values instead of silently losing precision.
Update capability claims, format documentation, and oracle coverage.
* Format: admit the declared .pcraft canvas before loading it (#2935)
A .pcraft manifest's canvas size was copied into the Document unchecked, so
a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that
the CPU reduction, thumbnails and display textures then work against in
proportion to its area.
The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB
limit, as for PSD and file.new) and an area above 4 gigapixels, right after
reading the manifest and before any tile or blob is decoded. Both limits are
LoadOptions fields so trusted callers can change them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format: admit every canvas the app can create on .pcraft load (#2935)
The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas
Size, Image Size with Resample off and PSB import all reach 300000 x
300000, so a document a user could create and save would not reopen.
The default is now the full 300000^2 PSB canvas; the side limit still
refuses anything larger, and callers handling untrusted files can lower
either budget. Loading stays proportional to the file: layers keep only
the tiles the bundle holds, so a huge declared canvas allocates nothing
for its area.
A compile-time assert in document_preset_cmds ties file.new's
MAX_DIMENSION to the loader's defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (#2487)
The app closes idle control connections after 30s (IO_TIMEOUT) while the
bridge kept one persistent connection, so the first call after a pause
failed with "operation may have completed". The bridge now drops and
reconnects before sending once the connection has been idle 25s, so a
request is never written to a socket the app may have dropped.
The default call timeout drops from 60s to 55s, strictly below the app's
60s reply deadline, so the bridge's own error — naming the method and the
likely causes — is the one callers see instead of a race between the
app's deadline reply and the caller's own cutoff.
Idempotent read methods (ui.inspect and friends) get one automatic retry
on a transport failure; edits keep #1007 semantics and are never resent.
A timed-out call is never retried (it would only double the wait).
* MCP bridge: caller-vouched read retries, per-failure wording, docs contract (#2487 review)
The retry decision moves from a hand-maintained method list — most of whose
entries were engine command ids that never travel as control-protocol
methods, so they could never match — to the call sites that know a call is
a read: call never resends (#1007), call_read retries once on a transport
failure, and the MCP server opts its pure-read tools in. Failures are
worded by what they prove: a connection that could never be established is
"the request was not sent", not "may have completed"; a read timeout says
a read may be retried safely instead of demanding a state inspection. The
55s client cap, the read-retry exception, and the idle reconnect are now
documented in the control protocol and the security book.
Progress docs per craftrules standards/progress-docs.md: target-app parity (merges parity-estimate.md), gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and UI, brush, format, hardware and localization parity docs. docs/parity.md is now docs/parity-checklist.md.
The Windows test failure (file_dialog::tests::save_dialogs_remember_where_each_document_was_last_saved) is pre-existing on main: the test hard-codes POSIX paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prerequisites listed `libxkbcommon-dev ... libgtk-3-dev`, which exist only
on Debian and Ubuntu: on Arch none of those names resolve and `pacman -S` fails
on the whole line, and on Fedora and openSUSE they all need `-devel`. Nothing
else in the repository gives the equivalents.
The app already speaks four distros at runtime: linux_libs.rs carries a
{soname, debian, fedora, arch, suse} table, reads ID and ID_LIKE from
/etc/os-release and prints the matching apt, dnf, pacman or zypper command when
a runtime library is missing. This brings the build instructions up to that.
Also adds the prerequisites to the book's Building page, which said only to
install the Rust toolchain.
Co-authored-by: Brandon Thomas <echelon@gmail.com>