Engineering reference for gmic-affinity. Captures how the plugin is
built, why the unusual bits are the way they are, and what we learned
making Affinity Photo 2 actually load it.
Product spec (requirements, goals, status table) lives in PRD.md. Day-to-day install / troubleshooting lives in README.md.
A Photoshop-compatible filter plugin is a macOS bundle directory with a
.plugin extension. The shipped layout:
GmicFilter.plugin/
├── Contents/
│ ├── Info.plist
│ ├── PkgInfo # 8 bytes: "8BFM8BIM"
│ ├── MacOS/
│ │ └── GmicFilter # universal Mach-O, filetype MH_BUNDLE
│ └── Resources/
│ ├── GmicFilter.rsrc # legacy binary PiPL (mandatory)
│ ├── PiPLs.json # modern SDK-2026 metadata (forward compat)
│ └── en.lproj/
│ └── InfoPlist.strings
Info.plist declares CFBundlePackageType=8BFM (filter plugin) plus the
Carbon-era hint CSResourcesFileMapped=yes that every working
Affinity-compatible plugin we surveyed (e.g. AKVIS Coloriage) also sets.
Photoshop hosts (including Affinity) load .plugin bundles via
CFBundleLoadExecutable, which only accepts Mach-O files with filetype
MH_BUNDLE (8). Rust's default crate-type = "cdylib" invokes the
linker with -dynamiclib and produces MH_DYLIB (6), which the host
silently rejects — the plugin does not even appear as "unknown" in the
Detected Plugins list.
The fix is in the Makefile:
crate-type = ["staticlib", "rlib"]inCargo.tomlso cargo emitslibGmicFilter.aplus anrlibfor unit tests.- Relink with
clang -bundle -Wl,-force_load,libGmicFilter.a -Wl,-exported_symbol,_PluginMain -Wl,-dead_stripper architecture. lipo -createthe per-arch results.make verify-bundlerunsotool -hon every slice and asserts filetype 8 before installation. This has already caught one regression.
Adobe SDK 2026 ships a modern JSON pipl format
(Contents/Resources/PiPLs.json). We initially shipped only that and
spent considerable time chasing red herrings — sandbox entitlements,
Info.plist locale strings, en.lproj localization — when Affinity
refused to detect the bundle.
The actual answer came from downloading the AKVIS Coloriage demo (a
confirmed-working Affinity Photo 2 plugin), mounting its DMG and diffing
its bundle against ours. AKVIS ships a 959-byte
Contents/Resources/ColoriagePlugin.rsrc file (named to match
CFBundleExecutable) containing a binary PiPL resource with the
classic 8BIMkind / 8BIMname / 8BIMcatg / 8BIMma64 / 8BIMmi64 /
8BIMmode / 8BIMenbl / 8BIMpmsa properties.
Without a .rsrc, Affinity silently rejects the bundle during
enumeration. With one, it shows up immediately.
We compile ours from GmicFilter.r with /usr/bin/Rez,
the Carbon-era resource compiler that is, mercifully, still part of Xcode
Command Line Tools:
Rez -i $(PHOTOSHOP_SDK)/pluginsdk/photoshopapi/resources \
-i $(PHOTOSHOP_SDK)/pluginsdk/photoshopapi/photoshop \
-d "__PIMac__=1" -d "PRAGMA_ONCE=0" -useDF \
-o GmicFilter.rsrc GmicFilter.rTwo non-obvious Rez flags matter:
-d "PRAGMA_ONCE=0"— Adobe'sPIResDefines.huses aPRAGMA_ONCEmacro that is never defined.- We deliberately do not
#include "PIUtilities.r", because it pulls in CarbonTypes.r/SysTypes.rheaders for the classic'STR 'and'vers'resource types that Apple removed from the CommandLineTools SDKs years ago. We do not need either resource for plugin discovery, so excluding the file lets Rez succeed on a stock Xcode-CLT install.
The compiled 610-byte binary GmicFilter.rsrc is committed to the
repository so CI environments without the Adobe SDK can still produce
a complete bundle. The Makefile re-runs Rez automatically when
PHOTOSHOP_SDK is set in the environment and the .r source is newer
than the committed binary.
PiPLs.json is also still committed — for forward compatibility with
SDK-2026-only hosts that don't read the legacy .rsrc. Both can live
in the same bundle.
The host calls a single exported C function whose signature is:
void PluginMain(int16_t selector,
FilterRecord *fr,
intptr_t *data,
int16_t *result);
selector is an integer phase code: ABOUT=0, PARAMETERS=1,
PREPARE=2, START=3, CONTINUE=4, FINISH=5. result is set to
0 (noErr) on success or a non-zero error code on failure. The
function lives in src/lib.rs.
Adobe's FilterRecord is pull-based, not push-based. At
SELECTOR_START the plugin only declares which rectangle and which
channels it wants:
- set
in_rect,in_lo_plane,in_hi_plane - set
out_rect,out_lo_plane,out_hi_plane
…and then must invoke FilterRecord.advance_state() (a function
pointer field at struct offset 296) to make the host actually allocate
buffers and populate in_data, in_row_bytes, out_data,
out_row_bytes. Only then does the host call SELECTOR_CONTINUE.
We initially expected CONTINUE to be called with pixels already in
place. That worked under naive dlopen-based testing but Affinity hit
us with in_row_bytes == 0 on the first real invocation. Wiring up
advance_state from START fixed it.
The FilterRecord mirror in src/ps_types.rs only
spells out the fields we use and pads the rest with [u8; N] to reach
the right total size. Total sizeof(FilterRecord) on macOS arm64 is
648 bytes. The offsets that matter — in_data, in_row_bytes,
out_data, out_row_bytes, advance_state, and the size of the whole
struct — are pinned by tests/layout.rs using
std::mem::offset_of!() so a future SDK header change will fail loudly
at cargo test rather than silently corrupting memory inside Affinity.
If you regenerate the struct against a new SDK, run that test first; it is the most important guard rail in the codebase.
The plugin probes the two Homebrew install locations and uses whichever exists:
| Architecture | Path |
|---|---|
| Apple Silicon (ARM64) | /opt/homebrew/bin/gmic |
| Intel (x86_64) | /usr/local/bin/gmic |
If neither exists, the filter returns a non-zero result code and the file logger explains why; Affinity surfaces a generic "filter failed" dialog.
src/gmic.rs shells out to gmic with:
- A cleared environment (
Command::env_clear()followed by a small allow-list —PATH,HOME,TMPDIR,LANG). This eliminates an entire class of injection / surprise-config attacks from a malicious user account. - A per-call 0700 tempdir (
tempfile::tempdir) holding the input and output TIFFs. Files inside are named with random suffixes; the dir is removed on drop regardless of success / failure / panic. - A tight
argvallow-list — only<input>, the configured filter command split on whitespace, and-output <path>go on the command line. The filter command itself is read at every invocation from~/.config/gmic-affinity/filter.txt(fallback: a compile-time default). - Output capture caps at 8 KB of stdout / 8 KB of stderr written to the file log; this is enough to debug typos in the filter command without OOM'ing on a chatty filter.
Almost every non-trivial gmic command (blur, convolution, FFT,
colour-space conversions, …) promotes the working image to gmic's
internal float representation and writes a float TIFF back, even
when the input was 8-bit. The set of -output filename,<type> strings
that force a specific output type is undocumented and varies between
gmic versions — gmic 3.7.6 accepts ,uchar at parse time but rejects it
at write time with:
*** Error *** Command 'output': File '…', invalid specified pixel type 'uchar'
Rather than chase per-version syntax, src/tiff_io.rs
accepts U8, U16, U32, F32 and F64 decoded results and quantises
each to u8 (clamp + round for floats, high-byte for unsigned ints).
Before -output, src/gmic.rs also asks gmic to match
the host plane count (-to_gray, -to_rgb, or -to_rgba). This avoids
two-channel grey+alpha float TIFFs such as the fx_ghost output that the
tiff crate rejects as BlackIsZero with two 32-bit samples. The
pipeline is still lossy if the user ever sends a 16-bit document through
— which is fine because the PiPL EnableInfo greys the filter out for
non-8-bit documents anyway.
The tiff crate's default Limits are also lifted to
Limits::unlimited(); the defaults are too strict for full-resolution
camera images (a 6000×4000 RGBA buffer is already 96 MB).
- Rust stable, with both
aarch64-apple-darwinandx86_64-apple-darwintargets installed viarustup. - Xcode Command Line Tools (provides
clang,lipo,codesign,Rez). - Optional: Adobe Photoshop SDK (only required to regenerate
GmicFilter.rsrcfromGmicFilter.r; a pre-built copy is committed). - Homebrew
gmicformula at runtime (provides thegmicCLI; the GUI / GIMP plugin "G'MIC-Qt" is a separate project from gmic.eu and is not used or needed by this plugin).
| Target | What it does |
|---|---|
make bundle |
Single-arch (host arm64) .plugin for fast dev iteration. |
make universal |
Universal .plugin (arm64 + x86_64), Rez'd PiPL, lipo'd, ad-hoc signed. |
make install |
make bundle + copy into every detected Affinity Plugins dir (Affinity Photo 2 + v3). |
make universal-install |
make universal + copy. This is the one you usually want. |
make verify-bundle |
otool -h every slice, assert filetype MH_BUNDLE (8). |
make pipl |
Re-run Rez if PHOTOSHOP_SDK is set; otherwise reuse committed .rsrc. |
make clean |
cargo clean and remove the bundle. |
The default cargo build produces a no-op PluginMain that never
dereferences FilterRecord. The real filter logic is gated behind
--features live; the Makefile passes FEATURES=live on its build
recipes by default. The cargo-feature gate means a freshly cloned repo
can build a safely-installable (does-nothing) plugin without first
reconciling struct offsets — useful for someone bringing the project up
on a new SDK version.
Local development uses ad-hoc signing (codesign --force --deep --sign -).
Affinity Photo 2 accepts ad-hoc-signed bundles for local use; no Apple
Developer ID is required. Stable distribution is handled by the
collaborator-run release pipeline in
release/notarisation/SIGNING.md,
which signs with Developer ID, notarises via notarytool, staples, and
verifies Gatekeeper acceptance before publishing.
[lib]
name = "GmicFilter"
crate-type = ["staticlib", "rlib"]staticlib → re-linked to a true MH_BUNDLE by clang -bundle.
rlib → so the same crate can still be linked into integration tests
under tests/.
Affinity routes plugin stderr to its own internal sink, so eprintln!
and dbg! output never reaches Console.app or log show. During
bring-up there was no way to tell whether PluginMain was even being
called.
The whole bring-up was unblocked by src/logging.rs,
which appends a single structured line per interesting event to:
~/Library/Logs/gmic-affinity.log
Format: <ISO-8601 UTC> pid=<pid> <message>. Future hosts (a Pixelmator
port, say) will almost certainly have the same stderr-swallowing
problem; keep that file logger around.
In order of how much time they cost:
cdylibproducesMH_DYLIB, Photoshop wantsMH_BUNDLE. Hosts silently rejectMH_DYLIB; the plugin doesn't even surface as "unknown". Fixed bystaticlib+clang -bundle. See §1.- Affinity ignores
PiPLs.jsonand requires a legacy.rsrc. No amount ofInfo.plisttweaking fixed detection until we shipped a Rez-compiled<CFBundleExecutable>.rsrc. AKVIS Coloriage was the smoking-gun reference. See §2. SELECTOR_STARTis not where pixels arrive. Must invokeFilterRecord.advance_state()fromSTARTfor the host to populatein_data/out_databeforeCONTINUE. See §3.- gmic always promotes to float. Output TIFFs are usually
F32regardless of input depth; per-version,ucharsyntax is too flaky to rely on. Reader now accepts U8/U16/U32/F32/F64 and quantises to U8. See §4. - Affinity sinks plugin stderr. Added the file logger; never looked back. See §6.
And one minor one we noticed but didn't pay for in time:
Info.plistCSResourcesFileMapped=yes— every working Affinity-compatible plugin we surveyed sets it. We never proved it is strictly required (vs just sufficient), but it costs nothing and matches known-working bundles.
gmic-affinity/
├── Cargo.toml
├── Makefile
├── Info.plist
├── PkgInfo # 8 bytes: 8BFM8BIM
├── GmicFilter.r # Rez source for PiPL
├── GmicFilter.rsrc # compiled PiPL (committed for SDK-less CI)
├── PiPLs.json # modern SDK-2026 PiPL (forward compat)
├── en.lproj/
│ └── InfoPlist.strings
├── src/
│ ├── lib.rs # PluginMain + selector dispatch
│ ├── ps_types.rs # FilterRecord + VRect + AdvanceStateProc
│ ├── ps_data.rs # typed Box-leak helpers for *data slot (v2)
│ ├── filter.rs # bridge from FilterRecord to gmic invocation
│ ├── gmic.rs # hardened subprocess wrapper
│ ├── tiff_io.rs # multi-bit-depth TIFF read, U8 write
│ ├── logging.rs # file logger to ~/Library/Logs/
│ ├── settings.rs # JSON persistence: last filter, recents, remembered args (v2)
│ ├── catalogue/ # parsed #@gui annotations + ChosenFilter type (v2)
│ └── ui/ # Cocoa picker dialog + NSAlert sink (v2, live-only)
├── tests/
│ ├── layout.rs # FilterRecord offset / size assertions
│ ├── catalogue_snapshot.rs # smoke-test the bundled gmic snapshot (v2)
│ └── error_matrix.rs # NSAlert message-formatting matrix (v2)
├── examples/
│ └── picker.rs # standalone picker for AppKit dev iteration (v2)
├── assets/
│ ├── gmic-catalogue.gmic.gz # LFS-tracked gmic update*.gmic snapshot (v2)
│ ├── gmic-catalogue.toc.txt # human-diffable dump for code review (v2)
│ └── gmic-catalogue.version.txt # gmic --version + ISO timestamp (v2)
├── PRD.md
├── IMPLEMENTATION_NOTES.md # ← this file
├── README.md
└── LICENSE
The v1 plugin took its single gmic command from a hand-edited
~/.config/gmic-affinity/filter.txt. v2 replaces that with a native
Cocoa dialog backed by the parsed G'MIC stdlib catalogue. The full
design is in docs/design/2026-05-17-gmic-picker-dialog.md; this
section is a 30-second map of the moving parts.
Five-module split (all under src/):
| Module | Responsibility |
|---|---|
catalogue/ |
Parses #@gui annotations into a tree of folders/filters/params and exposes ChosenFilter { command, args }. Snapshot bundled via include_bytes! from assets/gmic-catalogue.gmic.gz. |
settings.rs |
Atomic JSON read/write of ~/Library/Application Support/gmic-affinity/settings.json (last pick + recents MRU + per-filter remembered args). Corrupt files are renamed .broken-<ts> and replaced. |
ps_data.rs |
Generic leak<T> / borrow<T> / take_and_drop<T> helpers for the host's plugin-private *data slot. PARAMETERS leaks a ChosenFilter, CONTINUE borrows it, FINISH drops it. |
ui::picker |
The dialog itself: NSPanel + NSSplitView + NSOutlineView + NSSearchField + dynamic parameter form. Returns Option<ChosenFilter>. AppKit-only (#[cfg(feature = "live")]). |
ui::alert |
Sink trait + NsAlertSink (production NSAlert) + CaptureSink (used by tests/error_matrix.rs). Keeps every user-facing error string under a tested matrix. |
LFS gotcha + fail-fast. assets/gmic-catalogue.gmic.gz is Git
LFS-tracked because gzipped catalogue snapshots (≈2 MB) bloat regular
git history. The Makefile's check-lfs target verifies the gzip
magic bytes before every bundle / universal build — without that
guard a forgotten git lfs pull would compile the LFS pointer text
into the binary and the picker would open empty. Same flavour of
silent-failure trap as the PiPL story in §2.
examples/picker for dev iteration. Driving the picker through
Affinity Photo means restarting the host on every change. The
cargo run --release --example picker --features live standalone
binary opens the same panel against the same catalogue/settings and
prints the resulting ChosenFilter to stdout. The GMIC_PRESELECT=…
env var pre-opens a specific filter's form for fast layout iteration.
Run-loop modal pattern. Plain NSApp.runModal froze Affinity
because Affinity is already running its own event loop. The picker
instead uses beginModalSessionForWindow: + a hand-rolled
runModalSession: pump that drains both the default and
NSEventTrackingRunLoopMode modes, so scroll-wheel events keep
flowing while the panel is up. See src/ui/runloop.rs for the pump
and the ModalCloseDelegate that stops the session on window close.
The picker form treats any parameter that doesn't parse cleanly as a
read-only (unsupported: …) row. To prevent silent regressions when
the bundled gmic snapshot is refreshed, the parser ships with a
diagnostic:
cargo run --bin audit-unsupported(ormake audit-unsupported) walks the bundled catalogue, groups everyParamKind::Unknownby leading function name, and prints a frequency histogram with one sample payload per bucket. The shipped v3.7.6 snapshot resolves to 0 unsupported parameters (down from 12.2 % before thecolor(#hex),_<kind>(...),{...}grouping,point(...),value(...),button(...),file(...), and tolerant-bool(...)arms were added).bundled_catalogue_has_no_unsupported_params(insrc/catalogue/parser.rs) locks the invariant: if a futuremake refresh-catalogueintroduces a new syntax, the test fails and points at the first five offenders so we can add aparse_*arm intentionally instead of shipping ugly placeholder rows.
ParamKind::Internal is the catch-all for declarations that gmic-qt
hides (chiefly value(...) and button(...)): the form pane renders
them as a tiny (internal: <default>) row when they have a user
label and skips them otherwise, but collect_values still emits the
default verbatim so the gmic argv stays positionally correct.
Run these by hand before each release of the picker. None of them are worth automating against Affinity itself.
-
cargo run --release --example picker --features live— opens the dialog standalone. Verify the tree, search field, parameter form, OK, Cancel, double-click leaf, Esc, Return all behave per the design doc §4. -
make universal-install FEATURES=live— installs the universal bundle. Verifymake verify-bundlepasses (filetype 8 on both slices). - Open Affinity Photo 2, fresh launch, open an 8-bit RGB doc.
Filter → Plugins → G'MIC → G'MIC… Pick
Artistic / Paint Brushwith defaults. OK. Image transforms visibly. No crash. - Filter → Last Filter (
Cmd-F). The same filter re-runs without the dialog. No crash. - Quit Affinity. Relaunch. Open the dialog. Paint Brush is pre-selected and its sliders show the values from step 3 (not gmic stdlib defaults).
- Force errors:
-
sudo mv $(which gmic) /tmp/; pick a filter → NSAlert "G'MIC isn't installed…"; restore the binary. - Corrupt
~/Library/Application Support/gmic-affinity/settings.json→ next picker open still works; the broken file is renamed to.broken-<ts>and a fresh one is written. - Delete
settings.jsonand pressCmd-F→ NSAlert "No previous G'MIC filter to repeat…".
-
-
~/Library/Logs/gmic-affinity.logshows one structured line per interesting event across the run; no panics; no stray prints.
The full design lives in
docs/design/2026-05-18-release-v0.1-distribution.md.
This section is the operator's runbook only — when the design and this
section disagree, the design wins.
Two pipelines. Releases come out of two distinct pipelines:
| Pipeline | Tag shape | Where it runs | Output |
|---|---|---|---|
| Pre-release / RC | vX.Y.Z-* |
.github/workflows/release.yml (CI) |
Ad-hoc-signed zip on the GitHub Releases page |
| Stable (signed) | vX.Y.Z |
Signing collaborator's Mac, make release |
Notarised zip + GitHub release + Homebrew tap cask bump |
The split exists because Apple Developer ID material (the signing
certificate, the notarytool credential profile) only lives on the
collaborator's machine and intentionally never reaches CI. See
release/notarisation/SIGNING.md
for the friend-facing setup and per-release walkthrough; design doc
§12 for the rationale.
Current stable status. v0.2.0 shipped on 2026-05-25 through the
stable pipeline. The GitHub release is signed/notarised, the Homebrew
tap cask was bumped to 0.2.0, brew install --cask gmic-affinity
installs successfully, and Affinity Photo 2 has been smoke-tested
against the cask-installed plugin.
Tagging. Use semver. Stable tags are bare (v0.2.0); pre-release
tags use a hyphenated suffix (v0.2.0-rc.1, v0.2.0-beta.2). Prefer
signed annotated tags (git tag -s vX.Y.Z -m "vX.Y.Z"). The release
workflow's tag filter is v*-*, so only hyphenated tags trigger CI;
bare tags are reserved for the signed pipeline and never accidentally
publish an unsigned artifact under a stable name.
Each step is tagged 🤖 (agent-runnable in a workspace shell with gh
configured) or 👤 (requires a human).
- 🤖 Verify
mainis green on theci.ymlworkflow:gh run list --branch main --workflow ci.yml --limit 1. - 🤖 Tag and push the pre-release tag:
git tag -s vX.Y.Z-rc.N -m "vX.Y.Z-rc.N" && git push origin vX.Y.Z-rc.N. - 🤖 (auto-runs)
release.ymlbuilds the universalFEATURES=livezip viamake release-unsignedand publishesdist/GmicFilter-vX.Y.Z-rc.N.zipas a GitHub Release asset (auto-flagged as a prerelease). Watch withgh run watchorgh run list --workflow release.yml. - 🤖 Sanity-check the published asset matches what the runner built:
Compare against the workflow's "Show artifact metadata" log line — they must match.
curl -sL https://github.com/dstrupl/gmic-affinity/releases/download/vX.Y.Z-rc.N/GmicFilter-vX.Y.Z-rc.N.zip | shasum -a 256 - 👤 End-to-end verify on a fresh user account: download the zip,
unzip, double-click
install.command, restart Affinity Photo (both 2 and v3), run a filter. The pre-release zip is ad-hoc- signed;install.commandstripscom.apple.quarantineuser-side viaxattr -drso the bundle loads inside Affinity's hardened- runtime process. See design doc §3 (Phase 0) for the empirical checks gating this.
This is what you do once a candidate has been vetted via an RC and the project is ready for a stable release.
- 👤 Pick the version (
vX.Y.Z) and confirmmainat HEAD is the commit you want to release. - 🤖 Bump release metadata to match the chosen version before the
handoff:
Cargo.tomlpackage.versionandInfo.plistCFBundleShortVersionStringmust equalX.Y.Z.release-preflightenforces this for real releases. - 🤖 Make sure
mainis fully pushed:git push origin main. - 👤 Ping the signing collaborator with:
- The version string (e.g.
v0.2.0). - A link to the latest green
ci.ymlrun onmain. - A link to the most recent successful RC release (so they know this isn't a cold first attempt).
- The version string (e.g.
- 👤 (collaborator) Runs
make release RELEASE_VERSION=vX.Y.Zperrelease/notarisation/SIGNING.md. Wall-clock time: ~5–10 minutes. On success they tell you the GitHub release is live and the cask was bumped. - 🤖 Verify the GitHub release exists:
Confirm the asset is
gh release view vX.Y.Z
GmicFilter-vX.Y.Z.zipand that the release is not marked as prerelease. - 🤖 Verify the cask was bumped on the tap:
The
gh api repos/dstrupl/homebrew-gmic-affinity/contents/Casks/gmic-affinity.rb --jq '.content' | base64 -D | head -10
version "X.Y.Z"line should match the new release. - 👤 Smoke-test on a fresh user account or a clean macOS VM:
Then restart Affinity Photo (both 2 and v3) and run a filter. The cask install path skips
brew tap dstrupl/gmic-affinity brew install --cask gmic-affinity
install.commandentirely — the notarised bundle loads through Gatekeeper directly, no quarantine stripping needed. - 👤 Announce / update changelog / close milestone as you would for any release.
If a stable release is broken:
- 🤖
gh release delete vX.Y.Z --yesso users can't fetch the zip. - 🤖 Revert the tap cask bump:
git -C /tmp/tap clone --depth 5 git@github.com:dstrupl/homebrew-gmic-affinity.git cd /tmp/tap && git revert HEAD && git push origin main
brew upgrade --cask gmic-affinityon a user's machine then pulls the previous version. (For a pre-releasevX.Y.Z-rc.N, only step 1 is needed — the tap isn't bumped for RCs.) - 👤 File the failure mode in the design doc's Phase 0 deliverable so we don't repeat it.
install.command and the cask both bail cleanly on missing
GmicFilter.plugin, so a half-uninstalled state on a user's machine
is recoverable by re-running the install path from a known-good
release.
Homebrew ends support for casks that fail Gatekeeper checks on
2026-09-01
(Homebrew/brew#20755).
v0.2.0 shipped before that deadline, so the private tap path is now
viable. The manual zip channel remains a working fallback. Full
context: design doc §12.
End of Implementation Notes