Files
iccery-v2-mac/docs/04-argyll-binaries.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

913 lines
40 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.
# 04 — Argyll binary invocations
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Source tree: `/tmp/ICCery` (v0.8.5). ICCery never links Argyll; every binary is spawned as an isolated subprocess (`calibration.rs:14`). Sidecars come from the ICCery-patched fork `gronod/argyllcms` (GitHub `Gronod/argyllcms`), not stock Graeme Gill builds.
Two spawn paths exist:
| Path | Used by | Streams | Events |
|---|---|---|---|
| `ProcessManager::spawn` | targen, printtarg, chartread, average, colprof, profcheck, iccgamut, instlist | piped stdin/stdout/stderr, line-by-line | `process:stdout`, `process:stderr`, `process:exit`, `process:error`, `process:json_row` |
| `calibration::run_captured` | printcal, applycal | `.output().await` (full capture) | none — returns `(exit_code, stdout, stderr)` to the Tauri command |
---
## 0. Shared infrastructure
### 0.1 Binary resolution — `resolve_binary`
`commands.rs:40106`. Public Tauri command **and** internal helper.
Order of search:
1. Settings `argyll_binary_dir` (Settings dialog, `AppSettings.argyll_binary_dir`). If non-empty, join each candidate name; first existing path wins.
2. Bundled sidecar at `argyll/{platform}/{name}` under Tauri `BaseDirectory::Resource`.
3. If the resource path does not exist, **still return the constructed resource path** (does not search `$PATH`). Missing binaries surface later as spawn `process:error`.
`get_binary_candidates` (`commands.rs:4046`):
- Windows: `["{name}.exe", "{name}"]` unless `name` already ends with `.exe`.
- Unix: `["{name}"]`.
Platform directory selection (`commands.rs:6583`, mirrored in `build.rs:2644`):
| OS / arch | Resource dir |
|---|---|
| linux x86_64 | `linux-x86_64` |
| windows x86_64 | `windows-x86_64` |
| macos aarch64 | `macos-universal` if `argyll/macos-universal/instlist` exists, else `macos-aarch64` |
| macos x86_64 | `macos-universal` if that marker exists, else `macos-x86_64` |
| anything else | `linux-x86_64` (fallback) |
`default_instrument` is stored in settings (`settings.rs:76`) and shown in the Settings dialog, but **is never read when building any Argyll argv**. Instrument comes from Stage 2 `#instrumentSelect`.
### 0.2 Working directory — `resolve_safe_cwd`
`commands.rs:203217`. If `cwd_input` is a real directory, use it. Else: `document_dir``home_dir``app_data_dir`.
All specialised `run_*` commands pass `Some(resolve_safe_cwd(&app, &config.cwd))`. Exceptions:
- `detect_instruments` / `spawn_process`: `cwd = None` (inherit parent cwd).
- `extract_gamut`: parent of the ICC file, or default cwd if empty.
- `apply_calibration`: parent of the input profile.
### 0.3 Environment variables
Set on **every** spawn (ProcessManager **and** `run_captured`):
```
ARGYLL_NOT_INTERACTIVE=1
```
- ProcessManager: `process_manager.rs:97`
- `run_captured`: `calibration.rs:848`
This is the ICCery-side half of `gronod/argyllcms#24` (Windows anonymous-pipe deadlock). It forces Argyll's `check_if_not_interactive()` path so `con_char()` uses pipe I/O instead of a console. Added in ICCery#134 (`3d514b6`).
**Never set:**
- `ARGYLL_3D_DISP` — grep of the tree is empty. `iccgamut` is invoked without any 3D-display env; the `.gam` file is parsed in JS (`gamut_viewer.js`).
- `PATH` — not mutated. Binaries are always absolute paths from `resolve_binary`. Inherited PATH is whatever the OS/session provides (needed only if a user-supplied `argyll_binary_dir` binary dlopens something).
### 0.4 Windows `CREATE_NO_WINDOW`
Both spawn paths:
```rust
const CREATE_NO_WINDOW: u32 = 0x08000000; // 0x08000000
command.creation_flags(CREATE_NO_WINDOW);
```
- ProcessManager: `process_manager.rs:99103`
- `run_captured`: `calibration.rs:849853`
Fixes ICCery#46 (v0.1.6, `ec6205b`): Argyll tools are `IMAGE_SUBSYSTEM_WINDOWS_CUI`; without this flag a black console covers the UI.
`CommandExt` is pulled in via `tokio::process::Command::creation_flags` (tokio re-exports the Windows ext).
### 0.5 Event schema (ProcessManager only)
`events.rs`:
```rust
ProcessEventPayload { id: String, line: Option<String>, code: Option<i32>, error: Option<String> }
JsonRowPayload { id: String, json: String }
```
| Event | When | Fields |
|---|---|---|
| `process:stdout` | every stdout line **except** those starting `ROW_COLORS_JSON: ` | `id`, `line` |
| `process:stderr` | every stderr line | `id`, `line` |
| `process:json_row` | stdout line prefixed `ROW_COLORS_JSON: ` (prefix stripped) | `id`, `json` (raw JSON string) |
| `process:exit` | child reaped | `id`, `code` (`status.code().unwrap_or(0)` on natural exit; `unwrap_or(1)` on kill) |
| `process:error` | `Command::spawn` failed | `id`, `error` |
Stdout reader (`process_manager.rs:128139`):
```rust
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
if line.starts_with(JSON_ROW_PREFIX) {
emit_json_row(..., line[JSON_ROW_PREFIX.len()..]);
} else {
emit_stdout(...);
}
```
JSON-row lines are **not** forwarded as `process:stdout` and are **not** written to the subprocess log as info lines.
Logging: spawn logs sanitised argv (`~` for `$HOME`/`%USERPROFILE%`) at info, raw argv at debug (`process_manager.rs:2860, 105119`). Each stdout line → `log::info!`, stderr → `log::warn!`, exit → `log::info!`.
### 0.6 Sidecar fetch / bundle
`scripts/fetch-argyll.mjs` downloads from `https://github.com/Gronod/argyllcms/releases` (override: `ARGYLL_SERVER_URL`, `ARGYLL_REPO`, `ARGYLL_RELEASE_TAG`). Marker binary is `instlist` / `instlist.exe`. Windows also copies `usb/` (`ArgyllCMS_install_USB.exe`, `ArgyllCMS.inf`) to `src-tauri/argyll/usb/`.
`tauri.conf.json:3840`: `"resources": ["argyll/**/*"]`.
`build.rs:1964` panics the compile if the platform marker is missing (`npm run fetch-argyll` required).
NSIS (`windows/hooks.nsh:133158`): admin install prompts “Install ArgyllCMS USB instrument drivers?” and `ExecWait`s `ArgyllCMS_install_USB.exe`. Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe`.
---
## 1. `targen` — Stage 1 patch generation **and** Stage 0 calibration chart
### 1.1 When
| UI | Command | Process id |
|---|---|---|
| Stage 1 **Generate** (`#btnGenerate`) | `run_targen` | `targen_{basename}` |
| Stage 0 **Generate Calibration Target** (`#btnCalGenerate`) | `generate_calibration_target` | `targen_{CAL_basename}` |
`run_targen`: `commands.rs:952964`. `generate_calibration_target`: `calibration.rs:598613`. Both go through ProcessManager.
Stage 0 prefixes the basename with `CAL_` (`calibration_basename`, never double-prefix). Calibration charts must not collide with the profiling `.ti1`.
### 1.2 Profiling argv — `build_targen_args` (`commands.rs:788905`)
Always starts `-v -d {2|4}`. Colour space is the only discriminator for `-d`: RGB → `2`, CMYK → `4`. No other colourant counts.
| UI field (`#id` / config) | Flag | Condition |
|---|---|---|
| `#colourSpace` radio (`colour_space`) | `-d 2` or `-d 4` | always |
| `#patchCountPreset` / `#patchCountCustom` (`patch_count`, else `total_patches`) | `-f N` | N > 0; JS default 800 |
| `#whitePatches` (`white_patches`) | `-e N` | Some |
| `#blackPatches` (`black_patches`) | `-B N` | Some. JS: RGB default 4, CMYK default 0 on colour-space change |
| `#targenGreySteps` (`grey_steps`) | `-g N` | Some and N > 0 |
| `#targenSingleChannelSteps` (`single_channel_steps`) | `-s N` | Some and N > 0 |
| `#targenPrecondProfile` (`preconditioning_profile`) | `-c PATH` | non-empty trim |
| `#targenNeutralSteps` (`neutral_steps`) | `-n N` | Some and N > 0 |
| `#targenNeutralConcentration` (`neutral_concentration`) | `-N x.xx` | Some and `|x-0.50| > 0.001` (slider default 0.50 → omitted) |
| `#targenHighQuality` (`ofps_high_quality`) | `-G` | `Some(true)` |
| `#targenAdaptation` (`ofps_adaptation`) | `-A x.xx` | Some (even 0.10 — **no** default-skip) |
| `#targenAlgorithm` (`full_spread_algorithm`) | `-t` `-r` `-R` `-q` `-Q` `-i` `-I` | value in that set; `"ofps"` / default → no flag |
| `#targenInkLimit` (`total_ink_limit`) | `-l N` | **CMYK only**, 1..=400 |
| `#targenDarkEmphasis` (`dark_emphasis`) | `-V x.xx` | Some and `|x-1.0| > 0.001` |
| `#targenDevicePower` (`device_power`) | `-p x.xx` | Some and `|x-1.0| > 0.001` and `x > 0` |
| `#targetBasename` | positional | last arg, no extension |
**Not passed:** `-u` (Argyll fork has `targen -u` JSON progress — ICCery never enables it). `-v` always.
JS config construction: `targen.js:295314`. On success (`code === 0`) advances to Stage 2 via `setStage1Result` + `wizardState.navigateToStage(2)`.
### 1.3 Calibration argv — `build_calibration_targen_args` (`calibration.rs:146189`)
Hard-wired for a short per-channel wedge, **not** a full-spread profile chart:
```
-v -d {2|4} -s {steps} -g {steps} [-n {steps}] -e {white|4} [-l TAC] -f 0 {CAL_basename}
```
| UI / config | Flag | Condition |
|---|---|---|
| `#calColourSpace` | `-d 2` / `-d 4` | rgb / cmyk |
| `#calSteps` (`steps_per_channel`) | `-s N` and `-g N` | clamped 11..=51 (`DEFAULT_STEPS=21`) |
| `#calNeutralEmphasis` | `-n N` (same N) | checked |
| `#cal` white_patches (JS always sends `4`) | `-e 4` | if `None`, code also defaults to `-e 4` |
| `#calInkExplore` | `-l N` | CMYK only, 200..=400 |
| (hardcoded) | `-f 0` | always — “Full-spread patches are not useful on a calibration wedge” |
| `channels` field | — | **unused** in the builder |
Basename is sanitised (no `/` `\\` `..`). JS: `calibration.js:399410`.
### 1.4 Env / cwd / stdin
- cwd = `resolve_safe_cwd(config.cwd)` (Stage 1 browse dir / wizard cwd).
- `ARGYLL_NOT_INTERACTIVE=1`, Windows `CREATE_NO_WINDOW`.
- **No stdin protocol.** targen is batch.
### 1.5 stdout / exit
Frontend appends every `process:stdout`/`stderr` line into `#targenLog` / `#calLog`. Success = `code === 0`. No regex parsing.
### 1.6 Artefacts
Consumes: nothing required (optional `-c` ICC/ICM/MPP).
Produces in cwd:
- `{basename}.ti1` (always) — Stage 1 complete gate (`verify_stage_artefacts`).
- Calibration: `CAL_{name}.ti1`.
### 1.7 Tests
Rust: `commands.rs:14811628` (RGB, CMYK, total_patches fallback, all-advanced, RGB ignores `-l`). Calibration: `calibration.rs:903936` (RGB no `-l`, CMYK `-l 320` + `-n`, path-separator reject). JS: `calibration.test.js` basename prefix only (no argv).
---
## 2. `printtarg` — Stage 2 layout (also used by Stage 0 “Create Layout”)
### 2.1 When
Stage 2 **Create Layout** (`#btnCreateLayout`) → `run_printtarg` (`commands.rs:966978`). Process id `printtarg_{basename}`.
Stage 0 **Create Layout & Print** (`#btnCalLayout`) does **not** spawn printtarg itself: it sets `wizardState.sessionMode = 'calibration'`, copies the `CAL_` basename into Stage 2 via `setStage1Result`, and navigates to Stage 2. The user then hits Create Layout. `getPrinttargCalibrationFields('CAL_…')` returns `{calibration_file: null}` so `-K` is **never** applied to the calibration chart itself (`calibration.js:108116`, `AGENTS.md:7`).
### 2.2 argv — `build_printtarg_args` (`commands.rs:907950`)
Always:
```
-v -u -i {instrument} -p {page_size} [ -r | -R {seed} ] [-d {label}] {-t|-T} {dpi} [-K|-I {cal}] {basename}
```
| UI field | Flag | Notes |
|---|---|---|
| `#instrumentSelect` | `-i {code}` | `i1` (default), `p3`, `CM`, `SS`, `20`, `22`, `41`, `51` (`PrinttargConfig` comment `commands.rs:768`) |
| `#pageSizeSelect` / custom W×H | `-p {size}` | A4, A4R, A3, A2, Letter, LetterR, Legal, 4x6, 11x17, or `{W}x{H}` mm (JS requires ≥50) |
| `#printtargLayoutOrder` | `-R 1` (default), `-R {seed}`, or `-r` | `no_randomize` **supersedes** seed (`commands.rs:917922`). Default `random_seed: Some(1)` (`commands.rs:762764`) for ICCery#163 determinism |
| `#targetLabelPreview` (`custom_label`) | `-d {string}` | assembled `ICCery - {run} - {printer} - {ink} - {driver paper} - {actual paper} - DD/MM/YYYY HH:MM`. Argyll fork `argyllcms#19` |
| bit-depth radios | `-t {dpi}` (8-bit) or `-T {dpi}` (16-bit) | dpi from `#tiffDpi`, default 300 |
| calibration (`getPrinttargCalibrationFields`) | `-K {cal}` apply, or `-I {cal}` embed-only | only if Apply Calibration on **and** basename is **not** `CAL_*` |
| Stage 1 basename | positional | last |
**`-u` is always on.** That is the ICCery-patched JSON manifest (`argyllcms#3`).
### 2.3 JSON event schema (`-u` manifest)
Parsed in JS from the **accumulated stdout** after exit, not via `process:json_row` (`printtarg.js:680691`):
```js
stdout.match(/\{[\s\S]*?"event"\s*:\s*"manifest"[\s\S]*?\n\}/)
```
Expected object (from Argyll issue #3 and gallery use):
```json
{
"event": "manifest",
"pages": [
{ "filename": "target_01.tif", "patches": 800, "width_mm": 210, "height_mm": 297 }
]
}
```
`renderTiffGallery` uses `page.filename`, `page.patches`, `pages[0].width_mm/height_mm`. TIFF is previewed via `read_tiff_preview_png` (decode TIFF → PNG ≤1200px, base64).
### 2.4 stdin / env / cwd
No stdin. cwd = Stage 1 working dir. `ARGYLL_NOT_INTERACTIVE=1`, `CREATE_NO_WINDOW`.
### 2.5 Exit
`code === 0` → parse manifest, show gallery + raw-print panel, `setStage2Result`. Non-zero → log error, stay on Stage 2. Native print (`print_target_native`) is **not** an Argyll call (GDI / CUPS / NSPrintPanel).
### 2.6 Artefacts
Consumes: `{basename}.ti1` in cwd.
Produces:
- `{basename}.ti2` — Stage 2 gate.
- One or more TIFF pages named in the manifest (typically `{basename}.tif` or `{basename}_NN.tif`). 8-bit (`-t`) vs 16-bit (`-T`).
- If `-K`/`-I`: calibration is applied to / embedded in the printed patches; `.cal` is not copied.
### 2.7 Tests
`commands.rs:16311802`: i1/A4/8-bit, CM/Letter/16-bit, custom `200x400`, custom label, custom seed 42, raster `-r` (seed ignored), `-K`, `-I` embed-only. JS: `calibration.test.js` asserts CAL_ charts skip `-K`.
---
## 3. `chartread` — Stage 3 measurement (and Stage 0 “Measure Chart”)
### 3.1 When
Stage 3 **Start Measurement** (`#btnStartRead`) → `run_chartread` (`commands.rs:10451061`). Process id `chartread_{basename}`.
Stage 0 **Measure Chart** navigates to Stage 3 with the `CAL_` basename; the same `run_chartread` path is used.
`enable_i1pro2_leds` is **not** sent by JS. If the config field is `None`, the command loads `AppSettings.enable_i1pro2_leds` (`commands.rs:10511054`). Default `false` (stock Argyll compatibility; ICCery#204 / Argyll#37). The fork flag is `-Y l` (not the earlier proposed `-L`).
### 3.2 argv — `build_chartread_args` (`commands.rs:10231043`)
```
-v -u [-c {port}] [-Y l] {basename}
```
| UI / state | Flag | Condition |
|---|---|---|
| (always) | `-v -u` | `-u` = `ROW_COLORS_JSON` stream (`argyllcms#1`) |
| `#chartreadInstrumentSelect` (`port`) | `-c {port}` | non-empty. Port `"1"` is stored as `""` by the detector so default port is used (`chartread.js:488489`). ICCery#111: do **not** pass instlist device index as `-c`. |
| Settings `enable_i1pro2_leds` | `-Y l` | true. LEDs: white=cal, blue=ready, red=error, green=capture. Unpatched binaries reject `-Y l`; frontend captures `lastStderrLine` and opens the log. |
No `-p` (spot), `-t` (transmissive), `-N` (skip cal), `-H`, `-F`, `-r` resume, `-n`.
### 3.3 JSON event schema (`-u` / `ROW_COLORS_JSON`)
Intercepted in ProcessManager, emitted as `process:json_row`. Frontend: `swatch_grid.js:95116`. Ignored unless `data.event === "row_complete"`.
```json
{
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 12,
"patch_count": 21,
"patches": [
{
"id": "1",
"loc": "A1",
"is_pad": false,
"device": [0.0, 50.0, 100.0],
"expected": { "XYZ": [18.42, 20.12, 15.68], "Lab": [51.98, -8.45, 12.32] },
"measured": { "XYZ": [...], "Lab": [...], "spectral": { "bands": 36, "start_nm": 380, "end_nm": 730, "norm": 100, "values": [...] } }
}
]
}
```
`is_pad` patches are skipped only when they have no `measured` **and** all-zero `device` (`swatch_grid.js:137141`) so white-reference pads from targen `-e` still render.
On `row_index + 1 >= total_rows` the swatch listener forces `STATE.ALL_STRIPS_READ`.
Mock: `src-tauri/argyll/mocks/chartread.mock` (handheld + `--xy` / `MOCK_XY_TABLE=1`).
### 3.4 stdin protocol
All via `send_stdin` (`commands.rs:1623``ProcessManager::send_stdin`). Bytes are written **as-is** and flushed. No extra newline is added by Rust — JS includes `\n`.
| Button | State(s) | Bytes | Why |
|---|---|---|---|
| `#btnCalibrate` | `CALIBRATING` | `" \n"` (space + LF) | Argyll “hit any key / space to calibrate” |
| `#btnAccept` | `WARNING`, `PROMPT_CONTINUE`, `TABLE_PLACE_SHEET`, `TABLE_ALIGN` | `"\n"` | Continue / accept strip / sheet placed / fiducial aligned. TABLE_* does **not** force `READING` |
| `#btnRetry` | `AWAITING_STRIP`, `ALL_STRIPS_READ`, `WARNING`, `ERROR` | `" \n"` | Re-read strip |
| `#btnDoneRead` | `AWAITING_STRIP`, `ALL_STRIPS_READ` | `"d\n"` | Write `.ti3` and exit (ICCery#175) |
| `#btnUndo` | strip states | `"u\n"` | Undo last strip |
| `#btnSkip` | `AWAITING_STRIP`, `ERROR` | `"s\n"` | Skip current strip |
| `#btnCancel` | any; XY extra | `"q\n"` then 500 ms then `kill_process` | Park XY head (`AGENTS.md:170`) then SIGKILL-equivalent |
`send_stdin` errors with `"Process not found or stdin not available"` if the id is not in `stdins`.
### 3.5 stdout state machine — `classifyChartreadLine` (`chartread.js:70286`)
Pure function. Priority order:
1. “remove last sheet” → info, `isRemoveSheetNotice`, **state unchanged** (Argyll emits this just before writing `.ti3`).
2. `/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i``sheetOk` meta.
3. `/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i``TABLE_ALIGN`.
4. `/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i` or “place sheet” / “remove previous sheet” → `TABLE_PLACE_SHEET`.
5. “hit return to continue” (and not “use it anyway”) → sticky `TABLE_*` if already there, else `PROMPT_CONTINUE`.
6. `'d' if/when done`, “all strips/patches read”, “done reading” → `ALL_STRIPS_READ`.
7. “(warning)”, “use it anyway”, “seem to have read strip pass”, “unexpected response” → `WARNING`.
8. place + (reference|white|calibrat|standard) **or** “hit any key to continue” **or** “calibration”, excluding place-sheet/locate-patch → `CALIBRATING`.
9. “hit … read … strip”, “ready to read”, “read … strip … key” → `AWAITING_STRIP`.
10. “reading strip/sheet”, “processing”, “scanning” → `READING`.
11. “error”, “too fast/slow”, “misread”, “failed to read” → `ERROR`.
XY table is auto-detected from these prompts **or** from instlist `data-xy="1"` (`/spectro\s?scan|i1io/i`).
### 3.6 Exit / snapshot
`code === 0``snapshot_ti3` copies `{basename}.ti3``{basename}_pass{N}.ti3` and **deletes** the canonical `.ti3` so Stage 4 stays locked (ICCery#109/#110, `commands.rs:11121131`). `setStage3Result` is **not** called until Finish.
`code !== 0` → prompt shows last stderr line; log `<details>` opened.
Single pass Finish → `promote_ti3` restores `{basename}.ti3` from `*_pass1.ti3` (average is **not** invoked). Multi-pass → `run_average`.
Cancel: `kill_process` after optional `q\n`.
### 3.7 Artefacts
Consumes: `{basename}.ti2` (and the printed chart).
Produces: `{basename}.ti3` (ephemeral) then `{basename}_passN.ti3`. Canonical `.ti3` only after Finish/average.
### 3.8 Tests
Rust argv: `commands.rs:18051874` (auto, empty port, `-c 1`, `-Y l`, both, leds disabled). Snapshot roundtrip: `18981934`. JS classifier: `chartread.test.js` (39 cases, XY sticky continuation, strip mode, warnings). Mock script as above.
---
## 4. `instlist` — instrument detection
### 4.1 When
Stage 3 **Detect** (`#btnDetectInstruments`) → `detect_instruments` (`commands.rs:615619`).
```rust
let binary = resolve_binary(..., "instlist")?;
state.spawn(app, "instlist".to_string(), binary, vec![], None).await
```
**Empty argv. cwd = None.** Process id is the literal `"instlist"` (not namespaced). Duplicate Detect clicks while running → `"Process 'instlist' is still running"` (#116).
This is the Argyll fork USB enumeration API (`argyllcms#6`). ICCery does **not** pass `-u`; the forks `instlist` prints JSON on stdout by default (or ICCery treats the whole stdout as JSON).
### 4.2 stdout parsing (`chartread.js:438503`)
Accumulate stdout. On exit:
1. `JSON.parse(trimmed)` looking for `{ devices: [ { port, name, type } ] }`.
2. Fallback regex: `/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i` plus `KNOWN_INST_TOKENS = /i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein/i`.
Each device → `<option value="{port if port!=='1' else ''}">`. SpectroScan / i1iO get `data-xy="1"` and ` · XY Table`.
Default option: `"Auto (First available port)"` with empty value → no `-c`.
### 4.3 stdin / artefacts
None. No files.
---
## 5. `average` — multi-pass ti3 merge
### 5.1 When
Stage 3 **Finish & Average** with ≥2 recorded passes (`chartread.js:864947`) → `run_average` (`commands.rs:10791091`). Process id `average_{output}` e.g. `average_job.ti3`.
### 5.2 argv — `build_average_args` (`commands.rs:10701077`)
```
-v {input1} {input2} ... {output}
```
JS:
```js
{ inputs: recordedPasses.map(p => p.filename), // "job_pass1.ti3", ...
output: `${basename}.ti3`,
cwd }
```
No other flags. Inputs are **relative names** (cwd is the project dir).
### 5.3 Exit
`code === 0``setStage3Result`, Stage 4. Else fallback `promote_ti3` of pass 1.
No stdin. `ARGYLL_NOT_INTERACTIVE=1`.
### 5.4 Artefacts
Consumes: `{basename}_passN.ti3`. Produces: `{basename}.ti3` (Stage 3 gate).
### 5.5 Tests
`commands.rs:18771896`.
---
## 6. `colprof` — Stage 4 profile calculation
### 6.1 When
Stage 4 **Calculate Profile** (`#btnCreateProfile`) → `run_colprof` (`commands.rs:12431255`). Process id `colprof_{basename}`.
**`-u` is NOT passed** even though the fork has `colprof -u` progress JSON (`argyllcms#4`). Progress is guessed from plaintext stdout.
### 6.2 argv — `build_colprof_args` (`commands.rs:11761241`)
Always `-v -a {algorithm} -q {quality}`.
| UI | Flag | Condition |
|---|---|---|
| `#colprofAlgorithm` | `-a l\|x\|X\|m` | `l` Lab cLUT (default), `x` XYZ cLUT, `X` Display XYZ+matrix, `m` matrix |
| `#colprofQuality` | `-q l\|m\|h\|u` | `m` Medium default. `u` = Ultra |
| `intent` (preset field only) | `-t {intent}` | **no Stage 4 control**. Presets leave it `None`. Tests use `"p"` / `"a"` |
| `#colprofFwa` / custom `.sp` | `-f {val}` | `"none"` → omit; `"D50"`/`"D65"`/`path.sp``-f VAL`; empty string → bare `-f` |
| `#colprofIlluminant` | `-i A\|C\|D50M2\|D65\|F5\|F8\|F10` | empty = default D50, omit flag |
| `#colprofObserver` | `-o 1964_10\|2015_2\|2015_10` | empty = 1931 2°, omit |
| `#colprofInputViewCond` | `-c pc\|pp\|pe\|pm` | `"none"` omit |
| `#colprofOutputViewCond` | `-d mt\|mb\|md\|jm\|jd\|tv` | `"none"` omit |
| `#colprofDescription` | `-D {text}` | non-empty; JS falls back to basename |
| `#colprofCopyright` | `-C {text}` | non-empty |
| basename | positional | last |
JS config: `colprof.js:95107`. Custom FWA: `fwa: colprofCustomSpPath.value \|\| "none"`.
### 6.3 stdout “progress”
`colprof.js:119126` (case-insensitive):
- contains `"gamut mapping"` → “Gamut mapping calculation in progress...”
- `"fitting"` or `"clut"` → “Fitting cLUT grid points...”
- `"writing"` or `"icc profile"` → “Writing ICC profile header & tags...”
Mock (`colprof.mock`) emits exactly those phrases.
### 6.4 Exit / follow-on
`code === 0``get_profile_path` (`.icm` if it exists and `.icc` does not; else `.icc`; Windows default extension `.icm` if neither exists, Unix `.icc``commands.rs:621639`). Then if Apply Calibration: `apply_calibration` (see §8) in-place. Then `extract_gamut`.
`intent` is in `ColprofConfig` and presets but the Stage 4 form never sets it.
### 6.5 Artefacts
Consumes: `{basename}.ti3`. Produces: `{basename}.icc` (Unix/macOS) or `{basename}.icm` (Windows). Stage 4 gate.
### 6.6 Tests
`commands.rs:19372099` (base, FWA D50/none/custom .sp, illuminant+observer, viewing conditions, combined).
---
## 7. `applycal` — embed/unembed calibration curves
### 7.1 When
Not ProcessManager. `apply_calibration` (`calibration.rs:703762`) uses `run_captured`.
Callers:
1. Automatically after successful colprof (`colprof.js:163171`) if `cal.applyEnabled && cal.calPath`. `output_path: null` → in-place replace via `{input}.applycal.tmp` then `rename`.
2. Any future caller of the Tauri command.
### 7.2 argv — `build_applycal_args` (`calibration.rs:237261`)
```
-v {-a|-u} {cal_path} {input_path} [{output_path}]
```
| Config | Flag |
|---|---|
| always | `-v` |
| `unapply: false` (JS always) | `-a` (apply) |
| `unapply: true` | `-u` (**unapply**, NOT JSON) |
| `cal_path`, `input_path` | required; empty → Err |
| `output_path` | optional positional |
Note: applycal `-u` conflicts in meaning with every other tools `-u` (JSON). ICCery never sends `unapply: true` from JS.
cwd = parent of input profile. `ARGYLL_NOT_INTERACTIVE=1`, `CREATE_NO_WINDOW`. Exit ≠ 0 → delete tmp, return last stderr line.
### 7.3 Artefacts
Consumes: `.cal` + `.icc`/`.icm`. Produces: same path (in-place) or `output_path`. Temp extension `.applycal.tmp`.
### 7.4 Tests
`calibration.rs:9871015`.
---
## 8. `printcal` — compute `.cal` from measured calibration chart
### 8.1 When
Stage 0 **Compute Curves** (`#btnCalCompute`) → `compute_calibration_curves` (`calibration.rs:615701`). **Captured**, not streamed. Requires `{ti3_basename}.ti3` already on disk.
Collision: if `{basename}.cal` exists and `force_overwrite` is false → error string containing `"already exists"`; JS dialog Overwrite / Rename / Cancel (`calibration.js:418437, 485492`). Rename uses `{basename}_{ISO-stamp}.cal`.
### 8.2 argv — `build_printcal_args` (`calibration.rs:191235`)
Always `-v -e`. Then:
| Config / UI | Flag | Condition |
|---|---|---|
| `no_ink_limit` (JS always false) | `-I` | true |
| `verify` (JS always false) | `-z` | true |
| `previous_cal` (current `calState.calPath`) | `-a {path}` | non-empty |
| `#calTacOverride` (`total_ink_limit`) | `-m x.x` | Some and > 0 |
| `#calInkLimitControls` (`channel_limits`) | `-x{C} {percent}` | first char of channel, e.g. `-xC 95.0` |
| `output_cal` | `-o {file.cal}` | default `{basename}.cal` |
| `ti3_basename` | positional | last |
`-e` here is printcals even/estimate switch (always on), **not** targen white patches.
### 8.3 stdout parsing — `parse_printcal_stdout` (`calibration.rs:274307`)
Line-oriented, not JSON:
- line matching `ideal power` / `device power` / `power value` → first number → `recommended_power` (feeds Stage 1 targen `-p` hint in the dashboard).
- line containing `total` and (`ink`|`tac`|`limit`) → TAC.
- line starting Cyan/Magenta/Yellow/Black/Red/Green/Blue or `C:`/`C ` etc. → per-channel limit.
Then `parse_cal_file` on the written `.cal` (CGATS: `COLOR_REP`, `CREATED`, `DESCRIPTOR`, `MAX_TAC`/`TOTAL_INK_LIMIT`/`INK_LIMIT`, `INK_LIMIT_*`, `BEGIN_DATA_FORMAT` / `BEGIN_DATA` curves). If stdout limits empty, infer from curve max×100.
### 8.4 Artefacts
Consumes: `CAL_*.ti3`. Produces: `{basename}.cal` (or override). Also `iccery-calibration.json` project state and optional library copy under `app_data_dir/calibrations/`.
### 8.5 Tests
`calibration.rs:939984, 10181072`.
---
## 9. `profcheck` — Stage 5 verification
### 9.1 When
Stage 5 **Verify** (`#btnVerify`) → `run_profcheck` (`commands.rs:12751302`). Process id `profcheck_{ti3_path}` (full path, not basename — `colprof.js` uses basename; these must not be confused; #56 was exactly that class of bug for colprof).
ICC path auto-flips `.icc``.icm` if the requested file is missing (`commands.rs:12831295`).
### 9.2 argv — `build_profcheck_args` (`commands.rs:12641273`)
Hard-coded:
```
-v -k -s -u {ti3_path} {icc_path}
```
| Flag | Meaning in this fork |
|---|---|
| `-v` | verbose |
| `-k` | CIEDE2000 |
| `-s` | (Argyll: “standard” / summary — kept for the text line parser) |
| `-u` | JSON report (`argyllcms#5`) |
No UI toggles. Paths may be absolute (JS builds `cwd + sep + basename.ti3` / `get_profile_path`).
### 9.3 JSON + text parsing — `parseProfcheckReport` (`profcheck.js:27139`)
Order:
1. `No of test patches = (\d+)`.
2. All `{...}` objects in stdout; keep those with `event==="report"` or any of `avg_de`, `avg_de2000`, `peak_de`, `peak_de2000`, `rms`, `rms_de`. Prefer object with `*de2000` keys.
3. Else text: `Profile check complete, errors…: max. = X, avg. = Y, RMS = Z`.
4. Else regex families for avg/max/rms. Missing metrics → `warnings[]` and cards show 0.00 (`AGENTS.md:2324`).
JSON schema (mock + tests):
```json
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
```
Also accepted: `peak_de` / `avg_de` / `max_de` / `rms_de`.
Quality bands (ICCery#95, **not** Argyll): `<1` Excellent, `<2` Good, `<3.5` Acceptable, else Poor. Saved via `save_verification_record`.
### 9.4 stdin / artefacts
No stdin. Consumes `.ti3` + `.icc`/`.icm`. Produces no new Argyll files (verification_history.json is ICCery). Attempts to load `{basename}.gam` into the 3D viewer (created earlier by iccgamut).
### 9.5 Tests
Rust argv: `commands.rs:21022110`. JS: `profcheck.test.js` (real `-u` JSON, de2000 preference, text summary, legacy regex, breach alert). Mock: `profcheck.mock`.
---
## 10. `iccgamut` — 3D gamut mesh
### 10.1 When
Automatically after colprof success (`colprof.js:197214`) → `extract_gamut` (`commands.rs:693731`). Process id `iccgamut_{stem}`.
### 10.2 argv — `build_iccgamut_args` (`commands.rs:684691`)
```
-v -d 10 {resolved_icc_path}
```
`-d 10` is **surface density**, not a directory (ICCery#112). Path is the real `.icc`/`.icm` (auto-flip if missing). cwd = parent of the profile.
**No `ARGYLL_3D_DISP`.** The GUI never launches Argylls 3D viewer; it reads the `.gam` file in-process.
### 10.3 Exit
`code === 0``loadGamutMesh("{cwd}/{basename}.gam")`. Failures are `console.warn` only.
### 10.4 Artefacts
Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bundled reference: `src/assets/sRGB.gam` and `src-tauri/argyll/reference_gamuts/sRGB.gam`.
### 10.5 Tests
`commands.rs:14751478`.
---
## 11. Binaries that are **not** invoked
| Binary | Status |
|---|---|
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
| `dispread` | Same |
| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:614`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
---
## 12. ProcessManager internals
File: `src-tauri/src/process_manager.rs`.
### 12.1 Maps
```rust
stdins: Arc<Mutex<HashMap<String, Arc<Mutex<ChildStdin>>>>>
killers: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>
```
The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot hold a mutex that `send_stdin` needs.
### 12.2 Spawn ids (complete list)
| Pattern | Binary |
|---|---|
| `targen_{basename}` | targen (profile + CAL_) |
| `printtarg_{basename}` | printtarg |
| `chartread_{basename}` | chartread |
| `average_{output}` | average (`output` includes `.ti3`) |
| `colprof_{basename}` | colprof |
| `profcheck_{ti3_path}` | profcheck (full path) |
| `iccgamut_{stem}` | iccgamut |
| `instlist` | instlist (literal) |
| caller-supplied | unused `spawn_process` |
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
Before spawn, if `stdins.contains_key(&id)``Err("Process '{id}' is still running")`. Prevents a second `chartread_{basename}` from replacing map entries while the old wait task still `remove()`s that id on exit (which would orphan the live stdin).
Frontend also sets `measurementInProgress` to disable Start / Measure Another / Finish.
### 12.4 Wait / reap
Wait task (`process_manager.rs:166194`):
```rust
let exit_code = tokio::select! {
res = child.wait() => status.code().unwrap_or(0),
_ = kill_rx => { child.start_kill(); child.wait().await; code.unwrap_or(1) }
};
stdins.remove(id); killers.remove(id);
emit_exit(id, exit_code);
```
Natural exit also reaps, so `send_stdin` after the child dies returns “Process not found”.
v0.1.16 (`90012e4`, ICCery#63) originally awaited `child.wait()` **inside** `Mutex<Child>` — that is the bug #84 fixed.
### 12.5 stdin write
```rust
stdin.write_all(input.as_bytes()).await?;
stdin.flush().await?;
```
No encoding transform. JS is responsible for `\n`.
`kill` first `stdins.remove(id)` (drops/closes the pipe, which is what Argyll sees as EOF) then fires the oneshot so the wait task `start_kill()`s.
### 12.6 `kill_all`
`process_manager.rs:237267`:
1. `stdins.clear()` (close every pipe).
2. Drain `killers` and `send(())` each.
3. Sleep 50 ms so wait tasks can `start_kill()`.
4. Return count.
Invoked:
- Tauri command `kill_all_processes` (`commands.rs:3338`) — **no JS caller**.
- `RunEvent::Exit` / `ExitRequested` (`lib.rs:124129`).
- `WindowEvent::CloseRequested` (`lib.rs:136141`).
This is ICCery#147/#149: closing the window during chartread used to leave the instrument locked.
### 12.7 Windows pipe deadlock history
Two layers, two repos:
**ICCery#84 (host, v0.2.1, PR #83, `d6118a1`)** — Tokio `Child::wait()` deadlock:
- PR #77 / #63 replaced `try_wait` polling with `child.wait().await` while holding `Mutex<Child>`.
- Effect 1: `send_stdin` / `kill` blocked until the child exited (Calibrate/Retry/Skip/Cancel dead).
- Effect 2: Tokios `wait()` **drops stdin** to avoid parent/child pipe deadlock, so the pipe closed immediately after spawn — chartread never received keystrokes.
- Fix: `take()` all three stdio handles; store only `ChildStdin` in the map; `select!` wait vs oneshot kill.
**gronod/argyllcms#24 (child, Windows anonymous pipes)**`ReadFile` deadlock inside Argyll:
- `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes** (`CreatePipe` / Rust `Stdio::piped()`).
- `con_char(wait=0)` then `ReadFile()`s a blocking pipe → `poll_con_char` never returns → instrument switch (USB EP `0x84`) is never polled → lamp never lights after calibration.
- Fix in the fork: `PeekNamedPipe` before `ReadFile` (`spectro/conv.c`).
- ICCery-side companion: `ARGYLL_NOT_INTERACTIVE=1` (#134) so Argyll takes the pipe path at all. **Stock Graeme Gill binaries will still hang on Windows interactive chartread.**
ROADMAP.md:59: “Resolved P0 process manager deadlock and premature stdin pipe closure affecting interactive `chartread` instrument workflows.” / “Decoupled `ChildStdin` mutex management from child process wait/reap tasks.”
### 12.8 ProcessManager tests
`process_manager.rs:270325`: occupancy helper (does not actually spawn), `kill_all` drains 2 fake killers, path sanitise. No integration test that execs a real binary.
---
## 13. Every Tauri command related to processes
From `lib.rs:54119` plus the command bodies:
### Direct process control
| Command | File:line | Role |
|---|---|---|
| `spawn_process(id, binary, args)` | `commands.rs:614` | Generic spawn, **cwd=None**, unused by UI |
| `send_stdin(id, input)` | `commands.rs:1623` | Write bytes to ChildStdin |
| `kill_process(id)` | `commands.rs:2531` | Close stdin + start_kill one id |
| `kill_all_processes()` | `commands.rs:3338` | Kill all; returns count |
| `resolve_binary(binary_name)` | `commands.rs:48106` | Path lookup |
### Argyll runners (ProcessManager)
| Command | Binary | id |
|---|---|---|
| `run_targen` | targen | `targen_{basename}` |
| `run_printtarg` | printtarg | `printtarg_{basename}` |
| `run_chartread` | chartread | `chartread_{basename}` |
| `run_average` | average | `average_{output}` |
| `run_colprof` | colprof | `colprof_{basename}` |
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
| `detect_instruments` | instlist | `instlist` |
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
### Argyll runners (captured, no events)
| Command | Binary |
|---|---|
| `compute_calibration_curves` | printcal |
| `apply_calibration` | applycal |
### Artefact helpers used around those processes
`parse_ti2_header`, `snapshot_ti3`, `promote_ti3`, `verify_stage_artefacts`, `get_profile_path`, `read_tiff_preview_png`, `read_file_base64`, `select_*` dialogs, `parse_cal_file_cmd`, `list_saved_calibrations`, `save_calibration_to_library`, `select_cal_file`, `load/save_project_calibration`.
---
## 14. File artefacts by OS / stage
| Stage | Consumes | Produces | Gate |
|---|---|---|---|
| 0 cal chart | — | `CAL_*.ti1` | — |
| 0 print | `CAL_*.ti1` | `CAL_*.ti2`, `CAL_*.tif` | — |
| 0 measure | `CAL_*.ti2` | `CAL_*_passN.ti3``CAL_*.ti3` | — |
| 0 compute | `CAL_*.ti3` | `CAL_*.cal`, `iccery-calibration.json` | — |
| 1 | optional `-c` ICC | `{base}.ti1` | `stage1_complete` |
| 2 | `.ti1` | `.ti2`, `.tif`/`.tiff` pages | `stage2_complete` |
| 3 | `.ti2` | `_passN.ti3` then `.ti3` | `stage3_complete` = `.ti3` exists |
| 4 | `.ti3` | `.icc` (macOS/Linux) or `.icm` (Windows); optional in-place applycal | `stage4_complete` |
| 4b | ICC | `{stem}.gam` | not a gate |
| 5 | `.ti3` + ICC | none (history JSON is ICCery) | — |
`verify_stage_artefacts` (`commands.rs:650676`) checks existence only (no content). `.ti2` missing while `.ti3` present still reports `stage2_complete: false`.
`parse_ti2_header` (`commands.rs:366424`) reads `TARGET_INSTRUMENT`, `NUMBER_OF_FIELDS` (stored as `colorant_count` — this is CGATS field count, not ink count), `NUMBER_OF_SETS``patch_count`, `NUMBER_OF_PAGES`/`PAGES`.
---
## 15. Flag cheat-sheet (`-u`, `-Y`, `-K`, `-f`, `-c`, `-d`)
| Flag | Binary | Meaning in ICCery |
|---|---|---|
| `-u` | printtarg | JSON page manifest (always) |
| `-u` | chartread | `ROW_COLORS_JSON` per row (always) |
| `-u` | profcheck | JSON ΔE report (always) |
| `-u` | applycal | **unapply** curves (never sent from JS) |
| `-u` | targen / colprof | **not used** (fork supports JSON progress) |
| `-Y l` | chartread | i1Pro2 LED feedback (settings, default off) |
| `-K` | printtarg | apply `.cal` to printed patches (profiling only) |
| `-I` | printtarg | embed `.cal` without applying (builder supports; UI always `calibration_embed_only: false`) |
| `-I` | printcal | no ink limit (UI never sets) |
| `-f N` | targen | full-spread patch count; cal charts force `-f 0` |
| `-f VAL` | colprof | FWA/OBA (`D50`/`D65`/`.sp`/bare) |
| `-c PATH` | targen | preconditioning profile |
| `-c PORT` | chartread | comm port |
| `-c COND` | colprof | input viewing condition |
| `-d 2\|4` | targen | RGB / CMYK |
| `-d 10` | iccgamut | surface density |
| `-d STR` | printtarg | custom chart label |
| `-d COND` | colprof | output viewing condition |
---
## 16. Tests covering arg builders (index)
| Builder | Tests |
|---|---|
| `build_targen_args` | `commands.rs` `test_build_targen_args_{rgb,cmyk,total_patches_fallback,all_advanced_flags,rgb_ignores_ink_limit}` |
| `build_calibration_targen_args` | `calibration.rs` `test_build_calibration_targen_{rgb,cmyk_ink_limit,rejects_path}` |
| `build_printtarg_args` | `commands.rs` i1/A4/8bit, CM/Letter/16bit, custom page, label, seed, raster, `-K`, `-I` |
| `build_chartread_args` | auto, empty port, with port, leds on/off, port+leds |
| `build_average_args` | two-input, pass-file names |
| `build_colprof_args` | base, FWA D50/none/sp, illuminant+observer, viewing, combined |
| `build_profcheck_args` | `-v -k -s -u` + paths |
| `build_iccgamut_args` | `-v -d 10 path` |
| `build_printcal_args` | defaults, overrides+prev |
| `build_applycal_args` | apply+out, unapply+empty-err |
| `get_binary_candidates` | exe on Windows |
| JS parsers | `chartread.test.js`, `profcheck.test.js`, `calibration.test.js` |
| ProcessManager | duplicate-id occupancy, `kill_all`, path sanitise |
`npm test` runs the JS suites. Rust: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`.