Skip to content

Latest commit

 

History

History
621 lines (506 loc) · 28 KB

File metadata and controls

621 lines (506 loc) · 28 KB

Implementation Notes

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.


1. Plugin format on macOS

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.

Why the executable must be MH_BUNDLE, not MH_DYLIB

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:

  1. crate-type = ["staticlib", "rlib"] in Cargo.toml so cargo emits libGmicFilter.a plus an rlib for unit tests.
  2. Relink with clang -bundle -Wl,-force_load,libGmicFilter.a -Wl,-exported_symbol,_PluginMain -Wl,-dead_strip per architecture.
  3. lipo -create the per-arch results.
  4. make verify-bundle runs otool -h on every slice and asserts filetype 8 before installation. This has already caught one regression.

2. The PiPL metadata story (the part that hurt the most)

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.r

Two non-obvious Rez flags matter:

  • -d "PRAGMA_ONCE=0" — Adobe's PIResDefines.h uses a PRAGMA_ONCE macro that is never defined.
  • We deliberately do not #include "PIUtilities.r", because it pulls in Carbon Types.r / SysTypes.r headers 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.


3. PluginMain and the FilterRecord protocol

Entry point

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.

The advanceState pull model

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.

Struct layout is pinned by tests

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.


4. G'MIC invocation

Binary detection

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.

Subprocess hardening

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 argv allow-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.

Why the TIFF reader accepts five pixel formats

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).


5. Build pipeline

Toolchain

  • Rust stable, with both aarch64-apple-darwin and x86_64-apple-darwin targets installed via rustup.
  • Xcode Command Line Tools (provides clang, lipo, codesign, Rez).
  • Optional: Adobe Photoshop SDK (only required to regenerate GmicFilter.rsrc from GmicFilter.r; a pre-built copy is committed).
  • Homebrew gmic formula at runtime (provides the gmic CLI; the GUI / GIMP plugin "G'MIC-Qt" is a separate project from gmic.eu and is not used or needed by this plugin).

Make targets

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.

Code signing

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.

Cargo crate type recap

[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/.


6. Logging: why there is a separate file log

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.


7. Post-mortem: the five surprises that ate most of the bring-up

In order of how much time they cost:

  1. cdylib produces MH_DYLIB, Photoshop wants MH_BUNDLE. Hosts silently reject MH_DYLIB; the plugin doesn't even surface as "unknown". Fixed by staticlib + clang -bundle. See §1.
  2. Affinity ignores PiPLs.json and requires a legacy .rsrc. No amount of Info.plist tweaking fixed detection until we shipped a Rez-compiled <CFBundleExecutable>.rsrc. AKVIS Coloriage was the smoking-gun reference. See §2.
  3. SELECTOR_START is not where pixels arrive. Must invoke FilterRecord.advance_state() from START for the host to populate in_data / out_data before CONTINUE. See §3.
  4. gmic always promotes to float. Output TIFFs are usually F32 regardless of input depth; per-version ,uchar syntax is too flaky to rely on. Reader now accepts U8/U16/U32/F32/F64 and quantises to U8. See §4.
  5. 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:

  1. Info.plist CSResourcesFileMapped=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.

8. Recommended project structure

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

9. The catalogue picker (v2)

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.

Parser coverage — make audit-unsupported

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 (or make audit-unsupported) walks the bundled catalogue, groups every ParamKind::Unknown by 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 the color(#hex), _<kind>(...), {...} grouping, point(...), value(...), button(...), file(...), and tolerant-bool(...) arms were added).
  • bundled_catalogue_has_no_unsupported_params (in src/catalogue/parser.rs) locks the invariant: if a future make refresh-catalogue introduces a new syntax, the test fails and points at the first five offenders so we can add a parse_* 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.


10. Manual end-to-end pre-release checklist (picker)

Run these by hand before each release of the picker. None of them are worth automating against Affinity itself.

  1. 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.
  2. make universal-install FEATURES=live — installs the universal bundle. Verify make verify-bundle passes (filetype 8 on both slices).
  3. Open Affinity Photo 2, fresh launch, open an 8-bit RGB doc. Filter → Plugins → G'MIC → G'MIC… Pick Artistic / Paint Brush with defaults. OK. Image transforms visibly. No crash.
  4. Filter → Last Filter (Cmd-F). The same filter re-runs without the dialog. No crash.
  5. Quit Affinity. Relaunch. Open the dialog. Paint Brush is pre-selected and its sliders show the values from step 3 (not gmic stdlib defaults).
  6. 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.json and press Cmd-F → NSAlert "No previous G'MIC filter to repeat…".
  7. ~/Library/Logs/gmic-affinity.log shows one structured line per interesting event across the run; no panics; no stray prints.

11. Release process

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.

Pre-release / RC runbook (CI-driven)

Each step is tagged 🤖 (agent-runnable in a workspace shell with gh configured) or 👤 (requires a human).

  1. 🤖 Verify main is green on the ci.yml workflow: gh run list --branch main --workflow ci.yml --limit 1.
  2. 🤖 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.
  3. 🤖 (auto-runs) release.yml builds the universal FEATURES=live zip via make release-unsigned and publishes dist/GmicFilter-vX.Y.Z-rc.N.zip as a GitHub Release asset (auto-flagged as a prerelease). Watch with gh run watch or gh run list --workflow release.yml.
  4. 🤖 Sanity-check the published asset matches what the runner built:
    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
    Compare against the workflow's "Show artifact metadata" log line — they must match.
  5. 👤 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.command strips com.apple.quarantine user-side via xattr -dr so the bundle loads inside Affinity's hardened- runtime process. See design doc §3 (Phase 0) for the empirical checks gating this.

Stable release runbook (collaborator-driven)

This is what you do once a candidate has been vetted via an RC and the project is ready for a stable release.

  1. 👤 Pick the version (vX.Y.Z) and confirm main at HEAD is the commit you want to release.
  2. 🤖 Bump release metadata to match the chosen version before the handoff: Cargo.toml package.version and Info.plist CFBundleShortVersionString must equal X.Y.Z. release-preflight enforces this for real releases.
  3. 🤖 Make sure main is fully pushed: git push origin main.
  4. 👤 Ping the signing collaborator with:
    • The version string (e.g. v0.2.0).
    • A link to the latest green ci.yml run on main.
    • A link to the most recent successful RC release (so they know this isn't a cold first attempt).
  5. 👤 (collaborator) Runs make release RELEASE_VERSION=vX.Y.Z per release/notarisation/SIGNING.md. Wall-clock time: ~5–10 minutes. On success they tell you the GitHub release is live and the cask was bumped.
  6. 🤖 Verify the GitHub release exists:
    gh release view vX.Y.Z
    Confirm the asset is GmicFilter-vX.Y.Z.zip and that the release is not marked as prerelease.
  7. 🤖 Verify the cask was bumped on the tap:
    gh api repos/dstrupl/homebrew-gmic-affinity/contents/Casks/gmic-affinity.rb --jq '.content' | base64 -D | head -10
    The version "X.Y.Z" line should match the new release.
  8. 👤 Smoke-test on a fresh user account or a clean macOS VM:
    brew tap dstrupl/gmic-affinity
    brew install --cask gmic-affinity
    Then restart Affinity Photo (both 2 and v3) and run a filter. The cask install path skips install.command entirely — the notarised bundle loads through Gatekeeper directly, no quarantine stripping needed.
  9. 👤 Announce / update changelog / close milestone as you would for any release.

Roll-back

If a stable release is broken:

  1. 🤖 gh release delete vX.Y.Z --yes so users can't fetch the zip.
  2. 🤖 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-affinity on a user's machine then pulls the previous version. (For a pre-release vX.Y.Z-rc.N, only step 1 is needed — the tap isn't bumped for RCs.)
  3. 👤 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 Gatekeeper deadline

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