Files
iccery-v2-mac/docs/megaplans/M6-MEGAPLAN.md
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 64e9776a63
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Add Milestone 6 megaplan, branch map, risk register, and proposal note.
Planning-only deliverables for M6 (gamut, Stage 0 calibration, CGATS
interoperability, About/help chrome, packaging/CI). Includes #24
c FIXED-BY-POLICY wording, parallel branch topology, pre-split
implementation slices, and risk register.

Generated with [Devin](https://devin.ai)

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

209 lines
18 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.
# M6 megaplan
## 0. Baseline + Phase 0 audit table + cut checklist
**Baseline:** `develop` at `629a1fce` (PR #54, includes `ef56cdd7` from PR #53).
**Milestone issues:** #28, #29, #30, #31, #32.
**Out of scope:** #16 (`ICCeryPrintKit`), Windows/Linux-specific print/INF paths, Metal/WebGL/Three.js rewrites, `WKWebView`, linking Argyll.
### Phase 0 audit table
| Ref | Item | Status | Evidence |
|-----|------|--------|----------|
| #50.1 | `ArgyllRunner.runChartread` yields `.prompt` for handheld calibrate → trigger → done | **FIXED** | `ArgyllRunner.swift` compares `previous` and `classified.state`; `Milestone4UITests.testHandheldFixtureChartreadAndAverage` is unskipped and green. |
| #50.2 | `ChartreadClassifier` matcher order: error strings not misclassified as `awaitingStrip` | **FIXED** | Priority table in `ChartreadClassifier.swift`; 39+ fixture tests. |
| #50.3 | Stage 3 control matrix: one visible primary action per state, no Skip/Undo | **FIXED** | `Stage3View.swift` buttons `btnCalibrate`, `btnTrigger`, `btnDoneRead`, `btnRetry`, `btnFinishAndAverage`. |
| #50.4 | `btnFinishAndAverage` gated on a non-empty `passSnapshots` | **FIXED** | `MeasurementWorkflowViewModel.canFinish`. |
| #50.5 | Chartread child lifetime: cancel previous session, park XY on quit | **FIXED** | `ProcessManager.runChartread` preKill hook sends `q\n`, waits ~500ms, then kills; `AppDelegate.applicationShouldTerminate` calls `ProcessManager.killAll()`. |
| #50.6 | `collect()` returns even if a pipe EOF never arrives after process exit | **FIXED** | `ProcessManager` two-second watchdog after `didTerminate` plus `forceKill`/`forceFinalize`. |
| #50.7 | Killing captured `applycal` never replaces the input profile | **FIXED** | `ArgyllRunner.runApplycal` writes to `{input}.applycal.tmp` and only `replaceItemAt` on success; cancellation removes the temp. |
| #50.8 | `killAll` on quit covers `colprof_*`, `iccgamut_*`, `profcheck_*`, `applycal_*`, `chartread_*` | **FIXED** | `ProcessManager.children`/`captured` keyed by `ProcessID`; `killAll()` iterates all. |
| #52.1 | Profile installer **Overwrite** from the collision alert works | **FIXED** | `ProfileWorkflowViewModel.resolveInstallCollision(policy: .overwrite)` sets `options.forceOverwrite = true`. |
| #52.2 | `VerificationHistoryStore.append` no longer wipes the file | **FIXED** | `append()` calls `load()` first; tests in `VerificationHistoryStoreTests.swift`. |
| #52.3 | `createdProfileURL` restored from disk after relaunch | **FIXED** | `ProfileWorkflowViewModel.restoreCreatedProfileURL()` uses `ArtefactProbe.resolveProfile`; `Stage4View.onAppear` calls it. |
| #52.4 | Drift alert is consecutive and profcheck warning is not hidden | **FIXED** | `DriftAlert.compute` builds the longest suffix run of `.poor` records; `Stage5View` displays both `driftAlert` and `profcheckWarningBanner`. |
| #52.5 | Empty `printerName` rendered as "Unknown" everywhere | **FIXED** | `makeVerificationRecord` uses `wizard.printerName?.isEmpty == false ? … : "Unknown"`. |
| #52.6 | `ApplycalArgsTests.unapplyNotEmitted` renamed / guarded | **FIXED** | `ApplycalArgsTests` includes `unapplyNeverEmittedByUI`; `ProfileWorkflowViewModel` never passes `unapply: true`. |
| #52.7 | Profile installer preserves `.icm` and allows `foo..bar` stems | **FIXED** | `ProfileInstaller` uses `sourceURL.lastPathComponent`; stem validation rejects only literal `.`/`..` components. |
| #52.8 / #24 | `iccgamut` failure vs. issue #24 "`.gam` produced next to profile" | **FIXED-BY-POLICY** | Update Gitea issue #24 acceptance criteria **before `milestone/m6-gamut-stage0-cgats-release` is cut** (no code branch). See §1. |
### Pre-cut checklist
Must pass **before** `milestone/m6-gamut-stage0-cgats-release` is created:
- [x] Confirmed 2026-09-09T16:02:25Z — Gitea #24 body contains the FIXED-BY-POLICY failure-path paragraph from §1.
- [ ] Human confirms `Tests/ICCeryUITests/Milestone4UITests.swift` line 95 `testHandheldFixtureChartreadAndAverage` is **not** wrapped in `XCTSkip` or `XCTExpectFailure`.
- [ ] Human confirms `Sources/ICCery/ProfileWorkflowViewModel.swift` `resolveInstallCollision(policy: .overwrite)` (around line 442) sets `options.forceOverwrite = true`.
If either spot-check fails, Phase 0 is not empty. Record a residual row and open a single `fix/50-chartread-prompt-stream` or `fix/27-install-overwrite` branch off `develop` (or off the milestone branch if already cut). Do **not** reopen `feat/52-m5-bugfixes` and do not file a new umbrella issue.
---
## 1. #24 FIXED-BY-POLICY wording (unchanged)
The following text is now in the Gitea #24 acceptance criteria (confirmed 2026-09-09T16:02:25Z):
> - **Happy path unchanged:** `iccgamut -v -d 10 {resolvedProfile}` writes `{stem}.gam` next to the resolved profile after `colprof` (and `applycal` if Apply Calibration is on) succeeds.
> - **Failure path:** if `iccgamut` exits non-zero or the `.gam` file is missing, show an info banner, but `Create Profile` still advances to Stage 5. The Stage 5 gamut pane falls back to the docs/18 empty state (axes + bundled `sRGB.gam` only, no profile mesh).
> - Do not treat a mock `.gam` in `Milestone5UITests` as proof of extraction.
> - `applycal` is different: if **Apply Calibration** is on and `applycal` fails, `Create Profile` fails and does not advance, because `applycal` mutates the ICC.
This is a tracker-only policy change; there is no `fix/24-gamut-failure` branch.
---
## 2. Feature slices
One feature branch per issue. Work inside each branch is split into **named slices** that can each be a commit (or stacked PR into the same feat branch), each with its own test command and ≤4 files / ≤200 added lines.
### 2.1 #31 — About & Help Chrome
- **Branch:** `feat/31-about-help-chrome`
- **Base:** `milestone/m6-gamut-stage0-cgats-release`
- **Depends on:** #1, #5 (already on `develop`)
- **Files**
- `Sources/ICCery/AboutView.swift` (new)
- `Sources/ICCery/HelpOverlayView.swift` (new)
- `Sources/ICCery/RootView.swift` (mod: replace `Alert` with sheet, wire `openAboutBtn`)
- `Sources/ICCery/SidebarView.swift` (mod: enable `openAboutBtn`, help toggle)
- **In:** About sheet with version/build date from `getAppInfo`; overlay-positioned tooltips that never reflow layout (`#171`); banner auto-hide (uses existing `Notice.autoHideAfter`).
- **Out:** Settings dialog rebuild.
- **Slice / test:** `xcodebuild test -scheme ICCery -only-testing:ICCeryUITests/AboutHelpUITests -destination 'platform=macOS'`.
### 2.2 #30 — CGATS Dataset Interop
- **Branch:** `feat/30-cgats-interop`
- **Base:** `milestone/m6-gamut-stage0-cgats-release`
- **Depends on:** #4, #6 (already on `develop`)
- **30a — Parser + typed dataset + fixtures**
- Files: `Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift` (parser + `CGATSDataset` types), plus `Tests/ICCeryCoreTests/CGATSParserTests.swift`.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryCoreTests/CGATSParserTests`.
- **30b — Canonical `.ti3` writer + parse→write→reparse**
- Files: `Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift`, plus `Tests/ICCeryCoreTests/CGATSWriterTests.swift`.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryCoreTests/CGATSWriterTests`; must include a fixture that a mock `colprof` can consume.
- **30c — Open-dialog import + `setTarget` before stage jump**
- Files: `Sources/ICCery/TargetWorkflowViewModel.swift`, `Sources/ICCery/Stage1View.swift`, `Sources/ICCery/FileDialogService.swift` (filter only).
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryUITests/Milestone6CGATSUITests/importUsesOpenPanelNotSaveTi1`.
- **In:** Open dialog (never save) for `.ti3`, `.txt`, `.cgats`, `.csv`; parse CGATS.17 / CTI3 / ISO28178 / CSV; canonical `.ti3` writer with field aliases, 0255→0100 device scaling, synthesized `COLOR_REP`/`DEVICE_CLASS`; write imported `.ti3` to cwd and call `setTarget(basename:)` before jumping to Stage 4/5.
- **Out:** Unlocking Stages 12 without `.ti1`/`.ti2`; reusing `select_existing_target` / save-`.ti1` picker.
- **What not to touch:** `ProcessManager`, `PrinttargArgs`, `ChartreadClassifier`.
### 2.3 #29 — Stage 0: Printer Calibration
- **Branch:** `feat/29-stage0-calibration`
- **Base:** `milestone/m6-gamut-stage0-cgats-release`
- **Depends on:** #2, #5, #7, #9, #15, #17, #19, #23, #24 (all on `develop` or M5)
- **29a — Argv goldens only**
- Files: `Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrintcalArgs.swift`, `Packages/ICCeryCore/Sources/ICCeryCore/Argyll/CalibrationTargenArgs.swift`, plus tests.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryCoreTests/PrintcalArgsTests -only-testing:ICCeryCoreTests/CalibrationTargenArgsTests`; assert `CAL_` printtarg argv has **no** `-K`.
- **29b — `.cal` parse + `CalibrationStore` + Overwrite/Rename/Cancel**
- Files: `Packages/ICCeryCore/Sources/ICCeryCore/Calibration/CalibrationStore.swift` (store + curve types), plus `Tests/ICCeryCoreTests/CalibrationStoreTests.swift`.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryCoreTests/CalibrationStoreTests`.
- **29c — Dashboard + reuse Stage 2/3 with `CAL_` basename**
- Files: `Sources/ICCery/CalibrationView.swift`, `Sources/ICCery/CalibrationViewModel.swift`, plus small touches to `Sources/ICCery/SidebarView.swift` and `Sources/ICCery/RootView.swift` for the `.calibrate` stage.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryUITests/Milestone6CalibrationUITests` (walk to `btnCalCompute`).
- **29d — Apply toggle on the profiling basename only**
- Files: `Sources/ICCery/ProfileWorkflowViewModel.swift` (toggle wiring), `Sources/ICCery/Stage4View.swift` (existing toggle).
- Test: golden argv tests in `Tests/ICCeryCoreTests/PrinttargArgsTests` (profiling `printtarg` gets `-K`) and `Tests/ICCeryCoreTests/ApplycalArgsTests` (post-`colprof` `applycal -a`; `unapply` never true).
- **In:** `CAL_<run>` dashboard; calibration `targen`; re-use Stage 2 `printtarg` and Stage 3 `chartread`; `printcal` captured; `.cal` CGATS parse + stdout parse; collision; library (`iccery-calibration.json`); Apply Calibration toggle on profiling printtarg (`-K`) and post-colprof `applycal -a`; staleness; curve view.
- **Out:** Display calibration; forking `ChartreadClassifier` / `ProcessManager`; `$PATH` search; `applycal -u`; emitting `-K` for `CAL_` basenames.
### 2.4 #28 — 3D Gamut Viewer (SceneKit)
- **Branch:** `feat/28-scenekit-gamut`
- **Base:** `milestone/m6-gamut-stage0-cgats-release`
- **Depends on:** #3 (real `sRGB.gam` asset), #24 (`.gam` output + updated AC policy)
- **28a — Asset confirm + real-file fixture**
- Confirmed on `develop@629a1fce`: `Resources/Argyll/reference_gamuts/sRGB.gam` is tracked and contains `NUMBER_OF_SETS 448` vertices and `NUMBER_OF_SETS 892` face rows; it is the real Argyll mesh, not the 8-cusp stub.
- Add `Tests/ICCeryCoreTests/GamutParserTests.swift` fixture that parses this exact file and asserts the vertex/face counts.
- **28b — `.gam` parser + fixtures**
- Files: `Packages/ICCeryCore/Sources/ICCeryCore/Profile/GamutParser.swift`, plus tests.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryCoreTests/GamutParserTests` (dual `BEGIN_DATA`, `#` comments, OOB, vertex-only hull fallback).
- **28c — `GamutView` / `GamutSceneController`, lazy init on Stage 5 appear only**
- Files: `Sources/ICCery/GamutSceneController.swift`, `Sources/ICCery/GamutView.swift`.
- Mod: `Sources/ICCery/Stage5View.swift` (embed viewer, trigger load on appear and post-verify).
- Mod: `Sources/ICCery/ProfileWorkflowViewModel.swift`**only** a single load hook (Stage 5 appear + post-`iccgamut` / post-`profcheck` success). Scene state lives in `GamutSceneController`.
- Test: `xcodebuild test -scheme ICCery -only-testing:ICCeryUITests/GamutViewerUITests` (existence of `gamutViewerContainer` and `btnGamutResetCamera`).
- **In:** `.gam` parser (two `BEGIN_DATA` blocks, `#` comments, OOB warnings, native faces, vertex-only → convex-hull fallback); SceneKit with `X=a*`, `Y=L*`, `Z=b*`; axis scaffold; profile mesh with per-vertex Lab→sRGB; sRGB reference overlay; controls; reset camera; lazy init only when Stage 5 visible.
- **Out:** Metal, Three.js, WebGL, Adobe RGB, eager 3D at launch, `ConvexGeometry`/QuickHull for native `.gam` files.
- **What not to touch:** `ProcessManager`, `IccgamutArgs` (density stays `10`), `colprof` argv, `SettingsView`.
### 2.5 #32 — Packaging, Signing & CI
- **Branch:** `feat/32-packaging-ci`
- **Base:** `milestone/m6-gamut-stage0-cgats-release`
- **Depends on:** all v2.0 issues 115, 1731; does **not** block on #16
- **Files**
- New: `.gitea/workflows/ci.yml`, `scripts/dmgbuild-settings.py`, `scripts/notarize-dmg.sh`
- Mod: `project.yml` (add `Release` config with configurable `CODE_SIGN_IDENTITY`; keep `Debug` ad-hoc), `Makefile` (add `release-dmg`), `.gitignore`
- **In:** Gitea Actions on self-hosted macOS runner; `scripts/fetch-argyll.sh` before build; universal `xcodebuild build test`; unit and mock fixture tests; `dmgbuild`; notarization/staple gate; signed sidecars (`codesign -dvv` hard fail); artifact upload.
- **Out:** Finder AppleScript DMG, committing `Vendor/Argyll/` binaries, linking Argyll, Windows INF/Linux trees.
- **What not to touch:** App Sandbox, Debug `CODE_SIGN_IDENTITY` real cert, entitlements.
- **Test:** `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; `make universal`; `make release-dmg`; `codesign -dvv` on each bundled Mach-O.
---
## 3. Branch topology (parallel feats)
```
develop@629a1fce
└── milestone/m6-gamut-stage0-cgats-release # cut AFTER Gitea #24 AC update (done 2026-09-09T16:02:25Z)
├── feat/31-about-help-chrome → resolves issue #31
├── feat/30-cgats-interop → resolves issue #30
├── feat/29-stage0-calibration → resolves issue #29
├── feat/28-scenekit-gamut → resolves issue #28
└── feat/32-packaging-ci → resolves issue #32 (rebase last)
```
- All five feature branches are **parallel children** of `milestone/m6-gamut-stage0-cgats-release`.
- Merge order into the milestone branch: #31, #30, #29, #28 may merge independently as each goes green; #32 is rebased on the milestone tip and merged last.
- Final merge: `milestone/m6-gamut-stage0-cgats-release``develop` only after the full CI gate is green on the milestone branch.
- If two feat branches land in the same week, the second rebases onto milestone tip; **do not nest feat branches** (e.g. `feat/28` is not a child of `feat/29`).
- Every PR gets labels `Project/ICCery-v2`, a `Feature/*` or `Bug/*` label, and a `Priority/*` label.
---
## 4. Risks
| ID | Risk | Mitigation |
|----|------|------------|
| R1 | **#24 AC not updated in tracker** before the milestone branch is cut. | **Resolved** — Gitea #24 updated at 2026-09-09T16:02:25Z. Record the policy in the milestone PR body. |
| R2 | **Real `sRGB.gam` asset missing or stubbed**. | Confirmed 448/892 in `Resources/Argyll/reference_gamuts/sRGB.gam` on `develop@629a1fce`; 28a adds a fixture against this file. Viewer AC against 8 vertices is a failed AC. |
| R3 | **Apple Developer ID / notary secrets not provisioned**, so #32 cannot produce a Gatekeeper-clean release. | `Release` config uses configurable `CODE_SIGN_IDENTITY`; CI notarization is conditional on `APPLE_DEVELOPER_ID` and notary key secrets. |
| R4 | **#28 and #29 both touch `ProfileWorkflowViewModel` or `Stage5View`**. | #28 must add only a single load hook in `ProfileWorkflowViewModel`; scene state lives in `GamutSceneController`; #29 owns Apply-toggle / `CAL_` wiring. If both land the same week, rebase the second onto milestone tip. |
| R5 | **AGPL boundary violation** — linking Argyll or `$PATH` spawn. | All new tools go through `BinaryResolver.resolve` and `ProcessManager`; no `dlopen`/linking; no `$PATH` search. |
| R6 | **Stage 0 `printtarg -K` leaked onto a `CAL_` target** or `applycal -u` exposed. | Golden argv tests assert `CAL_` basenames never see `-K` and `ApplycalConfig.unapply` is never `true` from UI code. |
| R7 | **SceneKit performance / context loss on Intel Monterey** or at app launch. | Lazy init only when Stage 5 appears; pause render loop on leave; keep a fallback banner matching docs/18. |
| R8 | **CI `make universal` fails because `Vendor/Argyll` is unsigned or missing**. | Workflow always runs `scripts/fetch-argyll.sh` first and fails on `codesign -dvv` or `instlist` marker failure. |
| R9 | **CGATS import writes an invalid canonical `.ti3`** that `colprof` rejects. | Round-trip `CGATSParser` test plus a fixture that a mock `colprof` can consume. |
| R10 | **Milestone branch becomes a catch-all** for unrelated fixes. | New post-cut residuals get a single `fix/<issue#>-<slug>` branch off the milestone branch or a new issue; do not reopen `feat/52-m5-bugfixes`. |
| R11 | **#30 import dialog regression** (save-`.ti1` panel) if 30c test is omitted. | Mandatory UI test `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1`; fail if the import path uses a save panel or `.ti1` filter. |
| R12 | **Phase 0 FIXED table is wrong because M5 UI tests skip Stage 3**. | Pre-cut human spot-check of `Milestone4UITests.testHandheldFixtureChartreadAndAverage` and `ProfileWorkflowViewModel.resolveInstallCollision` (see §0). |
---
## 5. Gates
### CI / mock (every PR)
- `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`
- Per-slice: `xcodebuild test -scheme ICCery -only-testing:<Suite>/<Test>`
- `make universal`
- `scripts/fetch-argyll.sh` followed by `codesign -dvv` on each bundled Mach-O sidecar
- `make release-dmg` (ad-hoc if notary secrets absent; notarized/stapled if present)
### Hardware / manual (blocks release)
- **Stage 0:** one real printer calibration loop; `CAL_*.cal` produced and Stage 1 reminder cleared.
- **Gamut:** real profile `.gam` renders with sRGB overlay and axis scaffold.
- **Release:** Gatekeeper-open the notarized `.dmg` on a clean Mac; app launches without `Killed: 9`.
---
## 6. Non-goals
- Do **not** implement product code, entitlements, `project.yml` changes, or tests in this planning step.
- Do **not** create a second M6 plan file.
- Do **not** reopen `feat/52-m5-bugfixes` or any M5 umbrella branch.
- Preserve all M5 invariants: no `$PATH` lookup; no AGPL linking; `applycal -u` is never sent from the UI; `iccgamut -d 10`; atomic `applycal` temp-then-replace; `CAL_` namespace protected; artefact-driven stage gating; safe-path validation; process cancellation and `killAll`.