Files
iccery-v2-mac/docs/24-issues-invariants.md
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4c2863e52c Add bootstrap docs: AGENTS.md, BUILD-PLAN.md, LICENCE.md, vendored spec (docs/), brand assets
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 17:36:50 +01:00

549 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 24 — Issue tickets and rewrite invariants
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Source corpus: Gitea issues `#1``#225` (`/tmp/iccery-research/issues/`), `issues-index.md`, `ROADMAP.md`, `git log` on `/tmp/ICCery` (main @ `e12ef41`, tag `v0.8.5`), PR titles pages 12, plus sibling issue dumps `argyll-issues.json` and `cpu-issues.json`.
This is a rewrite-invariants document. Every closed bug gets a **root cause → fix → invariant**. Features are traced so a rewrite does not re-invent the same CLI/IPC contracts.
Open tickets `#223` and `#224` are **implemented on main** (see § Open issues). Won't-fix `#90` / `#96` are documented in § Won't-fix.
---
## 1. Feature genealogy (issue → ship)
The product is a **printer-profiling workstation**: vanilla JS + Tauri v2 wrapping ArgyllCMS sidecars over stdin/stdout JSON (`-u`) with a disk-artefact wizard.
| Era | Version | Issues | What landed |
|---|---|---|---|
| Scaffold | `v0.1.0` | `#1`, `#2` | Tauri v2 shell, AGPLv3 isolation, `ProcessManager` with `process:stdout/stderr/exit` events |
| Stage 12 | `v0.1.3` | `#3`, `#4` | `targen` patch sets; `printtarg` layout + instrument geometry |
| Stage 3 | `v0.1.6` | `#5`, `#6` | `chartread -u` state machine + live swatch grid + ΔE₀₀ |
| Stage 45 | `v0.1.9` | `#7`, `#8` | `colprof` cLUT; `profcheck` peak/avg/RMS |
| Print + UI | `v0.1.10``0.1.13` | `#21`, `#23`, `#25``#28`, `#34`, `#54`, `#61` | Logo; raw GDI/CUPS print; DEVMODE prefs; collapsible logs; 1280×800 window |
| Gamut + ship | `v0.1.14``0.2.0` | `#9`, `#10`, `#57`, `#58`, `#60`, `#69` | `iccgamut` + Three.js; settings/packaging; artefact-gated wizard |
| Hotfix | `v0.2.1` | `#84` (from `#63` regression) | Stdin decoupled from `Child::wait()` |
| M8 | `v0.3.0` | `#86`, `#87`, `#88`, `#89` | `instlist`; multi-pass `average`; presets; 3D hull |
| Hotfixes | `v0.3.1``0.3.6` | `#103`, `#108``#116`, `#119`, `#127`, `#134`, `#137` | Browse dialogs, GLIBC, averaging snapshots, comm-port vs index, density vs dir, XSS/DPI, fetch-argyll, `ARGYLL_NOT_INTERACTIVE`, Accept/Override |
| macOS print | `v0.4.0` | `#91`, `#92` | Universal dmg path + CUPS raw spooler |
| Production | `v0.5.0``0.5.5` | `#139``#141`, `#147`/`#149`, `#151`, `#158``#165` | Logging, Stage 3 resume from `.ti2`, advanced `targen`, `kill_all` on exit, deterministic `-R 1`, universal sidecars |
| Enterprise colour | `v0.6.0``0.7.4` | `#94`, `#171`, `#172`, `#175``#179`, `#184`, `#185`, `#188`, `#189` | CGATS import, overlay tooltips, profile picker, `d`/`u` chartread keys, OBA/FWA, button system, swatch split, `.gam` dual-table parse, ΔE thresholds, ColorSync/vendor bypass, DMG background |
| Hardware + analytics | `v0.8.1``0.8.2` | `#204`, `#93`, `#95` | i1Pro2 `-Y l`; XY tables; drift history |
| Reliability | `v0.8.3``0.8.4` | `#210``#215`, `#225` | Native file pickers, Node-safe gamut tests, atomic history, frontend CI, Monterey WKWebView survival |
| Cal + install | `v0.8.5` | `#224`, `#223` (tickets still *open*) | Stage 0 `printcal`/`applycal`; OS profile install |
ArgyllCMS fork genealogy (why ICCery's CLI looks the way it does):
| Argyll issue | Flag / contract ICCery depends on |
|---|---|
| argyll `#1` | `chartread -u``ROW_COLORS_JSON:` per row |
| argyll `#2` | `targen -u` JSON progress |
| argyll `#3` | `printtarg -u` page manifest JSON |
| argyll `#4` | `colprof -u` JSON progress |
| argyll `#5` | `profcheck -u` JSON ΔE report |
| argyll `#6` | `instlist` JSON `{event:"instruments", devices:[{port,name,type}]}` |
| argyll `#19` | `printtarg -d "<label>"` custom chart legend (ICCery `#119`) |
| argyll `#24` | Windows anonymous-pipe `PeekNamedPipe` so `chartread` does not deadlock on `ReadFile` |
| argyll `#32` | ad-hoc `codesign -s -` of Mach-O sidecars (ICCery `#165`) |
| argyll `#37` | i1Pro2 LED — ICCery shipped as `chartread -Y l` (`#204`), **not** the originally proposed `-L` |
---
## 2. Closed bugs — root cause → fix → rewrite invariant
Grouped by subsystem. Issue numbers are always cited.
### 2.1 Process manager / IPC
**`#21` Process logs spill horizontally**
- **Root cause:** `#colprofLog` / `#profcheckLog` had no CSS; even stages with `white-space: pre-wrap` could not wrap Argyll's space-less progress dots.
- **Fix:** Class selector `.log-container pre { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 200px; }` (PR `#22`). Later `#54` made boxes collapsible.
- **Invariant:** All subprocess consoles share one CSS class. Never style per-stage IDs. Argyll stdout is not English prose; it is long runs of `.` and must wrap *anywhere*.
**`#46` Visible cmd.exe on every Argyll spawn (Windows)**
- **Root cause:** Sidecars are `IMAGE_SUBSYSTEM_WINDOWS_CUI`. `tokio::process::Command` without creation flags allocates a console.
- **Fix:** `command.creation_flags(0x08000000)` (`CREATE_NO_WINDOW`) in `ProcessManager::spawn` (`#[cfg(windows)]`).
- **Invariant:** Every Windows spawn of an Argyll CUI binary **must** set `CREATE_NO_WINDOW`. Do not "fix" this by rebuilding Argyll as WINDOWS_GUI — that would also kill stdin/stdout.
**`#63` Zombie children (enhancement that caused `#84`)**
- **Root cause:** 100 ms `try_wait` polling never removed finished `Child` handles.
- **Fix (PR `#77`):** `child.wait().await` and reap from the map.
- **Invariant:** Reap is required. But **never** `wait()` while still holding the `Mutex<Child>` that `send_stdin` needs — see `#84`.
**`#84` P0: `Child::wait()` deadlocks `send_stdin` and closes stdin (v0.2.1)**
- **Root cause:** PR `#77` awaited `c.wait()` under `Arc<Mutex<Child>>`. Tokio's `Child::wait` **drops stdin** to avoid parent/child deadlock. Interactive `chartread` therefore (1) could not acquire the mutex for Calibrate/Retry/Skip/Cancel, and (2) had its pipe closed immediately after spawn.
- **Fix (PR `#83`, v0.2.1):** `take()` stdin/stdout/stderr at spawn; store `ChildStdin` in a separate `stdins: HashMap<String, Arc<Mutex<ChildStdin>>>`; `tokio::select!` between `child.wait()` and a oneshot `kill_rx`.
- **Invariant:** Interactive subprocess lifetime has **three** independent handles: wait task, stdin mutex, kill oneshot. `send_stdin` must never take the `Child` lock. Never call `wait()` before `take()`-ing stdin.
**`#85` `resolve_binary` ignores `.exe`**
- **Root cause:** Path joins used `"targen"`; on disk the file is `targen.exe`. `exists()` returned false for custom dirs and bundled resources.
- **Fix (PR `#97`):** On Windows, try `{name}` and `{name}.exe` for both settings dir and resource resolution. Unit-test both.
- **Invariant:** Windows binary lookup is always a two-candidate search. Do not assume Unix names in `tauri.conf.json` resources.
**`#116` Duplicate spawn ids steal the live child's maps**
- **Root cause:** `stdins.insert(id)` / `killers.insert(id)` with no occupancy check. Averaging reuses `chartread_${basename}`. Old wait-task `remove()`s the **new** child's entries → "Process not found" on Calibrate.
- **Fix (PR `#123`):** Hard-error if `id` is occupied; disable Start / Measure Another Sheet until `process:exit`. Tokio test: spawn, reject duplicate, stdin still works, kill, reuse.
- **Invariant:** Process IDs are exclusive leases. Prefer a loud error over silently replacing the occupant. After exit, the id is reusable.
**`#134` Calibrate button does nothing; `instlist` shows nothing**
- **Root cause:** (1) Without `ARGYLL_NOT_INTERACTIVE=1`, Argyll on Windows uses `_getch()` / `ReadConsoleInput()`, ignoring piped stdin — especially combined with `#46`'s hidden console. (2) Forked `instlist` emits JSON; frontend only had a text regex.
- **Fix (PR `#135`, v0.3.5):** `command.env("ARGYLL_NOT_INTERACTIVE", "1")` on every spawn; parse `event === "instruments"` JSON with text fallback.
- **Invariant:** All Argyll children get `ARGYLL_NOT_INTERACTIVE=1`. Frontend instrument lists parse JSON first. Related upstream: argyll `#24` (`PeekNamedPipe` before `ReadFile` on anonymous pipes) — the env var is necessary but not sufficient on Windows.
**`#147` / `#149` Orphan `chartread` after window close**
- **Root cause:** Only Stage 3 Cancel called `kill_process`. `lib.rs` used bare `.run(...)` with no `RunEvent::Exit` / `CloseRequested`. Spectrophotometer stays USB-locked.
- **Fix:** `ProcessManager::kill_all` (drop stdins, signal killers, `start_kill`); hook app/window exit.
- **Invariant:** Host exit **always** drains the process table. Interactive instruments are the scary case, but `colprof`/`printtarg` must die too.
**`#158` Settings log level is decorative**
- **Root cause:** `tauri-plugin-log` filter hardcoded `Debug` vs `Info` from `cfg!(debug_assertions)`. `AppSettings.log_level` was persisted and ignored.
- **Fix:** Map stored string → `LevelFilter` at startup and on Save.
- **Invariant:** A settings control that does not change observable behaviour is a bug. Log level is runtime state, not a compile-time constant.
---
### 2.2 Print (Win / Mac / Linux)
**`#33` Windows CI: unresolved GDI imports**
- **Root cause:** `windows` crate 0.58 does not export `SetICMMode` / `ICM_OFF` under `Win32::Graphics::Gdi` as used in `print/windows.rs`. GitHub `windows-2025` runner compiled the print backend and died with `error[E0432]`.
- **Fix:** Declare the GDI printing functions/structs used (`CreateDCW`, ICM off, `StretchDIBits`, …) directly rather than depending on incomplete crate bindings (`a9802ad`, `c9e16ab`). Later also: runner label, native rustup, `shell: powershell`.
- **Invariant:** Colour-management bypass (`SetICMMode(ICM_OFF)`) is a **link-time** contract. Pin/vend the FFI; do not assume `windows` crate surface area. CI on the real Windows target is the only proof.
**`#36` DEVMODE preferences not applied**
- **Root cause:** OEM dialog (`DocumentPropertiesW`) writes public `DEVMODEW` **plus** `dmDriverExtra` private bytes. The print path was not forwarding the full buffer (and mishandled the anonymous union for `dmDefaultSource` / `dmOrientation`) into `CreateDCW`/`StartDocW`. Epson "Print Preview" etc. therefore vanished.
- **Fix:** Retain the entire DEVMODE allocation including `dmDriverExtra`; fix union field access (`19449e3`).
- **Invariant:** Windows driver settings are a **blob**, not a parsed struct. Never memcpy only `dmSize` bytes. Never reconstruct DEVMODE from a handful of public fields.
**`#48` CUPS/PPD "Uncorrected Passthrough" shown on Windows**
- **Root cause:** `#cupsOptionsGroup` rendered unconditionally.
- **Fix:** Hide on `navigator.userAgent` Windows (v0.1.7).
- **Invariant:** Print UI is per-backend. Windows = DEVMODE; Unix = PPD/CUPS. Do not show controls the active spooler cannot honour.
**`#67` Duplicate print IPC (cleanup, not a user bug)**
- **Fix:** Collapse `get_windows_printers`/`get_cups_printers`/`print_target_*` into `get_printers` / `print_target`.
- **Invariant:** One IPC surface, platform switch inside Rust.
**`#188` macOS: Preferences opens System Settings; no way to disable driver CM (Epson XP-55, Canon Pro 9500 II)**
- **Root cause:** "Preferences" deep-linked to the OS Printers & Scanners pane, not the selected queue's driver panel. ColorSync plus vendor filters (`CNIJIntent2`, Epson `ColorCorrection`) re-managed the raw target. Follow-on: `PMSessionSetColorMatchingMode` 3-arg SPI **crashed**.
- **Fix (v0.7.20.7.3):** Native `NSPrintPanel` bound to the current `PMPrinter`; PPD/`lpoptions` media-type extraction; vendor uncorrected-color keys; `AP_ColorMatchingMode=AP_ApplicationColorMatching`; **remove** the crashing 3-arg SPI and use the validated 2-arg / lock sequence (`8b01c98`). Windows media_types added in the same stack so CI still built.
- **Invariant:** Raw print means **three** layers must all be off: OS ColorSync, driver colour, and ICM/CUPS filters. Opening the generic Printers pane is not a preferences dialog. Never call private Core Printing SPIs whose arity is guessed.
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
---
### 2.3 Stage 1 — targen
**`#44` Patch count ignored → always 836**
- **Root cause:** Frontend sent `patch_count`; Rust `build_targen_args` only read `total_patches`. `-f` omitted → Argyll's built-in default **836** RGB patches.
- **Fix (v0.1.5, PR `#45`):** Honour `patch_count` (fallback `total_patches`); unit-test argv.
- **Invariant:** `targen` without `-f` is not "use the UI value". Default 836 is an Argyll formula, not an ICCery default. Serde field names must match across JS and Rust **and** be tested.
**`#59` Empty working directory**
- **Root cause:** Basename typed without Browse → `cwd: ""` → process launch dir (read-only / undefined). Same hole in later stages.
- **Fix (v0.1.12):** Init `currentWorkingDir` to `document_dir`/`home_dir`; disable Generate until both basename and cwd exist; backend also refuses empty cwd.
- **Invariant:** Empty cwd is illegal. Frontend guard **and** backend fallback. Never spawn Argyll in the install/resource directory.
**`#103` Stage 1 Browse does nothing; Generate stays disabled**
- **Root cause:** Frontend called `window.__TAURI__.dialog` (Tauri v1 API). With `withGlobalTauri: true` on Tauri v2, `window.__TAURI__.dialog` is **undefined**. Same class of bug as `#210`/`#211`.
- **Fix (v0.3.1, PR `#104`):** Backend command `select_target_file` using `app.dialog()`.
- **Invariant:** **Never** use `window.__TAURI__.dialog`. All file pickers are Rust commands with explicit filters. See also `#172`, `#210`, `#211`.
**`#162` Advanced options misaligned**
- **Root cause:** Mixed control heights (number vs range vs checkbox-without-label) plus a full-width `-c` row breaking the 2-col grid; flex-end hacks.
- **Fix (v0.5.5 / v0.6.1):** Semantic sub-groups, normalized row heights, flexbox auto-margins.
- **Invariant:** Advanced `targen` controls are a 2-column grid of equal-height rows. Checkboxes get a label column. Do not use `justify-content: flex-end` to fake alignment.
**`#172` Preconditioning browse filters `.ti1/.ti2/.ti3` instead of `.icc/.icm/.mpp`**
- **Root cause:** Reused `select_target_file` (target artefacts) for `targen -c`.
- **Fix:** Dedicated `select_profile_file`.
- **Invariant:** Each browse button has its **own** backend command and extension filter. Sharing "the dialog command" is how you open a Save-`.ti1` dialog for an ICC.
**`#211` CGATS Import opens a Save `.ti1` dialog and jumps to Stage 4 with empty basename**
- **Root cause:** `handleImport` invoked `select_target_file` (`save_file`, filter `ti1`). Empty `cwd`/`basename` were forwarded into `import_measurement_dataset`; `wizardState.setTarget` never ran.
- **Fix (v0.8.3):** `select_dataset_file` (open, filters `ti3/txt/cgats/csv`); sync wizard target dir+basename from the imported artefact.
- **Invariant:** Import = **open** dialog. After import, `basename`/`cwd` are initialized from the dataset before any stage jump. Do not reuse the target-save picker.
---
### 2.4 Stage 2 — printtarg
**`#50` Redundant Stage 2 badges (cleanup)**
- **Fix:** Remove duplicate status text. Not a functional invariant beyond "one source of layout truth".
**`#58` / `#70` TIFF previews fail in WebView; `image` crate Windows-only**
- **Root cause:** Browsers/WKWebView/WebView2 do not decode TIFF. `img.src = base64(tiff)` is a blank. `image` was under `[target.'cfg(windows)'.dependencies]` (GDI print), so Linux/macOS could not even compile a converter.
- **Fix:** Move `image` (png+tiff features) to common deps; `read_tiff_preview_png` decodes, caps ~1200 px, returns PNG base64. Keep `read_file_base64` for **text** artefacts (`.gam`).
- **Invariant:** Never feed TIFF to `<img>`. Preview pipeline is always TIFF→PNG in Rust. The `image` crate is a **common** dependency, not Windows-only.
**`#68` printtarg JSON parsed by hunting for a brace**
- **Root cause:** String-index search for `{\n "event": "manifest"` instead of a structured object.
- **Fix:** Parse a single JSON event (Argyll `#3` `-u` manifest).
- **Invariant:** `-u` tools emit one JSON object per event. Accumulate stdout, `JSON.parse` whole objects; do not `indexOf` pretty-printed fragments.
**`#163` Regenerating Stage 2 shuffles patches (printed sheet ≠ `.ti2`)**
- **Root cause:** `printtarg` randomizes layout unless `-R <seed>` or `-r`. `build_printtarg_args` passed neither.
- **Fix (v0.5.5):** Default `-R 1`; optional custom seed and raster-order `-r`.
- **Invariant:** Printed charts are not reproducible without an explicit seed. Default is **deterministic**. Re-running Stage 2 on the same `.ti1` must produce the same `.ti2`/`.tif` or `chartread` will score the wrong patches.
**`#119` (feature, not bug) `printtarg -d` is a *label*, not a directory**
- See idiosyncrasies. Do not confuse with `iccgamut -d` (`#112`).
---
### 2.5 Stage 3 — chartread / averaging / XY
**`#109` Multi-pass averaging overwrites the same `.ti3`**
- **Root cause:** `#87` UI shipped, but every `chartread` wrote `{basename}.ti3`. Pass 2 clobbered pass 1. `average` was invoked as `foo.ti3 foo.ti3 foo.ti3`.
- **Fix:** After each successful exit, snapshot `{basename}.ti3``{basename}_pass{N}.ti3`. Canonical `{basename}.ti3` is written only at Finish (copy of pass1, or `average -v pass1 pass2 … basename.ti3`). Do **not** change the `chartread` inoutfile (still must match `.ti2` basename).
- **Invariant:** Measurement identity ≠ averaging identity. `chartread` always uses the chart basename. Pass files are snapshots. Stage 4 never sees a pass file unless Finish has run.
**`#110` Stage 4 unlocks on first pass, before Average**
- **Root cause:** `process:exit` of pass 1 called `wizardState.setStage3Result`. `verify_stage_artefacts` saw `{basename}.ti3` and enabled Stage 4 while "Measure Another Sheet" was still offered.
- **Fix:** Same PR as `#109`. `setStage3Result` only from **Finish and Average** / Accept-single-pass. Prefer the canonical-file rule over a new `stage3_accepted` flag.
- **Invariant:** Wizard gates on **disk artefacts with defined meaning**. `{basename}.ti3` means "accepted measurement", not "a pass happened".
**`#111` `instlist` ordinal passed as `chartread -c`**
- **Root cause:** Dropdown `value = inst.index` (1-based instrument list). `build_chartread_args` emitted `-c {index}`. Argyll `-c` is a **communication port** list (`chartread -??`), a different enumeration. Works by coincidence for a single USB i1Pro on port 1.
- **Fix:** Do not assume ordinal equality. Use `instlist` as presence/health; pass `-c` only when the value is a documented comm-port. Tighten regex to require instrument tokens.
- **Invariant:** `instlist` index ≠ `-c` port. After JSON `instlist` (argyll `#6` / ICCery `#134`), the field to pass is `device.port`, never the array index, and only if it is a real comm port.
**`#137` (feature) Accept/Override + multi-sheet prompts**
- Chartread emits multi-key warnings. UI must send the exact key (`n`/`y`/…) the prompt asks for, not just Enter.
**`#175` Stage 3 cannot complete — never sends `d`**
- **Root cause:** After the last strip, `chartread` waits for `d` (Done) before writing `.ti3`. ICCery never sent it. Follow-on: `snapshot_ti3` IPC used `passIndex` vs the deserializer's expected key.
- **Fix (v0.6.70.6.8):** Explicit **Done & Save .ti3**`d\n`; **Undo Strip**`u\n`; completion state machine; `invoke("snapshot_ti3", { passIndex })` camelCase to match Tauri v2 serde rename.
- **Invariant:** `chartread` is not finished when the last row JSON arrives. Done is a **keystroke**. Tauri v2 IPC argument names are camelCase on the JS side. Always add a completion control rather than inferring EOF.
**`#93` XY tables (SpectroScan / i1iO)** — feature, but its invariants are bug-shaped: classify **multi-line** prompts; fiducial alignment is a 4-step checklist; cancel must **park the head**.
**`#204` i1Pro2 LEDs** — pass `-Y l` (not `-L`). Silent no-op on instruments without LEDs.
---
### 2.6 Stage 4 — colprof
**`#56` Stage 4 is broken (IPC schema / process-id mismatch)**
- **Root cause:** `colprof.js` sent `{ basename, cwd }`. Rust expected `{ ti3_path, icc_path }`. Process IDs disagreed (`colprof_${basename}` vs `colprof_{icc_path}`), so UI listeners never fired even on a successful spawn.
- **Fix (PR `#64`, v0.1.11):** Align struct, argv builder, and process id with the frontend.
- **Invariant:** Spawn id is a **protocol**. Frontend listen-filters and backend spawn ids are the same string. Serde structs are tested against the JS payload, not against a parallel imagined schema.
**`#69` Profile extension `.icm` (Windows) vs `.icc` (Unix)**
- **Root cause:** Hardcoded `${basename}.icc`. Argyll `colprof` writes `.icm` on Windows. `profcheck` / `iccgamut` then "cannot find profile".
- **Fix:** Platform-aware resolver, or existence-check both extensions. Used by Stage 5 and gamut (`#57`).
- **Invariant:** Never hardcode `.icc`. Resolve `{icc, icm}` on disk. This also applies to install (`#223`) and preconditioning (`#172`).
**`#176` OBA/FWA + viewing conditions** — feature. `colprof -c` / `-d` here are **illuminant / viewing condition**, not directories and not `printtarg -d` labels.
**`#210` Custom `.sp` Browse: `window.__TAURI__.dialog` TypeError**
- **Root cause:** Identical to `#103`. No `select_spectrum_file` command.
- **Fix (v0.8.3):** Native `select_spectrum_file` (filter `.sp`).
- **Invariant:** Same as `#103`. Third time this class of bug shipped (`#103`, `#210`, `#211`). A rewrite should have a single `pick_file { filters }` command and never touch `window.__TAURI__.dialog`.
---
### 2.7 Stage 5 — gamut / profcheck / history
**`#57` 3D gamut is a dead panel**
- **Root cause:** `extract_gamut` / `loadGamutMesh` existed but nothing called them after `colprof`. `initGamutViewer` never stored meshes. Blocked by `#56` and `#69`.
- **Fix (v0.1.14):** Run `iccgamut` after successful profile; load printer `.gam` + bundled `sRGB.gam`; dispose previous geometries; `ResizeObserver` on the hidden Stage 5 canvas.
- **Invariant:** Gamut is a **post-colprof artefact**, not a demo mesh. Viewer must not allocate WebGL until the stage is shown (`#225`).
**`#112` `iccgamut -d 50.0` is surface density, not a directory**
- **Root cause:** `extract_gamut` passed `-d 50.0`. In Argyll, `iccgamut -d s` is **surface point density** (useful ~110). Density 50 yields thousands of vertices and freezes the O(n²) hull filter. Cwd is already set on the spawn.
- **Fix (PR `#121`):** `-d 10`; extract `build_iccgamut_args`; unit-test argv. Do not pass `-w` (VRML) unless required.
- **Invariant:** `-d` is **not** "directory" on `iccgamut`. Cwd is a ProcessManager concern. Density default is 10. Argv builders exist so this cannot silently regress.
**`#179` Mesh empty; profcheck cards show 0.00**
- **Root cause:** `.gam` has **two** CGATS tables (vertices `VERTEX_NO LAB_L LAB_A LAB_B`, then faces `VERTEX_0 VERTEX_1 VERTEX_2`). Parser kept only the first `BEGIN_DATA` and produced `{L,a,b}`. QuickHull indexed `p.x/p.y/p.z` → all undefined → empty hull. Profcheck regex expected one wording of `errors(CIEDE2000): max. =`; real output variants yielded 0.
- **Fix (v0.7.0):** Dual-table parse; map `x=a*, y=L*, z=b*`; use file faces when present; broader max/avg/rms regexes + user-visible parse warnings.
- **Invariant:** `.gam` is two tables. Lab is not XYZ-style `{x,y,z}` until remapped. Profcheck text is not a stable API — prefer `profcheck -u` JSON (argyll `#5`) and keep regex as fallback only.
**`#117` Inlined QuickHull** — modularize under `src/js/vendor/quickhull.js` (and later prefer file faces over recomputing hulls).
**`#185` Gamut rework** — CIELAB axes + CSS2D labels, `EdgesGeometry` sRGB reference, per-vertex `labToSrgb()`, layer toggles. Invariant: reference overlay is edges, not a competing solid that hides the profile.
**`#212` `gamut_viewer.test.js` : `window is not defined`**
- **Root cause:** Module top-level `const { invoke } = window.__TAURI__.core`. Node test import evaluates that before any polyfill. Unlike `profcheck.test.js` / `chartread.test.js`, no `globalThis.window` mock and no CLI auto-run.
- **Fix:** Guard `typeof window !== 'undefined'`; polyfill in the test; `node src/js/gamut_viewer.test.js` must exit non-zero on failure. Later `#215` runs this in CI.
- **Invariant:** Frontend modules that `import` in Node tests cannot touch `window` at load time. Tests are first-class; if AGENTS.md documents `node src/js/….test.js`, CI must run it (`#215`).
**`#213` `verification_history.json` non-atomic write**
- **Root cause:** `fs::write` in place. Crash ⇒ truncated JSON. `load_history_file` treats parse failure as **empty vec**, so the next save wipes the archive. Contradicted AGENTS.md's own atomic-write rule.
- **Fix (v0.8.4):** Write `.tmp`, flush/sync, `rename`.
- **Invariant:** History files are append-only user data. Write temp+rename. Parse failure must **not** be indistinguishable from "no history".
**`#95` Drift analytics** — 1,000-record cap, SVG dual-series, consecutive-breach card, RFC-4180 CSV. Relies on `#213`.
---
### 2.8 macOS window / WebKit
**`#61` Default window 800×600**
- **Fix:** 1280×800 default, min 1100×700.
- **Invariant:** The wizard + TIFF cards + WebGL viewer are not a mobile layout. Set min bounds in `tauri.conf.json`.
**`#148` Align `minimumSystemVersion` with Argyll**
- Argyll macOS builds and Tauri 2 / WebGL do not support 10.15 in practice.
**`#165` Unsigned Argyll sidecars `SIGKILL` on Apple Silicon**
- **Root cause:** arm64 requires at least ad-hoc signature. `fetch-argyll.mjs` staged Jam-built unsigned Mach-Os into Resources. Kernel kills with `Killed: 9`, no useful error.
- **Fix:** Upstream argyll `#32` (`v3.5.0-ICCery.1.5`) `codesign -f -s -` in `makepackagebin.sh` + CI `codesign -dvv`, including `lipo` fat binaries.
- **Invariant:** Every shipped Mach-O sidecar is signed (ad-hoc at minimum). Fetch pipeline must verify signatures, not assume "it ran on Intel".
**`#164` Universal binary (Intel + arm64)**
- Need universal Argyll archive (argyll `#27`), runtime fallback if a slice is missing, CI `--no-run` when cross-compiling tests for the other arch.
**`#225` Monterey white-flash loop then silent exit (v0.8.4)**
- **Root cause:** Main window `visible: true` with no dark backing. `initGamutViewer()` created `THREE.WebGLRenderer({ antialias: true, alpha: true })` and started rAF at `DOMContentLoaded` **while Stage 5 was hidden**. On macOS 12 (esp. Intel GPU) the Web Content/GPU helper died; WKWebView respawned its **white** default store; after a few cycles the host tore the window down. No Crash Reporter on `ICCery.app`. `minimumSystemVersion` still claimed 10.15.
- **Fix:** Defer WebGL until the gamut viewer is shown; survive context-lost; hide window until first paint; dark WKWebView `backgroundColor`; log unexpected window destroy / Web Content death; raise `minimumSystemVersion` to **12.0**.
- **Invariant:** Do not create a WebGL context at startup. Do not show a window before first paint on macOS. Helper-process death ≠ app crash — log it. Support matrix must match WKWebView/WebGL reality.
---
### 2.9 Packaging / CI
**`#38` Unused / duplicated crates** — audit deps; don't let Windows-only crates leak (inverse of `#70`).
**`#40` Linux CI too slow**
- **Fix (PR `#41`):** Allow strip (`NO_STRIP` off), rust-cache `workspaces: "src-tauri -> target"`, apt archive cache, `--bundles deb,appimage` (skip rpm), upload-artifact v4.
**`#42` Windows MSI/NSIS branding.**
**`#52` Strip foreign-platform Argyll binaries from each OS bundle.**
- **Invariant:** A Windows installer must not ship `linux-x86_64/` and `macOS/` trees. Fetch-at-build (`#127`) makes this a packaging filter, not a git-lfs problem.
**`#80` Mermaid diagram does not render**
- **Root cause:** Node label `Raw Print Subsystem (GDI / CUPS)` — parentheses parsed as shape syntax.
- **Fix:** Quote / drop parens.
- **Invariant:** Mermaid node text with `()` must be quoted.
**`#91` Universal `.dmg` + codesign pipeline (still evolving through `#189`).**
**`#108` `.deb` requires GLIBC_2.39 (won't run on Ubuntu 22.04)**
- **Root cause:** Linux CI runner newer than the support target. Binary linked against glibc 2.39; 22.04 ships 2.35.
- **Fix (v0.3.2):** Build on **Ubuntu 22.04 LTS**.
- **Invariant:** The CI image **is** the minimum glibc. If you support 22.04, you build on 22.04 (or older), not `ubuntu-latest`.
**`#127` Fetch Argyll at build time + Windows USB driver offer**
- Removed vendored binaries. NSIS hook for `ArgyllCMS_install_USB.exe`. Follow-on Windows NSIS bugs in the same train: LogicLib admin check, domain-user install path must be a local fixed disk, shell-folder drive mapping, skip WebView2 bootstrapper `msiexec` invalid-drive.
- **Invariant:** Sidecars are release artefacts of `gronod/argyllcms`, not git blobs. Windows USB instruments need the libusb0 INF. Packaging scripts must `tr -d '\r'` manifest lists (argyll `#21` — CRLF in `binfiles` dropped drivers/docs from the zip).
**`#189` DMG has no background image**
- AppleScript Finder decoration does not run headlessly. Multiple CI attempts (`#196`, `#208`, `#219`) then **dmgbuild** (`#222`, commit `6976ced`).
- **Invariant:** Do not use Finder AppleScript to style a DMG on a headless runner. Use `dmgbuild`.
**`#215` Frontend tests not in CI**
- Added `npm test` (profcheck, chartread, gamut_viewer) to macOS/Linux/Windows workflows. Prevents `#212`-class breakage.
**`#214` Docs drift** — AGENTS/README/licenses vs code. Keep ROADMAP version = Cargo version (`#115`, `#152`).
---
### 2.10 Security (innerHTML presets)
**`#114` Preset manager XSS via `innerHTML`**
- **Root cause:** `renderManagePresetsList` interpolated `p.name` / `p.description` into `innerHTML`. Imported JSON is attacker-controlled. Tauri WebView is a privileged surface (`<img src=x onerror=…>` runs with backend IPC).
- **Fix (PR `#122`, with `#113`):** `createElement` + `textContent`. Optional Rust reject of `<` / control chars. **No** HTML sanitizer.
- **Invariant:** Untrusted strings (preset import, CGATS descriptors, chart labels, log excerpts) never go through `innerHTML`. `textContent` only.
---
### 2.11 Settings / presets / wizard chrome
**`#113` `applyPreset` never applies TIFF DPI**
- **Root cause:** Schema had `dpi`; Fast RGB Draft is 150. `applyPreset` skipped `#tiffDpi`; `collectCurrentSettingsAsPreset` hardcoded `dpi: 300`.
- **Fix:** Round-trip `#tiffDpi` (72600); show DPI in manage-presets subtitle.
- **Invariant:** Every `ProfilingPreset` field that exists on disk has a DOM binding in both directions. Adding a Stage 1/2/4 control without a preset field (or vice versa) is a bug. (v0.7.0 also broke the build by adding fields without updating the struct — `c917f05`.)
**`#60` Wizard is not a wizard**
- **Root cause:** Stepper allowed clicking Stage 5 with no files. Modules fell back to `"test_target"`.
- **Fix (v0.1.15 / v0.2.0):** Gate `.ti1``.ti2`+`.tif``.ti3``.icc`/`.icm`. Remove every `|| "test_target"`. Completed stages remain clickable backwards.
- **Invariant:** No placeholder basenames. Navigation = disk. `#151` re-validates artefacts on stage entry and app start (deleted files must re-lock).
**`#171` Tooltips reflow the layout**
- **Root cause:** Tooltip content inserted in-flow.
- **Fix (v0.6.2):** Absolute overlay on hover/focus; in-flow hints only when the global "show tooltip hints" toggle is on.
- **Invariant:** Hover must not change document flow.
**`#184` Configurable ΔE₀₀ thresholds** — settings persistence applied to the Stage 3 traffic lights. Same "settings must actually apply" rule as `#158`.
---
### 2.12 UI leftovers that still encode invariants
**`#178` Swatch grid:** honour `is_pad` (padding patches, `id == "0"`); render white patches; diagonal split intended vs measured; `printtarg` row/column order is canonical after bi-directional reverse. Device channels are 0100%; XYZ in JSON is 0100 and **must be /100** before `icmXYZ2Lab`.
**`#177` Buttons:** `.btn-sm/.btn-md/.btn-lg/.btn-icon-sq` only. No inline sizes. `#223` install button must use this hierarchy.
**`#140` Resume:** opening a `.ti2` jumps to Stage 3; `.ti1` to Stage 2. Parse header (instrument, patch count, colorants, pages). Do not regenerate `#163`-style random layouts over a chart that was already printed.
---
## 3. Open issues — implemented on main (`v0.8.5`)
Gitea still shows these as **open**. Git on `main` (merge `e12ef41`, tag `v0.8.5`) already contains the work. ROADMAP "Printer Calibration Release (`v0.8.5`)" matches.
| Ticket | State in tracker | Evidence on `main` | What shipped |
|---|---|---|---|
| **`#223` System-wide ICC/ICM install** | open | `980b44b` `feat(stage5): install generated ICC/ICM profiles into the OS (#223)` ; PR `#228` → development; PR `#229` → main | Stage 5 "Install Profile to System": copy into OS colour store (user or system), overwrite/rename/cancel, elevation guidance, note when printcal curves were applied |
| **`#224` printcal / applycal** | open | `24318bc` `feat(cal): printer linearization via printcal / applycal (#224)` ; PR `#227` → development; PR `#229` → main | Optional Stage 0: `CAL_` artefacts, Apply Calibration toggle → `printtarg -K` + `applycal`, channel-response plots, stale-cal warnings, project/library persistence |
Rewrite must treat Stage 0 calibration and Stage 5 install as **shipped** contracts, not backlog. Close the tickets when convenient.
---
## 4. Won't-fix
### `#90` Display wizard (`dispwin` / `dispread`) — Reviewed/Won't Fix
Asked for a second wizard mode: emissive monitor profiling (white point, gamma, cd/m², colorimeter loop, `dispwin -I` install).
**Why not:**
- ICCery's architecture, print engine, artefact chain (`.ti1``.tif``.ti3``.icc`), and raw-print colour-bypass work are **reflective printer** problems. Display calibration is a different product (full-screen test patches, VCGT, per-monitor ColorSync/ICC install).
- `dispwin`/`dispread` already *are* interactive GUIs; wrapping them duplicates Argyll's own display tools and DisplayCAL without sharing the print pipeline.
- Hardware, prompts, and OS install APIs do not reuse Stage 3's strip-reader state machine.
- Priority/Low, closed the same day it was filed during M8 triage. Printer-only scope is the product.
Do not quietly add a "Print vs Display" toggle in a rewrite. If display profiling happens, it is a separate app.
### `#96` i18n (en/de/fr/ja) — Reviewed/Won't Fix
ROADMAP: *Closed — Won't Fix (English UI retained as standard color-management terminology).*
**Why not:**
- The domain language **is** English: ΔE₀₀, cLUT, CGATS, Lab, FWA, OFPS, `targen -f`. Translating chrome without translating Argyll's English prompts (`Place the instrument on its reflective white reference…`) produces a mixed UI. Translating prompt matchers (`#137`, `#93`, `#175`) is how Stage 3 silently deadlocks.
- Argyll itself is English-only; `ARGYLL_NOT_INTERACTIVE` keystroke protocol is letter keys (`d`, `u`, `n`), not localized buttons.
- Cost of four complete dictionaries + locale-aware regex across every prompt classifier exceeded the value for a specialist tool.
Keep the UI English. Do not introduce `t()` until prompt classification is table-driven **and** Argyll grows locale support — which it will not.
---
## 5. Idiosyncrasies (do not rediscover)
These are the sharp edges issues burned in. A rewrite that "simplifies" any of them will re-open the ticket.
### 5.1 Argyll CLI is a minefield of one-letter flags
| Tool | Flag | Means | Ticket |
|---|---|---|---|
| `targen` | `-f N` | patch count; omit ⇒ **836** RGB | `#44` |
| `targen` | `-c file` | preconditioning **profile** (`.icc/.icm/.mpp`) | `#172` |
| `targen` | `-u` | JSON progress | argyll `#2` |
| `printtarg` | `-d "str"` | **custom label** on the chart | `#119` / argyll `#19` |
| `printtarg` | `-D` | 8-bit TIFF dither (different letter) | argyll `#19` |
| `printtarg` | `-R N` | PRNG seed; omit ⇒ non-deterministic layout | `#163` |
| `printtarg` | `-r` | raster order, no randomize | `#163` |
| `printtarg` | `-K` | apply calibration (Stage 0) | `#224` |
| `printtarg` | `-u` | JSON page manifest | `#68` / argyll `#3` |
| `chartread` | `-u` | `ROW_COLORS_JSON:` stream | `#5` / argyll `#1` |
| `chartread` | `-c N` | **comm port**, not instlist index | `#111` |
| `chartread` | `-d` | **display** read mode (not a directory, not a label) | argyll `#37` audit |
| `chartread` | `-Y l` | i1Pro2 LED (not `-L`) | `#204` |
| `chartread` | stdin `d` | **Done**, write `.ti3` | `#175` |
| `chartread` | stdin `u` | undo last strip | `#175` |
| `iccgamut` | `-d s` | **surface density** (use 10, not 50, not a path) | `#112` |
| `colprof` | `-d` | viewing condition | `#176` |
| `colprof` | `-c` | illuminant / FWA | `#176` |
| `colprof` | `-u` | JSON progress | argyll `#4` |
| `profcheck` | `-u` | JSON ΔE report | argyll `#5` |
| `instlist` | stdout JSON | `{event:"instruments", devices:[{port,name,type}]}` | `#134` / argyll `#6` |
**Same letter, four meanings of `-d`:** printtarg label, iccgamut density, chartread display mode, colprof viewing condition. Never name a helper `dash_d` without the tool.
### 5.2 Process / Windows / stdin
- Argyll binaries are **console** subsystem → `CREATE_NO_WINDOW` (`0x08000000`) (`#46`).
- `ARGYLL_NOT_INTERACTIVE=1` is mandatory or Windows ignores pipes (`#134`).
- Even then, Argyll's `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes**; upstream `PeekNamedPipe` (argyll `#24`) is required or the instrument trigger never fires.
- `Child::wait()` drops stdin (`#84`). Take streams first; store stdin separately; kill via oneshot.
- Spawn ids are exclusive (`#116`).
- App exit must `kill_all` (`#147`, `#149`) or the spectro stays locked.
- Windows resolve: `targen` **and** `targen.exe` (`#85`).
- `tokio::process` + GDI print FFI: don't trust `windows` crate completeness (`#33`).
### 5.3 Files, extensions, artefacts
- `colprof` writes **`.icm` on Windows, `.icc` on Unix** (`#69`). Always probe both.
- Wizard chain: `.ti1``.ti2` + `.tif``.ti3``.icc`/`.icm` (+ `.gam`) (`#60`, `#151`).
- `{basename}.ti3` is the **accepted** measurement; passes live in `{basename}_passN.ti3` (`#109`, `#110`).
- Calibration artefacts use `CAL_` prefix and `.cal` (`#224`).
- TIFF is not a web image (`#58`). Convert in Rust.
- `.gam` = vertex table **plus** face table (`#179`).
- History JSON: atomic rename or you delete the user's drift archive (`#213`).
- Empty cwd is forbidden (`#59`).
### 5.4 Frontend / Tauri v2
- `window.__TAURI__.dialog` **does not exist**. Three tickets (`#103`, `#210`, `#211`). File dialogs are Rust commands with dedicated filters.
- Tauri v2 invoke args are **camelCase** (`passIndex`, not `pass_index`) (`#175`).
- JS payload field names must match Rust (`patch_count` vs `total_patches``#44`; `basename/cwd` vs `ti3_path/icc_path``#56`).
- Spawn id strings are part of the UI protocol (`colprof_${basename}`).
- Do not `innerHTML` untrusted preset/CGATS text (`#114`).
- Do not read `window` at module top-level if Node tests import the file (`#212`).
- Do not create WebGL on `DOMContentLoaded` (`#225`).
- Tooltips are overlays (`#171`).
### 5.5 Colour / print pipeline
- Raw print = unmanaged raster. Bypass **OS + driver + ICM** (`#25``#28`, `#36`, `#188`).
- Windows: full DEVMODE blob including `dmDriverExtra` (`#36`); `SetICMMode(ICM_OFF)` (`#33`).
- macOS: `AP_ApplicationColorMatching` + vendor keys (`CNIJIntent2`, Epson `ColorCorrection`); never crash-call 3-arg `PMSessionSetColorMatchingMode` (`#188`).
- PPD: send **choice codes**, display **titles**; vendor keywords differ (`InputSlot` / `EPIJ_FdSo` / `CNIJMediaSupply`; `MediaType` / `CNIJMediaType`) — CPU `#1`/`#2`.
- Hide CUPS widgets on Windows (`#48`).
- Default printtarg seed `-R 1` (`#163`).
- Device JSON is 0100%; XYZ 0100 must be divided by 100 before Lab (`ROW_COLORS_JSON` contract, argyll `#1`).
- Padding patches: `is_pad` / `id == "0"` (`#178`).
### 5.6 Packaging
- Linux: build on the oldest glibc you support (`#108` = Ubuntu 22.04).
- Strip foreign Argyll trees (`#52`).
- Fetch sidecars from `gronod/argyllcms` releases (`#127`); verify macOS ad-hoc signatures (`#165`).
- Manifest copy loops: strip CR (`\r`) or Windows zips silently omit USB drivers (argyll `#21`).
- Headless DMG: `dmgbuild`, not Finder AppleScript (`#189`).
- `minimumSystemVersion` is 12.0 after Monterey WebKit reality (`#225`), not 10.15.
### 5.7 Defaults people will hardcode wrong
- Patch count default if `-f` missing: **836**, not 0, not 1000 (`#44`).
- TIFF DPI built-in Standard = 300; Fast RGB Draft preset = **150** (`#113`).
- `iccgamut -d` = **10** (`#112`).
- Window 1280×800, min 1100×700 (`#61`).
- Log level is a setting, not `cfg!(debug_assertions)` (`#158`).
- LED flag is `-Y l` (`#204`).
---
## 6. Suggested rewrite test locks (from the tickets)
If the rewrite has tests, these are the ones the history says you will regret omitting:
1. `build_targen_args` includes `-f` from `patch_count` (`#44`).
2. `build_printtarg_args` includes `-R 1` (or explicit seed) (`#163`).
3. `build_iccgamut_args` is `-v -d 10 {profile}` — never a path, never 50 (`#112`).
4. `build_chartread_args` does not pass instlist ordinal as `-c` (`#111`).
5. Windows `resolve_binary("targen")` finds `targen.exe` (`#85`).
6. ProcessManager: duplicate id errors; stdin works during wait; kill_all on drop (`#84`, `#116`, `#149`).
7. Averaging: two snapshots, `average` argv unique files, Stage 4 locked until Finish (`#109`, `#110`).
8. Preset round-trip includes DPI; imported `<img onerror>` is text (`#113`, `#114`).
9. Profile resolver accepts `.icc` and `.icm` (`#69`).
10. `.gam` dual-table parse; profcheck JSON then regex fallback (`#179`).
11. History write is temp+rename (`#213`).
12. Node can `import` gamut/profcheck/chartread modules (`#212`, `#215`).
---
## 7. PR title index (page 2, remaining)
Page 2 of `pulls?state=all&limit=50&page=2` (older half of the genealogy):
`#144` Stage 1 advanced targen (`#141`) · `#143` resume `.ti2` (`#140`) · `#142` logging (`#139`) · `#138` Accept/Override (`#137`) · `#136`/`#135` v0.3.5 `ARGYLL_NOT_INTERACTIVE` (`#134`) · `#133` v0.3.4 · `#132` printtarg `-d` (`#119`) · `#131` v0.4.0 · `#130` macOS CUPS (`#92`) · `#129`/`#128` fetch-argyll (`#127`) · `#126` v0.3.3 · `#125` QuickHull module (`#117`) · `#124` docs v0.3.2 (`#115`) · `#123` duplicate spawn (`#116`) · `#122` preset DPI+XSS (`#113`,`#114`) · `#121` iccgamut density (`#112`) · `#120` comm port (`#111`) · `#118` averaging unique ti3 (`#109`,`#110`) · `#107` release asset naming · `#105`/`#104` Stage 1 browse (`#103`) · `#102` v0.3.0 · `#101` 3D hull (`#89`) · `#100` presets (`#88`) · `#99` averaging (`#87`) · `#98` instlist (`#86`) · `#97` `.exe` (`#85`) · `#83` process deadlock v0.2.1 (`#84`) · `#81` mermaid (`#80`) · `#79` print IPC consolidate (`#67`,`#68`) · `#77` wait/reap (`#63`) · `#75` wizard gating (`#60`) · `#74` gamut+extension (`#57`,`#69`) · `#73` TIFF PNG + window (`#58`,`#61`) · `#72` default cwd (`#59`) · `#64` colprof IPC (`#56`) · `#55` collapsible logs (`#54`) · `#53` strip foreign binaries (`#52`) · `#51` Stage 2 cleanup (`#50`) · `#49` hide CUPS on Windows (`#48`) · `#47` `CREATE_NO_WINDOW` (`#46`) · `#45` targen `-f` (`#44`) · `#43` installer art (`#42`) · `#41` Linux CI (`#40`)
Recent page-1 merges of note: `#229` v0.8.5 (`#223`+`#224`) · `#228` install · `#227` printcal · `#226` Monterey (`#225`) · `#222` dmgbuild (`#189`).