From 4c2863e52c21e7398e0dfd832bd5982501b7effa Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 17:36:50 +0100 Subject: [PATCH 001/138] 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> --- .gitignore | 19 + AGENTS.md | 55 ++ BUILD-PLAN.md | 48 ++ LICENCE.md | 20 + brand/ICCery-logo.svg | 47 ++ brand/app-icon.svg | 40 ++ docs/01-overview.md | 71 +++ docs/02-architecture.md | 149 +++++ docs/03-ipc-and-process-manager.md | 64 ++ docs/04-argyll-binaries.md | 912 +++++++++++++++++++++++++++++ docs/05-argyll-fork.md | 820 ++++++++++++++++++++++++++ docs/06-wizard-and-artefacts.md | 48 ++ docs/07-stage0-calibration.md | 62 ++ docs/08-stage1-targen.md | 45 ++ docs/09-stage2-printtarg.md | 56 ++ docs/10-print-system.md | 181 ++++++ docs/11-print-macos.md | 369 ++++++++++++ docs/12-print-windows.md | 170 ++++++ docs/13-print-linux.md | 89 +++ docs/14-iccery-cpu-targetprint.md | 256 ++++++++ docs/15-stage3-chartread.md | 82 +++ docs/16-stage4-colprof.md | 38 ++ docs/17-stage5-verification.md | 66 +++ docs/18-gamut-viewer.md | 829 ++++++++++++++++++++++++++ docs/19-profile-install.md | 75 +++ docs/20-cgats-interop.md | 50 ++ docs/21-ui-reference.md | 82 +++ docs/22-settings-presets.md | 69 +++ docs/23-assets.md | 47 ++ docs/24-issues-invariants.md | 548 +++++++++++++++++ docs/25-rewrite-notes.md | 135 +++++ docs/26-v2-mac-ticket-plan.md | 638 ++++++++++++++++++++ docs/README.md | 47 ++ docs/nav.json | 137 +++++ 34 files changed, 6364 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 BUILD-PLAN.md create mode 100644 LICENCE.md create mode 100644 brand/ICCery-logo.svg create mode 100644 brand/app-icon.svg create mode 100644 docs/01-overview.md create mode 100644 docs/02-architecture.md create mode 100644 docs/03-ipc-and-process-manager.md create mode 100644 docs/04-argyll-binaries.md create mode 100644 docs/05-argyll-fork.md create mode 100644 docs/06-wizard-and-artefacts.md create mode 100644 docs/07-stage0-calibration.md create mode 100644 docs/08-stage1-targen.md create mode 100644 docs/09-stage2-printtarg.md create mode 100644 docs/10-print-system.md create mode 100644 docs/11-print-macos.md create mode 100644 docs/12-print-windows.md create mode 100644 docs/13-print-linux.md create mode 100644 docs/14-iccery-cpu-targetprint.md create mode 100644 docs/15-stage3-chartread.md create mode 100644 docs/16-stage4-colprof.md create mode 100644 docs/17-stage5-verification.md create mode 100644 docs/18-gamut-viewer.md create mode 100644 docs/19-profile-install.md create mode 100644 docs/20-cgats-interop.md create mode 100644 docs/21-ui-reference.md create mode 100644 docs/22-settings-presets.md create mode 100644 docs/23-assets.md create mode 100644 docs/24-issues-invariants.md create mode 100644 docs/25-rewrite-notes.md create mode 100644 docs/26-v2-mac-ticket-plan.md create mode 100644 docs/README.md create mode 100644 docs/nav.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e57a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Xcode +*.xcuserstate +xcuserdata/ +DerivedData/ +*.xccheckout +*.moved-aside +*.xcscmblueprint +*.xccrashreport + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Fetched Argyll sidecars (release artefacts, not git blobs — #127) +Resources/argyll/ + +# macOS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c5077d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md — ICCery v2 Mac + +## Product +Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5.0 fork as AGPL-isolated subprocesses. Spec snapshot lives in `docs/` (chapters 01–25). Ticket plan: `BUILD-PLAN.md`. + +## Stack +- SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing / panels / file dialogs. +- Minimum macOS **14.0**. Universal `arm64` + `x86_64`. +- Bundle id **`com.gronod.iccery2`**. Product name ICCery. +- App Sandbox **OFF**. Hardened Runtime **ON**. Entitlements in `ICCery.entitlements`. +- No Tauri, no Rust host, no WKWebView, no Three.js. + +## Package layout +- `ICCery` — app target (SwiftUI shell). +- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel). +- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types. + +## AGPL boundary +Never link Argyll. Spawn only. +- Streaming: `ProcessManager` actor (`targen`, `printtarg`, `chartread`, `average`, `colprof`, `profcheck`, `iccgamut`, `instlist`). +- Captured: `runCaptured` (`printcal`, `applycal` only). +Both paths set `ARGYLL_NOT_INTERACTIVE=1`. Never search `$PATH` for binaries. + +## Concurrency +No blocking subprocess I/O on `@MainActor`. +Do not hop to main per stdout line (colprof emits thousands of `.`). +Stdin handle is independent of wait (#84). Process ids are exclusive leases (#116). +`killAll` on `NSApplication.willTerminate` and last-window close (#147, #149). +XY cancel: send `q\n`, wait ~500 ms, then kill. + +## Argyll flag discipline +See `docs/25-rewrite-notes.md` and `docs/04-argyll-binaries.md` §15. +`-d` / `-r` / `-R` / `-u` / `-Y` / `-c` mean different things per tool. +v2.0 `-u` policy: printtarg + chartread + profcheck only. + +## Files +Artefact gating on disk. No placeholder basenames (#60). +Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213). +User-supplied strings via SwiftUI `Text` only (#114). +TIFF never rendered directly — host-side PNG preview (#58). + +## Branching +`develop` ← `milestone/mN-` ← `feat/-`. +PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`. + +## Verify +``` +xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO +codesign -dvv +``` + +## Private ColorSync SPI +2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. +Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. +`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md new file mode 100644 index 0000000..2896da5 --- /dev/null +++ b/BUILD-PLAN.md @@ -0,0 +1,48 @@ +# BUILD-PLAN.md — ICCery v2 Mac + +Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later. + +## Sprint rule +Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green. +Hardware gates block *release of that sprint*, not filing, and not starting coding of the next sprint's non-dependent tickets. + +## Milestone map + +| Id | Name | Issues | CI/mock gate | Hardware gate | +|----|------|--------|--------------|---------------| +| M1 | Foundation & process core | 1–6 | App launches; wizard shell; ProcessManager + `runCaptured`; artefact gating tests; settings persist + dialog | N/A | +| M2 | Target generation & layout | 7–11 | targen → `.ti1`; printtarg → `.ti2`+TIFF; manifest+gallery; resume; presets | N/A | +| M3 | Unmanaged printing (`lp`) | 12–15, 17 | parsers; `build_lp_args` goldens (both `AP_*`); cancel → nil | Preferences shows driver PDE; unmanaged page on Epson or Canon | +| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` | +| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility | +| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile | +| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | + +Issue **16 is not an M3 or M6 exit gate.** + +## Branch taxonomy + +```mermaid +flowchart LR + main[main] + develop[develop] + m1["milestone/m1-foundation"] + m2["milestone/m2-targets"] + feat["feat/7-targen-argv"] + main --> develop + develop --> m1 + develop --> m2 + m2 --> feat +``` + +Quoted node labels are required (v0.8.5 #80). + +## Command surface (parity with v0.8.5, native names) + +Process: `spawn`, `sendStdin`, `kill`, `killAll`, `resolveBinary`, `runCaptured`. +Files: dedicated picker per purpose; `readTiffPreviewPng`; `parseTi2Header`. +Wizard: `verifyStageArtefacts`, `getProfilePath`, `snapshotTi3`, `promoteTi3`. +Runners: `runTargen`, `runPrinttarg`, `runChartread`, `runAverage`, `runColprof`, `runProfcheck`, `extractGamut`, `detectInstruments`. +Cal: `generateCalibrationTarget`, `computeCalibrationCurves`, `applyCalibration`, `parseCalFile`, library + project state. +Print: `getPrinters`, `getPrinterCapabilities`, `showPrinterProperties`, `printTargetNative`. +Install / quality / settings / CGATS: same semantics as `docs/25-rewrite-notes.md` host command list. diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 0000000..69c6431 --- /dev/null +++ b/LICENCE.md @@ -0,0 +1,20 @@ +# LICENCE + +**Copyright (c) 2026 Gordon Bolton** +**All Rights Reserved.** + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback. + +Under this licence, you may **not**: + +* Modify, alter, or create derivative works of the Software. +* Distribute, publish, or sublicense the Software or any derivatives. +* Use the Software for any commercial or production purpose. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Bundled ArgyllCMS sidecar binaries + +This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries. diff --git a/brand/ICCery-logo.svg b/brand/ICCery-logo.svg new file mode 100644 index 0000000..2b7b7e2 --- /dev/null +++ b/brand/ICCery-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCery + diff --git a/brand/app-icon.svg b/brand/app-icon.svg new file mode 100644 index 0000000..917269a --- /dev/null +++ b/brand/app-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 0000000..7191fd9 --- /dev/null +++ b/docs/01-overview.md @@ -0,0 +1,71 @@ +# 01 — Product overview + +ICCery is a native desktop GUI that walks a user through creating a printer ICC/ICM profile with ArgyllCMS. It is **not** a colour engine. All measurement, chart generation, and profile mathematics live in AGPLv3 Argyll binaries spawned as children. ICCery owns UI, artefact gating, unmanaged printing, and visualisation. + +Version analysed: **0.8.5** (`com.gronod.iccery`). + +## Platforms + +| OS | Packaged as | Floor | Notes | +|----|-------------|-------|-------| +| Windows x86_64 | NSIS `.exe` + WiX `.msi` | WebView2 | `.icm` profiles; GDI ICM-off printing; optional Argyll USB driver install | +| macOS Intel + Apple Silicon | `.dmg` / `.app` | **12.0 Monterey** (`LSMinimumSystemVersion`) | Universal binary preferred; WKWebView; NSPrintPanel ColorSync suppression | +| Linux x86_64 | `.AppImage` / `.deb` | Ubuntu 22.04 glibc | CUPS `lp -o raw`; `libcups` | + +macOS 11 and 10.15 are **not** supported after #225. Monterey Intel is best-effort: Stages 1–4 work if WebGL dies. + +## User-facing workflow + +``` +[optional Stage 0] printcal / applycal linearization + │ + ▼ +Stage 1 targen → basename.ti1 +Stage 2 printtarg + OS print → basename.ti2 + page TIFFs +Stage 3 chartread (+ average) → basename.ti3 +Stage 4 colprof (+ applycal) → basename.icc|.icm +Stage 5 profcheck + iccgamut + install → verification, 3D gamut, OS profile store +``` + +Navigation is a left stepper. Forward motion is **artefact-gated on disk**, not on in-memory flags (#60, #151). Backward motion is always allowed. + +## Colour spaces + +- **RGB (Printer Driver)** — `targen -d 2`. Host/driver colour management is expected to be **turned off** at print time; ICCery prints unmanaged. +- **CMYK (RIP)** — `targen -d 4`. Typical for RIP-driven presses. Calibration (Stage 0) is recommended; Stage 1 shows a reminder when no `.cal` is applied. + +## Instruments (Stage 2 layout + Stage 3 read) + +`printtarg -i` codes used in the UI: + +| UI | `-i` | Hardware | +|----|------|----------| +| i1 Pro / i1 Pro 2 | `i1` | Handheld strip (optional `-Y l` LEDs on i1Pro 2 Rev E) | +| ColorMunki | `CM` | Handheld | +| SpyderPrint | `p3` | Handheld (PrintFix Pro) | +| SpectroScan | `SS` | XY table | +| DTP20 / 22 / 41 / 51 | `20` `22` `41` `51` | Legacy X-Rite | + +XY tables (SpectroScan, i1iO) are auto-detected from `instlist` names matching `/spectro\s?scan|i1io/i` and from `chartread` prompts (#93). + +## AGPL boundary (non-negotiable) + +ArgyllCMS is AGPLv3. ICCery is proprietary. The original design **never** `dlopen`s or statically links Argyll. Communication is: + +- spawn with piped stdin/stdout/stderr +- `ARGYLL_NOT_INTERACTIVE=1` in the child environment +- structured JSON on stdout for tools compiled with the Gronod fork `-u` switch +- keystrokes on stdin for interactive `chartread` + +A rewrite that in-process-links Argyll **contaminates the GUI with AGPL**. Keep the process boundary. + +## Identity & assets + +- Wordmark: waffle cone + CMY scoops (C `#00BCEB`, M `#EC008C`, Y `#FFED00`) + K cherry, text `ICC` white + `ery` cyan→blue. File: `src/assets/ICCery-logo.svg`. +- App icon: `src/assets/app-icon.svg` and raster set under `src-tauri/icons/`. +- Accent in UI CSS is VS Code blue `#007acc` on dark `#1e1e1e` / `#252526`. +- Window: 1280×800, min 1100×700, starts **hidden** until first paint (#225). + +## What the rewrite must preserve + +Everything in this spec is behavioural, not Tauri-specific: CLI flags, JSON prefixes, stdin bytes, PPD keys, ColorSync SPI, artefact names, ΔE bands, collision dialogs, and the bugs listed in [24-issues-invariants.md](24-issues-invariants.md). diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..288b1f1 --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,149 @@ +# 02 — Architecture + +## Host / child split + +``` +┌─ ICCery host (any stack) ─────────────────────────────────────┐ +│ Wizard UI ─► WizardState (basename, cwd, printerName) │ +│ │ │ +│ ├─ ProcessManager (spawn / stdin / kill / kill_all) │ +│ ├─ Print subsystem (Win GDI / macOS NSPrint+lp / Linux) │ +│ ├─ Settings + presets (settings.json) │ +│ ├─ Quality store (verification_history.json) │ +│ ├─ Calibration library (.cal files) │ +│ └─ Profile installer (OS colour stores) │ +└──────────────┬────────────────────────────────────────────────┘ + │ stdin / stdout / stderr pipes + │ env ARGYLL_NOT_INTERACTIVE=1 + ▼ +┌─ ArgyllCMS sidecars (AGPLv3) ─────────────────────────────────┐ +│ instlist targen printtarg chartread average │ +│ printcal applycal colprof profcheck iccgamut │ +└───────────────────────────────────────────────────────────────┘ +``` + +Original implementation: Tauri v2 webview (`withGlobalTauri: true`) so the vanilla JS frontend calls `window.__TAURI__.core.invoke` and `window.__TAURI__.event.listen`. A rewrite may replace this with any IPC (HTTP local server, native bindings, gRPC, etc.) but **must keep the same command semantics**. + +## Frontend modules (legacy) + +| Module | Role | +|--------|------| +| `app.js` | Boot, safeInit per stage, `show_main_window` double-rAF + 1500 ms fallback | +| `state.js` | `wizardState`, artefact gating, stage DOM, gamut pause/ensure | +| `targen.js` | Stage 1 | +| `printtarg.js` | Stage 2 + native print UI | +| `chartread.js` | Stage 3 state machine | +| `swatch_grid.js` | Live ΔE₀₀ patches from `process:json_row` | +| `colprof.js` | Stage 4 | +| `profcheck.js` | Stage 5 metrics, drift SVG, CSV | +| `gamut_viewer.js` | Three.js CIELAB viewer (lazy) | +| `calibration.js` | Stage 0 | +| `profile_install.js` | Stage 5 install | +| `settings.js` / `presets.js` | Persistence | +| `cgats_interop.js` | Import/export datasets | +| `delta_e.js` | CIEDE2000 | +| `color_convert.js` | Lab/device → CSS | +| `logger.js` | Forwards console to `log_frontend_message` | + +**Do not** create WebGL during boot. `ensureGamutViewer()` runs only when Stage 5 becomes visible (#225). + +## Backend modules (legacy Rust) + +| Module | Role | +|--------|------| +| `lib.rs` | Tauri builder, plugins, `generate_handler!`, RunEvent kill-all | +| `commands.rs` | Binary resolve, arg builders, dialogs, stage artefact verify | +| `process_manager.rs` | tokio spawn, JSON-row split, CREATE_NO_WINDOW | +| `events.rs` | `process:stdout|stderr|exit|error|json_row` | +| `print/*` | OS printing | +| `calibration.rs` | printcal/applycal + `.cal` parser | +| `profile_install.rs` | OS colour-store copy | +| `quality_store.rs` | verification history, atomic write | +| `settings.rs` | settings + presets | +| `cgats.rs` | CGATS/ti3 parser + canonical serializer | +| `macos_webview.rs` | Dark WKWebView backing | +| `window_lifecycle.rs` | Close vs Web Content death | + +## Sidecar layout + +`scripts/fetch-argyll.mjs` downloads Gronod/argyllcms GitHub (or Gitea) releases into: + +``` +src-tauri/argyll/ + linux-x86_64/instlist + windows-x86_64/instlist.exe + macos-x86_64/instlist + macos-aarch64/instlist + macos-universal/instlist + mocks/ # chartread.mock, colprof.mock, profcheck.mock + reference_gamuts/sRGB.gam +``` + +`resolve_binary(name)`: + +1. If `settings.argyll_binary_dir` is set and the file exists, use it. +2. Else resource `argyll//[.exe]`. +3. On macOS, prefer `macos-universal` if that folder contains `instlist`. +4. Windows always tries `name.exe` first (#85). + +Env override: `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`. + +## Working directory + +Every Argyll run is given an explicit cwd. Empty cwd falls back to Documents → Home → app data (`resolve_safe_cwd`, #59). Basename must not contain `/`, `\`, or `..`. + +Default artefacts live next to each other: + +``` +/.ti1 +/.ti2 +/.tif (and .1.tif, .2.tif … for multi-page) +/.ti3 +/_passN.ti3 # averaging snapshots (#109) +/.icc | .icm +/.gam +/CAL_.ti1|.ti2|.ti3|.cal # calibration, never collides +``` + +## Persistence locations + +| File | Where | Notes | +|------|-------|-------| +| `settings.json` | app data dir | thresholds, argyll dir, presets, LED flag | +| `verification_history.json` | app data dir | max 1000 records, atomic `.tmp` + rename (#213) | +| `iccery.log` | app log dir | 5 MiB rotate, keep 5 historical segments | +| Calibration library | app data / user-chosen | `.cal` files | + +macOS log path: `~/Library/Logs/com.gronod.iccery/iccery.log`. + +## Event bus (must be replicated) + +| Event | Payload | When | +|-------|---------|------| +| `process:stdout` | `{ id, line }` | Non-JSON stdout line | +| `process:stderr` | `{ id, line }` | stderr line | +| `process:exit` | `{ id, code }` | child exited (0 = success; killed may be 1) | +| `process:error` | `{ id, error }` | spawn failure | +| `process:json_row` | `{ id, json }` | stdout line starting `ROW_COLORS_JSON: ` — prefix **stripped** | + +Process ids are deterministic strings, e.g. `targen_${basename}`, `chartread_${basename}`, `instlist`, `iccgamut_${stem}`. Duplicate spawn of a still-running id is rejected (#116). + +Frontend listeners **must** filter on `payload.id`. A historical bug (#56) was process-id mismatch so UI never saw exit. + +## Logging + +- Host: `tauri-plugin-log` to log dir + stdout + webview. wry / tauri_runtime_wry at Info so Monterey "web content process terminated" is captured (#225). +- Subprocess stdout → `log::info!(target: "subprocess")`; stderr → warn. +- Paths in spawn logs are home-sanitized to `~` (`sanitize_arg_for_logging`). +- JS `logger.js` invokes `log_frontend_message`. +- Settings `log_level` is applied at startup **and** when saved (#158). + +## Window / WebView contract (macOS especially) + +See #225 and `macos_webview.rs`: + +- Window `visible: false`, `backgroundColor: #1A1A22`. +- After CSS first paint: invoke `show_main_window` (double `requestAnimationFrame` + 1500 ms fallback). +- `paint_dark_webview`: `setBackgroundColor`, KVC `drawsBackground = NO`, `setUnderPageBackgroundColor:` on macOS 12+. +- Do **not** set `transparent: true` (hit-testing / titlebar). +- On `Exit` / `CloseRequested`: `kill_all` Argyll children (#147, #149) **before** teardown so `chartread` can park an XY head if the UI already sent `q\n`. diff --git a/docs/03-ipc-and-process-manager.md b/docs/03-ipc-and-process-manager.md new file mode 100644 index 0000000..cf59e3f --- /dev/null +++ b/docs/03-ipc-and-process-manager.md @@ -0,0 +1,64 @@ +# 03 — IPC and process manager + +The host never waits on a child from the request that spawned it (except `printcal`/`applycal`, which use captured `.output()`). Streaming tools go through a process manager that: + +1. Rejects a duplicate `id` while that child is still mapped (#116). +2. Pipes stdin/stdout/stderr. +3. Sets `ARGYLL_NOT_INTERACTIVE=1`. +4. On Windows sets `CREATE_NO_WINDOW` (`0x08000000`) (#46). +5. Splits stdout: lines beginning `ROW_COLORS_JSON: ` become `process:json_row` (prefix stripped); everything else is `process:stdout`. +6. Reaps the child on natural exit **or** kill signal; then emits `process:exit`. +7. Drops stdin from the map on kill so writers fail fast. + +## Commands the rewrite must expose + +| Command | Args | Returns | +|---------|------|---------| +| `spawn_process` | `{ id, binary, args }` | `()` — unused by current JS (registered only) | +| `send_stdin` | `{ id, input }` | `()` — `input` is the **exact bytes**, already including `\n` | +| `kill_process` | `{ id }` | `()` | +| `kill_all_processes` | — | `usize` count signaled | +| `resolve_binary` | `{ binaryName }` | absolute path string | +| `run_targen` / `run_printtarg` / `run_chartread` / `run_average` / `run_colprof` / `run_profcheck` / `extract_gamut` / `detect_instruments` | typed configs | `()` after spawn (not after exit) | +| `generate_calibration_target` / `compute_calibration_curves` / `apply_calibration` | typed | captured result | + +Frontend waits for `process:exit` with matching `id`. **Never** assume invoke() resolves when the tool finishes. + +## Deadlock history (must not regress) + +### ICCery #84 (P0) + +Early ProcessManager held a `Mutex` across `Child::wait()`. `send_stdin` needed the same mutex → Calibrate/Retry hung. Dropping the mutex also dropped `ChildStdin` at spawn, closing the pipe immediately. + +**Invariant:** stdin handle lives in its own map, independent of wait. Wait runs in a background task with a oneshot kill channel. + +### ArgyllCMS fork #24 + ICCery #134 + +On Windows, `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes** created by `Stdio::piped()`. Argyll's `con_char(wait=0)` then `ReadFile`s a blocking pipe during `uicallback`, so the instrument trigger thread never sees the button and the lamp never lights. + +Fork fix: `PeekNamedPipe` before `ReadFile` (`spectro/conv.c`). ICCery always sets `ARGYLL_NOT_INTERACTIVE=1` so Argyll uses the pipe path, not a console. + +**Invariant:** ship the Gronod fork (or equivalent PeekNamedPipe patch). Stock Argyll 3.5.0 will hang interactive chartread on Windows. + +### Kill on window close (#147, #149) + +`chartread` outlives the UI if not killed. XY tables need `q\n` first to park the head, then kill. On `CloseRequested` / `Exit`, `kill_all` is mandatory. Frontend Cancel in `TABLE_*` states sends `q\n` then kills. + +## stdin protocol + +`send_stdin` writes UTF-8 bytes and flushes. ICCery strings (see [05](05-argyll-fork.md) §12.5): + +| UI | Bytes | Meaning in real chartread | +|----|-------|---------------------------| +| Calibrate / Retry strip / Accept (many states) | `" \n"` or `"\n"` | Space or Return = trigger (`DUIH_TRIG`) | +| Done & Save | `"d\n"` | finish and write `.ti3` | +| Skip | `"s\n"` | **not a skip in real strip mode** — treated as trigger. Mock/UI invention. Rewrite should verify against fork `chartread.c` before advertising Skip. | +| Undo | `"u\n"` | same caveat | +| XY cancel | `"q\n"` | abort / park | +| Warning accept | `"\n"` | "use it anyway" | + +Always include the newline. Argyll line-buffers prompts. + +## Logging hygiene + +Spawn argv is logged with home directories rewritten to `~` (case-insensitive on Windows). Raw argv only at debug. diff --git a/docs/04-argyll-binaries.md b/docs/04-argyll-binaries.md new file mode 100644 index 0000000..9c06ff4 --- /dev/null +++ b/docs/04-argyll-binaries.md @@ -0,0 +1,912 @@ +# 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:1–4`). 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:40–106`. 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:40–46`): + +- Windows: `["{name}.exe", "{name}"]` unless `name` already ends with `.exe`. +- Unix: `["{name}"]`. + +Platform directory selection (`commands.rs:65–83`, mirrored in `build.rs:26–44`): + +| 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:203–217`. 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:99–103` +- `run_captured`: `calibration.rs:849–853` + +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, code: Option, error: Option } +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:128–139`): + +```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:28–60, 105–119`). 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:38–40`: `"resources": ["argyll/**/*"]`. + +`build.rs:19–64` panics the compile if the platform marker is missing (`npm run fetch-argyll` required). + +NSIS (`windows/hooks.nsh:133–158`): 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:952–964`. `generate_calibration_target`: `calibration.rs:598–613`. 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:788–905`) + +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:295–314`. On success (`code === 0`) advances to Stage 2 via `setStage1Result` + `wizardState.navigateToStage(2)`. + +### 1.3 Calibration argv — `build_calibration_targen_args` (`calibration.rs:146–189`) + +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:399–410`. + +### 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:1481–1628` (RGB, CMYK, total_patches fallback, all-advanced, RGB ignores `-l`). Calibration: `calibration.rs:903–936` (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:966–978`). 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:108–116`, `AGENTS.md:7`). + +### 2.2 argv — `build_printtarg_args` (`commands.rs:907–950`) + +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:917–922`). Default `random_seed: Some(1)` (`commands.rs:762–764`) 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:680–691`): + +```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:1631–1802`: 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:1045–1061`). 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:1051–1054`). 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:1023–1043`) + +``` +-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:488–489`). 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:95–116`. 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:137–141`) 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:16–23` → `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:70–286`) + +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:1112–1131`). `setStage3Result` is **not** called until Finish. + +`code !== 0` → prompt shows last stderr line; log `
` 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:1805–1874` (auto, empty port, `-c 1`, `-Y l`, both, leds disabled). Snapshot roundtrip: `1898–1934`. 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:615–619`). + +```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 fork’s `instlist` prints JSON on stdout by default (or ICCery treats the whole stdout as JSON). + +### 4.2 stdout parsing (`chartread.js:438–503`) + +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 → `