Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc | ||
|
|
716b302374 | ||
|
|
20d6bf7fdc | ||
|
|
5bb057a1e3 | ||
|
|
b998b48abf | ||
|
|
4440a26476 | ||
|
|
4c2863e52c |
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# 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)
|
||||||
|
Vendor/Argyll/
|
||||||
|
|
||||||
|
# XcodeGen output (regenerate with `make gen`)
|
||||||
|
ICCery.xcodeproj/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
@@ -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-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
|
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 <sidecar>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
+20
@@ -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.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
SCHEME := ICCery
|
||||||
|
DEST := 'platform=macOS'
|
||||||
|
|
||||||
|
.PHONY: gen build test universal fetch-argyll clean
|
||||||
|
|
||||||
|
gen:
|
||||||
|
xcodegen generate
|
||||||
|
|
||||||
|
build: gen
|
||||||
|
xcodebuild build -scheme $(SCHEME) -destination $(DEST)
|
||||||
|
|
||||||
|
test: gen
|
||||||
|
xcodebuild build test -scheme $(SCHEME) -destination $(DEST)
|
||||||
|
|
||||||
|
universal: gen
|
||||||
|
xcodebuild build -scheme $(SCHEME) -destination $(DEST) \
|
||||||
|
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
|
fetch-argyll:
|
||||||
|
scripts/fetch-argyll.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ICCeryCore",
|
||||||
|
platforms: [.macOS(.v14)],
|
||||||
|
products: [
|
||||||
|
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "ICCeryCore"),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
|
||||||
|
///
|
||||||
|
/// Order:
|
||||||
|
/// 1. Settings `argyll_binary_dir` override — only if `<dir>/<name>`
|
||||||
|
/// exists there.
|
||||||
|
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
|
||||||
|
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
|
||||||
|
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
|
||||||
|
/// 3. If nothing exists the *constructed* bundled path is still returned
|
||||||
|
/// — a missing binary surfaces later as `process:error` on spawn,
|
||||||
|
/// matching v1 semantics.
|
||||||
|
public struct BinaryResolver: Sendable {
|
||||||
|
|
||||||
|
/// Root that contains the platform dirs — `Bundle.resource/Argyll` in
|
||||||
|
/// the app, a fixture dir in tests.
|
||||||
|
public let bundledRoot: URL
|
||||||
|
/// `settings.argyll_binary_dir`, already expanded to a URL.
|
||||||
|
public let overrideDir: URL?
|
||||||
|
/// Host architecture directory names, universal preferred.
|
||||||
|
public let archDirs: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
bundledRoot: URL = AppPaths.bundledArgyllDir,
|
||||||
|
overrideDir: URL? = nil,
|
||||||
|
archDirs: [String]? = nil
|
||||||
|
) {
|
||||||
|
self.bundledRoot = bundledRoot
|
||||||
|
self.overrideDir = overrideDir
|
||||||
|
#if arch(arm64)
|
||||||
|
let fallback = ["macos-arm64", "macos-aarch64"]
|
||||||
|
#else
|
||||||
|
let fallback = ["macos-x86_64"]
|
||||||
|
#endif
|
||||||
|
self.archDirs = archDirs ?? ["macos-universal"] + fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker used to decide whether `macos-universal` is usable.
|
||||||
|
public static let markerBinary = "instlist"
|
||||||
|
|
||||||
|
/// Resolves a tool name to an absolute URL (never throws — see type
|
||||||
|
/// docs). `name` is the bare tool name, e.g. `"targen"`.
|
||||||
|
public func resolve(_ name: String) -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
|
||||||
|
if let dir = overrideDir {
|
||||||
|
let candidate = dir.appendingPathComponent(name)
|
||||||
|
if fm.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundledRoot
|
||||||
|
.appendingPathComponent(platformDir(), isDirectory: true)
|
||||||
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled platform directory that resolution will use.
|
||||||
|
public func platformDir() -> String {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let universal = bundledRoot.appendingPathComponent("macos-universal")
|
||||||
|
if fm.fileExists(
|
||||||
|
atPath: universal.appendingPathComponent(Self.markerBinary).path
|
||||||
|
) {
|
||||||
|
return "macos-universal"
|
||||||
|
}
|
||||||
|
for dir in archDirs where dir != "macos-universal" {
|
||||||
|
if fm.fileExists(
|
||||||
|
atPath: bundledRoot
|
||||||
|
.appendingPathComponent(dir)
|
||||||
|
.appendingPathComponent(Self.markerBinary).path
|
||||||
|
) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing present — still return the preferred dir so the error
|
||||||
|
// message points at where the user should drop binaries.
|
||||||
|
return archDirs.first ?? "macos-universal"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
|
||||||
|
public func mock(_ name: String) -> URL {
|
||||||
|
bundledRoot
|
||||||
|
.appendingPathComponent("mocks", isDirectory: true)
|
||||||
|
.appendingPathComponent("\(name).mock", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||||
|
public func referenceGamut(_ name: String) -> URL {
|
||||||
|
bundledRoot
|
||||||
|
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||||
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the resolved path exists and is executable.
|
||||||
|
public func exists(_ url: URL) -> Bool {
|
||||||
|
FileManager.default.isExecutableFile(atPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Small host-side file helpers (issue #6).
|
||||||
|
public enum ArtefactFiles {
|
||||||
|
|
||||||
|
/// `get_default_working_dir` — `resolveSafeCwd(nil)`.
|
||||||
|
public static func defaultWorkingDirectory() -> URL {
|
||||||
|
PathSecurity.resolveSafeCwd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `read_file_base64` — for **text artefacts** the UI needs verbatim
|
||||||
|
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
|
||||||
|
/// go through `TiffPreview` instead.
|
||||||
|
public static func readBase64(_ url: URL) throws -> String {
|
||||||
|
try Data(contentsOf: url).base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `get_app_info` — version + build for the About dialog.
|
||||||
|
public static func appInfo(
|
||||||
|
bundle: Bundle = .main
|
||||||
|
) -> (version: String, build: String) {
|
||||||
|
let info = bundle.infoDictionary ?? [:]
|
||||||
|
return (
|
||||||
|
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
||||||
|
info["CFBundleVersion"] as? String ?? "0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
|
||||||
|
public struct StageArtefacts: Sendable, Equatable {
|
||||||
|
/// `<basename>.ti1` exists (Stage 1 done → unlocks Stage 2).
|
||||||
|
public var stage1Complete = false
|
||||||
|
/// `<basename>.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
|
||||||
|
public var stage2Complete = false
|
||||||
|
/// `<basename>.ti3` exists (Stage 3 done → unlocks Stage 4).
|
||||||
|
public var stage3Complete = false
|
||||||
|
/// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5).
|
||||||
|
public var stage4Complete = false
|
||||||
|
/// Absolute path of the profile file when present.
|
||||||
|
public var profilePath: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stage1Complete: Bool = false,
|
||||||
|
stage2Complete: Bool = false,
|
||||||
|
stage3Complete: Bool = false,
|
||||||
|
stage4Complete: Bool = false,
|
||||||
|
profilePath: URL? = nil
|
||||||
|
) {
|
||||||
|
self.stage1Complete = stage1Complete
|
||||||
|
self.stage2Complete = stage2Complete
|
||||||
|
self.stage3Complete = stage3Complete
|
||||||
|
self.stage4Complete = stage4Complete
|
||||||
|
self.profilePath = profilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||||
|
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
|
||||||
|
public enum ArtefactProbe {
|
||||||
|
|
||||||
|
/// `verify_stage_artefacts` — the gating truth source.
|
||||||
|
public static func verify(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> StageArtefacts {
|
||||||
|
var out = StageArtefacts()
|
||||||
|
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
|
||||||
|
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
|
||||||
|
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
|
||||||
|
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||||
|
out.stage4Complete = true
|
||||||
|
out.profilePath = profile
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<cwd>/<basename>.<ext>` — the canonical artefact URL.
|
||||||
|
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
|
||||||
|
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile extension resolution (#69): existing `.icm` wins over
|
||||||
|
/// `.icc`; when neither exists the macOS default is `.icc`.
|
||||||
|
/// (`profcheck`/`iccgamut` swap extension when the requested path is
|
||||||
|
/// missing.)
|
||||||
|
public static func resolveProfile(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
let icm = artefact(basename, "icm", cwd)
|
||||||
|
if exists(icm, fm: fileManager) { return icm }
|
||||||
|
let icc = artefact(basename, "icc", cwd)
|
||||||
|
if exists(icc, fm: fileManager) { return icc }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default extension for a *new* profile on macOS (#69).
|
||||||
|
public static let defaultProfileExtension = "icc"
|
||||||
|
|
||||||
|
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
|
||||||
|
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
|
||||||
|
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` … and
|
||||||
|
/// `<basename>_NN.tif` (manifest naming).
|
||||||
|
public static func existingArtefacts(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefixes = [basename + ".", "CAL_" + basename + "."]
|
||||||
|
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
|
||||||
|
let passPrefix = basename + "_pass"
|
||||||
|
let tifStemPrefix = basename + "_"
|
||||||
|
let calPrefix = "CAL_" + basename
|
||||||
|
|
||||||
|
return entries.filter { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
guard suffixes.contains(ext) else { return false }
|
||||||
|
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
|
||||||
|
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
|
||||||
|
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
|
||||||
|
if name.hasPrefix(calPrefix) { return true }
|
||||||
|
return false
|
||||||
|
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func exists(_ url: URL, fm: FileManager) -> Bool {
|
||||||
|
fm.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Atomic `.tmp`-then-rename file writes — the convention used by
|
||||||
|
/// settings.json, verification_history.json and wizard_state.json
|
||||||
|
/// (docs/02 §Persistence, #213).
|
||||||
|
public enum AtomicFileWriter {
|
||||||
|
|
||||||
|
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
|
||||||
|
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
|
||||||
|
public static func write(_ data: Data, to url: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let dir = url.deletingLastPathComponent()
|
||||||
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let tmp = url.appendingPathExtension("tmp")
|
||||||
|
do {
|
||||||
|
try data.write(to: tmp, options: [])
|
||||||
|
// replaceItemAt handles same-volume atomic swap and removes
|
||||||
|
// the destination cleanly; fall back to remove+move.
|
||||||
|
if fm.fileExists(atPath: url.path) {
|
||||||
|
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmp, to: url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmp)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ text: String, to url: URL) throws {
|
||||||
|
try write(Data(text.utf8), to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Basename sanitisation and safe working-directory resolution
|
||||||
|
/// (docs/02 §Working directory, docs/06 §Empty cwd).
|
||||||
|
public enum PathSecurity {
|
||||||
|
|
||||||
|
public enum Error: Swift.Error, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basenames must not contain `/`, `\`, or `..` and must be
|
||||||
|
/// non-empty. Never invent a default basename (#60).
|
||||||
|
public static func isValidBasename(_ name: String) -> Bool {
|
||||||
|
guard !name.isEmpty else { return false }
|
||||||
|
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func sanitizeBasename(_ name: String) throws -> String {
|
||||||
|
guard isValidBasename(name) else {
|
||||||
|
throw Error.invalidBasename(name)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory →
|
||||||
|
/// Documents → Home → app-data. Never returns an empty/nil cwd.
|
||||||
|
public static func resolveSafeCwd(
|
||||||
|
_ explicit: URL?,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL {
|
||||||
|
if let explicit,
|
||||||
|
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
let candidates: [URL?] = [
|
||||||
|
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||||
|
fileManager.homeDirectoryForCurrentUser,
|
||||||
|
AppPaths.appDataDir,
|
||||||
|
]
|
||||||
|
for candidate in candidates {
|
||||||
|
guard let url = candidate else { continue }
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: app-data, created unconditionally.
|
||||||
|
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
|
||||||
|
return AppPaths.appDataDir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
|
||||||
|
/// `parse_ti2_header` reads only CGATS keyword lines — the data grid
|
||||||
|
/// itself belongs to issue #30.
|
||||||
|
public struct Ti2Header: Sendable, Equatable {
|
||||||
|
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
|
||||||
|
public var instrument: String?
|
||||||
|
/// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is
|
||||||
|
/// the CGATS column count, *not* the patch count.
|
||||||
|
public var patchCount: Int?
|
||||||
|
/// `NUMBER_OF_PAGES`.
|
||||||
|
public var pageCount: Int?
|
||||||
|
/// A sibling `<stem>.ti1` exists next to the parsed file.
|
||||||
|
public var hasSiblingTi1 = false
|
||||||
|
|
||||||
|
public static func parse(
|
||||||
|
_ url: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Ti2Header {
|
||||||
|
var header = Ti2Header()
|
||||||
|
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
|
||||||
|
guard let space = line.firstIndex(of: " ") else { continue }
|
||||||
|
let key = String(line[..<space])
|
||||||
|
let value = String(line[line.index(after: space)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
switch key {
|
||||||
|
case "TARGET_INSTRUMENT":
|
||||||
|
header.instrument = value
|
||||||
|
case "NUMBER_OF_SETS":
|
||||||
|
header.patchCount = Int(value)
|
||||||
|
case "NUMBER_OF_PAGES":
|
||||||
|
header.pageCount = Int(value)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stem = url.deletingPathExtension()
|
||||||
|
header.hasSiblingTi1 = fileManager.fileExists(
|
||||||
|
atPath: stem.appendingPathExtension("ti1").path
|
||||||
|
)
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// TIFF → PNG preview for the Stage 2 gallery (#58): decode on the host
|
||||||
|
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
|
||||||
|
/// bytes to the UI.
|
||||||
|
public enum TiffPreview {
|
||||||
|
|
||||||
|
public static let maxEdge: Int = 1200
|
||||||
|
|
||||||
|
/// Returns PNG data for the first page of a TIFF, or `nil` when the
|
||||||
|
/// file cannot be decoded.
|
||||||
|
public static func previewPNG(
|
||||||
|
tiff url: URL,
|
||||||
|
maxEdge: Int = Self.maxEdge
|
||||||
|
) -> Data? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
source, 0, options as CFDictionary
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
let out = NSMutableData()
|
||||||
|
guard let dest = CGImageDestinationCreateWithData(
|
||||||
|
out, UTType.png.identifier as CFString, 1, nil
|
||||||
|
) else { return nil }
|
||||||
|
CGImageDestinationAddImage(dest, image, nil)
|
||||||
|
guard CGImageDestinationFinalize(dest) else { return nil }
|
||||||
|
return out as Data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Severity levels, matching the v1 `log_level` setting values.
|
||||||
|
public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
||||||
|
case error, warn, info, debug, trace
|
||||||
|
|
||||||
|
var osType: OSLogType {
|
||||||
|
switch self {
|
||||||
|
case .error: return .error
|
||||||
|
case .warn: return .default
|
||||||
|
case .info: return .info
|
||||||
|
case .debug: return .debug
|
||||||
|
case .trace: return .debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
|
||||||
|
var rank: Int {
|
||||||
|
switch self {
|
||||||
|
case .error: return 0
|
||||||
|
case .warn: return 1
|
||||||
|
case .info: return 2
|
||||||
|
case .debug: return 3
|
||||||
|
case .trace: return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
|
||||||
|
/// gated at write time so a settings save takes effect immediately
|
||||||
|
/// (#158).
|
||||||
|
public struct AppLogger: Sendable {
|
||||||
|
public static let shared = AppLogger(category: "app")
|
||||||
|
|
||||||
|
private let osLog: Logger
|
||||||
|
private let sink: LogSink
|
||||||
|
public let category: String
|
||||||
|
|
||||||
|
public init(category: String, sink: LogSink = .shared) {
|
||||||
|
self.category = category
|
||||||
|
self.sink = sink
|
||||||
|
self.osLog = Logger(
|
||||||
|
subsystem: AppPaths.bundleIdentifier,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
||||||
|
let text = LogSanitizer.sanitize(message())
|
||||||
|
if level.rank <= sink.level.rank {
|
||||||
|
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
||||||
|
}
|
||||||
|
sink.write(level: level, category: category, message: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
|
||||||
|
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
|
||||||
|
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
|
||||||
|
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
|
||||||
|
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Rewrites the user's home directory to `~` in log output
|
||||||
|
/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`).
|
||||||
|
public enum LogSanitizer {
|
||||||
|
/// Replaces every occurrence of the current user's home path with `~`.
|
||||||
|
public static func sanitize(_ text: String) -> String {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
guard !home.isEmpty else { return text }
|
||||||
|
return text.replacingOccurrences(of: home, with: "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitizes an argv list for display.
|
||||||
|
public static func sanitizeArgs(_ args: [String]) -> String {
|
||||||
|
args.map(sanitize).joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Rolling file sink for `AppLogger` — `~/Library/Logs/<bundle>/
|
||||||
|
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
|
||||||
|
/// (`iccery.log.1` … `iccery.log.5`).
|
||||||
|
///
|
||||||
|
/// The minimum level is **runtime state** (#158): `setLevel` takes
|
||||||
|
/// effect immediately — at startup and on every settings save.
|
||||||
|
public final class LogSink: @unchecked Sendable {
|
||||||
|
|
||||||
|
public static let shared = LogSink(fileURL: AppPaths.logFile)
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let fileURL: URL
|
||||||
|
private var minimumLevel: LogLevel
|
||||||
|
private var handle: FileHandle?
|
||||||
|
|
||||||
|
/// 5 MiB per segment, 5 historical segments kept.
|
||||||
|
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
|
||||||
|
public static let keptSegments = 5
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL = AppPaths.logFile,
|
||||||
|
minimumLevel: LogLevel? = nil
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
#if DEBUG
|
||||||
|
self.minimumLevel = minimumLevel ?? .debug
|
||||||
|
#else
|
||||||
|
self.minimumLevel = minimumLevel ?? .info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public var level: LogLevel {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return minimumLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied at startup AND on every settings save (issue #5, #158).
|
||||||
|
public func setLevel(_ level: LogLevel) {
|
||||||
|
lock.lock()
|
||||||
|
minimumLevel = level
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` → DEBUG-build default (.debug) / release (.info).
|
||||||
|
public func applySettings(_ settings: AppSettings) {
|
||||||
|
setLevel(settings.effectiveLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func shouldLog(_ level: LogLevel) -> Bool {
|
||||||
|
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writing
|
||||||
|
|
||||||
|
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
|
||||||
|
/// rotating first when the active segment exceeds 5 MiB.
|
||||||
|
public func write(level: LogLevel, category: String, message: String) {
|
||||||
|
guard shouldLog(level) else { return }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
rotateIfNeeded()
|
||||||
|
openIfNeeded()
|
||||||
|
let stamp = Self.timestamp()
|
||||||
|
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
handle?.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static func timestamp() -> String {
|
||||||
|
formatter.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openIfNeeded() {
|
||||||
|
guard handle == nil else { return }
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
||||||
|
}
|
||||||
|
handle = try? FileHandle(forWritingTo: fileURL)
|
||||||
|
try? handle?.seekToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the
|
||||||
|
/// writer. Oldest segment is deleted.
|
||||||
|
private func rotateIfNeeded() {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path),
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||||
|
let size = attrs[.size] as? UInt64,
|
||||||
|
size >= Self.maxSegmentBytes
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
try? handle?.close()
|
||||||
|
handle = nil
|
||||||
|
let fm = FileManager.default
|
||||||
|
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
|
||||||
|
try? fm.removeItem(at: oldest)
|
||||||
|
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
|
||||||
|
let src = fileURL.appendingPathExtension("\(i)")
|
||||||
|
let dst = fileURL.appendingPathExtension("\(i + 1)")
|
||||||
|
if fm.fileExists(atPath: src.path) {
|
||||||
|
try? fm.moveItem(at: src, to: dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tail of the active log for the settings dialog's "copy excerpt".
|
||||||
|
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else { return "" }
|
||||||
|
let slice = data.suffix(maxBytes)
|
||||||
|
return String(decoding: slice, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Well-known filesystem locations for the ICCery host process.
|
||||||
|
///
|
||||||
|
/// macOS paths (docs/02 §Persistence):
|
||||||
|
/// - App data: `~/Library/Application Support/<bundle-id>/`
|
||||||
|
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
|
||||||
|
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
|
||||||
|
public enum AppPaths {
|
||||||
|
|
||||||
|
/// `com.gronod.iccery2` — read from the main bundle so tests can override.
|
||||||
|
public static var bundleIdentifier: String {
|
||||||
|
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||||
|
public static var appDataDir: URL {
|
||||||
|
FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/Library/Logs/com.gronod.iccery2`
|
||||||
|
public static var logDir: URL {
|
||||||
|
FileManager.default
|
||||||
|
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Logs", isDirectory: true)
|
||||||
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
||||||
|
public static var logFile: URL {
|
||||||
|
logDir.appendingPathComponent("iccery.log", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<app>/Contents/Resources/Argyll` — bundled sidecar root.
|
||||||
|
public static var bundledArgyllDir: URL {
|
||||||
|
Bundle.main.resourceURL?
|
||||||
|
.appendingPathComponent("Argyll", isDirectory: true)
|
||||||
|
?? URL(fileURLWithPath: "/nonexistent")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the app data and log directories if missing.
|
||||||
|
@discardableResult
|
||||||
|
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
|
||||||
|
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
|
||||||
|
return (appDataDir, logDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Accumulates stdout lines into a complete JSON document.
|
||||||
|
///
|
||||||
|
/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest)
|
||||||
|
/// emit pretty-printed multi-line JSON on stdout. Individual lines are
|
||||||
|
/// *not* valid JSON — only the whole block is — so callers route stdout
|
||||||
|
/// lines here and get `Data` back once the buffer parses.
|
||||||
|
///
|
||||||
|
/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager
|
||||||
|
/// diverts them to `jsonRow` events first.
|
||||||
|
public struct JSONAccumulator: Sendable {
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Appends one stdout line. Returns the complete document bytes when
|
||||||
|
/// the accumulated buffer forms valid JSON, otherwise `nil`.
|
||||||
|
public mutating func feed(line: String) -> Data? {
|
||||||
|
buffer.append(Data(line.utf8))
|
||||||
|
buffer.append(0x0A)
|
||||||
|
return tryParse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to decode the accumulated buffer; clears it on success.
|
||||||
|
public mutating func decode<T: Decodable>(_ type: T.Type) -> T? {
|
||||||
|
guard let data = tryParse() else { return nil }
|
||||||
|
return try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw buffer when it parses, `nil` while still incomplete.
|
||||||
|
public var completeData: Data? {
|
||||||
|
var copy = self
|
||||||
|
return copy.tryParse()
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func reset() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isEmpty: Bool { buffer.isEmpty }
|
||||||
|
|
||||||
|
private mutating func tryParse() -> Data? {
|
||||||
|
// Cheap gate: JSON documents start with { or [.
|
||||||
|
guard let first = buffer.first(where: { !$0.isJSONWhitespace }),
|
||||||
|
first == UInt8(ascii: "{") || first == UInt8(ascii: "[")
|
||||||
|
else { return nil }
|
||||||
|
guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil }
|
||||||
|
let out = buffer
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UInt8 {
|
||||||
|
var isJSONWhitespace: Bool {
|
||||||
|
self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Events on the process bus — the v2 equivalent of the v1 Tauri events
|
||||||
|
/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus).
|
||||||
|
public enum ProcessEvent: Sendable, Equatable {
|
||||||
|
/// Non-JSON stdout line. (`process:stdout`)
|
||||||
|
case stdout(id: String, line: String)
|
||||||
|
/// stderr line. (`process:stderr`)
|
||||||
|
case stderr(id: String, line: String)
|
||||||
|
/// Child exited; 0 = success. (`process:exit`)
|
||||||
|
case exit(id: String, code: Int32)
|
||||||
|
/// Spawn failure. (`process:error`)
|
||||||
|
case error(id: String, message: String)
|
||||||
|
/// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped,
|
||||||
|
/// payload is the remaining raw bytes. (`process:json_row`)
|
||||||
|
case jsonRow(id: String, payload: Data)
|
||||||
|
|
||||||
|
public var id: String {
|
||||||
|
switch self {
|
||||||
|
case .stdout(let id, _), .stderr(let id, _), .exit(let id, _),
|
||||||
|
.error(let id, _), .jsonRow(let id, _):
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ProcessError: Error, Equatable, Sendable {
|
||||||
|
/// A child with this id is still running (#116).
|
||||||
|
case duplicateID(String)
|
||||||
|
/// No child registered under this id.
|
||||||
|
case unknownID(String)
|
||||||
|
/// Process refused to launch.
|
||||||
|
case spawnFailed(String)
|
||||||
|
/// stdin write failed (pipe closed / process gone).
|
||||||
|
case stdinFailed(String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Deterministic process ids (docs/02 §Event bus). Listeners must always
|
||||||
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
|
public enum ProcessID {
|
||||||
|
public static let instlist = "instlist"
|
||||||
|
|
||||||
|
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
public static func chartread(_ basename: String) -> String { "chartread_\(basename)" }
|
||||||
|
public static func average(_ basename: String) -> String { "average_\(basename)" }
|
||||||
|
public static func colprof(_ basename: String) -> String { "colprof_\(basename)" }
|
||||||
|
public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" }
|
||||||
|
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
|
||||||
|
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
|
||||||
|
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Incremental byte→line decoder for process pipes.
|
||||||
|
///
|
||||||
|
/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never
|
||||||
|
/// appear inside a multi-byte UTF-8 sequence (continuation bytes are
|
||||||
|
/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line
|
||||||
|
/// is then decoded with a lossy fallback for non-UTF-8 output.
|
||||||
|
public struct ProcessLineDecoder: Sendable {
|
||||||
|
public private(set) var pending = Data()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Feeds a chunk; returns every complete line found (without `\n`).
|
||||||
|
public mutating func feed(_ chunk: Data) -> [String] {
|
||||||
|
guard !chunk.isEmpty else { return [] }
|
||||||
|
pending.append(chunk)
|
||||||
|
var lines: [String] = []
|
||||||
|
while let nl = pending.firstIndex(of: 0x0A) {
|
||||||
|
var slice = pending.prefix(upTo: nl)
|
||||||
|
pending = pending.suffix(from: pending.index(after: nl))
|
||||||
|
// Tolerate CRLF output.
|
||||||
|
if slice.last == 0x0D { slice = slice.dropLast() }
|
||||||
|
lines.append(Self.decode(slice))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flushes any unterminated remainder at EOF. Returns `nil` when empty.
|
||||||
|
public mutating func finish() -> String? {
|
||||||
|
guard !pending.isEmpty else { return nil }
|
||||||
|
var rest = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||||
|
return rest.isEmpty ? nil : Self.decode(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||||
|
String(decoding: bytes, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Captured output from `runCaptured` (used by printcal/applycal —
|
||||||
|
/// the only tools whose results arrive as one-shot output).
|
||||||
|
public struct CapturedResult: Sendable, Equatable {
|
||||||
|
public let stdout: String
|
||||||
|
public let stderr: String
|
||||||
|
public let exitCode: Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn / stdin / kill / event bus for Argyll sidecar children
|
||||||
|
/// (docs/02 §Event bus, docs/03 §Process manager).
|
||||||
|
///
|
||||||
|
/// Invariants:
|
||||||
|
/// - Duplicate `id` while a child runs is rejected (#116).
|
||||||
|
/// - The stdin handle lives in its own map, independent of wait, so
|
||||||
|
/// `sendStdin` never blocks on process exit (#84).
|
||||||
|
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
|
||||||
|
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
|
||||||
|
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||||
|
/// - `exit` is emitted exactly once per child, and only after both
|
||||||
|
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||||
|
/// exits or kills.
|
||||||
|
/// - `kill` drops the stdin handle so writers fail fast.
|
||||||
|
public actor ProcessManager {
|
||||||
|
|
||||||
|
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||||
|
|
||||||
|
public static let shared = ProcessManager()
|
||||||
|
|
||||||
|
// MARK: - Event bus (multicast)
|
||||||
|
|
||||||
|
private var subscribers: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
/// Subscribe to the event bus. Each call returns an independent
|
||||||
|
/// stream; every event is delivered to every live subscriber.
|
||||||
|
public nonisolated func events() -> AsyncStream<ProcessEvent> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let token = UUID()
|
||||||
|
Task { await self.addSubscriber(continuation, token: token) }
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
Task { await self.removeSubscriber(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addSubscriber(
|
||||||
|
_ continuation: AsyncStream<ProcessEvent>.Continuation,
|
||||||
|
token: UUID
|
||||||
|
) {
|
||||||
|
subscribers[token] = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSubscriber(_ token: UUID) {
|
||||||
|
subscribers.removeValue(forKey: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(_ event: ProcessEvent) {
|
||||||
|
for continuation in subscribers.values {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Child registry
|
||||||
|
|
||||||
|
private struct RunningChild {
|
||||||
|
let process: Process
|
||||||
|
/// stdin lives in its own slot, independent of process wait (#84).
|
||||||
|
var stdin: FileHandle?
|
||||||
|
var stdoutDecoder: ProcessLineDecoder
|
||||||
|
var stderrDecoder: ProcessLineDecoder
|
||||||
|
var stdoutEOF = false
|
||||||
|
var stderrEOF = false
|
||||||
|
/// Set by the termination handler; `exit` is emitted once both
|
||||||
|
/// pipes have also reached EOF.
|
||||||
|
var pendingExitCode: Int32?
|
||||||
|
var finalized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var children: [String: RunningChild] = [:]
|
||||||
|
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||||
|
private var captured: [String: Process] = [:]
|
||||||
|
|
||||||
|
/// Ids of currently-running children.
|
||||||
|
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||||
|
|
||||||
|
public func isRunning(_ id: String) -> Bool {
|
||||||
|
children[id] != nil || captured[id] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (streaming)
|
||||||
|
|
||||||
|
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||||
|
/// `exit(id:)` events — never assume the return means the tool
|
||||||
|
/// finished (docs/03).
|
||||||
|
public func runStreaming(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
environment: [String: String] = [:]
|
||||||
|
) throws {
|
||||||
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
let stdinPipe = Pipe()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardInput = stdinPipe
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
|
||||||
|
children[id] = RunningChild(
|
||||||
|
process: process,
|
||||||
|
stdin: stdinPipe.fileHandleForWriting,
|
||||||
|
stdoutDecoder: ProcessLineDecoder(),
|
||||||
|
stderrDecoder: ProcessLineDecoder()
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||||
|
let stderrHandle = stderrPipe.fileHandleForReading
|
||||||
|
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
|
||||||
|
}
|
||||||
|
stderrHandle.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
process.terminationHandler = { [weak self] proc in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (captured)
|
||||||
|
|
||||||
|
/// Runs a child to completion and returns all output. Reads stdout
|
||||||
|
/// and stderr concurrently so a full pipe buffer can never deadlock
|
||||||
|
/// the child. Used by `printcal` / `applycal` (docs/03).
|
||||||
|
public func runCaptured(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
environment: [String: String] = [:]
|
||||||
|
) async throws -> CapturedResult {
|
||||||
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register before run() so a concurrent duplicate spawn fails.
|
||||||
|
captured[id] = process
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
captured.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
async let outData = Task.detached {
|
||||||
|
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
async let errData = Task.detached {
|
||||||
|
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
|
||||||
|
let code = await withCheckedContinuation { continuation in
|
||||||
|
process.terminationHandler = { proc in
|
||||||
|
continuation.resume(returning: proc.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (out, err) = await (outData, errData)
|
||||||
|
// If kill() already reaped this child, its exit event went out.
|
||||||
|
if captured.removeValue(forKey: id) != nil {
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
}
|
||||||
|
|
||||||
|
return CapturedResult(
|
||||||
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
|
exitCode: code
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - stdin
|
||||||
|
|
||||||
|
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
|
||||||
|
/// and flushes (docs/03 §stdin protocol).
|
||||||
|
public func sendStdin(id: String, bytes: Data) throws {
|
||||||
|
guard let child = children[id] else { throw ProcessError.unknownID(id) }
|
||||||
|
guard let handle = child.stdin else {
|
||||||
|
throw ProcessError.stdinFailed("stdin closed for \(id)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try handle.write(contentsOf: bytes)
|
||||||
|
} catch {
|
||||||
|
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendStdin(id: String, text: String) throws {
|
||||||
|
try sendStdin(id: id, bytes: Data(text.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kill
|
||||||
|
|
||||||
|
/// Terminates a child. The `exit` event still fires exactly once.
|
||||||
|
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
||||||
|
public func kill(id: String) {
|
||||||
|
if var child = children[id] {
|
||||||
|
try? child.stdin?.close()
|
||||||
|
child.stdin = nil
|
||||||
|
children[id] = child
|
||||||
|
if child.process.isRunning {
|
||||||
|
child.process.terminate()
|
||||||
|
} else {
|
||||||
|
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let process = captured[id] {
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
if captured.removeValue(forKey: id) != nil {
|
||||||
|
emit(.exit(id: id, code: process.terminationStatus))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminates every running child; returns how many were signaled
|
||||||
|
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||||
|
@discardableResult
|
||||||
|
public func killAll() -> Int {
|
||||||
|
let ids = Array(children.keys) + Array(captured.keys)
|
||||||
|
for id in ids { kill(id: id) }
|
||||||
|
return ids.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||||
|
for (key, value) in extra { env[key] = value }
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ingestOutput(
|
||||||
|
_ data: Data,
|
||||||
|
id: String,
|
||||||
|
isStderr: Bool,
|
||||||
|
handle: FileHandle
|
||||||
|
) {
|
||||||
|
guard var child = children[id] else { return }
|
||||||
|
|
||||||
|
if data.isEmpty {
|
||||||
|
// EOF on this pipe.
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines: [String] = isStderr
|
||||||
|
? child.stderrDecoder.feed(data)
|
||||||
|
: child.stdoutDecoder.feed(data)
|
||||||
|
children[id] = child
|
||||||
|
|
||||||
|
let log = AppLogger(category: "subprocess")
|
||||||
|
for line in lines {
|
||||||
|
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else if isStderr {
|
||||||
|
log.warn("[\(id)] \(line)")
|
||||||
|
emit(.stderr(id: id, line: line))
|
||||||
|
} else {
|
||||||
|
log.info("[\(id)] \(line)")
|
||||||
|
emit(.stdout(id: id, line: line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func didTerminate(id: String, code: Int32) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
child.pendingExitCode = code
|
||||||
|
try? child.stdin?.close()
|
||||||
|
child.stdin = nil
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits `exit` once the child has terminated *and* both pipes have
|
||||||
|
/// drained to EOF, so no buffered output is lost.
|
||||||
|
private func maybeFinalize(id: String) {
|
||||||
|
guard var child = children[id],
|
||||||
|
let code = child.pendingExitCode,
|
||||||
|
child.stdoutEOF, child.stderrEOF,
|
||||||
|
!child.finalized
|
||||||
|
else { return }
|
||||||
|
child.finalized = true
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
|
||||||
|
// Flush unterminated tail lines.
|
||||||
|
if var decoder = Optional(child.stdoutDecoder),
|
||||||
|
let tail = decoder.finish() {
|
||||||
|
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if var decoder = Optional(child.stderrDecoder),
|
||||||
|
let tail = decoder.finish() {
|
||||||
|
emit(.stderr(id: id, line: tail))
|
||||||
|
}
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A saved wizard preset slot (docs/22 §Presets). The preset *engine*
|
||||||
|
/// lands in issue #11; for M1 the store only needs a Codable container.
|
||||||
|
public struct CustomPreset: Codable, Equatable, Sendable {
|
||||||
|
public var name: String
|
||||||
|
/// Opaque per-stage form values — keyed by field id.
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(name: String, values: [String: String] = [:]) {
|
||||||
|
self.name = name
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `install_profile` drops finished profiles (docs/22).
|
||||||
|
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
|
||||||
|
case user
|
||||||
|
case system
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `settings.json` model (docs/22). snake_case keys match the v1 file
|
||||||
|
/// so field names stay identical across rewrites.
|
||||||
|
public struct AppSettings: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// User override for Argyll binaries; `nil` → bundled sidecars.
|
||||||
|
public var argyllBinaryDir: String?
|
||||||
|
|
||||||
|
/// Stored but **never applied to argv** — Stage 2's own instrument
|
||||||
|
/// select is the live source (docs/04 §0.1).
|
||||||
|
public var defaultInstrument: String?
|
||||||
|
|
||||||
|
/// `nil` → `.debug` in debug builds, `.info` in release (#158).
|
||||||
|
public var logLevel: LogLevel?
|
||||||
|
|
||||||
|
public var deltaEGoodMax: Double
|
||||||
|
public var deltaEWarningMax: Double
|
||||||
|
public var customPresets: [CustomPreset]
|
||||||
|
public var enableI1Pro2Leds: Bool
|
||||||
|
public var calibrationStaleDays: Int
|
||||||
|
public var defaultInstallLocation: InstallLocation
|
||||||
|
public var askBeforeOverwriteProfile: Bool
|
||||||
|
public var openColorPanelAfterInstall: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
argyllBinaryDir: String? = nil,
|
||||||
|
defaultInstrument: String? = nil,
|
||||||
|
logLevel: LogLevel? = nil,
|
||||||
|
deltaEGoodMax: Double = 2.0,
|
||||||
|
deltaEWarningMax: Double = 5.0,
|
||||||
|
customPresets: [CustomPreset] = [],
|
||||||
|
enableI1Pro2Leds: Bool = false,
|
||||||
|
calibrationStaleDays: Int = 30,
|
||||||
|
defaultInstallLocation: InstallLocation = .user,
|
||||||
|
askBeforeOverwriteProfile: Bool = true,
|
||||||
|
openColorPanelAfterInstall: Bool = false
|
||||||
|
) {
|
||||||
|
self.argyllBinaryDir = argyllBinaryDir
|
||||||
|
self.defaultInstrument = defaultInstrument
|
||||||
|
self.logLevel = logLevel
|
||||||
|
self.deltaEGoodMax = deltaEGoodMax
|
||||||
|
self.deltaEWarningMax = deltaEWarningMax
|
||||||
|
self.customPresets = customPresets
|
||||||
|
self.enableI1Pro2Leds = enableI1Pro2Leds
|
||||||
|
self.calibrationStaleDays = calibrationStaleDays
|
||||||
|
self.defaultInstallLocation = defaultInstallLocation
|
||||||
|
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
|
||||||
|
self.openColorPanelAfterInstall = openColorPanelAfterInstall
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = AppSettings()
|
||||||
|
|
||||||
|
/// Effective log level — runtime state, not just persistence (#158).
|
||||||
|
public var effectiveLogLevel: LogLevel {
|
||||||
|
if let logLevel { return logLevel }
|
||||||
|
#if DEBUG
|
||||||
|
return .debug
|
||||||
|
#else
|
||||||
|
return .info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case argyllBinaryDir = "argyll_binary_dir"
|
||||||
|
case defaultInstrument = "default_instrument"
|
||||||
|
case logLevel = "log_level"
|
||||||
|
case deltaEGoodMax = "delta_e_good_max"
|
||||||
|
case deltaEWarningMax = "delta_e_warning_max"
|
||||||
|
case customPresets = "custom_presets"
|
||||||
|
case enableI1Pro2Leds = "enable_i1pro2_leds"
|
||||||
|
case calibrationStaleDays = "calibration_stale_days"
|
||||||
|
case defaultInstallLocation = "default_install_location"
|
||||||
|
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
|
||||||
|
case openColorPanelAfterInstall = "open_color_panel_after_install"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-facing validation. Strings are part of the contract (issue #5).
|
||||||
|
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
|
||||||
|
public static let errorThresholdOrder =
|
||||||
|
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||||
|
|
||||||
|
/// All validation errors, in declaration order. Empty = valid.
|
||||||
|
public func validate() -> [String] {
|
||||||
|
var errors: [String] = []
|
||||||
|
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
|
||||||
|
errors.append(Self.errorNegativeDeltaE)
|
||||||
|
}
|
||||||
|
if deltaEGoodMax >= deltaEWarningMax {
|
||||||
|
errors.append(Self.errorThresholdOrder)
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isValid: Bool { validate().isEmpty }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persists `AppSettings` to
|
||||||
|
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||||
|
/// (issue #5 — the v1 path is never read).
|
||||||
|
///
|
||||||
|
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||||
|
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||||
|
/// reclassify swatches.
|
||||||
|
public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
|
/// Posted on `NotificationCenter.default` after every successful save.
|
||||||
|
public static let settingsDidChange =
|
||||||
|
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||||
|
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() -> AppSettings {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
|
else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates before persisting — throws `SettingsError` listing
|
||||||
|
/// every violation; nothing is written on failure.
|
||||||
|
public func save(_ settings: AppSettings) throws {
|
||||||
|
let errors = settings.validate()
|
||||||
|
guard errors.isEmpty else {
|
||||||
|
throw SettingsError.validationFailed(errors)
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||||
|
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SettingsError: Error, Equatable {
|
||||||
|
case validationFailed([String])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
|
||||||
|
///
|
||||||
|
/// Navigation is *disk*, not buttons: a stage unlocks only when its
|
||||||
|
/// predecessor artefacts exist. Forward moves are gated; backward is
|
||||||
|
/// always allowed. Gating is re-evaluated on window focus and on stage
|
||||||
|
/// entry (#151 — files can disappear in Finder).
|
||||||
|
public enum WizardGating {
|
||||||
|
|
||||||
|
/// Whether `stage` is reachable given the probed artefacts.
|
||||||
|
///
|
||||||
|
/// - Stage 0 (calibrate): always — it is out-of-band, not gated.
|
||||||
|
/// - Stage 1: always.
|
||||||
|
/// - Stage 2: `.ti1` exists.
|
||||||
|
/// - Stage 3: `.ti1` **and** `.ti2`.
|
||||||
|
/// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2`
|
||||||
|
/// alone never unlocks it; #109/#110).
|
||||||
|
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
|
||||||
|
public static func isUnlocked(
|
||||||
|
_ stage: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
switch stage {
|
||||||
|
case .calibrate: return true
|
||||||
|
case .generate: return true
|
||||||
|
case .layOutPrint: return artefacts.stage1Complete
|
||||||
|
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
|
||||||
|
case .buildProfile: return artefacts.stage3Complete
|
||||||
|
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `go(to:)` may proceed. Backward moves and the current
|
||||||
|
/// stage are always allowed; forward moves must be unlocked.
|
||||||
|
public static func canNavigate(
|
||||||
|
to target: WizardStage,
|
||||||
|
from current: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
if target == current { return true }
|
||||||
|
if target == .calibrate || current == .calibrate {
|
||||||
|
// Stage 0 is a side-trip, not stepper navigation.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if target.rawValue < current.rawValue { return true }
|
||||||
|
return isUnlocked(target, artefacts: artefacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deepest unlocked stepper stage — used when revalidation
|
||||||
|
/// locks the current stage (#151).
|
||||||
|
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
|
||||||
|
for stage in WizardStage.stepperStages.reversed()
|
||||||
|
where isUnlocked(stage, artefacts: artefacts) {
|
||||||
|
return stage
|
||||||
|
}
|
||||||
|
return .generate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The five wizard stages plus Stage 0 (calibration), matching the v1
|
||||||
|
/// `data-stage` contract in docs/21. Stepper buttons 1–5 map to
|
||||||
|
/// `.generate` … `.verifyInstall`; `.calibrate` lives outside the stepper.
|
||||||
|
public enum WizardStage: Int, CaseIterable, Sendable, Codable {
|
||||||
|
case calibrate = 0
|
||||||
|
case generate = 1
|
||||||
|
case layOutPrint = 2
|
||||||
|
case measure = 3
|
||||||
|
case buildProfile = 4
|
||||||
|
case verifyInstall = 5
|
||||||
|
|
||||||
|
/// Sidebar stepper position (1–5); `nil` for the out-of-band calibrate stage.
|
||||||
|
public var stepperIndex: Int? {
|
||||||
|
self == .calibrate ? nil : rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public var title: String {
|
||||||
|
switch self {
|
||||||
|
case .calibrate: return "Printer Calibration"
|
||||||
|
case .generate: return "Generate Target"
|
||||||
|
case .layOutPrint: return "Lay Out & Print"
|
||||||
|
case .measure: return "Measure Chart"
|
||||||
|
case .buildProfile: return "Build Profile"
|
||||||
|
case .verifyInstall: return "Verify & Install"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .calibrate: return "slider.horizontal.3"
|
||||||
|
case .generate: return "square.grid.3x3"
|
||||||
|
case .layOutPrint: return "printer"
|
||||||
|
case .measure: return "eyedropper.halffull"
|
||||||
|
case .buildProfile: return "paintpalette"
|
||||||
|
case .verifyInstall: return "checkmark.seal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages shown in the sidebar stepper, in order.
|
||||||
|
public static var stepperStages: [WizardStage] {
|
||||||
|
[.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Session mode (docs/06 §wizardState). `"calibration"` is set while
|
||||||
|
/// Stage 0 is driving a `CAL_` chart through the same pipeline.
|
||||||
|
public enum SessionMode: String, Codable, Sendable {
|
||||||
|
case profile
|
||||||
|
case calibration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted wizard state (docs/06 §wizardState fields) —
|
||||||
|
/// `wizard_state.json` in app data.
|
||||||
|
public struct WizardState: Codable, Equatable, Sendable {
|
||||||
|
/// 0–5 (`WizardStage.rawValue`).
|
||||||
|
public var currentStage: Int
|
||||||
|
/// Run name without extension — never invented (#60).
|
||||||
|
public var basename: String
|
||||||
|
/// Working directory for artefacts; empty → `resolveSafeCwd` (#59).
|
||||||
|
public var cwd: String
|
||||||
|
/// Last spooled printer, for calibration drift history.
|
||||||
|
public var printerName: String?
|
||||||
|
public var sessionMode: SessionMode
|
||||||
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
|
public var profileBasename: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
currentStage: Int = WizardStage.generate.rawValue,
|
||||||
|
basename: String = "",
|
||||||
|
cwd: String = "",
|
||||||
|
printerName: String? = nil,
|
||||||
|
sessionMode: SessionMode = .profile,
|
||||||
|
profileBasename: String? = nil
|
||||||
|
) {
|
||||||
|
self.currentStage = currentStage
|
||||||
|
self.basename = basename
|
||||||
|
self.cwd = cwd
|
||||||
|
self.printerName = printerName
|
||||||
|
self.sessionMode = sessionMode
|
||||||
|
self.profileBasename = profileBasename
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = WizardState()
|
||||||
|
|
||||||
|
/// The stage a saved `currentStage` resolves to, clamped to a valid
|
||||||
|
/// value (corrupt ints fall back to Stage 1).
|
||||||
|
public var stage: WizardStage {
|
||||||
|
WizardStage(rawValue: currentStage) ?? .generate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||||
|
public final class WizardStateStore: Sendable {
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() -> WizardState {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||||
|
else { return .default }
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ state: WizardState) throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,166 +1 @@
|
|||||||
# ICCery
|
# iccery-v2-mac
|
||||||
|
|
||||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
|
|
||||||
|
|
||||||
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Product | ICCery v2 for macOS |
|
|
||||||
| Bundle | `com.gronod.iccery2` |
|
|
||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
|
||||||
| Default branch | `develop` (M1–M5) |
|
|
||||||
| In flight | `milestone/m6-gamut-stage0-cgats-release` (M6) |
|
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
On `develop` the wizard is artefact-gated:
|
|
||||||
|
|
||||||
1. **Stage 1** — `targen` → `.ti1`
|
|
||||||
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
|
||||||
3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
|
||||||
4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
|
||||||
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
|
||||||
|
|
||||||
Settings, logging, resume-from-artefacts, and ColorSync suppression for unmanaged jobs ship with M1–M5.
|
|
||||||
|
|
||||||
**M6 (milestone branch, not on `develop` yet):** Stage 0 printer calibration, CGATS dataset import, SceneKit gamut viewer, About/help chrome, signed `.dmg`.
|
|
||||||
|
|
||||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- macOS 14+
|
|
||||||
- Xcode 15+ with the macOS 14 SDK (Swift 5.10)
|
|
||||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
|
||||||
- Network once, to fetch Argyll sidecars
|
|
||||||
|
|
||||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.i3omb.com/gronod/iccery-v2-mac.git
|
|
||||||
cd iccery-v2-mac
|
|
||||||
git checkout develop
|
|
||||||
|
|
||||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
|
||||||
make test # xcodegen + xcodebuild build test
|
|
||||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent without Make:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xcodegen generate
|
|
||||||
xcodebuild test -scheme ICCery \
|
|
||||||
-destination 'platform=macOS' \
|
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# optional
|
|
||||||
export ARGYLL_SERVER_URL=https://git.i3omb.com
|
|
||||||
export ARGYLL_REPO=gronod/argyllcms
|
|
||||||
export ARGYLL_RELEASE_TAG=… # default: latest
|
|
||||||
export GITEA_TOKEN=… # private releases
|
|
||||||
```
|
|
||||||
|
|
||||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
|
||||||
|
|
||||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
Sources/ICCery/ SwiftUI + AppKit shell, stage views, workflow VMs
|
|
||||||
Packages/ICCeryCore/ wizard state, ProcessManager, argv builders,
|
|
||||||
settings, CGATS, ΔE₀₀ — no NSPrintPanel
|
|
||||||
Resources/ assets; Argyll reference files (not the tools)
|
|
||||||
Vendor/Argyll/ fetched sidecars (gitignored)
|
|
||||||
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
|
||||||
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
|
||||||
scripts/fetch-argyll.sh
|
|
||||||
docs/ functional spec + v2 ticket plan
|
|
||||||
```
|
|
||||||
|
|
||||||
`ICCeryPrintKit` (issue #16, Quartz / TargetPrint) is v2.1 and is not in this tree.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
|
|
||||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, 500 ms, kill; `killAll` on terminate.
|
|
||||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
|
|
||||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
|
|
||||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
|
|
||||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# full suite (universal)
|
|
||||||
xcodebuild test -scheme ICCery \
|
|
||||||
-destination 'platform=macOS' \
|
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
|
|
||||||
# examples
|
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|
||||||
-only-testing:ICCeryCoreTests/ChartreadClassifierTests
|
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|
||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
|
||||||
```
|
|
||||||
|
|
||||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
|
|
||||||
|
|
||||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
|
||||||
|
|
||||||
## Instruments
|
|
||||||
|
|
||||||
Detected via bundled `instlist`:
|
|
||||||
|
|
||||||
- i1 Pro / i1 Pro 2 (`i1`)
|
|
||||||
- ColorMunki (`CM`)
|
|
||||||
- SpyderPrint (`p3`)
|
|
||||||
- SpectroScan (`SS`)
|
|
||||||
- DTP20 / 22 / 41 / 51
|
|
||||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
|
||||||
|
|
||||||
| Doc | Topic |
|
|
||||||
|---|---|
|
|
||||||
| [`docs/01-overview.md`](docs/01-overview.md) | Product and wizard |
|
|
||||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
|
||||||
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
|
|
||||||
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
|
|
||||||
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
|
|
||||||
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
|
|
||||||
|
|
||||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
```
|
|
||||||
develop
|
|
||||||
└── milestone/mN-<slug> # integration only
|
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
|
||||||
```
|
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Do not open umbrella “bugfix” branches that mix tickets.
|
|
||||||
|
|
||||||
## Licence
|
|
||||||
|
|
||||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
|
|
||||||
|
|
||||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
|
|
||||||
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
|
||||||
-
|
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mock script for chartread -u
|
||||||
|
# This script simulates the behaviour of chartread for testing purposes.
|
||||||
|
|
||||||
|
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||||
|
IS_XY=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--xy" ]; then
|
||||||
|
IS_XY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
read -r _calib
|
||||||
|
echo "Calibration successful."
|
||||||
|
|
||||||
|
echo "Please place sheet 1 of 1 on the table"
|
||||||
|
echo "hit return to continue, Esc or 'q' to give up"
|
||||||
|
read -r _sheet1
|
||||||
|
|
||||||
|
echo "locate patch A1 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid1
|
||||||
|
|
||||||
|
echo "locate patch B24 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid2
|
||||||
|
|
||||||
|
echo "Reading sheet 1..."
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Sheet 1 of 1 read OK"
|
||||||
|
echo "Please remove last sheet from table"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handheld / strip reader simulation
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
|
||||||
|
# We don't really wait for input, just wait 1 second
|
||||||
|
sleep 1
|
||||||
|
echo "Calibration successful."
|
||||||
|
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip A..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||||
|
sleep 1
|
||||||
|
echo "Reading strip B..."
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Ready to read... done."
|
||||||
|
exit 0
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mock script for colprof
|
||||||
|
# Simulates colprof execution and outputs progress log
|
||||||
|
|
||||||
|
basename="$1"
|
||||||
|
# Find last argument if -D or other flags are used
|
||||||
|
for arg in "$@"; do
|
||||||
|
basename="$arg"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "colprof: Starting profile calculation for $basename"
|
||||||
|
sleep 1
|
||||||
|
echo "Gamut mapping calculation..."
|
||||||
|
sleep 1
|
||||||
|
echo "Fitting cLUT grid points..."
|
||||||
|
sleep 1
|
||||||
|
echo "Writing ICC profile $basename.icc..."
|
||||||
|
touch "$basename.icc"
|
||||||
|
echo "Done."
|
||||||
|
exit 0
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Mock script for profcheck
|
||||||
|
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||||
|
|
||||||
|
echo "profcheck: Checking profile accuracy..."
|
||||||
|
echo "No of test patches = 52"
|
||||||
|
sleep 1
|
||||||
|
cat << 'EOF'
|
||||||
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
|
EOF
|
||||||
|
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||||
|
exit 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.800",
|
||||||
|
"green" : "0.478",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "mac",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ICCery-logo.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "original"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="coneClip">
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#00AEEF" />
|
||||||
|
<stop offset="100%" stop-color="#0066CC" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Drop Shadow -->
|
||||||
|
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
|
||||||
|
|
||||||
|
<!-- Ice Cream Cone (Waffle) -->
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
|
||||||
|
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
|
||||||
|
<line x1="40" y1="80" x2="120" y2="160" />
|
||||||
|
<line x1="55" y1="80" x2="135" y2="160" />
|
||||||
|
<line x1="70" y1="80" x2="150" y2="160" />
|
||||||
|
<line x1="85" y1="80" x2="165" y2="160" />
|
||||||
|
|
||||||
|
<line x1="120" y1="80" x2="40" y2="160" />
|
||||||
|
<line x1="105" y1="80" x2="25" y2="160" />
|
||||||
|
<line x1="90" y1="80" x2="10" y2="160" />
|
||||||
|
<line x1="75" y1="80" x2="-5" y2="160" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CMYK Scoops (C, M, Y) -->
|
||||||
|
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
|
||||||
|
<!-- Black (Key) Cherry -->
|
||||||
|
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Highlights on scoops for 3D effect -->
|
||||||
|
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
|
||||||
|
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Text: ICCery -->
|
||||||
|
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method
|
||||||
|
/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs).
|
||||||
|
/// No call site shares a generic picker (#103/#210/#211).
|
||||||
|
@MainActor
|
||||||
|
final class FileDialogService {
|
||||||
|
|
||||||
|
static let shared = FileDialogService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - selectDirectory
|
||||||
|
|
||||||
|
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||||
|
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||||
|
func selectDirectory(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
panel.prompt = "Choose"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dedicated open pickers
|
||||||
|
|
||||||
|
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||||
|
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "target.ti1"
|
||||||
|
panel.allowedContentTypes = utTypes(["ti1"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
panel.message = "Choose the .ti1 target file to create"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||||
|
func selectExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti1", "ti2"], startingAt: start,
|
||||||
|
message: "Open an existing target (.ti1 or .ti2)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*`
|
||||||
|
/// (#172: the profile filter must not accept datasets).
|
||||||
|
func selectProfileFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["icc", "icm", "mpp"], startingAt: start,
|
||||||
|
message: "Choose an ICC/ICM profile or measurement preconditioning file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i).
|
||||||
|
func selectSpectrumFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["sp"], startingAt: start,
|
||||||
|
message: "Choose a custom illuminant spectrum (.sp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`,
|
||||||
|
/// `.cgats`, `.csv`). Always an *open* dialog, never save (#211).
|
||||||
|
func selectDatasetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start,
|
||||||
|
message: "Import a measured dataset")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCsvSavePath` — verification-history CSV export.
|
||||||
|
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "verification-history.csv"
|
||||||
|
panel.allowedContentTypes = utTypes(["csv"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCalFile` — `.cal` calibration curves.
|
||||||
|
func selectCalFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["cal"], startingAt: start,
|
||||||
|
message: "Choose a calibration file (.cal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
|
private func open(
|
||||||
|
extensions: [String],
|
||||||
|
startingAt start: URL?,
|
||||||
|
message: String?
|
||||||
|
) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.directoryURL = start
|
||||||
|
if let message { panel.message = message }
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func utTypes(_ extensions: [String]) -> [UTType] {
|
||||||
|
extensions.compactMap { UTType(filenameExtension: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSSavePanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import AppKit
|
||||||
|
import ICCeryCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ICCeryApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@State private var model = WizardViewModel()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
try? AppPaths.ensureDirectories()
|
||||||
|
// Log level is runtime state — apply persisted settings at
|
||||||
|
// startup (#158); the Settings sheet re-applies on save.
|
||||||
|
LogSink.shared.applySettings(SettingsStore().load())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||||
|
Window("ICCery", id: "main") {
|
||||||
|
RootView(model: model)
|
||||||
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
.defaultSize(width: 1280, height: 800)
|
||||||
|
.windowResizability(.contentMinSize)
|
||||||
|
.defaultPosition(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AppDelegate: quit when the single window closes, and `killAll` Argyll
|
||||||
|
/// children before teardown (#147/#149). Termination is deferred until
|
||||||
|
/// `killAll` has signaled every child so `chartread` can park an XY head
|
||||||
|
/// when the UI already sent `q\n`.
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var terminationRequested = false
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||||
|
guard !terminationRequested else { return .terminateNow }
|
||||||
|
terminationRequested = true
|
||||||
|
Task {
|
||||||
|
await ProcessManager.shared.killAll()
|
||||||
|
NSApplication.shared.reply(toApplicationShouldTerminate: true)
|
||||||
|
}
|
||||||
|
return .terminateLater
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Banner notice model — the v2 equivalent of `#wizardNotification`
|
||||||
|
/// (docs/21 §Banner).
|
||||||
|
struct Notice: Identifiable, Equatable {
|
||||||
|
enum Kind: Equatable {
|
||||||
|
case info, warning, error
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "info.circle"
|
||||||
|
case .warning: return "exclamationmark.triangle"
|
||||||
|
case .error: return "xmark.octagon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .info: return Theme.accent
|
||||||
|
case .warning: return .orange
|
||||||
|
case .error: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let kind: Kind
|
||||||
|
let text: String
|
||||||
|
/// Auto-dismiss interval; `nil` keeps the banner until closed.
|
||||||
|
var autoHideAfter: TimeInterval? = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoticeBanner: View {
|
||||||
|
let notice: Notice
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: notice.kind.symbolName)
|
||||||
|
.foregroundStyle(notice.kind.tint)
|
||||||
|
Text(notice.text)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(3)
|
||||||
|
Spacer()
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.frame(height: 1)
|
||||||
|
.foregroundStyle(Theme.border),
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
|
/// banner pinned to the top (docs/21 §Shell).
|
||||||
|
struct RootView: View {
|
||||||
|
@Bindable var model: WizardViewModel
|
||||||
|
@State private var showingSettings = false
|
||||||
|
@State private var showingAbout = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
SidebarView(
|
||||||
|
model: model,
|
||||||
|
onOpenSettings: { showingSettings = true },
|
||||||
|
onOpenAbout: { showingAbout = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.border)
|
||||||
|
.frame(width: 1)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let notice = model.notice {
|
||||||
|
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
||||||
|
}
|
||||||
|
StagePlaceholderView(stage: model.stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
|
.background(Theme.background)
|
||||||
|
// #151: re-probe artefacts when the window regains focus —
|
||||||
|
// files deleted in Finder must re-lock stages.
|
||||||
|
.onReceive(
|
||||||
|
NotificationCenter.default.publisher(
|
||||||
|
for: NSWindow.didBecomeKeyNotification
|
||||||
|
)
|
||||||
|
) { _ in model.windowDidBecomeKey() }
|
||||||
|
.sheet(isPresented: $showingSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||||
|
Button("OK") {}
|
||||||
|
} message: {
|
||||||
|
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
|
struct SettingsView: View {
|
||||||
|
@State var model = SettingsViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
|
("i1", "X-Rite i1Pro / i1Pro 2"),
|
||||||
|
("p3", "X-Rite i1Pro 3 / 3 Plus"),
|
||||||
|
("CM", "ColorMunki"),
|
||||||
|
("SS", "Specbos / Spectraval"),
|
||||||
|
("20", "Gretag i1Display 2"),
|
||||||
|
("22", "X-Rite i1Display Pro / ColorMunki Display"),
|
||||||
|
("41", "Datacolor Spyder 4/5"),
|
||||||
|
("51", "Spyder X"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Form {
|
||||||
|
Section("Argyll") {
|
||||||
|
HStack {
|
||||||
|
TextField(
|
||||||
|
"Bundled sidecars",
|
||||||
|
text: Binding(
|
||||||
|
get: { model.settings.argyllBinaryDir ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.argyllBinaryDir =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Button("Browse…") {
|
||||||
|
if let dir = FileDialogService.shared.selectDirectory() {
|
||||||
|
model.settings.argyllBinaryDir = dir.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Leave empty to use the bundled Argyll tools.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
"Default instrument",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.defaultInstrument ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.defaultInstrument =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("None").tag("")
|
||||||
|
ForEach(Self.instruments, id: \.code) {
|
||||||
|
Text($0.label).tag($0.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
"Enable i1Pro 2 LEDs",
|
||||||
|
isOn: $model.settings.enableI1Pro2Leds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Verification") {
|
||||||
|
HStack {
|
||||||
|
Text("Good ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"2.0",
|
||||||
|
value: $model.settings.deltaEGoodMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Warning ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"5.0",
|
||||||
|
value: $model.settings.deltaEWarningMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
ForEach(model.validationErrors, id: \.self) { error in
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Calibration") {
|
||||||
|
HStack {
|
||||||
|
Text("Stale after")
|
||||||
|
TextField(
|
||||||
|
"30",
|
||||||
|
value: $model.settings.calibrationStaleDays,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Profile install") {
|
||||||
|
Picker(
|
||||||
|
"Install location",
|
||||||
|
selection: $model.settings.defaultInstallLocation
|
||||||
|
) {
|
||||||
|
Text("User library").tag(InstallLocation.user)
|
||||||
|
Text("System library").tag(InstallLocation.system)
|
||||||
|
}
|
||||||
|
Toggle(
|
||||||
|
"Ask before overwriting a profile",
|
||||||
|
isOn: $model.settings.askBeforeOverwriteProfile
|
||||||
|
)
|
||||||
|
Toggle(
|
||||||
|
"Open ColorSync after install",
|
||||||
|
isOn: $model.settings.openColorPanelAfterInstall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Logging") {
|
||||||
|
Picker(
|
||||||
|
"Log level",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.logLevel },
|
||||||
|
set: { model.settings.logLevel = $0 }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Default").tag(LogLevel?.none)
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) {
|
||||||
|
Text($0.rawValue.capitalized).tag(LogLevel?.some($0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Open log folder") { model.openLogFolder() }
|
||||||
|
Button("Copy path") { model.copyLogPath() }
|
||||||
|
Button("Copy excerpt") { model.copyLogExcerpt() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if model.savedFlash {
|
||||||
|
Text("Saved")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button("Save") {
|
||||||
|
if model.save() { dismiss() }
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.frame(width: 560, height: 620)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Backs the Settings sheet (issue #5). Load → edit → save with
|
||||||
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel {
|
||||||
|
|
||||||
|
var settings: AppSettings
|
||||||
|
var validationErrors: [String] = []
|
||||||
|
var savedFlash = false
|
||||||
|
|
||||||
|
private let store: SettingsStore
|
||||||
|
private let sink: LogSink
|
||||||
|
|
||||||
|
init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) {
|
||||||
|
self.store = store
|
||||||
|
self.sink = sink
|
||||||
|
self.settings = store.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists after validation. Returns false (and shows inline
|
||||||
|
/// errors) when the form is invalid.
|
||||||
|
@discardableResult
|
||||||
|
func save() -> Bool {
|
||||||
|
validationErrors = settings.validate()
|
||||||
|
guard validationErrors.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
try store.save(settings)
|
||||||
|
sink.applySettings(settings)
|
||||||
|
savedFlash = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
savedFlash = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
validationErrors = ["Could not save settings: \(error.localizedDescription)"]
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log helpers
|
||||||
|
|
||||||
|
var logFileURL: URL { AppPaths.logFile }
|
||||||
|
|
||||||
|
func openLogFolder() {
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: AppPaths.logDir, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
NSWorkspace.shared.selectFile(
|
||||||
|
AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogPath() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogExcerpt() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||||
|
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||||
|
struct SidebarView: View {
|
||||||
|
@Bindable var model: WizardViewModel
|
||||||
|
var onOpenSettings: () -> Void
|
||||||
|
var onOpenAbout: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Image("ICCery-logo")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 40)
|
||||||
|
Spacer()
|
||||||
|
Button(action: onOpenSettings) {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Settings")
|
||||||
|
Button(action: onOpenAbout) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("About ICCery")
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
|
// Preset select (`#presetSelect`). Disabled until the preset
|
||||||
|
// engine lands in issue #11.
|
||||||
|
Picker("Preset", selection: .constant("none")) {
|
||||||
|
Text("No preset").tag("none")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.disabled(true)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
||||||
|
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
||||||
|
Button(action: { model.enterCalibration() }) {
|
||||||
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(true)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Stepper 1–5.
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(WizardStage.stepperStages, id: \.self) { stage in
|
||||||
|
StepperRow(
|
||||||
|
stage: stage,
|
||||||
|
isActive: model.stage == stage,
|
||||||
|
// Artefact gating (issue #4) — disk is truth.
|
||||||
|
isEnabled: model.isUnlocked(stage)
|
||||||
|
) {
|
||||||
|
model.go(to: stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: Theme.Metrics.sidebarWidth)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StepperRow: View {
|
||||||
|
let stage: WizardStage
|
||||||
|
let isActive: Bool
|
||||||
|
let isEnabled: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(isActive ? Theme.accent : Theme.border)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
Text("\(stage.stepperIndex ?? 0)")
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundStyle(isActive ? .white : Theme.text)
|
||||||
|
}
|
||||||
|
Label(stage.title, systemImage: stage.symbolName)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(isActive ? Theme.text : .secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.opacity(isEnabled ? 1 : 0.45)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
||||||
|
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
||||||
|
struct StagePlaceholderView: View {
|
||||||
|
let stage: WizardStage
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: stage.symbolName)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
Text(stage.title)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text("This stage is not implemented yet — see the milestone plan.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens).
|
||||||
|
enum Theme {
|
||||||
|
static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255)
|
||||||
|
static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255)
|
||||||
|
static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255)
|
||||||
|
static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255)
|
||||||
|
static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255)
|
||||||
|
/// v1 window/titlebar backing colour (docs/02 §Window contract).
|
||||||
|
static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255)
|
||||||
|
|
||||||
|
enum Metrics {
|
||||||
|
static let sidebarWidth: CGFloat = 270
|
||||||
|
static let buttonSmall: CGFloat = 28
|
||||||
|
static let buttonMedium: CGFloat = 36
|
||||||
|
static let buttonLarge: CGFloat = 40
|
||||||
|
static let cornerSmall: CGFloat = 4
|
||||||
|
static let cornerMedium: CGFloat = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
|
///
|
||||||
|
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
||||||
|
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
|
||||||
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
|
/// navigation is disk, not buttons.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class WizardViewModel {
|
||||||
|
|
||||||
|
// MARK: - wizardState fields (persisted)
|
||||||
|
|
||||||
|
var stage: WizardStage {
|
||||||
|
didSet { if stage != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||||
|
var basename: String {
|
||||||
|
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||||
|
}
|
||||||
|
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||||
|
var workingDirectory: URL? {
|
||||||
|
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||||
|
}
|
||||||
|
var printerName: String? {
|
||||||
|
didSet { if printerName != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
var sessionMode: SessionMode {
|
||||||
|
didSet { if sessionMode != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||||
|
var profileBasename: String? {
|
||||||
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
|
var notice: Notice?
|
||||||
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
|
private(set) var artefacts = StageArtefacts()
|
||||||
|
|
||||||
|
private let stateStore: WizardStateStore
|
||||||
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(stateStore: WizardStateStore = WizardStateStore()) {
|
||||||
|
self.stateStore = stateStore
|
||||||
|
let s = stateStore.load()
|
||||||
|
self.stage = s.stage
|
||||||
|
self.basename = s.basename
|
||||||
|
self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd)
|
||||||
|
self.printerName = s.printerName
|
||||||
|
self.sessionMode = s.sessionMode
|
||||||
|
self.profileBasename = s.profileBasename
|
||||||
|
refreshGating()
|
||||||
|
// A restored stage may have been locked since (#151).
|
||||||
|
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
||||||
|
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gating
|
||||||
|
|
||||||
|
/// `isUnlocked` for the sidebar stepper.
|
||||||
|
func isUnlocked(_ stage: WizardStage) -> Bool {
|
||||||
|
WizardGating.isUnlocked(stage, artefacts: artefacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` while Stage 0 (printer calibration) is shown.
|
||||||
|
var isCalibrating: Bool { stage == .calibrate }
|
||||||
|
|
||||||
|
/// Re-probes the artefact directory and re-locks (#151). Called on
|
||||||
|
/// window focus, stage entry, and basename/cwd changes.
|
||||||
|
func refreshGating() {
|
||||||
|
guard !basename.isEmpty, let dir = effectiveWorkingDirectory else {
|
||||||
|
artefacts = StageArtefacts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artefacts = ArtefactProbe.verify(basename: basename, cwd: dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `setTarget(basename, cwd)` — validates the basename (no `/`, `\`,
|
||||||
|
/// `..`; no placeholders — #60) and resolves the cwd (#59).
|
||||||
|
func setTarget(basename: String, workingDirectory: URL?) {
|
||||||
|
do {
|
||||||
|
self.basename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
} catch {
|
||||||
|
showNotice("Invalid target name.", kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwd never stays empty once a basename exists (#59).
|
||||||
|
var effectiveWorkingDirectory: URL? {
|
||||||
|
if let workingDirectory { return workingDirectory }
|
||||||
|
return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
/// `navigateToStage(n)` — refuses locked forward moves with a
|
||||||
|
/// warning banner; backward is always allowed (docs/06).
|
||||||
|
func go(to target: WizardStage) {
|
||||||
|
guard target != .calibrate else { enterCalibration(); return }
|
||||||
|
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
||||||
|
stage = target
|
||||||
|
} else {
|
||||||
|
showNotice(
|
||||||
|
"Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.",
|
||||||
|
kind: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enterCalibration() {
|
||||||
|
sessionMode = .calibration
|
||||||
|
stage = .calibrate
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitCalibration() {
|
||||||
|
sessionMode = .profile
|
||||||
|
stage = .generate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
|
||||||
|
/// If the current stage re-locked, fall back to the deepest unlocked.
|
||||||
|
func windowDidBecomeKey() {
|
||||||
|
refreshGating()
|
||||||
|
if stage != .calibrate,
|
||||||
|
!WizardGating.isUnlocked(stage, artefacts: artefacts) {
|
||||||
|
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notice
|
||||||
|
|
||||||
|
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
||||||
|
noticeDismissTask?.cancel()
|
||||||
|
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||||
|
self.notice = notice
|
||||||
|
if let delay = notice.autoHideAfter {
|
||||||
|
noticeDismissTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
if self?.notice?.id == notice.id {
|
||||||
|
self?.notice = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissNotice() {
|
||||||
|
noticeDismissTask?.cancel()
|
||||||
|
notice = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
let state = WizardState(
|
||||||
|
currentStage: stage.rawValue,
|
||||||
|
basename: basename,
|
||||||
|
cwd: workingDirectory?.path ?? "",
|
||||||
|
printerName: printerName,
|
||||||
|
sessionMode: sessionMode,
|
||||||
|
profileBasename: profileBasename
|
||||||
|
)
|
||||||
|
try? stateStore.save(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("AppPaths")
|
||||||
|
struct AppPathsTests {
|
||||||
|
@Test func appDataDirUsesBundleID() {
|
||||||
|
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func logFileIsUnderLibraryLogs() {
|
||||||
|
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
||||||
|
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bundledArgyllDirIsInsideResources() {
|
||||||
|
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("WizardStage")
|
||||||
|
struct WizardStageTests {
|
||||||
|
@Test func stepperOrderIsOneThroughFive() {
|
||||||
|
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
||||||
|
#expect(WizardStage.calibrate.stepperIndex == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func tempURL(_ name: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Ti2Header")
|
||||||
|
struct Ti2HeaderTests {
|
||||||
|
@Test func parsesKeywordsAndSibling() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
try """
|
||||||
|
CTI2
|
||||||
|
TARGET_INSTRUMENT "i1iO"
|
||||||
|
NUMBER_OF_FIELDS 9
|
||||||
|
NUMBER_OF_SETS 800
|
||||||
|
NUMBER_OF_PAGES 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R
|
||||||
|
END_DATA_FORMAT
|
||||||
|
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
|
||||||
|
try "CGATS".write(
|
||||||
|
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||||
|
#expect(h.instrument == "i1iO")
|
||||||
|
#expect(h.patchCount == 800)
|
||||||
|
#expect(h.pageCount == 3)
|
||||||
|
#expect(h.hasSiblingTi1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileYieldsEmptyHeader() {
|
||||||
|
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||||
|
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func numberOfFieldsIsNotPatchCount() throws {
|
||||||
|
let url = tempURL("t.ti2")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||||
|
to: url, atomically: true, encoding: .utf8
|
||||||
|
)
|
||||||
|
#expect(Ti2Header.parse(url).patchCount == 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TiffPreview")
|
||||||
|
struct TiffPreviewTests {
|
||||||
|
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
||||||
|
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
||||||
|
let url = tempURL("big.tif")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||||
|
let ctx = CGContext(
|
||||||
|
data: nil, width: width, height: height,
|
||||||
|
bitsPerComponent: 8, bytesPerRow: width * 4,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
)!
|
||||||
|
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
let image = ctx.makeImage()!
|
||||||
|
|
||||||
|
guard let dest = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
|
||||||
|
) else { throw CocoaError(.fileWriteUnknown) }
|
||||||
|
CGImageDestinationAddImage(dest, image, nil)
|
||||||
|
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func producesCappedPNG() throws {
|
||||||
|
let tiff = try makeTiff()
|
||||||
|
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||||
|
#expect(png != nil)
|
||||||
|
// PNG magic
|
||||||
|
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||||
|
// Verify the cap by decoding the thumbnail header.
|
||||||
|
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
||||||
|
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
||||||
|
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||||
|
#expect(img.width == 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonTiffReturnsNil() throws {
|
||||||
|
let url = tempURL("not-tiff.txt")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ArtefactFiles")
|
||||||
|
struct ArtefactFilesTests {
|
||||||
|
@Test func base64RoundTrip() throws {
|
||||||
|
let url = tempURL("a.txt")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let b64 = try ArtefactFiles.readBase64(url)
|
||||||
|
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultWorkingDirExists() {
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("BinaryResolver")
|
||||||
|
struct BinaryResolverTests {
|
||||||
|
|
||||||
|
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
try body(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ url: URL, executable: Bool = true) throws {
|
||||||
|
FileManager.default.createFile(atPath: url.path, contents: Data())
|
||||||
|
if executable {
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overrideDirWinsWhenFileExists() throws {
|
||||||
|
let override = try makeTree { root in
|
||||||
|
try touch(root.appendingPathComponent("targen"))
|
||||||
|
}
|
||||||
|
let bundled = try makeTree { _ in }
|
||||||
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
|
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overrideFallsThroughWhenMissing() throws {
|
||||||
|
let override = try makeTree { _ in }
|
||||||
|
let bundled = try makeTree { root in
|
||||||
|
let dir = root.appendingPathComponent("macos-universal")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
try touch(dir.appendingPathComponent("instlist"))
|
||||||
|
}
|
||||||
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
|
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||||
|
let bundled = try makeTree { root in
|
||||||
|
for dir in ["macos-universal", "macos-x86_64"] {
|
||||||
|
let d = root.appendingPathComponent(dir)
|
||||||
|
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||||
|
try touch(d.appendingPathComponent("instlist"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
|
#expect(r.platformDir() == "macos-universal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fallsBackToArchDir() throws {
|
||||||
|
let bundled = try makeTree { root in
|
||||||
|
let d = root.appendingPathComponent("macos-x86_64")
|
||||||
|
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||||
|
try touch(d.appendingPathComponent("instlist"))
|
||||||
|
}
|
||||||
|
let r = BinaryResolver(
|
||||||
|
bundledRoot: bundled,
|
||||||
|
archDirs: ["macos-universal", "macos-x86_64"]
|
||||||
|
)
|
||||||
|
#expect(r.platformDir() == "macos-x86_64")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||||
|
let bundled = try makeTree { _ in }
|
||||||
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
|
// v1 semantic: path is returned; spawn surfaces the error.
|
||||||
|
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||||
|
#expect(!r.exists(r.resolve("targen")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func mockAndGamutPaths() throws {
|
||||||
|
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||||
|
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||||
|
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-files-\(name)")
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ url: URL, _ contents: String = "x") throws {
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("PathSecurity")
|
||||||
|
struct PathSecurityTests {
|
||||||
|
@Test func rejectsTraversalAndSeparators() {
|
||||||
|
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||||
|
#expect(!PathSecurity.isValidBasename(bad))
|
||||||
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
|
try PathSecurity.sanitizeBasename(bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func acceptsNormalNames() {
|
||||||
|
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||||
|
#expect(PathSecurity.isValidBasename(good))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||||
|
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||||
|
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("AtomicFileWriter")
|
||||||
|
struct AtomicFileWriterTests {
|
||||||
|
@Test func writesAndLeavesNoTmp() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("state.json")
|
||||||
|
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overwritesExistingAtomically() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("f.txt")
|
||||||
|
try AtomicFileWriter.write("one", to: url)
|
||||||
|
try AtomicFileWriter.write("two-longer", to: url)
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func createsParentDirs() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||||
|
try AtomicFileWriter.write("{}", to: url)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ArtefactProbe")
|
||||||
|
struct ArtefactProbeTests {
|
||||||
|
@Test func verifyProgression() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v == StageArtefacts())
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.ti1"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.ti2"))
|
||||||
|
try touch(dir.appendingPathComponent("t.ti3"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.icc"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func icmWinsOverIcc() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
try touch(dir.appendingPathComponent("p.icc"))
|
||||||
|
try touch(dir.appendingPathComponent("p.icm"))
|
||||||
|
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||||
|
#expect(profile?.pathExtension == "icm")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
for name in [
|
||||||
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
|
"t.icc", "t.gam",
|
||||||
|
"CAL_t.ti1", "CAL_t.cal",
|
||||||
|
// must NOT match:
|
||||||
|
"other.ti1", "t.txt", "CAL_other.ti1",
|
||||||
|
] { try touch(dir.appendingPathComponent(name)) }
|
||||||
|
|
||||||
|
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
|
||||||
|
.map(\.lastPathComponent)
|
||||||
|
for expected in [
|
||||||
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
|
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||||
|
] {
|
||||||
|
#expect(names.contains(expected), "missing \(expected)")
|
||||||
|
}
|
||||||
|
#expect(!names.contains("other.ti1"))
|
||||||
|
#expect(!names.contains("t.txt"))
|
||||||
|
#expect(!names.contains("CAL_other.ti1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyDirReturnsEmpty() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||||
|
/// scripts written to a temp dir — no resource bundling required.
|
||||||
|
@Suite("ProcessManager", .serialized)
|
||||||
|
struct ProcessManagerTests {
|
||||||
|
|
||||||
|
// MARK: - Fixture plumbing
|
||||||
|
|
||||||
|
private static let fixtureDir: URL = {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
|
||||||
|
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Writes a shell script fixture and returns its executable URL.
|
||||||
|
private func script(_ name: String, _ body: String) throws -> URL {
|
||||||
|
let url = Self.fixtureDir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects events for `id` until `.exit`, `timeout` seconds max.
|
||||||
|
private func collect(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> [ProcessEvent] {
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
let box = Box()
|
||||||
|
Task {
|
||||||
|
for await event in manager.events() {
|
||||||
|
guard event.id == id else { continue }
|
||||||
|
box.append(event)
|
||||||
|
if case .exit = event { break }
|
||||||
|
}
|
||||||
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(timeout))
|
||||||
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Box: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _events: [ProcessEvent] = []
|
||||||
|
private var finished = false
|
||||||
|
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
|
||||||
|
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
|
||||||
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
||||||
|
async let events = collect(pm, id: "t1")
|
||||||
|
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
|
||||||
|
let evs = await events
|
||||||
|
let lines = evs.compactMap { e -> String? in
|
||||||
|
if case .stdout(_, let l) = e { return l }; return nil
|
||||||
|
}
|
||||||
|
#expect(lines == ["hello", "world"])
|
||||||
|
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func routesStderrSeparately() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
||||||
|
async let evs = collect(pm, id: "t2")
|
||||||
|
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
||||||
|
let events = await evs
|
||||||
|
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
||||||
|
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stripsRowColorsJSONPrefix() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"rows.sh",
|
||||||
|
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
|
||||||
|
)
|
||||||
|
async let evs = collect(pm, id: "t3")
|
||||||
|
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
|
||||||
|
let events = await evs
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":1}"])
|
||||||
|
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
||||||
|
// Prefixed lines must not leak into stdout.
|
||||||
|
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedTailFlushesOnExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
||||||
|
async let evs = collect(pm, id: "t4")
|
||||||
|
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
||||||
|
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stdinRoundTrip() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
// Read two lines then exit naturally — a killed sh would lose its
|
||||||
|
// buffered stdio output, which is exactly the chartread pattern.
|
||||||
|
let bin = try script(
|
||||||
|
"echo.sh",
|
||||||
|
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
|
||||||
|
)
|
||||||
|
async let evs = collect(pm, id: "t5")
|
||||||
|
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
|
||||||
|
try await pm.sendStdin(id: "t5", text: " \n")
|
||||||
|
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||||
|
let events = await evs
|
||||||
|
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||||
|
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateIDRejected() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
await #expect(throws: ProcessError.duplicateID("t6")) {
|
||||||
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
}
|
||||||
|
await pm.kill(id: "t6")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killEmitsExitAndClosesStdin() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||||
|
async let evs = collect(pm, id: "t7")
|
||||||
|
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
|
||||||
|
await pm.kill(id: "t7")
|
||||||
|
let events = await evs
|
||||||
|
// exit emitted exactly once
|
||||||
|
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||||
|
#expect(exits.count == 1)
|
||||||
|
await #expect(throws: ProcessError.unknownID("t7")) {
|
||||||
|
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killAllCountsSignaled() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
||||||
|
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
||||||
|
let count = await pm.killAll()
|
||||||
|
#expect(count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunReturnsBothStreams() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
||||||
|
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout.contains("out-data"))
|
||||||
|
#expect(result.stderr.contains("err-data"))
|
||||||
|
#expect(result.exitCode == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||||
|
let bin = try script(
|
||||||
|
"big.sh",
|
||||||
|
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
||||||
|
)
|
||||||
|
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout.contains("out-4999"))
|
||||||
|
#expect(result.stderr.contains("err-4999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func argyllEnvVarIsSet() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
||||||
|
async let evs = collect(pm, id: "t10")
|
||||||
|
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
||||||
|
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownIDStdinThrows() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
await #expect(throws: ProcessError.unknownID("nope")) {
|
||||||
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ProcessLineDecoder")
|
||||||
|
struct ProcessLineDecoderTests {
|
||||||
|
@Test func splitsAcrossChunkBoundaries() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
#expect(d.feed(Data("he".utf8)) == [])
|
||||||
|
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
|
||||||
|
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
|
||||||
|
#expect(d.finish() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func crlfIsStripped() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func finishReturnsRemainder() {
|
||||||
|
var d = ProcessLineDecoder()
|
||||||
|
_ = d.feed(Data("x".utf8))
|
||||||
|
#expect(d.finish() == "x")
|
||||||
|
#expect(d.finish() == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("JSONAccumulator")
|
||||||
|
struct JSONAccumulatorTests {
|
||||||
|
@Test func multilinePrettyJSON() {
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
#expect(acc.feed(line: "{") == nil)
|
||||||
|
#expect(acc.feed(line: " \"k\": 1") == nil)
|
||||||
|
let done = acc.feed(line: "}")
|
||||||
|
#expect(done != nil)
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
||||||
|
#expect(obj?["k"] == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonJSONLinesIgnored() {
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
#expect(acc.feed(line: "Reading instrument...") == nil)
|
||||||
|
#expect(acc.feed(line: "still text") == nil)
|
||||||
|
#expect(acc.completeData == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeTyped() {
|
||||||
|
struct Doc: Decodable { let n: Int }
|
||||||
|
var acc = JSONAccumulator()
|
||||||
|
// Split so the doc completes on the second feed.
|
||||||
|
#expect(acc.feed(line: "{\"n\":") == nil)
|
||||||
|
let data = acc.feed(line: "7}")
|
||||||
|
#expect(data != nil)
|
||||||
|
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
||||||
|
#expect(doc?.n == 7)
|
||||||
|
#expect(acc.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("LogSanitizer")
|
||||||
|
struct LogSanitizerTests {
|
||||||
|
@Test func homeIsRewritten() {
|
||||||
|
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
||||||
|
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func tempStoreURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-settings-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("AppSettings")
|
||||||
|
struct AppSettingsTests {
|
||||||
|
@Test func defaults() {
|
||||||
|
let s = AppSettings.default
|
||||||
|
#expect(s.argyllBinaryDir == nil)
|
||||||
|
#expect(s.defaultInstrument == nil)
|
||||||
|
#expect(s.logLevel == nil)
|
||||||
|
#expect(s.deltaEGoodMax == 2.0)
|
||||||
|
#expect(s.deltaEWarningMax == 5.0)
|
||||||
|
#expect(s.customPresets.isEmpty)
|
||||||
|
#expect(!s.enableI1Pro2Leds)
|
||||||
|
#expect(s.calibrationStaleDays == 30)
|
||||||
|
#expect(s.defaultInstallLocation == .user)
|
||||||
|
#expect(s.askBeforeOverwriteProfile)
|
||||||
|
#expect(!s.openColorPanelAfterInstall)
|
||||||
|
#expect(s.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func negativeThresholds() {
|
||||||
|
var s = AppSettings.default
|
||||||
|
s.deltaEGoodMax = -1
|
||||||
|
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
||||||
|
s.deltaEGoodMax = 2.0
|
||||||
|
s.deltaEWarningMax = -0.5
|
||||||
|
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
||||||
|
#expect(s.validate() == [
|
||||||
|
AppSettings.errorNegativeDeltaE,
|
||||||
|
AppSettings.errorThresholdOrder,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func goodMustBeStrictlyLessThanWarning() {
|
||||||
|
var s = AppSettings.default
|
||||||
|
s.deltaEGoodMax = 5.0
|
||||||
|
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||||
|
s.deltaEGoodMax = 6.0
|
||||||
|
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||||
|
s.deltaEGoodMax = 4.9
|
||||||
|
#expect(s.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func snakeCaseKeys() throws {
|
||||||
|
let s = AppSettings.default
|
||||||
|
let data = try JSONEncoder().encode(s)
|
||||||
|
let json = String(data: data, encoding: .utf8)!
|
||||||
|
#expect(json.contains("\"delta_e_good_max\""))
|
||||||
|
#expect(json.contains("\"default_install_location\""))
|
||||||
|
#expect(json.contains("\"enable_i1pro2_leds\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("SettingsStore")
|
||||||
|
struct SettingsStoreTests {
|
||||||
|
@Test func roundTrip() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var s = AppSettings.default
|
||||||
|
s.deltaEGoodMax = 1.5
|
||||||
|
s.defaultInstrument = "p3"
|
||||||
|
try store.save(s)
|
||||||
|
#expect(store.load() == s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func corruptJsonFallsBackToDefaults() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(SettingsStore(fileURL: url).load() == .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileReturnsDefaults() {
|
||||||
|
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func invalidSettingsNotPersisted() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var s = AppSettings.default
|
||||||
|
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||||
|
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func savePostsNotification() async throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var fired = false
|
||||||
|
let token = NotificationCenter.default.addObserver(
|
||||||
|
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||||
|
) { _ in fired = true }
|
||||||
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
|
try store.save(.default)
|
||||||
|
#expect(fired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("LogSink")
|
||||||
|
struct LogSinkTests {
|
||||||
|
private func tempLog() -> (URL, LogSink) {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent("iccery.log")
|
||||||
|
return (url, LogSink(fileURL: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func writesFormattedLines() {
|
||||||
|
let (url, sink) = tempLog()
|
||||||
|
sink.setLevel(.debug)
|
||||||
|
sink.write(level: .info, category: "test", message: "hello")
|
||||||
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
|
#expect(content.contains("[INFO] test: hello"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func levelFilteringIsLive() {
|
||||||
|
let (url, sink) = tempLog()
|
||||||
|
sink.setLevel(.error)
|
||||||
|
sink.write(level: .info, category: "t", message: "hidden")
|
||||||
|
sink.setLevel(.info) // runtime change, no restart (#158)
|
||||||
|
sink.write(level: .info, category: "t", message: "shown")
|
||||||
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
|
#expect(!content.contains("hidden"))
|
||||||
|
#expect(content.contains("shown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
||||||
|
let (url, sink) = tempLog()
|
||||||
|
sink.setLevel(.trace)
|
||||||
|
// Pre-fill the active log just under the cap, then cross it.
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes))
|
||||||
|
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: url.appendingPathExtension("1").path
|
||||||
|
))
|
||||||
|
// Active log is small again.
|
||||||
|
let size = try FileManager.default.attributesOfItem(
|
||||||
|
atPath: url.path
|
||||||
|
)[.size] as? UInt64
|
||||||
|
#expect((size ?? 0) < 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tailExcerptCaps() throws {
|
||||||
|
let (url, sink) = tempLog()
|
||||||
|
sink.setLevel(.debug)
|
||||||
|
sink.write(level: .info, category: "t", message: "line")
|
||||||
|
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func artefacts(
|
||||||
|
ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false
|
||||||
|
) -> StageArtefacts {
|
||||||
|
var a = StageArtefacts()
|
||||||
|
a.stage1Complete = ti1
|
||||||
|
a.stage2Complete = ti2
|
||||||
|
a.stage3Complete = ti3
|
||||||
|
a.stage4Complete = profile
|
||||||
|
if profile {
|
||||||
|
a.profilePath = URL(fileURLWithPath: "/x/t.icc")
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("WizardGating matrix")
|
||||||
|
struct WizardGatingTests {
|
||||||
|
|
||||||
|
@Test func emptyProjectOnlyStage1() {
|
||||||
|
let a = artefacts()
|
||||||
|
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||||
|
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||||
|
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
||||||
|
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ti1UnlocksStage2Only() {
|
||||||
|
let a = artefacts(ti1: true)
|
||||||
|
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||||
|
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
||||||
|
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
|
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stage3NeedsTi1AndTi2() {
|
||||||
|
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||||
|
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stage4NeedsTi3NotTi2() {
|
||||||
|
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||||
|
let a = artefacts(ti1: true, ti2: true)
|
||||||
|
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
|
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stage5NeedsTi3AndProfile() {
|
||||||
|
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||||
|
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||||
|
#expect(WizardGating.isUnlocked(
|
||||||
|
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func forwardGatedBackwardFree() {
|
||||||
|
let a = artefacts()
|
||||||
|
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||||
|
// Backward always allowed even when artefacts vanished.
|
||||||
|
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||||
|
// Same stage is a no-op.
|
||||||
|
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||||
|
// Stage 0 is a side-trip, never gated.
|
||||||
|
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deepestUnlocked() {
|
||||||
|
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
||||||
|
#expect(WizardGating.deepestUnlocked(
|
||||||
|
artefacts: artefacts(ti1: true, ti2: true)
|
||||||
|
) == .measure)
|
||||||
|
#expect(WizardGating.deepestUnlocked(
|
||||||
|
artefacts: artefacts(ti3: true, profile: true)
|
||||||
|
) == .verifyInstall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("WizardStateStore")
|
||||||
|
struct WizardStateStoreTests {
|
||||||
|
private func tempURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent("wizard_state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTrip() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
let store = WizardStateStore(fileURL: url)
|
||||||
|
var s = WizardState()
|
||||||
|
s.currentStage = 3
|
||||||
|
s.basename = "run-42"
|
||||||
|
s.cwd = "/tmp/charts"
|
||||||
|
s.sessionMode = .calibration
|
||||||
|
s.profileBasename = "imported"
|
||||||
|
try store.save(s)
|
||||||
|
#expect(store.load() == s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileDefaults() {
|
||||||
|
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||||
|
#expect(s == .default)
|
||||||
|
#expect(s.stage == .generate)
|
||||||
|
#expect(s.sessionMode == .profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func corruptStageFallsBackToGenerate() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||||
|
.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||||
|
var s = WizardState(sessionMode: .calibration)
|
||||||
|
let data = try JSONEncoder().encode(s)
|
||||||
|
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||||
|
#expect(decoded.sessionMode == .calibration)
|
||||||
|
s.sessionMode = .profile
|
||||||
|
#expect(s.sessionMode == .profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="coneClip">
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#00AEEF" />
|
||||||
|
<stop offset="100%" stop-color="#0066CC" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Drop Shadow -->
|
||||||
|
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
|
||||||
|
|
||||||
|
<!-- Ice Cream Cone (Waffle) -->
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
|
||||||
|
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
|
||||||
|
<line x1="40" y1="80" x2="120" y2="160" />
|
||||||
|
<line x1="55" y1="80" x2="135" y2="160" />
|
||||||
|
<line x1="70" y1="80" x2="150" y2="160" />
|
||||||
|
<line x1="85" y1="80" x2="165" y2="160" />
|
||||||
|
|
||||||
|
<line x1="120" y1="80" x2="40" y2="160" />
|
||||||
|
<line x1="105" y1="80" x2="25" y2="160" />
|
||||||
|
<line x1="90" y1="80" x2="10" y2="160" />
|
||||||
|
<line x1="75" y1="80" x2="-5" y2="160" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CMYK Scoops (C, M, Y) -->
|
||||||
|
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
|
||||||
|
<!-- Black (Key) Cherry -->
|
||||||
|
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Highlights on scoops for 3D effect -->
|
||||||
|
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
|
||||||
|
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Text: ICCery -->
|
||||||
|
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="coneClip">
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Drop Shadow -->
|
||||||
|
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
|
||||||
|
|
||||||
|
<!-- Ice Cream Cone (Waffle) -->
|
||||||
|
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
|
||||||
|
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
|
||||||
|
<line x1="40" y1="80" x2="120" y2="160" />
|
||||||
|
<line x1="55" y1="80" x2="135" y2="160" />
|
||||||
|
<line x1="70" y1="80" x2="150" y2="160" />
|
||||||
|
<line x1="85" y1="80" x2="165" y2="160" />
|
||||||
|
|
||||||
|
<line x1="120" y1="80" x2="40" y2="160" />
|
||||||
|
<line x1="105" y1="80" x2="25" y2="160" />
|
||||||
|
<line x1="90" y1="80" x2="10" y2="160" />
|
||||||
|
<line x1="75" y1="80" x2="-5" y2="160" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CMYK Scoops (C, M, Y) -->
|
||||||
|
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
|
||||||
|
|
||||||
|
<!-- Black (Key) Cherry -->
|
||||||
|
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Highlights on scoops for 3D effect -->
|
||||||
|
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||||
|
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
|
||||||
|
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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).
|
||||||
@@ -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/<platform>/<name>[.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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<cwd>/<basename>.ti1
|
||||||
|
<cwd>/<basename>.ti2
|
||||||
|
<cwd>/<basename>.tif (and .1.tif, .2.tif … for multi-page)
|
||||||
|
<cwd>/<basename>.ti3
|
||||||
|
<cwd>/<basename>_passN.ti3 # averaging snapshots (#109)
|
||||||
|
<cwd>/<basename>.icc | .icm
|
||||||
|
<cwd>/<basename>.gam
|
||||||
|
<cwd>/CAL_<basename>.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`.
|
||||||
@@ -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<Child>` 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.
|
||||||
@@ -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<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: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 `<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: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 → `<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:864–947`) → `run_average` (`commands.rs:1079–1091`). Process id `average_{output}` e.g. `average_job.ti3`.
|
||||||
|
|
||||||
|
### 5.2 argv — `build_average_args` (`commands.rs:1070–1077`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-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:1877–1896`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `colprof` — Stage 4 profile calculation
|
||||||
|
|
||||||
|
### 6.1 When
|
||||||
|
|
||||||
|
Stage 4 **Calculate Profile** (`#btnCreateProfile`) → `run_colprof` (`commands.rs:1243–1255`). 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:1176–1241`)
|
||||||
|
|
||||||
|
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:95–107`. Custom FWA: `fwa: colprofCustomSpPath.value \|\| "none"`.
|
||||||
|
|
||||||
|
### 6.3 stdout “progress”
|
||||||
|
|
||||||
|
`colprof.js:119–126` (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:621–639`). 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:1937–2099` (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:703–762`) uses `run_captured`.
|
||||||
|
|
||||||
|
Callers:
|
||||||
|
|
||||||
|
1. Automatically after successful colprof (`colprof.js:163–171`) 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:237–261`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-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 tool’s `-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:987–1015`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `printcal` — compute `.cal` from measured calibration chart
|
||||||
|
|
||||||
|
### 8.1 When
|
||||||
|
|
||||||
|
Stage 0 **Compute Curves** (`#btnCalCompute`) → `compute_calibration_curves` (`calibration.rs:615–701`). **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:418–437, 485–492`). Rename uses `{basename}_{ISO-stamp}.cal`.
|
||||||
|
|
||||||
|
### 8.2 argv — `build_printcal_args` (`calibration.rs:191–235`)
|
||||||
|
|
||||||
|
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 printcal’s even/estimate switch (always on), **not** targen white patches.
|
||||||
|
|
||||||
|
### 8.3 stdout parsing — `parse_printcal_stdout` (`calibration.rs:274–307`)
|
||||||
|
|
||||||
|
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:939–984, 1018–1072`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `profcheck` — Stage 5 verification
|
||||||
|
|
||||||
|
### 9.1 When
|
||||||
|
|
||||||
|
Stage 5 **Verify** (`#btnVerify`) → `run_profcheck` (`commands.rs:1275–1302`). 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:1283–1295`).
|
||||||
|
|
||||||
|
### 9.2 argv — `build_profcheck_args` (`commands.rs:1264–1273`)
|
||||||
|
|
||||||
|
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:27–139`)
|
||||||
|
|
||||||
|
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:23–24`).
|
||||||
|
|
||||||
|
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:2102–2110`. 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:197–214`) → `extract_gamut` (`commands.rs:693–731`). Process id `iccgamut_{stem}`.
|
||||||
|
|
||||||
|
### 10.2 argv — `build_iccgamut_args` (`commands.rs:684–691`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-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 Argyll’s 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:1475–1478`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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:6–14`) 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:166–194`):
|
||||||
|
|
||||||
|
```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:237–267`:
|
||||||
|
|
||||||
|
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:33–38`) — **no JS caller**.
|
||||||
|
- `RunEvent::Exit` / `ExitRequested` (`lib.rs:124–129`).
|
||||||
|
- `WindowEvent::CloseRequested` (`lib.rs:136–141`).
|
||||||
|
|
||||||
|
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: Tokio’s `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:270–325`: 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:54–119` plus the command bodies:
|
||||||
|
|
||||||
|
### Direct process control
|
||||||
|
|
||||||
|
| Command | File:line | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `spawn_process(id, binary, args)` | `commands.rs:6–14` | Generic spawn, **cwd=None**, unused by UI |
|
||||||
|
| `send_stdin(id, input)` | `commands.rs:16–23` | Write bytes to ChildStdin |
|
||||||
|
| `kill_process(id)` | `commands.rs:25–31` | Close stdin + start_kill one id |
|
||||||
|
| `kill_all_processes()` | `commands.rs:33–38` | Kill all; returns count |
|
||||||
|
| `resolve_binary(binary_name)` | `commands.rs:48–106` | 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:650–676`) checks existence only (no content). `.ti2` missing while `.ti3` present still reports `stage2_complete: false`.
|
||||||
|
|
||||||
|
`parse_ti2_header` (`commands.rs:366–424`) 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`.
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
# 05 — Gronod ArgyllCMS fork protocols
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
Source tree analysed: `/tmp/argyllcms` (fork of upstream ArgyllCMS **V3.5.0**, 21 Aug 2026).
|
||||||
|
Related: `/tmp/ICCery`, `/tmp/iccery-research/argyll-issues.json`, `/tmp/iccery-research/issues/111.md`.
|
||||||
|
|
||||||
|
This document is the fork-vs-upstream delta that ICCery actually depends on. Upstream Graeme Gill ArgyllCMS 3.5.0 has **none** of the `-u` JSON streams, `instlist`, `printtarg -d`, `chartread -Y l`, or the Windows `PeekNamedPipe` stdin fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fork identity, licence, subprocess isolation
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Upstream | ArgyllCMS V3.5.0 (Graeme W. Gill) |
|
||||||
|
| Fork | `gronod/argyllcms` — Gordon Bolton `<gordon@i3omb.com>` |
|
||||||
|
| Repo | https://git.i3omb.com/gronod/argyllcms |
|
||||||
|
| Version string | `ARGYLL_VERSION_STR "3.5.0"` (`h/aconfig.h`) |
|
||||||
|
| Release tags | `v3.5.0-ICCery.1.x` (e.g. 1.0, 1.2, 1.5) |
|
||||||
|
| Licence | **GNU Affero GPL v3** (`License.txt`, `ReadMe.txt`) |
|
||||||
|
|
||||||
|
`ReadMe.txt` states the fork's purpose:
|
||||||
|
|
||||||
|
> this fork adds structured JSON output capabilities (via a common `-u` switch) to several tools so they can be driven as isolated subprocesses by external UIs while preserving AGPLv3 licence isolation (no library linking; communication over stdin / stdout / stderr only).
|
||||||
|
|
||||||
|
### Why ICCery cannot link
|
||||||
|
|
||||||
|
ArgyllCMS is AGPLv3. Linking `libinst` / `libicc` / `libcgats` into the MIT/proprietary host would taint ICCery. The integration contract is:
|
||||||
|
|
||||||
|
1. Never `#include` or link Argyll libraries.
|
||||||
|
2. Spawn each tool as a child process with piped stdin/stdout/stderr.
|
||||||
|
3. Treat the binaries as an external OS utility.
|
||||||
|
4. Host parses line-oriented stdout; never shares address space.
|
||||||
|
|
||||||
|
ICCery implements this in `src-tauri/src/process_manager.rs`: `Command::new(binary)` + `Stdio::piped()` on all three FDs, plus `ARGYLL_NOT_INTERACTIVE=1`.
|
||||||
|
|
||||||
|
The fork ships a dedicated spec at `doc/chartread_integration_guide.md` covering AGPL isolation, `ROW_COLORS_JSON` framing, and host-side parsers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Each tool's `-u` JSON protocol
|
||||||
|
|
||||||
|
**Prefix rule (critical):** only `chartread` prefixes. Every other tool emits **bare JSON** on stdout.
|
||||||
|
|
||||||
|
| Tool | Flag | Prefix | Framing | When | Progress vs final |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `chartread` | `-u` | `ROW_COLORS_JSON: ` (space after colon) | **one compact line** + `fflush` | after each completed row (strip / XY sheet rows / whole-chart rows / per-patch in `-p`) | **progress** (one event per row); no final summary JSON |
|
||||||
|
| `instlist` | *(always)* | none | **pretty-printed multi-line** object | once at exit | **final only** |
|
||||||
|
| `printtarg` | `-u` | none | **pretty-printed multi-line** object | once after all pages written | **final only** (`event: manifest`) |
|
||||||
|
| `targen` | `-u` | none | **one compact line** per tick | during OFPS/grid generation | **progress** (`generating` / `seeding` / `optimising`) |
|
||||||
|
| `colprof` | `-u` | none | **one compact line** per tick | during A2B/B2A/gamut | **progress** (no final report JSON) |
|
||||||
|
| `profcheck` | `-u` | none | **one compact line** | after all patches scored | **final only** (`event: report`) |
|
||||||
|
|
||||||
|
ICCery's process manager only special-cases the chartread prefix:
|
||||||
|
|
||||||
|
```129:138:/tmp/ICCery/src-tauri/src/process_manager.rs
|
||||||
|
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
|
||||||
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
|
if line.starts_with(JSON_ROW_PREFIX) {
|
||||||
|
let json_str = line[JSON_ROW_PREFIX.len()..].to_string();
|
||||||
|
crate::events::emit_json_row(&app_clone, &id_clone, json_str);
|
||||||
|
} else {
|
||||||
|
log::info!(target: "subprocess", "[{id_clone}] {line}");
|
||||||
|
emit_stdout(&app_clone, &id_clone, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else (instlist, printtarg, profcheck, colprof, targen) arrives as ordinary `process:stdout` lines. instlist is reassembled client-side and `JSON.parse`'d as a whole document.
|
||||||
|
|
||||||
|
### 2.1 `chartread -u`
|
||||||
|
|
||||||
|
Flag parser:
|
||||||
|
|
||||||
|
```2885:2887:/tmp/argyllcms/spectro/chartread.c
|
||||||
|
/* Enable UI JSON output */
|
||||||
|
} else if (argv[fa][1] == 'u') {
|
||||||
|
json_ui_out = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Emitter (the only place `ROW_COLORS_JSON:` is written):
|
||||||
|
|
||||||
|
```240:282:/tmp/argyllcms/spectro/chartread.c
|
||||||
|
static void emit_row_json_colors(const char *row_id, int row_index, int total_rows, int patch_count, int nchan, chcol **scbs) {
|
||||||
|
int i, j;
|
||||||
|
if (!json_ui_out) return;
|
||||||
|
fprintf(stdout, "ROW_COLORS_JSON: {\"event\": \"row_complete\", \"row_id\": \"%s\", \"row_index\": %d, \"total_rows\": %d, \"patch_count\": %d, \"patches\": [", row_id ? row_id : "", row_index, total_rows, patch_count);
|
||||||
|
/* ... per-patch objects ... */
|
||||||
|
fprintf(stdout, "]}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call sites:
|
||||||
|
|
||||||
|
| `rmode` | Mode | When emitted |
|
||||||
|
|---|---|---|
|
||||||
|
| 3 | whole-chart saved readings | one event per row after all patches transferred |
|
||||||
|
| 2 | XY table | one event per row of the just-read sheet |
|
||||||
|
| 1 | strip (handheld i1Pro etc.) | one event per successfully accepted strip, **after** bi-di reversal + DTP51 offset, patches left-to-right |
|
||||||
|
| 0 | spot / `-p` | **one event per patch**, `patch_count=1` |
|
||||||
|
|
||||||
|
Without `-u`, stdout is 100% unmodified (the early `if (!json_ui_out) return`).
|
||||||
|
|
||||||
|
### 2.2 `targen -u`
|
||||||
|
|
||||||
|
```161:167:/tmp/argyllcms/target/targen.c
|
||||||
|
int json_progress = 0;
|
||||||
|
|
||||||
|
void emit_json_progress(const char *stage, int percent) {
|
||||||
|
if (!json_progress) return;
|
||||||
|
printf("{\"event\": \"progress\", \"stage\": \"%s\", \"percent\": %d}\n", stage, percent);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stages observed in source:
|
||||||
|
|
||||||
|
- `"generating"` — cube / full-spread fill (`targen.c`, `ifarp.c`)
|
||||||
|
- `"seeding"` — OFPS seed (`ofps.c`)
|
||||||
|
- `"optimising"` — OFPS / display-delay iteration (`ofps.c`, `targen.c`); last tick is `percent: 100`
|
||||||
|
|
||||||
|
ICCery **does not currently pass `-u` to targen** (`build_targen_args` in `commands.rs`). Progress is inferred from verbose text.
|
||||||
|
|
||||||
|
### 2.3 `printtarg -u`
|
||||||
|
|
||||||
|
Final-only, pretty-printed, **no prefix**:
|
||||||
|
|
||||||
|
```2949:2968:/tmp/argyllcms/target/printtarg.c
|
||||||
|
if (json_manifest) {
|
||||||
|
int pi;
|
||||||
|
printf("{\n");
|
||||||
|
printf(" \"event\": \"manifest\",\n");
|
||||||
|
printf(" \"pages\": [\n");
|
||||||
|
for (pi = 0; pi < npages; pi++) {
|
||||||
|
printf(" {\"filename\": \"%s\", \"patches\": %d, \"width_mm\": %g, \"height_mm\": %g}%s\n",
|
||||||
|
page_filenames[pi] != NULL ? page_filenames[pi] : "",
|
||||||
|
page_patches[pi],
|
||||||
|
pw,
|
||||||
|
ph,
|
||||||
|
(pi < npages - 1) ? "," : "");
|
||||||
|
if (page_filenames[pi] != NULL)
|
||||||
|
free(page_filenames[pi]);
|
||||||
|
}
|
||||||
|
printf(" ]\n");
|
||||||
|
printf("}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "manifest",
|
||||||
|
"pages": [
|
||||||
|
{"filename": "target.tif", "patches": 800, "width_mm": 210, "height_mm": 297},
|
||||||
|
{"filename": "target_02.tif", "patches": 250, "width_mm": 210, "height_mm": 297}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `filename` is the basename actually written (`%s.tif` if one page, `%s_%02d.tif` if more).
|
||||||
|
- `width_mm` / `height_mm` are paper size in millimetres (`pw`/`ph`).
|
||||||
|
- `patches` is a running count of TID/real patches assigned to that page (includes some padding/TID cells — not strictly "user patches only").
|
||||||
|
- ICCery **does** pass `-u` (`build_printtarg_args`).
|
||||||
|
|
||||||
|
### 2.4 `colprof -u`
|
||||||
|
|
||||||
|
Same compact progress line as targen:
|
||||||
|
|
||||||
|
```78:84:/tmp/argyllcms/profile/colprof.c
|
||||||
|
int json_progress = 0;
|
||||||
|
|
||||||
|
void emit_json_progress(const char *stage, int percent) {
|
||||||
|
if (!json_progress) return;
|
||||||
|
printf("{\"event\": \"progress\", \"stage\": \"%s\", \"percent\": %d}\n", stage, percent);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stages: `"gamut_mapping"` (10/25/40/75/100), `"a2b_table"`, `"b2a_table"`, `"gamut_table"`. Percent ticks also fire from LUT fill callbacks in `profout.c` / `profin.c`.
|
||||||
|
|
||||||
|
**Flag collision with upstream `-u`.** Parser (`colprof.c:503-521`):
|
||||||
|
|
||||||
|
- `-ua` / `-uc` / `-u <number>` → original input-profile white-point flags
|
||||||
|
- `-u` with no extra token → `json_progress = 1`
|
||||||
|
|
||||||
|
For ICCery's **output/printer** profiles this is safe (bare `-u` means JSON). For scanner/camera input profiles it is **not** drop-in. ICCery currently **does not pass `-u` to colprof**; `colprof.mock` emits only human text.
|
||||||
|
|
||||||
|
### 2.5 `profcheck -u`
|
||||||
|
|
||||||
|
```1256:1268:/tmp/argyllcms/profile/profcheck.c
|
||||||
|
if (json_report) {
|
||||||
|
if (cie2k) {
|
||||||
|
printf("{\"event\": \"report\", \"peak_de2000\": %.2f, \"avg_de2000\": %.2f, \"rms\": %.2f}\n",
|
||||||
|
merr, aerr/nsamps, sqrt(rerr/nsamps));
|
||||||
|
} else if (cie94) {
|
||||||
|
printf("{\"event\": \"report\", \"peak_de94\": %.2f, \"avg_de94\": %.2f, \"rms\": %.2f}\n",
|
||||||
|
merr, aerr/nsamps, sqrt(rerr/nsamps));
|
||||||
|
} else {
|
||||||
|
printf("{\"event\": \"report\", \"peak_de\": %.2f, \"avg_de\": %.2f, \"rms\": %.2f}\n",
|
||||||
|
merr, aerr/nsamps, sqrt(rerr/nsamps));
|
||||||
|
}
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field names change with `-k` (CIEDE2000, ICCery default) vs `-c` (CIE94) vs default CIE76. ICCery always sends `-v -k -s -u`. The human line `Profile check complete, errors(CIEDE2000): ...` is still printed unless `-v` is off **and** `-u` is on.
|
||||||
|
|
||||||
|
`profcheck.mock` emits exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `chartread` `ROW_COLORS_JSON` schema (field-by-field)
|
||||||
|
|
||||||
|
Exact prefix: **`ROW_COLORS_JSON: `** (17 chars including the trailing space). Payload is compact JSON, no newlines inside. Host must strip the prefix then `JSON.parse`.
|
||||||
|
|
||||||
|
### Top-level
|
||||||
|
|
||||||
|
| Field | Type | Source | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `event` | string | literal | always `"row_complete"` |
|
||||||
|
| `row_id` | string | `paix->aix(paix, row_index)` | strip letter, e.g. `"A"`; `""` if NULL |
|
||||||
|
| `row_index` | int | 0-based overall row | |
|
||||||
|
| `total_rows` | int | `totpa` | total passes in the chart |
|
||||||
|
| `patch_count` | int | `stipa` (or `1` in `-p`) | length of `patches[]` |
|
||||||
|
| `patches` | array | | left-to-right after bi-di reversal |
|
||||||
|
|
||||||
|
### Patch object
|
||||||
|
|
||||||
|
| Field | Type | Source | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | string | `scb->id` | `"0"` ⇒ spacer |
|
||||||
|
| `loc` | string | `scb->loc` | e.g. `"A1"` |
|
||||||
|
| `is_pad` | bool | `strcmp(scb->id, "0") == 0` | JSON `true`/`false` (unquoted) |
|
||||||
|
| `device` | `[float]` | `scb->dev[j] * 100.0` | `%.4f`, **0–100%**, length = `nchan` (RGB=3, CMYK=4) |
|
||||||
|
| `expected` | object, **optional** | omitted if `eXYZ` is all zeros | present only when `.ti2` has a reference |
|
||||||
|
| `expected.XYZ` | `[3]` | `scb->eXYZ` | scale **0–100** |
|
||||||
|
| `expected.Lab` | `[3]` | `icmXYZ2Lab(&icmD50, …, eXYZ/100)` | D50 |
|
||||||
|
| `measured` | object | **always present** | even for pads |
|
||||||
|
| `measured.XYZ` | `[3]` | `scb->XYZ` | scale **0–100** |
|
||||||
|
| `measured.Lab` | `[3]` | `icmXYZ2Lab(&icmD50, …, XYZ/100)` | D50; L* ~0–100 |
|
||||||
|
| `measured.spectral` | object, **optional** | omitted if `sp.spec_n == 0` | omitted by `-n` or colorimeter |
|
||||||
|
| `measured.spectral.bands` | int | `sp.spec_n` | |
|
||||||
|
| `measured.spectral.start_nm` | float `%.1f` | `sp.spec_wl_short` | typically 380 |
|
||||||
|
| `measured.spectral.end_nm` | float `%.1f` | `sp.spec_wl_long` | typically 730 |
|
||||||
|
| `measured.spectral.norm` | float `%.1f` | `sp.norm` | typically 100.0 |
|
||||||
|
| `measured.spectral.values` | `[float]` | `sp.spec[j]` `%.4f` | length = `bands` |
|
||||||
|
|
||||||
|
Lab conversion (XYZ must be divided by 100 before `icmXYZ2Lab`):
|
||||||
|
|
||||||
|
```218:237:/tmp/argyllcms/spectro/chartread.c
|
||||||
|
static void compute_patch_metrics(chcol *scb, double *eLab, double *mLab) {
|
||||||
|
if (scb->eXYZ[0] != 0.0 || scb->eXYZ[1] != 0.0 || scb->eXYZ[2] != 0.0) {
|
||||||
|
double scaled_exyz[3];
|
||||||
|
scaled_exyz[0] = scb->eXYZ[0] / 100.0;
|
||||||
|
/* ... */
|
||||||
|
icmXYZ2Lab(&icmD50, eLab, scaled_exyz);
|
||||||
|
} else {
|
||||||
|
eLab[0] = eLab[1] = eLab[2] = 0.0;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
double scaled_mxyz[3];
|
||||||
|
scaled_mxyz[0] = scb->XYZ[0] / 100.0;
|
||||||
|
/* ... */
|
||||||
|
icmXYZ2Lab(&icmD50, mLab, scaled_mxyz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Worked example (from `chartread.mock`, which matches the C serializer):
|
||||||
|
|
||||||
|
```
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pad patches still have `device` + `measured`; they usually lack `expected`. UIs must skip `is_pad==true` for ΔE stats (ICCery `swatch_grid.js` does).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `instlist` JSON device enumeration (ICCery bug #111)
|
||||||
|
|
||||||
|
New binary `spectro/instlist.c`. Always JSON; no `-u`. Usage: `instlist [-v] [-D [level]]`.
|
||||||
|
|
||||||
|
```90:135:/tmp/argyllcms/spectro/instlist.c
|
||||||
|
if ((icmps = new_icompaths(g_log)) == NULL) {
|
||||||
|
printf("{\n \"event\": \"instruments\",\n \"devices\": []\n}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
paths = icmps->paths;
|
||||||
|
printf("{\n \"event\": \"instruments\",\n \"devices\": [\n");
|
||||||
|
if (paths != NULL) {
|
||||||
|
for (i = 0; paths[i] != NULL; i++) {
|
||||||
|
char *tname = inst_name(paths[i]->dtype);
|
||||||
|
char *name = paths[i]->name ? paths[i]->name : "Unknown";
|
||||||
|
/* ... */
|
||||||
|
printf(" \"port\": %d,\n", i + 1);
|
||||||
|
printf(" \"name\": \""); /* escaped " and \ */
|
||||||
|
printf("\",\n");
|
||||||
|
printf(" \"type\": \"%s\"\n", (tname != NULL && tname[0] != '\000') ? tname : "Unknown");
|
||||||
|
printf(" }");
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf("\n ]\n}\n");
|
||||||
|
fflush(stdout);
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema (from `doc/instlist.html` + source):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "instruments",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"port": 1,
|
||||||
|
"name": "usb:/bus0/dev1 (X-Rite i1Pro)",
|
||||||
|
"type": "i1Pro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `port` | int, **1-based** | `i+1` over `icompaths->paths[]` — **the same integer as `chartread -c N`** |
|
||||||
|
| `name` | string | `icompath->name` (USB path + product string). `"` and `\` escaped. |
|
||||||
|
| `type` | string | `inst_name(dtype)` e.g. `"i1Pro"`, `"ColorMunki"`, `"SpectroScan"` |
|
||||||
|
|
||||||
|
Empty enumeration (no USB perms / no devices / `new_icompaths` fail) still emits `{"event":"instruments","devices":[]}` (exit 1 only if `new_icompaths` itself fails).
|
||||||
|
|
||||||
|
### Bug #111 — ports vs device index
|
||||||
|
|
||||||
|
ICCery originally treated instlist's ordinal as `chartread -c`. That is **correct in this fork** *if* you use `devices[i].port` (which **is** the comm-port index). It is **wrong** if you:
|
||||||
|
|
||||||
|
- use 0-based array index,
|
||||||
|
- parse `chartread -??` separately (different listing / extra serial junk),
|
||||||
|
- or pass `-c 1` when the UI meant "auto".
|
||||||
|
|
||||||
|
ICCery's current fix (`chartread.js`):
|
||||||
|
|
||||||
|
- JSON path: `opt.value = inst.port && inst.port !== "1" ? inst.port : ""`
|
||||||
|
- empty value → `build_chartread_args` **omits `-c`** (Argyll default port 1)
|
||||||
|
- regex fallback requires a known instrument token or `on '…'` clause
|
||||||
|
|
||||||
|
`doc/instlist.html` explicitly: *The `port` number directly corresponds to the `-c` parameter accepted by chartread, spotread, and dispread.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `chartread -Y l` LED protocol / colours
|
||||||
|
|
||||||
|
**Not `-L`.** Issue #37 proposed `-L`; the implementation is **`-Y l`** (also accepts `-Y L`).
|
||||||
|
|
||||||
|
```2943:2953:/tmp/argyllcms/spectro/chartread.c
|
||||||
|
else if (argv[fa][1] == 'Y') {
|
||||||
|
fa = nfa;
|
||||||
|
if (na == NULL)
|
||||||
|
usage();
|
||||||
|
if (na[0] == 'l' || na[0] == 'L') {
|
||||||
|
g_use_leds = 1;
|
||||||
|
} else {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ICCery: `enable_i1pro2_leds` → `["-Y", "l"]`. Usage text: `-Y l Enable i1Pro 2 visual LED feedback`. **Not documented in `doc/chartread.html` yet** (only `usage()`).
|
||||||
|
|
||||||
|
Dispatch is capability-silent: missing `set_led_state` or non-i1Pro2 returns `inst_unsupported` and is ignored.
|
||||||
|
|
||||||
|
```207:216:/tmp/argyllcms/spectro/chartread.c
|
||||||
|
static int g_use_leds = 0;
|
||||||
|
static void update_led_state(inst *it, inst_led_state state) {
|
||||||
|
if (!g_use_leds || it == NULL)
|
||||||
|
return;
|
||||||
|
if (it->set_led_state != NULL) {
|
||||||
|
it->set_led_state(it, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enum (`spectro/inst.h`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
typedef enum {
|
||||||
|
inst_led_off = 0,
|
||||||
|
inst_led_cal_wait = 1, /* Flashing White */
|
||||||
|
inst_led_row_ready = 2, /* Flashing Blue */
|
||||||
|
inst_led_row_fail = 3, /* Flashing Red */
|
||||||
|
inst_led_row_success = 4 /* Solid/Flash Green */
|
||||||
|
} inst_led_state;
|
||||||
|
```
|
||||||
|
|
||||||
|
Hardware: **X-Rite i1Pro 2 (Rev E) only** (`p->dtype != instI1Pro2` → `inst_unsupported`). Default `inst.c` stub returns `inst_unsupported`. Implementation is a background thread in `i1pro_imp.c` driving `i1pro2_indLEDset` bitmasks:
|
||||||
|
|
||||||
|
| State | Visual | Timing | Mask |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `inst_led_cal_wait` | White flash | 500 ms on / 500 ms off | `0x3F` (L+R R+G+B) |
|
||||||
|
| `inst_led_row_ready` | Blue pulse | 300 ms on / 700 ms off | `0x24` (L+R Blue) |
|
||||||
|
| `inst_led_row_fail` | Red strobe | 3× (100 ms on / 100 ms off), then auto-off | `0x09` (L+R Red) |
|
||||||
|
| `inst_led_row_success` | Green confirm | 400 ms solid, then auto-off | `0x12` (L+R Green) |
|
||||||
|
| `inst_led_off` | Off | — | `i1pro2_indLEDoff` |
|
||||||
|
|
||||||
|
Lifecycle hooks in `chartread.c`:
|
||||||
|
|
||||||
|
- before `inst_handle_calibrate` → `cal_wait`; after → `off`
|
||||||
|
- before `read_strip` / `read_sample` wait → `row_ready`
|
||||||
|
- misread / coms fail / unexpected error → `row_fail`
|
||||||
|
- accepted strip → `row_success`
|
||||||
|
- abort / session end → `off`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `printtarg -d` custom label
|
||||||
|
|
||||||
|
Issue #19. `-d` was unused (TIFF 8-bit dither is `-D`).
|
||||||
|
|
||||||
|
```3342:3348:/tmp/argyllcms/target/printtarg.c
|
||||||
|
else if (argv[fa][1] == 'd') {
|
||||||
|
fa = nfa;
|
||||||
|
if (na == NULL) usage("Expected argument to -d");
|
||||||
|
custom_label = na;
|
||||||
|
custom_label_set = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```3836:3847:/tmp/argyllcms/target/printtarg.c
|
||||||
|
if (custom_label_set) {
|
||||||
|
if (custom_label[0] != '\000') {
|
||||||
|
strncpy(label, custom_label, sizeof(label) - 1);
|
||||||
|
label[sizeof(label) - 1] = '\000';
|
||||||
|
ocg->add_kword(ocg, 0, "CHART_LABEL", custom_label, NULL);
|
||||||
|
} else {
|
||||||
|
label[0] = '\000';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sprintf(label, "ArgyllCMS - Chart \"%s\" (%s %d) %s",
|
||||||
|
psname, rand ? "Random Start" : "Chart ID", rstart, atm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And inside layout:
|
||||||
|
|
||||||
|
```2299:2300:/tmp/argyllcms/target/printtarg.c
|
||||||
|
if (label == NULL || label[0] == '\000')
|
||||||
|
dopglabel = 0; /* Omit per-page labelling */
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
| Invocation | Border text | `.ti2` keyword |
|
||||||
|
|---|---|---|
|
||||||
|
| no `-d` | `ArgyllCMS - Chart "<psname>" (Chart ID\|Random Start N) <datetime>` | none (`CHART_ID` / `RANDOM_START` still written) |
|
||||||
|
| `-d "My Media 1440dpi"` | that string (truncated to 1023 chars) | `CHART_LABEL` |
|
||||||
|
| `-d ""` | omitted (`dopglabel=0`) | not written |
|
||||||
|
|
||||||
|
ICCery always passes `-d` when `custom_label` is `Some(...)` (test: `"ICCery - Pro900 - Luster - 29/08/2026 12:00"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Windows anonymous-pipe stdin deadlock (argyllcms #24)
|
||||||
|
|
||||||
|
Symptom: ICCery/Tauri on Windows, `chartread` as child with redirected stdin. Calibration works; holding the i1Pro button never lights the lamp. Process stuck in stdin poll.
|
||||||
|
|
||||||
|
Root cause (upstream `ARGYLL_NOT_INTERACTIVE`):
|
||||||
|
|
||||||
|
1. `numlib/numsup.c` `check_if_not_interactive()` calls `SetNamedPipeHandleState(PIPE_NOWAIT)` on stdin.
|
||||||
|
2. That **fails on Win32 anonymous pipes** (`CreatePipe` / Rust `Stdio::piped()`). Pipe stays blocking.
|
||||||
|
3. `spectro/conv.c` `con_char(wait=0)` then `ReadFile()`'s the pipe, **blocks forever**.
|
||||||
|
4. `def_uicallback` → `poll_con_char` → `con_char(0)` runs from `i1pro_imp_measure()`'s UI callback, so USB EP `0x84` switch polling never resumes.
|
||||||
|
|
||||||
|
Fork fix in `spectro/conv.c`: `PeekNamedPipe` before `ReadFile`:
|
||||||
|
|
||||||
|
```203:237:/tmp/argyllcms/spectro/conv.c
|
||||||
|
} else if (stdin_type == FILE_TYPE_PIPE) {
|
||||||
|
int i, bib;
|
||||||
|
DWORD bytes_avail = 0;
|
||||||
|
|
||||||
|
if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
|
||||||
|
if (!wait) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bib = 0; bib < 10;) {
|
||||||
|
if (!wait) {
|
||||||
|
if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0)
|
||||||
|
&& !wait) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
/* ... wait for \n / \r / ^C ... */
|
||||||
|
}
|
||||||
|
rv = buf[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetNamedPipeHandleState` is still attempted (harmless failure). `PeekNamedPipe` is valid on both anonymous and named pipes.
|
||||||
|
|
||||||
|
Shipped in `v3.5.0-ICCery.1.2`. ICCery always sets `ARGYLL_NOT_INTERACTIVE=1` and `CREATE_NO_WINDOW` on Windows, so this fix is load-bearing.
|
||||||
|
|
||||||
|
Doc (`doc/Environment.html`): *On MSWin systems, the character and return or line feed characters must be written to stdin in a single operation.* ICCery honours this (`" \n"`, `"d\n"`, `"q\n"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. USB drivers packaging
|
||||||
|
|
||||||
|
Windows release zip must contain `usb/` (issue #21: CRLF in `binfiles` / `doc/afiles` / `usb/binfiles.msw` made `cp` miss every manifest entry). Fork sanitises with `tr -d '\r'` in `makepackagebin.sh` and copies missing `ArgyllCMS_{x64,arm64}.cat` from `ArgyllCMS.cat`.
|
||||||
|
|
||||||
|
`usb/binfiles.msw`:
|
||||||
|
|
||||||
|
```
|
||||||
|
ArgyllCMS_install_USB.exe
|
||||||
|
ArgyllCMS_uninstall_USB.exe
|
||||||
|
ArgyllCMS.cat
|
||||||
|
ArgyllCMS_x64.cat
|
||||||
|
ArgyllCMS_arm64.cat
|
||||||
|
ArgyllCMS.inf
|
||||||
|
bin/libusb-win32-bin-README.txt
|
||||||
|
bin/x86/libusb0.sys
|
||||||
|
bin/amd64/libusb0.sys
|
||||||
|
bin/arm64/libusb0.sys
|
||||||
|
```
|
||||||
|
|
||||||
|
ICCery `scripts/fetch-argyll.mjs` copies `usb/` from the Windows archive into `src-tauri/argyll/usb/` and **fails the fetch** if `ArgyllCMS_install_USB.exe` or `ArgyllCMS.inf` is missing.
|
||||||
|
|
||||||
|
NSIS (`src-tauri/windows/hooks.nsh`): admin install prompts "Install ArgyllCMS USB instrument drivers?" then `ExecWait ArgyllCMS_install_USB.exe`. Uninstall does **not** auto-run `ArgyllCMS_uninstall_USB.exe` (would break other Argyll apps).
|
||||||
|
|
||||||
|
Linux: udev rules live in the Argyll tarball `usb/` (`binfiles.lx`); ICCery does not currently stage them (USB access is via user-installed udev / plugdev).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. macOS ad-hoc signing of Mach-O sidecars
|
||||||
|
|
||||||
|
Issue #32, release `v3.5.0-ICCery.1.5` (`1eb72e8`, `cf93305`).
|
||||||
|
|
||||||
|
`makepackagebin.sh`:
|
||||||
|
|
||||||
|
```176:185:/tmp/argyllcms/makepackagebin.sh
|
||||||
|
# Apply ad-hoc code signatures to macOS Mach-O binaries before staging
|
||||||
|
if [ "${OSTYPE#*darwin*}" != "$OSTYPE" ] ; then
|
||||||
|
echo "=== Applying ad-hoc code signatures to macOS Mach-O binaries ==="
|
||||||
|
for f in bin/* ; do
|
||||||
|
if [ -f "$f" ] && file "$f" | grep -q "Mach-O" ; then
|
||||||
|
echo "Signing $f..."
|
||||||
|
codesign -f -s - "$f" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Also signs `lipo` universal (`x86_64` + `arm64`) binaries (issue #27: `Argyll_V*_macOS_universal_bin.tgz`). CI verifies with `codesign -dvv`.
|
||||||
|
|
||||||
|
ICCery prefers `argyll/macos-universal/instlist` when present (`commands.rs` / `build.rs`); otherwise `macos-aarch64` / `macos-x86_64`. Unsigned Mach-O sidecars are killed by Gatekeeper / `killed: 9` on Apple Silicon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Which binaries ICCery actually ships vs uses
|
||||||
|
|
||||||
|
`fetch-argyll.mjs` copies the **entire** `bin/` of the Gronod release (full Argyll suite: `dispcal`, `dispread`, `spotread`, `collink`, `cctiff`, `oeminst`, … plus fork `instlist`). Marker binary: `instlist` / `instlist.exe`. Plus `License.txt`. Plus Windows `usb/`.
|
||||||
|
|
||||||
|
**Invoked by ICCery** (`resolve_binary` call sites):
|
||||||
|
|
||||||
|
| Binary | Stage / feature |
|
||||||
|
|---|---|
|
||||||
|
| `instlist` | instrument dropdown |
|
||||||
|
| `targen` | Stage 1 + calibration charts |
|
||||||
|
| `printtarg` | Stage 2 |
|
||||||
|
| `chartread` | Stage 3 |
|
||||||
|
| `average` | multi-pass `.ti3` merge |
|
||||||
|
| `colprof` | Stage 4 |
|
||||||
|
| `profcheck` | Stage 4 QA |
|
||||||
|
| `iccgamut` | gamut viewer |
|
||||||
|
| `printcal` | Stage 0 linearization (#224) |
|
||||||
|
| `applycal` | embed/apply `.cal` into ICC |
|
||||||
|
|
||||||
|
The rest of the suite is on disk as AGPL corresponding source/binary distribution but unused.
|
||||||
|
|
||||||
|
Mocks used by tests (`src-tauri/argyll/mocks/`): `chartread.mock`, `colprof.mock`, `profcheck.mock` only. `chartread.mock` speaks `ROW_COLORS_JSON`; `profcheck.mock` speaks bare `{"event":"report",...}`; `colprof.mock` does **not** speak JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Environment variables the fork / tools honour
|
||||||
|
|
||||||
|
### Fork-critical (ICCery always sets)
|
||||||
|
|
||||||
|
| Var | Set by ICCery | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `ARGYLL_NOT_INTERACTIVE` | `"1"` in `process_manager.rs` and `calibration.rs` | LF instead of CR on progress; stdin is "char + return" not raw key; Windows pipe NOWAIT + (fork) PeekNamedPipe; unbuffered/line-buffered stdout |
|
||||||
|
|
||||||
|
### Upstream vars still live in this tree (host may set)
|
||||||
|
|
||||||
|
| Var | Consumer |
|
||||||
|
|---|---|
|
||||||
|
| `ARGYLL_3D_DISP_FORMAT` | `VRML` / `X3D` / `X3DOM` |
|
||||||
|
| `ARGYLL_COLMTER_CAL_SPEC_SET` / `ARGYLL_COLMTER_COR_MATRIX` | default CCSS/CCMX (`-X`) |
|
||||||
|
| `ARGYLL_MIN_DISPLAY_UPDATE_DELAY_MS` | display tools |
|
||||||
|
| `ARGYLL_DISPLAY_SETTLE_TIME_MULT` | display tools |
|
||||||
|
| `ARGYLL_DISPLAY_FAKE_RAND_SEED` | `dispsup.c` |
|
||||||
|
| `ARGYLL_CREATE_WRONG_VON_KRIES_OUTPUT_CLASS_REL_WP` | ICC writer |
|
||||||
|
| `ARGYLL_CREATE_DISPLAY_PROFILE_WITH_CHAD` / `_WITHOUT_CHAD` | ICC writer |
|
||||||
|
| `ARGYLL_CREATE_OUTPUT_PROFILE_WITH_CHAD` | ICC writer |
|
||||||
|
| `ARGYLL_CREATE_V2COLORANT_TABLE` | ICC writer |
|
||||||
|
| `ARGYLL_PLATFORM_OVERRIDE` | `icc.c` |
|
||||||
|
| `ARGYLL_CCAST_DEFAULT_RECEIVER` / `ARGYLL_CCAST_TEST_PATTERN` | Chromecast |
|
||||||
|
| `ARGYLL_IGNORE_XRANDR1_2` / `ARGYLL_IGNORE_XINERAMA` | Linux display |
|
||||||
|
| `ARGYLL_USE_COLORD` | Linux profile store |
|
||||||
|
| `ARGYLL_DISABLE_I1PRO2_DRIVER` | force i1Pro2 legacy |
|
||||||
|
| `ARGYLL_EXCLUDE_SERIAL_SCAN` | skip COM/tty fast-scan |
|
||||||
|
| `ARGYLL_XRGA` | `XRGA` / `XRDI` / `GMDI` reflective conversion |
|
||||||
|
| `ARGYLL_XCALSTD` | per-driver X-Rite cal standard (i1pro, munki, dtp*, ss) |
|
||||||
|
| `ARGYLL_UNTWIST_GAMUT_SURFACE` | B2A / collink clip |
|
||||||
|
| `ARGYLL_REV_CACHE_MULT` / `ARGYLL_REV_ACC_GRID_RES_MULT` | rspl invert |
|
||||||
|
| `ARGYLL_SUPPRESS_PLOT` | `plot.c` |
|
||||||
|
| `XDG_{DATA,CONFIG,CACHE}_{HOME,DIRS}` | Linux paths |
|
||||||
|
| `SPYD2024_LOWLEV_MEASURE` | Spyder 2024 |
|
||||||
|
| `I1D3_DISABLE_AIO` | i1d3 (upstream) |
|
||||||
|
| `DISPLAY` / `SUDO_UID` / `SUDO_GID` | X11 / privilege drop |
|
||||||
|
|
||||||
|
ICCery does not currently set any of these besides `ARGYLL_NOT_INTERACTIVE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `chartread` interactive stdin commands and UI classifier
|
||||||
|
|
||||||
|
### 12.1 Real fork `chartread` (strip mode, `rmode==1`)
|
||||||
|
|
||||||
|
UIH table (`chartread.c:1547-1561`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
inst_set_uih(0x00, 0xff, DUIH_TRIG); /* every other key starts a read */
|
||||||
|
inst_set_uih('f'/'F'/'b'/'B'/'n'/'N'/'g'/'G'/'d'/'D', DUIH_CMND);
|
||||||
|
inst_set_uih('q'/'Q'/^C/Esc, DUIH_ABORT);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Class | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **any other key**, Space, Return, instrument switch | TRIG | start strip read |
|
||||||
|
| `f` / `F` | CMND | next row / +10 (spot: F=+10) |
|
||||||
|
| `b` / `B` | CMND | previous row / −10 |
|
||||||
|
| `n` / `N` | CMND | next **unread** row |
|
||||||
|
| `g` / `G` | CMND | goto (spot mode; registered in strip too) |
|
||||||
|
| `d` / `D` | CMND | done & save `.ti3`. If unread patches remain → confirm `[y/n]` |
|
||||||
|
| `q` / `Q` / Esc / `^C` | ABORT | quit without saving (retry prompt first) |
|
||||||
|
| `y` / `Y` | confirm | "Are you sure [y/n]" (done-with-unread, abort) |
|
||||||
|
| `n` (at `[y/n]`) | confirm | stay in session |
|
||||||
|
| `s` / `S` | **optional calibration only** | `inst_handle_calibrate`: "or S to skip" when `inst_calc_optional_flag` |
|
||||||
|
| `k` | CMND (spot only) | force calibrate |
|
||||||
|
|
||||||
|
**There is no strip-mode `s` = skip row, and no `u` = undo.** Those exist only in ICCery's mock / UI. Sending `s` or `u` during "Ready to read strip" is **DUIH_TRIG** → starts a measurement.
|
||||||
|
|
||||||
|
### 12.2 Spot mode (`-p`) extra keys
|
||||||
|
|
||||||
|
`f/F/b/B/n/N/g/G/d/D/k` as above; Return/Space/`0` = take reading.
|
||||||
|
|
||||||
|
### 12.3 XY table (`rmode==2`)
|
||||||
|
|
||||||
|
Return = continue; `q`/Esc/`^C` = give up (parks head if `q` sent before kill — ICCery does this).
|
||||||
|
|
||||||
|
### 12.4 Exact prompt strings the C code prints
|
||||||
|
|
||||||
|
(With `ARGYLL_NOT_INTERACTIVE=1`, prompt lines still have **no trailing LF** unless noted; they are flushed via `do_fflush()`. ICCery's line reader may not see a prompt until a later `\n`.)
|
||||||
|
|
||||||
|
**Calibration** (`instappsup.c`) — real i1Pro:
|
||||||
|
|
||||||
|
```
|
||||||
|
Place the instrument on its reflective white reference S/N <id>,
|
||||||
|
and then hit any key to continue,
|
||||||
|
or hit Esc or Q to abort:
|
||||||
|
```
|
||||||
|
|
||||||
|
(optional cal appends `or S to skip`)
|
||||||
|
|
||||||
|
Other cal variants: `"Do a reflective white calibration,"`, `"Place the instrument on light trap..."`, `"Click the instrument on its reflective white reference..."`, `"Hit any key to retry, or Esc or Q to abort:"`.
|
||||||
|
|
||||||
|
**Strip ready:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ready to read strip pass <row>[ (!! ALL ROWS READ !!)| (This row has been read)]
|
||||||
|
Press 'f' to move forward, 'b' to move back, 'n' for next unread,
|
||||||
|
'd' when done, Esc or 'q' to quit without saving.
|
||||||
|
Trigger instrument switch to start reading. # uswitch==1
|
||||||
|
Trigger instrument switch or any other key to start: # uswitch==2
|
||||||
|
Press any other key to start: # uswitch==0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success / fail:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Strip read OK
|
||||||
|
(Warning) Seem to have read strip pass <X> rather than <Y>!
|
||||||
|
Hit Return to use it anyway, any other key to retry, Esc or 'q' to give up:
|
||||||
|
There is at least one patch with an very unexpected response! (DeltaE <n>)
|
||||||
|
Hit Return to use it anyway, any other key to retry, Esc or 'q' to give up:
|
||||||
|
Strip read failed due to misread (<reason>)
|
||||||
|
Hit Esc to give up, any other key to retry:
|
||||||
|
Strip read failed due to communication problem.
|
||||||
|
Hit Esc or 'q' to give up, any other key to retry:
|
||||||
|
Done ? - At least one unread patch (<id>, <loc>), Are you sure [y/n]:
|
||||||
|
```
|
||||||
|
|
||||||
|
**XY table:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Please place sheet <n> of <N> on table, then
|
||||||
|
hit return to continue, Esc or 'q' to give up
|
||||||
|
Please remove previous sheet, then place sheet <n> of <N> on table, then
|
||||||
|
hit return to continue, Esc or 'q' to give up
|
||||||
|
Using the XY table controls, locate patch <id> with the sight,
|
||||||
|
then hit return to continue, Esc or 'q' to give up
|
||||||
|
Sheet <n> of <N> read OK
|
||||||
|
Please remove last sheet from table
|
||||||
|
```
|
||||||
|
|
||||||
|
**Done / abort:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Chart read OK
|
||||||
|
Abort ? - Are you sure ? [y/n]:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.5 What ICCery actually sends (`chartread.js`)
|
||||||
|
|
||||||
|
| UI button | stdin bytes | Intended |
|
||||||
|
|---|---|---|
|
||||||
|
| Calibrate / Retry | `" \n"` | space + NL (single WriteFile) |
|
||||||
|
| Accept / Continue | `"\n"` | Return (use-it-anyway, XY continue) |
|
||||||
|
| Done & Save | `"d\n"` | done |
|
||||||
|
| Undo | `"u\n"` | **not a chartread command** — will TRIG a read |
|
||||||
|
| Skip | `"s\n"` | **only skips optional cal**; otherwise TRIG a read |
|
||||||
|
| Cancel (XY) | `"q\n"` then kill after 500 ms | park head |
|
||||||
|
| Cancel (strip) | kill process | |
|
||||||
|
|
||||||
|
### 12.6 `classifyChartreadLine` (tests in `chartread.test.js`)
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
|
||||||
|
1. `"remove last sheet from table"` → info, no state change (`isRemoveSheetNotice`)
|
||||||
|
2. `/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i` → `sheetOk`
|
||||||
|
3. `"locate patch … with … sight"` → `TABLE_ALIGN` (`meta.patch`)
|
||||||
|
4. `"place sheet N of M"` / `"remove previous sheet"` → `TABLE_PLACE_SHEET`
|
||||||
|
5. `"hit return to continue"` (sticky if already TABLE_*) → `PROMPT_CONTINUE` otherwise
|
||||||
|
6. `"'d' if/when done"`, `"all strips/patches read"`, `"d to finish/save"` → `ALL_STRIPS_READ`
|
||||||
|
7. `"(warning)"`, `"use it anyway"`, `"seem to have read strip pass"`, `"unexpected response"` → `WARNING`
|
||||||
|
8. place + (reference|white|calibrat|standard) / `"hit any key to continue"` / `"calibration"` → `CALIBRATING`
|
||||||
|
9. hit+read+strip / `"ready to read"` → `AWAITING_STRIP`
|
||||||
|
10. `"reading strip"` / `"processing"` / `"scanning"` / `"reading sheet"` → `READING`
|
||||||
|
11. `"error"` / `"too fast"` / `"too slow"` / `"misread"` / `"failed to read"` → `ERROR`
|
||||||
|
|
||||||
|
### 12.7 Mock vs real (tests rely on mock phrasing)
|
||||||
|
|
||||||
|
`chartread.mock` (and classifier tests) use **synthetic** lines that real chartread never prints:
|
||||||
|
|
||||||
|
| Mock / test line | Real chartread |
|
||||||
|
|---|---|
|
||||||
|
| `Place instrument on calibration tile and hit [Space] to calibrate.` | `Place the instrument on its reflective white reference S/N …` + `hit any key to continue` |
|
||||||
|
| `Hit [Space] to read strip A (or 's' to skip).` | `Ready to read strip pass A` + f/b/n/d menu |
|
||||||
|
| `Reading strip A...` | no such line; success is ` Strip read OK` |
|
||||||
|
| `Calibration successful.` | no such line |
|
||||||
|
| `Ready to read... done.` | `Chart read OK` |
|
||||||
|
|
||||||
|
Classifier still matches real prompts because of substring rules (`"ready to read"`, `"hit any key to continue"`, `"use it anyway"`, `"place sheet"`, `"locate patch"`). Rewrite UIs should classify the **C strings above**, not only the mock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Fork vs upstream 3.5.0 checklist
|
||||||
|
|
||||||
|
| Feature | Upstream 3.5.0 | Gronod fork |
|
||||||
|
|---|---|---|
|
||||||
|
| `chartread -u` / `ROW_COLORS_JSON` | no | yes |
|
||||||
|
| `instlist` | no | yes |
|
||||||
|
| `printtarg -u` JSON manifest | no | yes |
|
||||||
|
| `printtarg -d` custom label | no | yes |
|
||||||
|
| `targen -u` progress JSON | no | yes |
|
||||||
|
| `colprof -u` progress JSON (bare `-u`) | `-u` is WP-scale for **input** profiles | same, plus bare `-u` → JSON |
|
||||||
|
| `profcheck -u` ΔE JSON | no | yes |
|
||||||
|
| `chartread -Y l` i1Pro2 LEDs | no | yes |
|
||||||
|
| Windows `PeekNamedPipe` stdin | no (deadlocks on anon pipes) | yes |
|
||||||
|
| `makepackagebin.sh` CRLF sanitize + cat copies | no | yes |
|
||||||
|
| macOS `codesign -s -` + universal lipo archive | no | yes |
|
||||||
|
| `doc/chartread_integration_guide.md` | no | yes |
|
||||||
|
| `doc/instlist.html` | no | yes |
|
||||||
|
|
||||||
|
## Appendix B — ICCery `-u` usage vs fork capability
|
||||||
|
|
||||||
|
| Tool | Fork `-u` | ICCery currently passes `-u` |
|
||||||
|
|---|---|---|
|
||||||
|
| chartread | yes | **yes** (`-v -u [-c port] [-Y l] basename`) |
|
||||||
|
| printtarg | yes | **yes** (`-v -u …`) |
|
||||||
|
| profcheck | yes | **yes** (`-v -k -s -u`) |
|
||||||
|
| targen | yes | **no** |
|
||||||
|
| colprof | yes | **no** |
|
||||||
|
| instlist | always JSON | spawned with no args |
|
||||||
|
|
||||||
|
## Appendix C — Issues mapped
|
||||||
|
|
||||||
|
| Repo | # | Topic |
|
||||||
|
|---|---|---|
|
||||||
|
| argyllcms | 1 | chartread `-u` ROW_COLORS_JSON |
|
||||||
|
| argyllcms | 2 | targen `-u` |
|
||||||
|
| argyllcms | 3 | printtarg `-u` |
|
||||||
|
| argyllcms | 4 | colprof `-u` |
|
||||||
|
| argyllcms | 5 | profcheck `-u` |
|
||||||
|
| argyllcms | 6 | instlist |
|
||||||
|
| argyllcms | 19 | printtarg `-d` |
|
||||||
|
| argyllcms | 21 | Windows zip missing usb/doc (CRLF) |
|
||||||
|
| argyllcms | 24 | PeekNamedPipe deadlock |
|
||||||
|
| argyllcms | 27 | macOS universal lipo |
|
||||||
|
| argyllcms | 32 | ad-hoc codesign |
|
||||||
|
| argyllcms | 37 | i1Pro2 LEDs (spec said `-L`, code is `-Y l`) |
|
||||||
|
| ICCery | 111 | instlist port vs `-c` index |
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 06 — Wizard, artefact gating, resume
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
| # | DOM | Unlocked when |
|
||||||
|
|---|-----|----------------|
|
||||||
|
| 0 | `#stage-cal` | Always (optional, not in stepper) |
|
||||||
|
| 1 | `#stage-1` | Always |
|
||||||
|
| 2 | `#stage-2` | `{basename}.ti1` exists |
|
||||||
|
| 3 | `#stage-3` | `.ti1` **and** `.ti2` |
|
||||||
|
| 4 | `#stage-4` | `.ti3` exists (imported datasets may skip 1–2) |
|
||||||
|
| 5 | `#stage-5` | `.ti3` **and** `.icc`/`.icm` |
|
||||||
|
|
||||||
|
`verify_stage_artefacts(cwd, basename)` returns `{ stage1_complete, stage2_complete, stage3_complete, stage4_complete, profile_path }`.
|
||||||
|
|
||||||
|
Stage 4 is **not** unlocked by `.ti2`. Multi-pass averaging **deletes** the canonical `.ti3` after each snapshot (`snapshot_ti3`) so Stage 4 stays locked until Finish (#109, #110).
|
||||||
|
|
||||||
|
## `wizardState` fields
|
||||||
|
|
||||||
|
| Field | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `currentStage` | 0–5 |
|
||||||
|
| `basename` | run name without extension |
|
||||||
|
| `cwd` | working directory |
|
||||||
|
| `printerName` | last spooled printer (drift history) |
|
||||||
|
| `sessionMode` | `"profile"` (extensible) |
|
||||||
|
| `profileBasename` | may differ after import |
|
||||||
|
| `noticeTimer` | banner auto-hide |
|
||||||
|
|
||||||
|
`setTarget(basename, cwd)` updates gating. `navigateToStage(n)` refuses locked stages with a warning banner. Window `focus` re-runs `updateGating` (user may have deleted files in Finder/Explorer) (#151).
|
||||||
|
|
||||||
|
## Resume from existing target (#140)
|
||||||
|
|
||||||
|
Stage 1 **Open Existing** (`select_existing_target`) filters `.ti1`/`.ti2`. `parse_ti2_header` reads `TARGET_INSTRUMENT`, `NUMBER_OF_SETS`, `NUMBER_OF_PAGES`, sibling `.ti1` presence. If `.ti2` exists, Stage 3 banner shows "Resumed from .ti2".
|
||||||
|
|
||||||
|
CGATS import (#94, #211) can jump to Stage 4/5 with a synthesised `.ti3` — must use an **open** dialog, never save (#211).
|
||||||
|
|
||||||
|
## Empty cwd (#59)
|
||||||
|
|
||||||
|
Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents → Home → app data). Disable Generate until basename **and** directory are set. Display path in `#selectedPathDisplay`.
|
||||||
|
|
||||||
|
## Profile extension (#69)
|
||||||
|
|
||||||
|
`resolve_profile_extension`: if only `.icm` exists, use it; else if `.icc` exists, use it; else default `.icm` on Windows, `.icc` elsewhere. `profcheck` and `iccgamut` also swap extension if the requested path is missing.
|
||||||
|
|
||||||
|
## No `"test_target"` fallbacks (#60)
|
||||||
|
|
||||||
|
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 07 — Stage 0: printer calibration (`printcal` / `applycal`) (#224)
|
||||||
|
|
||||||
|
Optional dashboard opened by **Calibrate Printer** (`#btnCalibratePrinter`). The 1–5 wizard is unchanged when skipped.
|
||||||
|
|
||||||
|
## Why `CAL_` prefix
|
||||||
|
|
||||||
|
Calibration charts use basename `CAL_<run>` so they never collide with profiling `.ti1/.ti2/.ti3`. `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. **Generate Calibration Target** → `targen` via `build_calibration_targen_args`
|
||||||
|
2. **Create Layout & Print** → existing Stage 2 printtarg + native print (same unmanaged path)
|
||||||
|
3. **Measure Chart** → existing Stage 3 chartread
|
||||||
|
4. **Compute Curves** → `printcal` (captured, not streamed)
|
||||||
|
5. Toggle **Apply Calibration** → subsequent profiling `printtarg -K` and post-`colprof` `applycal -a`
|
||||||
|
|
||||||
|
## `targen` for calibration wedges
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -d {2|4} -s {steps} -g {steps} [-n {steps}] -e {white|4} [-l {tac}] -f 0 CAL_<basename>
|
||||||
|
```
|
||||||
|
|
||||||
|
- RGB: `-d 2`; CMYK: `-d 4`
|
||||||
|
- Steps clamped 11–51, default 21 (`#calSteps`)
|
||||||
|
- Neutral emphasis: `-n` same as steps
|
||||||
|
- CMYK ink-limit exploration: `-l` 200–400 (`#calInkExplore`, default 320)
|
||||||
|
- `-f 0` — no full-spread patches (wedges only)
|
||||||
|
- Default 4 white patches if unspecified
|
||||||
|
|
||||||
|
## `printcal`
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -e [-I] [-z] [-a previous.cal] [-m TAC] [-xC pct]… -o out.cal CAL_basename
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `-e` | even (always passed) |
|
||||||
|
| `-I` | no ink limit |
|
||||||
|
| `-z` | verify |
|
||||||
|
| `-a` | previous `.cal` |
|
||||||
|
| `-m` | total ink limit |
|
||||||
|
| `-xC` / `-xM` / … | per-channel limit |
|
||||||
|
| `-o` | output `.cal` |
|
||||||
|
|
||||||
|
`.cal` overwrite requires Overwrite / Rename / Cancel (`#calCollisionDialog`). Never silently clobber.
|
||||||
|
|
||||||
|
## `applycal` (after Stage 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -a <cal> <input.icc> [output]
|
||||||
|
```
|
||||||
|
|
||||||
|
JS never sends `-u` (unapply). Fork `-u` on applycal is **not** JSON.
|
||||||
|
|
||||||
|
## Staleness
|
||||||
|
|
||||||
|
Warn if loaded `.cal` is older than `calibration_stale_days` (default 30) **or** stored printer name differs. CMYK Stage 1 shows "No calibration applied" reminder.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Channel-response SVG (`#calCurveSvg`): solid = post-linearization, dashed = identity. TAC card + per-channel limit editors. Recommended power shown for `targen -p`.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 08 — Stage 1: `targen`
|
||||||
|
|
||||||
|
UI: `#stage-1`. Action: `#btnGenerate` → `run_targen`.
|
||||||
|
|
||||||
|
## `TargenConfig` → argv (`build_targen_args`)
|
||||||
|
|
||||||
|
Always starts `-v -d {2|4}`.
|
||||||
|
|
||||||
|
| UI | Config field | Flag | Notes |
|
||||||
|
|----|--------------|------|-------|
|
||||||
|
| Colour space RGB/CMYK | `colour_space` | `-d 2` / `-d 4` | 2 = Print RGB, 4 = CMYK |
|
||||||
|
| Patch count | `patch_count` or `total_patches` | `-f N` | omitted if 0. **Must honour UI** — #44 shipped 836 because `-f` was dropped |
|
||||||
|
| White patches | `white_patches` | `-e N` | |
|
||||||
|
| Black patches | `black_patches` | `-B N` | |
|
||||||
|
| Grey steps | `grey_steps` | `-g N` | if > 0 |
|
||||||
|
| Single channel steps | `single_channel_steps` | `-s N` | if > 0 |
|
||||||
|
| Neutral steps | `neutral_steps` | `-n N` | if > 0 |
|
||||||
|
| Neutral concentration | `neutral_concentration` | `-N x.xx` | only if not ≈ 0.50 |
|
||||||
|
| Preconditioning profile | `preconditioning_profile` | `-c path` | file filter **`.icc/.icm/.mpp`** not `.ti*` (#172) |
|
||||||
|
| OFPS high quality | `ofps_high_quality` | `-G` | |
|
||||||
|
| OFPS adaptation | `ofps_adaptation` | `-A x.xx` | |
|
||||||
|
| Full spread algorithm | `full_spread_algorithm` | `-t -r -R -q -Q -i -I` | default OFPS = no extra flag |
|
||||||
|
| Total ink limit | `total_ink_limit` | `-l N` | **CMYK only**, 1–400 |
|
||||||
|
| Dark emphasis | `dark_emphasis` | `-V x.xx` | if not ≈ 1.0 |
|
||||||
|
| Device power | `device_power` | `-p x.xx` | if not ≈ 1.0 and > 0 |
|
||||||
|
| Basename | `basename` | trailing arg | no extension |
|
||||||
|
|
||||||
|
Process id: `targen_${basename}`. Cwd: resolved working directory.
|
||||||
|
|
||||||
|
Fork `targen -u` JSON progress exists but **ICCery does not pass `-u`**.
|
||||||
|
|
||||||
|
## Patch-count presets
|
||||||
|
|
||||||
|
`#patchCountPreset`: 400 Draft, **800 Standard (default)**, 1500 Photo, 2500+ custom via `#patchCountCustom`.
|
||||||
|
|
||||||
|
## Extra actions
|
||||||
|
|
||||||
|
- `#btnOpenExisting` — resume `.ti1`/`.ti2` (#140). **Open** dialog (#103 used save by mistake).
|
||||||
|
- `#btn-import-dataset` — CGATS/ti3 import (#94). Open dialog, not save (#211).
|
||||||
|
- `#btnBrowse` — save `.ti1` location (`select_target_file`).
|
||||||
|
- `#btnToggleAllHelp` — help mode; tooltips must **not** reflow layout (#171) — use overlay positioning, not in-flow height.
|
||||||
|
|
||||||
|
## Advanced options
|
||||||
|
|
||||||
|
Collapsible `#targenAdvancedDetails`. Ink-limit group `#targenInkLimitGroup` hidden for RGB.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 09 — Stage 2: `printtarg` and target layout
|
||||||
|
|
||||||
|
UI: `#stage-2`. Action: `#btnCreateLayout` → `run_printtarg`.
|
||||||
|
|
||||||
|
## `PrinttargConfig` → argv (`build_printtarg_args`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
| UI | Field | Flag |
|
||||||
|
|----|-------|------|
|
||||||
|
| Instrument | `instrument` | `-i i1\|p3\|CM\|SS\|20\|22\|41\|51` |
|
||||||
|
| Page size | `page_size` | `-p A4\|A4R\|A3\|A2\|Letter\|LetterR\|Legal\|4x6\|11x17\|WWWxHHH` |
|
||||||
|
| Custom page | `customPageW/H` | `-p {w}x{h}` millimetres, ≥ 50 |
|
||||||
|
| Bit depth 8/16 | `bit_depth` | `-t` 8-bit TIFF / `-T` 16-bit |
|
||||||
|
| DPI | `dpi` | after `-t/-T`, default **300** |
|
||||||
|
| Layout order | `random_seed`, `no_randomize` | see below |
|
||||||
|
| Custom label | `custom_label` | `-d` string (fork #19 / ICCery #119) |
|
||||||
|
| Calibration | `calibration_file`, `calibration_embed_only` | `-K file.cal` apply, `-I file.cal` embed only |
|
||||||
|
|
||||||
|
Process id: `printtarg_${basename}`. **Always** passes fork `-u` so a JSON manifest can be parsed from stdout.
|
||||||
|
|
||||||
|
## Randomisation (#163)
|
||||||
|
|
||||||
|
Default **must** be deterministic (`-R 1`). A missing seed produced different TIFF layouts every run, breaking re-prints.
|
||||||
|
|
||||||
|
| `#printtargLayoutOrder` | Flags |
|
||||||
|
|-------------------------|-------|
|
||||||
|
| `deterministic` (default) | `-R 1` |
|
||||||
|
| `custom_seed` | `-R N` (N ≥ 1) |
|
||||||
|
| `raster` | `-r` (no randomize) — **not** `-R` |
|
||||||
|
|
||||||
|
Do **not** confuse printtarg `-r` (raster/no-random) with targen's `-r` (full-spread algorithm).
|
||||||
|
|
||||||
|
## Custom label (`-d`)
|
||||||
|
|
||||||
|
Assembled as:
|
||||||
|
|
||||||
|
```
|
||||||
|
ICCery - {basename} - {printer} - {ink} - {driverPaper} - {actualPaper} - DD/MM/YYYY HH:MM
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual override via `#targetLabelPreview`. Fork `printtarg -d` is the **label**, not colour space (targen `-d`) and not iccgamut density (`iccgamut -d`).
|
||||||
|
|
||||||
|
## JSON manifest
|
||||||
|
|
||||||
|
Pretty-printed bare JSON on stdout (no prefix). Frontend `extractManifest` scans accumulated stdout. Typical fields: `pages[]` with TIFF paths, patch counts, instrument. Gallery renders each page via `read_tiff_preview_png` — **never** feed TIFF bytes to `<img>` (#58). Max edge 1200 px, Lanczos3, PNG base64.
|
||||||
|
|
||||||
|
## Colour-management warning
|
||||||
|
|
||||||
|
`#cmWarningBanner` tells the user to set the driver to "No Colour Adjustment" (Epson) / "Off (No Colour Adjustment)" (Canon). Native print then **also** forces bypass (see [11](11-print-macos.md) / [12](12-print-windows.md)).
|
||||||
|
|
||||||
|
## Raw print panel
|
||||||
|
|
||||||
|
After successful printtarg, `#rawPrintPanel` is shown. `#cupsOptionsGroup` (PPD uncorrected passthrough) is **hidden on Windows** (#48).
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 10 — Print system (shared types & UI)
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
## Architecture map
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 2 UI (src/js/printtarg.js + src/index.html #rawPrintPanel)
|
||||||
|
│ invoke("get_printers")
|
||||||
|
│ invoke("get_printer_capabilities", { printerName })
|
||||||
|
│ invoke("show_printer_properties", { printerName })
|
||||||
|
│ invoke("print_target_native", { printerName, tiffPath, options })
|
||||||
|
▼
|
||||||
|
src-tauri/src/commands.rs (cfg-gated dispatch)
|
||||||
|
│
|
||||||
|
├── windows → print/windows.rs GDI + DEVMODE + SetICMMode(ICM_OFF)
|
||||||
|
├── macos → print/macos.rs NSPrintPanel + private SPI + lp
|
||||||
|
└── unix → print/unix.rs lpstat/lpoptions + lp -o raw
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared types live in `src-tauri/src/print/mod.rs`. `PrinterDevModeStore` is a Tauri managed state (`lib.rs:53`). macOS re-exports Unix enumeration (`macos.rs:10`).
|
||||||
|
|
||||||
|
ICCery-CPU (`TargetPrint.app`) is a **separate** AppKit process that ICCery is specified to spawn fire-and-forget with `--job <json>`. It is **not yet wired** into the Tauri commands; the current ICCery print path is in-process / `lp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared print types (`src-tauri/src/print/mod.rs`)
|
||||||
|
|
||||||
|
All structs derive `Debug, Clone, Serialize, Deserialize`. Frontend JS uses **snake_case** field names when building the `PrintOptions` payload (see `printtarg.js:268-275`), matching serde's default.
|
||||||
|
|
||||||
|
### `Printer` (mod.rs:6–13)
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `name` | `String` | OS queue / destination name. Windows: `PRINTER_INFO_4W.pPrinterName`. Unix/macOS: CUPS destination from `lpstat -e` / `-p`. This is the value sent back as `printerName` to all subsequent commands. |
|
||||||
|
| `status` | `String` | Windows always `"Ready"`. Unix: `"Idle"` / `"Printing"` / `"Stopped"` / `"Unknown"` from `lpstat -p`. |
|
||||||
|
| `is_default` | `bool` | Windows: always `false` (default printer is **not** queried). Unix: true when name matches `lpstat -d` (`system default destination:`). |
|
||||||
|
| `display_name` | `Option<String>` | Human-readable CUPS `printer-info` from `lpoptions -p <name>`. Windows always `None`. Used on macOS as `NSPrinter::printerWithName` fallback when `PMPrinterCreateFromPrinterID` fails (#188). Serde `#[serde(default)]`. |
|
||||||
|
|
||||||
|
AGENTS.md warns: adding fields requires updating **every** platform constructor (`windows.rs`, `unix.rs`) or the other OS builds break.
|
||||||
|
|
||||||
|
### `PrinterTray` (mod.rs:16–19)
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). |
|
||||||
|
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). |
|
||||||
|
|
||||||
|
### `PrinterPaperSize` (mod.rs:22–25)
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `id` | `u16` | Windows: `DC_PAPERS` DMPAPER_* id. Unix: 1-based index of `PageSize` / `MediaSize` choice. |
|
||||||
|
| `name` | `String` | Windows: 64-WCHAR `DC_PAPERNAMES`, or `"Paper Size {id}"`. Unix: PPD token (`A4`, `Letter`, …). |
|
||||||
|
|
||||||
|
**Gap:** the frontend never populates a paper-size `<select>` from `caps.paper_sizes`. Stage 2's `#pageSizeSelect` is the **printtarg layout** page size (mm), not the printer's PPD `PageSize`. `PrintOptions.paper_size` is set from that same `#pageSizeSelect` (`printtarg.js:271`) and forwarded to `lp -o PageSize=` on Unix/macOS. Windows `apply_print_options_to_devmode` **ignores** `paper_size`.
|
||||||
|
|
||||||
|
### `PrinterMediaType` (mod.rs:28–31)
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `id` | `String` | Windows: `DC_MEDIATYPES` DWORD as decimal string (parsed back to `u32` for `dmMediaType`). Unix: PPD choice token (`92`, `13`, `Plain`, …). When a PPD file is readable, `id` is the machine token and `name` is the human label after `/`. |
|
||||||
|
| `name` | `String` | Display label. Unix `lpoptions -l` path uses the same token for both. Unix PPD path (`parse_ppd_media_types`) splits `*CNIJMediaType 42/Photo Paper Plus Semi-gloss:` into id=`42`, name=`Photo Paper Plus Semi-gloss`. |
|
||||||
|
|
||||||
|
### `PrinterCapabilities` (mod.rs:34–40)
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `trays` | `Vec<PrinterTray>` | Paper sources. |
|
||||||
|
| `paper_sizes` | `Vec<PrinterPaperSize>` | Queried but unused by the Stage 2 UI. |
|
||||||
|
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
|
||||||
|
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
|
||||||
|
|
||||||
|
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq`
|
||||||
|
|
||||||
|
| Field | Type | Windows | Linux | macOS |
|
||||||
|
|-------|------|---------|-------|-------|
|
||||||
|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` |
|
||||||
|
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
|
||||||
|
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
|
||||||
|
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
|
||||||
|
| `ppd_uncorrected_passthrough` | `Option<bool>` | **ignored** | if true: `-o ColorModel=Gray -o cm-calibration` instead of `-o raw` | **ignored as a gate**; macOS always ColorSync-bypasses and always injects driver color-bypass. Native panel sets this to `Some(true)` on OK. |
|
||||||
|
| `cups_options` | `Option<String>` | **ignored** | **ignored** | space-separated `key=value` captured from `PMPrintSettingsToOptions`, filtered, then expanded to `-o key=value`. `#[serde(default)]`. |
|
||||||
|
|
||||||
|
### `PrintPropertiesResult` (mod.rs:57–61)
|
||||||
|
|
||||||
|
Returned by `show_printer_properties` on macOS only.
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `selected_printer` | `Option<String>` | CUPS Printer ID from `PMPrinterGetID` after the panel, or the original `printer_name` if the session printer cannot be read but `PMPrinterCreateFromPrinterID` had succeeded. `None` if the panel was opened via the `NSPrinter::printerWithName(display_name)` fallback. |
|
||||||
|
| `options` | `PrintOptions` | Snapshot: `media_type` extracted from captured options, `cups_options` filtered string, `ppd_uncorrected_passthrough: Some(true)`, everything else `Default`. |
|
||||||
|
|
||||||
|
Windows `show_printer_properties` returns `Ok(None)` from the Tauri command after storing DEVMODE (the inner fn returns `Result<(), String>`). Linux returns `Ok(None)` with no dialog. Frontend treats `null` as **user cancelled** (`printtarg.js:390-393`) — so on Windows a successful Preferences OK **also** shows "Printer properties dialog cancelled." That is a known UX mismatch: Windows success and cancel both surface as `null`.
|
||||||
|
|
||||||
|
### `PrinterDevModeStore` (mod.rs:63–87)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PrinterDevModeStore {
|
||||||
|
pub devmodes: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Keyed by printer name.
|
||||||
|
- Stores the **full** `DocumentPropertiesW` output buffer (public `DEVMODEW` + `dmDriverExtra` private OEM bytes). This is the #36 fix.
|
||||||
|
- `get` clones; `set` inserts. Poisoned mutex → `get` returns `None`, `set` is a no-op.
|
||||||
|
- `#[allow(dead_code)]` because the type is compiled on all platforms; only Windows uses it.
|
||||||
|
- Constructed once in `lib.rs:53`: `.manage(print::PrinterDevModeStore::new())`.
|
||||||
|
- Session-only. Not persisted to disk.
|
||||||
|
|
||||||
|
### Module cfg gates (mod.rs:89–99)
|
||||||
|
|
||||||
|
```
|
||||||
|
#[cfg(windows)] pub mod windows;
|
||||||
|
#[cfg(any(target_os = "macos", test))] pub mod macos;
|
||||||
|
#[cfg(any(unix, test))] pub mod unix;
|
||||||
|
#[cfg(test)] mod tests;
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS **is** unix, so both `macos` and `unix` compile on macOS. Tests always compile both `macos` and `unix`. `macos.rs` `pub use`s `unix::{get_printer_capabilities, get_printers}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tauri command surface (`src-tauri/src/commands.rs:1304–1415`)
|
||||||
|
|
||||||
|
Registered in `lib.rs:92–95`.
|
||||||
|
|
||||||
|
| Command | Args | Windows | macOS | Linux |
|
||||||
|
|---------|------|---------|-------|-------|
|
||||||
|
| `get_printers` | none | `windows::get_printers` | `macos::get_printers` (= unix) | `unix::get_printers` |
|
||||||
|
| `get_printer_capabilities` | `printer_name: String` | DeviceCapabilitiesW | unix lpoptions/PPD | unix lpoptions/PPD |
|
||||||
|
| `show_printer_properties` | `printer_name: String` + `AppHandle` + `State<PrinterDevModeStore>` | `DocumentPropertiesW` modal; returns `Ok(None)` | `NSPrintPanel` on main thread; returns `Option<PrintPropertiesResult>` | **no-op** `Ok(None)` |
|
||||||
|
| `print_target_native` | `printer_name`, `tiff_path`, `options: Option<PrintOptions>` | GDI path, passes store | `macos::print_target` → `lp` | `unix::print_target` → `lp` |
|
||||||
|
|
||||||
|
IPC argument casing is camelCase at the Tauri boundary (`printerName`, `tiffPath`) and snake_case inside the nested `PrintOptions` struct because the frontend builds that object itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend (`src/js/printtarg.js` + `src/index.html`)
|
||||||
|
|
||||||
|
### Stage 2 print UI (`index.html:568–638`)
|
||||||
|
|
||||||
|
Hidden `#rawPrintPanel` revealed after `printtarg` succeeds and a JSON manifest is parsed.
|
||||||
|
|
||||||
|
- `#printerSelect` + `#btnRefreshPrinters` (↻) + `#btnPrinterProperties` (⚙️ Preferences, class `.btn-properties`) + `#printerStatusBadge`
|
||||||
|
- `#cupsOptionsGroup` / `#chkPpdFallback` — "PPD Uncorrected Passthrough (Fallback)". **Hidden on Windows** (#48, `printtarg.js:167-170`) via `navigator.userAgent` / `userAgentData.platform`.
|
||||||
|
- `#printerTraySelect`, `#printerMediaTypeSelect`
|
||||||
|
- Orientation toggle `#btnOrientPortrait` / `#btnOrientLandscape` (default portrait)
|
||||||
|
- `#btnPrintAll` — "Print All Pages (Bypass CM)"
|
||||||
|
- `#btnAdvanceToStage3`
|
||||||
|
- Per-page "Print Page" buttons on gallery cards (`printtarg.js:737-748`)
|
||||||
|
|
||||||
|
There is **no** printer paper-size dropdown in the raw-print panel. `#pageSizeSelect` above is printtarg's layout size and is reused as `PrintOptions.paper_size`.
|
||||||
|
|
||||||
|
### `capturedCupsOptions` (`printtarg.js:14–17, 266, 411-415`)
|
||||||
|
|
||||||
|
Module-level `{}` keyed by **printer name**. Populated only when macOS `show_printer_properties` returns a result with `options.cups_options`. Cleared for that printer if the result has no cups_options. Fed into every subsequent `print_target_native` via `getSelectedPrintOptions()`.
|
||||||
|
|
||||||
|
Cancellation: `result === null` → info toast, **no** map mutation.
|
||||||
|
|
||||||
|
If the user switched printer inside `NSPrintPanel`, the dropdown is updated when that CUPS id exists in the option list (`printtarg.js:402-409`). Captured media type is applied to `#printerMediaTypeSelect` when a matching option exists.
|
||||||
|
|
||||||
|
### Print payload (`printtarg.js:260-276`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
paper_source: trayVal ? parseInt(trayVal, 10) : null, // NaN if unix tray name is non-numeric — Unix tray ids are 1-based indexes so this is OK
|
||||||
|
orientation: selectedOrientation, // "portrait" | "landscape"
|
||||||
|
paper_size: pageSizeSelect.value, // printtarg page size, not printer PageSize
|
||||||
|
media_type: mediaType || null,
|
||||||
|
ppd_uncorrected_passthrough: chkPpdFallback.checked,
|
||||||
|
cups_options: capturedCupsOptions[activePrinter] || null,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wizard state
|
||||||
|
|
||||||
|
`wizardState.printerName` is set at spool time (`printtarg.js:579, 625`) so Stage 5 verification history (#95) can record the device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# 11 — macOS printing and ColorSync suppression
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
This is the most implementation-sensitive chapter. A rewrite that opens System Settings or the CUPS web UI instead of `NSPrintPanel` will regress #188.
|
||||||
|
|
||||||
|
## macOS (`src-tauri/src/print/macos.rs`) — MOST DETAIL
|
||||||
|
|
||||||
|
864 lines. This is the ColorSync-suppression engine built for #92 and rewritten for #188.
|
||||||
|
|
||||||
|
### What Preferences opens — NSPrintPanel vs CUPS web UI vs System Settings
|
||||||
|
|
||||||
|
**Issue #188 (user report):** clicking Preferences opened **System Settings → Printers & Scanners** and did not bind the selected Epson/Canon queue. No driver PDE, so no "Off (No Color Adjustment)".
|
||||||
|
|
||||||
|
**Current code (AGENTS.md:105-118, macos.rs:245-516, 679-710):** Preferences opens a **native AppKit `NSPrintPanel`**, pre-bound to the selected CUPS destination via Core Printing. It is **not**:
|
||||||
|
|
||||||
|
- CUPS web UI (`http://localhost:631/printers/…`)
|
||||||
|
- System Settings → Printers & Scanners
|
||||||
|
- `NSWorkspace` open of the printer
|
||||||
|
- `lpoptions` GUI
|
||||||
|
|
||||||
|
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation.
|
||||||
|
|
||||||
|
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
|
||||||
|
|
||||||
|
1. Resolve `display_name` **off** the main thread via `unix::get_printer_display_name` (`lpoptions -p`).
|
||||||
|
2. `app.run_on_main_thread` → `run_native_print_panel`.
|
||||||
|
3. `tokio::sync::oneshot` awaits the result.
|
||||||
|
|
||||||
|
### objc2 crates (`Cargo.toml:42-47`, AGENTS.md:120-126)
|
||||||
|
|
||||||
|
```
|
||||||
|
objc2 0.6 MainThreadMarker
|
||||||
|
objc2-app-kit 0.3.2 features = ["NSPrintInfo", "NSPrintPanel", "NSPrinter"]
|
||||||
|
objc2-foundation 0.3.2 NSString
|
||||||
|
objc2-core-foundation 0.3.2 CFString, CFType
|
||||||
|
objc2-application-services 0.3.2 features = ["PMCore", "PMDefinitions", "PrintCore"]
|
||||||
|
PMPrintSession, PMPrinter, PMPrintSettings, PMPageFormat, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Local `extern "C" { fn free; fn dlsym; }`. No `libloading` crate.
|
||||||
|
|
||||||
|
### Binding the selected printer (macos.rs:276-347)
|
||||||
|
|
||||||
|
CUPS destination IDs ≠ NSPrinter display names. Core Printing "Printer ID" **is** the CUPS queue name.
|
||||||
|
|
||||||
|
```
|
||||||
|
printer_id_cf = CFString(printer_name)
|
||||||
|
pm_printer = PMPrinterCreateFromPrinterID(&printer_id_cf)
|
||||||
|
|
||||||
|
if pm_printer is null:
|
||||||
|
fallback NSPrinter::printerWithName(display_name)
|
||||||
|
print_info.setPrinter(ns_printer)
|
||||||
|
print_info.setUpPrintOperationDefaultValues()
|
||||||
|
if that also fails → Err("No printer found for '…'")
|
||||||
|
|
||||||
|
pm_session = print_info.PMPrintSession()
|
||||||
|
pm_settings = print_info.PMPrintSettings()
|
||||||
|
pm_page_format = print_info.PMPageFormat()
|
||||||
|
|
||||||
|
if pm_printer was created from ID:
|
||||||
|
PMSessionSetCurrentPMPrinter(pm_session, pm_printer) // non-zero → Err
|
||||||
|
PMSessionDefaultPrintSettings(pm_session, pm_settings) // warn on fail
|
||||||
|
PMSessionDefaultPageFormat(pm_session, pm_page_format) // warn on fail
|
||||||
|
```
|
||||||
|
|
||||||
|
`Printer.display_name` is the CUPS `printer-info` cached at enumeration. That is what System Settings shows ("Epson XP-55 Series") while `printer_name` is the queue id (`EPSON_XP_55_Series`). #188 failed because the old path used the wrong name with the wrong API.
|
||||||
|
|
||||||
|
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
|
||||||
|
|
||||||
|
### ColorSync suppression strategy — UI click to `lp`
|
||||||
|
|
||||||
|
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Preferences click]
|
||||||
|
show_printer_properties
|
||||||
|
run_on_main_thread
|
||||||
|
run_native_print_panel
|
||||||
|
① PMSessionSetCurrentPMPrinter bind queue
|
||||||
|
② set_session_color_matching_mode SPI gray out PDE Color Matching
|
||||||
|
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
|
||||||
|
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
|
||||||
|
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
|
||||||
|
NSPrintPanel.runModalWithPrintInfo
|
||||||
|
user picks media / quality (color locked)
|
||||||
|
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult
|
||||||
|
[frontend]
|
||||||
|
capturedCupsOptions[printer] = cups_options
|
||||||
|
[Print Target]
|
||||||
|
print_target_native → macos::print_target → build_lp_args
|
||||||
|
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
THEN captured cups_options as -o k=v
|
||||||
|
THEN media_type if not already present (detected key)
|
||||||
|
THEN detect_driver_color_bypass if no color-bypass key yet
|
||||||
|
THEN orientation / PageSize if not already present
|
||||||
|
lp -d <queue> -t "ICCery Target - …" … <tiff>
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
|
||||||
|
|
||||||
|
### Layer ② — private SPI via `dlsym` (`set_session_color_matching_mode`, macos.rs:32-130)
|
||||||
|
|
||||||
|
Used by Photoshop, Lightroom, X-Rite i1Profiler to gray-out Color Matching in driver PDEs. Undocumented; resolved at runtime so the binary does not hard-link a private symbol.
|
||||||
|
|
||||||
|
```
|
||||||
|
RTLD_DEFAULT = (-2isize) as *mut c_void
|
||||||
|
|
||||||
|
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeLock")
|
||||||
|
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingMode")
|
||||||
|
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeNoLock")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signature (all three):**
|
||||||
|
|
||||||
|
```c
|
||||||
|
OSStatus PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef mode);
|
||||||
|
OSStatus PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef mode);
|
||||||
|
OSStatus PMSessionSetColorMatchingModeNoLock(PMPrintSession, CFStringRef mode);
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32;
|
||||||
|
```
|
||||||
|
|
||||||
|
`status == 0` is success (`noErr`).
|
||||||
|
|
||||||
|
**Priority:**
|
||||||
|
|
||||||
|
1. `PMSessionSetColorMatchingModeLock` — sets **and locks** the UI in one call (controls grayed).
|
||||||
|
2. `PMSessionSetColorMatchingMode` — sets mode; lock behaviour unspecified.
|
||||||
|
3. `PMSessionSetColorMatchingModeNoLock` — sets without locking.
|
||||||
|
|
||||||
|
For each symbol, try mode strings in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
"AP_ApplicationColorMatching" // kPMApplicationColorMatching
|
||||||
|
"ApplicationColorMatching" // unprefixed alias
|
||||||
|
```
|
||||||
|
|
||||||
|
First `(symbol, mode)` that returns 0 wins; then break both loops.
|
||||||
|
|
||||||
|
**Preconditions:** `pm_session` non-null; `PMSessionGetCurrentPrinter` succeeds with a non-null printer. Otherwise skip SPI (session has no printer to attach a PDE to).
|
||||||
|
|
||||||
|
**Stub on non-macOS:** always `false` so unit tests compile.
|
||||||
|
|
||||||
|
If all fail: log `"All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed"` and fall through to public `PMPrintSettingsSetValue`.
|
||||||
|
|
||||||
|
### Why `AP_ColorSyncMatching` and `AP_VendorColorMatching` are avoided
|
||||||
|
|
||||||
|
AGENTS.md:118, macos.rs:82-88:
|
||||||
|
|
||||||
|
> Only application-managed color-matching modes are appropriate for profiling targets. ColorSync or vendor modes would apply color management and corrupt the target patches.
|
||||||
|
|
||||||
|
| Mode string | Effect on a profiling target |
|
||||||
|
|-------------|------------------------------|
|
||||||
|
| `AP_ApplicationColorMatching` / `ApplicationColorMatching` | **Used.** "Application already did color." Filters/PDEs must not transform pixels. UI Color Matching grayed. |
|
||||||
|
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
|
||||||
|
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
|
||||||
|
|
||||||
|
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries.
|
||||||
|
|
||||||
|
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
|
||||||
|
|
||||||
|
```
|
||||||
|
key = "AP_ColorMatchingMode" and "AP.ColorMatchingMode"
|
||||||
|
val = "AP_ApplicationColorMatching"
|
||||||
|
lock = true
|
||||||
|
PMPrintSettingsSetValue(pm_settings, key, val, true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The dotted `AP.ColorMatchingMode` is a **legacy form used by some raster drivers and PDEs** (macos.rs:540-541). Both are always written.
|
||||||
|
|
||||||
|
### Layer ④ — pre-select driver "no color adjustment" in the panel (macos.rs:377-397, 422-432)
|
||||||
|
|
||||||
|
`lpoptions -p <printer> -l` → `unix::detect_driver_color_bypass`:
|
||||||
|
|
||||||
|
| Driver | Key | Value |
|
||||||
|
|--------|-----|-------|
|
||||||
|
| Canon | `CNIJIntent2` | `4` |
|
||||||
|
| Canon (old) | `CNIJIntent` | `4` |
|
||||||
|
| Epson | `EPIJ_CCor` | `0` (if that key exists, preferred) |
|
||||||
|
| Epson | `EPIJ_CMat` | `3` |
|
||||||
|
| Gutenprint | `StpColorCorrection` | `Uncorrected` |
|
||||||
|
| generic | `ColorCorrection` | `Uncorrected` |
|
||||||
|
| Epson alt | `EpsonColorMode` | `Off` |
|
||||||
|
|
||||||
|
Written twice: `PMPrintSettingsSetValue(..., lock=false)` **and** `print_info.printSettings().insert`. Lock is false so the user can still change media/quality; only color matching is intended to be locked by the SPI.
|
||||||
|
|
||||||
|
### Layer ⑤ — `NSPrintInfo.printSettings` dictionary (macos.rs:399-432)
|
||||||
|
|
||||||
|
AppKit PDEs and some raster drivers read this `NSMutableDictionary`, not the PM object.
|
||||||
|
|
||||||
|
```
|
||||||
|
print_info.updateFromPMPageFormat()
|
||||||
|
print_info.updateFromPMPrintSettings()
|
||||||
|
print_settings.insert("AP_ColorMatchingMode", "AP_ApplicationColorMatching")
|
||||||
|
print_settings.insert("AP.ColorMatchingMode", "AP_ApplicationColorMatching")
|
||||||
|
print_settings.insert(<bypass_key>, <bypass_val>)
|
||||||
|
```
|
||||||
|
|
||||||
|
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
|
||||||
|
|
||||||
|
### Panel options (macos.rs:434-449)
|
||||||
|
|
||||||
|
```
|
||||||
|
NSPrintPanel::printPanel(mtm)
|
||||||
|
opts = NSPrintPanelOptions::all()
|
||||||
|
opts.insert(ShowsPageSetupAccessory)
|
||||||
|
panel.setOptions(opts)
|
||||||
|
panel.setDefaultButtonTitle(Some("Use Settings"))
|
||||||
|
response = panel.runModalWithPrintInfo(&print_info)
|
||||||
|
```
|
||||||
|
|
||||||
|
`response != 1` (`NSModalResponseOK` / `NSOKButton`) → **`Ok(None)`** (cancellation is not an error). `PMPrinter` released.
|
||||||
|
|
||||||
|
### Layer ⑥ — `PMPrintSettingsToOptions` capture (macos.rs:452-515)
|
||||||
|
|
||||||
|
After OK:
|
||||||
|
|
||||||
|
1. `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → `selected_printer` string.
|
||||||
|
2. `panel.printInfo().PMPrintSettings()`.
|
||||||
|
3. `PMPrintSettingsToOptions(updated_settings, &mut opts_ptr)` — CUPS malloc'd C string of `key=value key=value …`.
|
||||||
|
4. Copy via `CStr`, `free(opts_ptr)`.
|
||||||
|
5. `filter_cups_options_string` → `cups_options`.
|
||||||
|
6. `extract_media_type_from_options` → `media_type`.
|
||||||
|
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
|
||||||
|
|
||||||
|
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
|
||||||
|
|
||||||
|
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
|
||||||
|
|
||||||
|
Forwarded from the panel to `lp`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
|
||||||
|
Tray: InputSlot, AP_D_InputSlot
|
||||||
|
Size: PageSize
|
||||||
|
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
|
||||||
|
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
|
||||||
|
Quality: Resolution, cupsPrintQuality, Quality, EPIJ_Quality, CNIJQuality,
|
||||||
|
StpQuality, OutputMode
|
||||||
|
Duplex: Duplex, sides
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_relevant_cups_option` (macos.rs:178-204) additionally:
|
||||||
|
|
||||||
|
- Drops empty keys
|
||||||
|
- Drops `com.apple.*` ticket keys
|
||||||
|
- Drops `AP_ColorMatchingMode` and `AP.ColorMatchingMode` (we always set those ourselves)
|
||||||
|
- Drops empty values (`AP_D_InputSlot=`)
|
||||||
|
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
|
||||||
|
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
|
||||||
|
|
||||||
|
### `build_lp_args` (macos.rs:518-643)
|
||||||
|
|
||||||
|
Always, even with `options=None`:
|
||||||
|
|
||||||
|
```
|
||||||
|
lp -d <printer> -t "ICCery Target - <filename>"
|
||||||
|
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
… captured / detected options …
|
||||||
|
<tiff_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Order after the two AP_* flags:
|
||||||
|
|
||||||
|
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`.
|
||||||
|
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it.
|
||||||
|
3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.**
|
||||||
|
4. Orientation → `orientation-requested=4|3` unless already present.
|
||||||
|
5. `PageSize=` unless `pagesize` already present.
|
||||||
|
|
||||||
|
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS.
|
||||||
|
|
||||||
|
### `print_target` (macos.rs:646-677)
|
||||||
|
|
||||||
|
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
|
||||||
|
|
||||||
|
### Cancellation as `None`
|
||||||
|
|
||||||
|
`run_native_print_panel` → `Ok(None)` on cancel. `show_printer_properties` propagates that. Frontend: `if (result === null) { showNotification("info", "Printer properties dialog cancelled."); return; }`.
|
||||||
|
|
||||||
|
### Preferences crash / #188
|
||||||
|
|
||||||
|
#188 was not a segfault; it was a **wrong UI**: System Settings instead of the driver PDE, so color management could not be disabled on Epson XP-55 / Canon Pro 9500 II.
|
||||||
|
|
||||||
|
The rewrite:
|
||||||
|
|
||||||
|
- Bind by CUPS Printer ID (`PMPrinterCreateFromPrinterID` + `PMSessionSetCurrentPMPrinter`), not by opening System Settings.
|
||||||
|
- `display_name` fallback for `NSPrinter::printerWithName`.
|
||||||
|
- Private SPI to lock Color Matching.
|
||||||
|
- Dual AP_* keys (underscore + dotted).
|
||||||
|
- Driver-specific PPD bypass pre-selected and re-applied on `lp`.
|
||||||
|
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`.
|
||||||
|
|
||||||
|
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
|
||||||
|
|
||||||
|
### macOS tests (macos.rs:712-863 + tests.rs:278-318)
|
||||||
|
|
||||||
|
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
|
||||||
|
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
|
||||||
|
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path.
|
||||||
|
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
|
||||||
|
- Missing TIFF errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ColorSync suppression — complete key/SPI/flag roster
|
||||||
|
|
||||||
|
### Private SPI symbols (dlsym, macOS only)
|
||||||
|
|
||||||
|
```
|
||||||
|
PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef) -> i32
|
||||||
|
PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef) -> i32
|
||||||
|
PMSessionSetColorMatchingModeNoLock (PMPrintSession, CFStringRef) -> i32
|
||||||
|
```
|
||||||
|
|
||||||
|
Mode strings attempted: `AP_ApplicationColorMatching`, `ApplicationColorMatching`.
|
||||||
|
|
||||||
|
Public Core Printing used around the SPI:
|
||||||
|
|
||||||
|
```
|
||||||
|
PMPrinterCreateFromPrinterID
|
||||||
|
PMSessionSetCurrentPMPrinter
|
||||||
|
PMSessionGetCurrentPrinter
|
||||||
|
PMSessionDefaultPrintSettings
|
||||||
|
PMSessionDefaultPageFormat
|
||||||
|
PMPrintSettingsSetValue
|
||||||
|
PMPrintSettingsToOptions
|
||||||
|
PMPrinterGetID
|
||||||
|
PMRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
|
||||||
|
|
||||||
|
### CUPS / lp flags
|
||||||
|
|
||||||
|
| Flag | Platform | When |
|
||||||
|
|------|----------|------|
|
||||||
|
| `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
|
||||||
|
| `-o AP.ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
|
||||||
|
| `-o CNIJIntent2=4` | macOS (auto) / if captured | Canon |
|
||||||
|
| `-o CNIJIntent=4` | macOS auto if no Intent2 | Canon old |
|
||||||
|
| `-o EPIJ_CCor=0` | macOS auto if present in lpoptions | Epson |
|
||||||
|
| `-o EPIJ_CMat=3` | macOS auto | Epson Off |
|
||||||
|
| `-o StpColorCorrection=Uncorrected` | macOS auto | Gutenprint |
|
||||||
|
| `-o ColorCorrection=Uncorrected` | macOS auto | generic |
|
||||||
|
| `-o EpsonColorMode=Off` | macOS auto | Epson alt |
|
||||||
|
| `-o CNIJMediaType=` / `EPIJ_Medi=` / `StpMediaType=` / `MediaType=` | macOS (detected key); Linux always `MediaType=` | media |
|
||||||
|
| `-o PageSize=` | macOS/Linux | if set |
|
||||||
|
| `-o orientation-requested=3\|4` | macOS/Linux | portrait/landscape |
|
||||||
|
| `-o raw` | **Linux only** | default |
|
||||||
|
| `-o ColorModel=Gray -o cm-calibration` | **Linux only** | PPD fallback checkbox |
|
||||||
|
| captured `InputSlot`, `AP_D_InputSlot`, `Resolution`, `cupsPrintQuality`, `Quality`, `EPIJ_Quality`, `CNIJQuality`, `StpQuality`, `OutputMode`, `Duplex`, `sides`, `EPIJ_OSColMat`, `ColorModel` | macOS | if panel produced them |
|
||||||
|
|
||||||
|
### PPD keys (detection)
|
||||||
|
|
||||||
|
```
|
||||||
|
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize
|
||||||
|
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 12 — Windows GDI raw printing
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
## Windows (`src-tauri/src/print/windows.rs`)
|
||||||
|
|
||||||
|
Direct Win32. No `lp`, no XPS, no `winprint` crate. Evaluated and rejected in #26.
|
||||||
|
|
||||||
|
### Exact Win32 APIs used
|
||||||
|
|
||||||
|
From `windows` crate 0.61 (`Cargo.toml:50-55`: `Win32_Foundation`, `Win32_Graphics_Gdi`, `Win32_Graphics_Printing`, `Win32_UI_WindowsAndMessaging`) plus a local `extern "system"` block for symbols the crate feature set does not expose.
|
||||||
|
|
||||||
|
| API | Header / crate | Where | Purpose |
|
||||||
|
|-----|----------------|-------|---------|
|
||||||
|
| `EnumPrintersW` | `Win32::Graphics::Printing` | `get_printers` | Level 4 (`PRINTER_INFO_4W`) first; fall back to Level 1 (`PRINTER_INFO_1W`) |
|
||||||
|
| `OpenPrinterW` | Printing | properties + print | `PRINTER_HANDLE` |
|
||||||
|
| `ClosePrinter` | Printing | both | |
|
||||||
|
| `DocumentPropertiesW` | Printing | properties + print | size query (`fMode=0`), `DM_OUT_BUFFER` init, `DM_IN_PROMPT\|DM_IN_BUFFER\|DM_OUT_BUFFER` dialog |
|
||||||
|
| `GetForegroundWindow` | `Win32::UI::WindowsAndMessaging` | properties | parent HWND of the modal |
|
||||||
|
| `DeviceCapabilitiesW` | **local extern** | capabilities | `DC_BINS`(6), `DC_BINNAMES`(12), `DC_PAPERS`(2), `DC_PAPERNAMES`(16), `DC_MEDIATYPES`(35), `DC_MEDIATYPENAMES`(34) |
|
||||||
|
| `CreateDCW` | `Win32::Graphics::Gdi` | print | printer DC with DEVMODE |
|
||||||
|
| `DeleteDC` | Gdi | print | |
|
||||||
|
| `GetDeviceCaps` | Gdi | print | `LOGPIXELSX/Y`, `HORZRES`, `VERTRES` |
|
||||||
|
| `SetICMMode` | **local extern** | print | `ICM_OFF = 1` |
|
||||||
|
| `StartDocW` / `StartPage` / `EndPage` / `EndDoc` | **local extern** | print | GDI job |
|
||||||
|
| `StretchDIBits` | Gdi | print | unmanaged 24-bit BGR DIB |
|
||||||
|
|
||||||
|
`DOCINFOW` is a **local** `#[repr(C)]` struct (windows.rs:24-31), not the crate type, because `StartDocW` is also local-extern.
|
||||||
|
|
||||||
|
Constants (windows.rs:48-66):
|
||||||
|
|
||||||
|
```
|
||||||
|
ICM_OFF = 1
|
||||||
|
DM_IN_PROMPT = 4
|
||||||
|
DM_IN_BUFFER = 8
|
||||||
|
DM_OUT_BUFFER = 2
|
||||||
|
DM_ORIENTATION = 0x00000001
|
||||||
|
DM_DEFAULTSOURCE = 0x00000200
|
||||||
|
DM_ICMMETHOD = 0x00800000
|
||||||
|
DMICMMETHOD_NONE = 1
|
||||||
|
DMORIENT_PORTRAIT = 1
|
||||||
|
DMORIENT_LANDSCAPE = 2
|
||||||
|
DM_MEDIATYPE = 0x02000000
|
||||||
|
DC_PAPERS=2, DC_BINS=6, DC_BINNAMES=12, DC_PAPERNAMES=16,
|
||||||
|
DC_MEDIATYPENAMES=34, DC_MEDIATYPES=35
|
||||||
|
IDOK = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enumeration (`get_printers`, windows.rs:78-168)
|
||||||
|
|
||||||
|
1. `EnumPrintersW(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 4, …)` to get byte count.
|
||||||
|
2. If `bytes_needed == 0`, retry at Level 1 (older/limited spooler).
|
||||||
|
3. Level 4: `pPrinterName`. Level 1: `pName`.
|
||||||
|
4. `is_default` always false, `display_name` always `None`, `status` always `"Ready"`.
|
||||||
|
5. Empty list is success, not error.
|
||||||
|
|
||||||
|
### Capabilities (`get_printer_capabilities`, windows.rs:171-326)
|
||||||
|
|
||||||
|
`DeviceCapabilitiesW(printer, NULL port, cap, …)`. Bin names are 24 WCHARs each; paper and media names 64 WCHARs. Media type IDs are read as `u32` via a `*mut u16` cast of a `Vec<u32>` (windows.rs:288-289) — the Win32 API writes DWORDs for `DC_MEDIATYPES`.
|
||||||
|
|
||||||
|
`supports_orientation: true` unconditionally.
|
||||||
|
|
||||||
|
### Driver preferences dialog (`show_printer_properties`, windows.rs:329-401) — #34 / #36
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenPrinterW
|
||||||
|
DocumentPropertiesW(..., fMode=0) → required buffer size
|
||||||
|
load cached DEVMODE or zeros
|
||||||
|
if all-zero: DocumentPropertiesW(DM_OUT_BUFFER) to populate defaults
|
||||||
|
DocumentPropertiesW(DM_IN_PROMPT | DM_IN_BUFFER | DM_OUT_BUFFER)
|
||||||
|
ClosePrinter
|
||||||
|
if res == IDOK (1): store out_buf, Ok(())
|
||||||
|
if res == 2: user cancel, Ok(()) // IDCANCEL
|
||||||
|
else: Err(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Parent window is `GetForegroundWindow()`, **not** the Tauri webview HWND. The dialog is modal to whatever is foreground.
|
||||||
|
|
||||||
|
The **entire** `out_buf` (header + `dmDriverExtra`) is stored. That is the #36 fix: Epson "Print Preview" and other OEM private flags live past `dmSize` and were previously dropped.
|
||||||
|
|
||||||
|
### ICM bypass + option overlay (`apply_print_options_to_devmode`, windows.rs:404-437)
|
||||||
|
|
||||||
|
Always:
|
||||||
|
|
||||||
|
```
|
||||||
|
dmFields |= DM_ICMMETHOD
|
||||||
|
dmICMMethod = DMICMMETHOD_NONE // 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, if options present:
|
||||||
|
|
||||||
|
- `paper_source` → `dmFields |= DM_DEFAULTSOURCE`; `dmDefaultSource = tray_id as i16`
|
||||||
|
- `orientation == "landscape"` (case-insensitive) → `DMORIENT_LANDSCAPE` else portrait
|
||||||
|
- `media_type` parsed as `u32` → `dmFields |= DM_MEDIATYPE`; `dmMediaType = id`
|
||||||
|
|
||||||
|
**Not applied:** `paper_size`, `ppd_uncorrected_passthrough`, `cups_options`. Private OEM bytes after the public header are **not** touched (verified by `tests.rs:219-276`).
|
||||||
|
|
||||||
|
### GDI raw print path (`print_target`, windows.rs:440-643)
|
||||||
|
|
||||||
|
1. `image::open` TIFF → `to_rgb8()`. Crate features: `png`, `tiff` (`Cargo.toml:40`).
|
||||||
|
2. Pack **24-bit BGR** DIB, 4-byte row stride: `row_stride = ((w*3+3)/4)*4`. Top-down (`biHeight` negative).
|
||||||
|
3. `OpenPrinterW` → `DocumentPropertiesW` size → reuse cached DEVMODE (resized up if needed) or `DM_OUT_BUFFER` defaults → `apply_print_options_to_devmode`.
|
||||||
|
4. `CreateDCW(NULL, printer, NULL, pDevMode)`.
|
||||||
|
5. **`SetICMMode(hdc, ICM_OFF)`** — "STRICT ICM BYPASS".
|
||||||
|
6. `StartDocW` with title `ICCery Target - <filename>`. `lpszDatatype` null (driver default, not `"RAW"`).
|
||||||
|
7. `StartPage`.
|
||||||
|
8. Auto-fit scaler (#34):
|
||||||
|
|
||||||
|
```
|
||||||
|
dpi_x/y = GetDeviceCaps(LOGPIXELSX/Y) // used only for BITMAPINFO biX/YPelsPerMeter
|
||||||
|
page_w/h = GetDeviceCaps(HORZRES/VERTRES)
|
||||||
|
scale = min(page_w/img_w, page_h/img_h)
|
||||||
|
dest = floor(img * scale), centered
|
||||||
|
```
|
||||||
|
|
||||||
|
Physical offsets (`PHYSICALOFFSETX/Y`) from #34's wish-list are **not** queried. Fit is to the printable DC area (`HORZRES`/`VERTRES`), which already excludes hardware margins.
|
||||||
|
|
||||||
|
9. `StretchDIBits(..., DIB_RGB_COLORS, SRCCOPY)`. This **can resample** if dest ≠ source pixels. Color is unmanaged (ICM off, BI_RGB) but geometric interpolation is GDI's. Contrast with ICCery-CPU, which forbids interpolation.
|
||||||
|
|
||||||
|
10. `EndPage` / `EndDoc` / `DeleteDC` / `ClosePrinter`.
|
||||||
|
|
||||||
|
Zero-dimension images error out before GDI. Missing TIFF: `"Target TIFF file not found: …"`.
|
||||||
|
|
||||||
|
### CREATE_NO_WINDOW vs print
|
||||||
|
|
||||||
|
`CREATE_NO_WINDOW` (`0x08000000`) is **not used on the print path**. Windows printing is in-process GDI; no child process is spawned.
|
||||||
|
|
||||||
|
`CREATE_NO_WINDOW` **is** applied to ArgyllCMS console-subsystem children so a black `cmd` window does not flash (#46):
|
||||||
|
|
||||||
|
- `src-tauri/src/process_manager.rs:99-103` — every `ProcessManager::spawn` (`targen`, `printtarg`, `chartread`, `colprof`, `profcheck`, `instlist`, …)
|
||||||
|
- `src-tauri/src/calibration.rs:849-853` — `printcal` / `applycal` captured runs
|
||||||
|
|
||||||
|
`printtarg` itself only **generates** TIFFs; it does not print. Native print is a separate IPC command.
|
||||||
|
|
||||||
|
### NSIS USB drivers (#127) — not printer drivers
|
||||||
|
|
||||||
|
USB install is for **spectrophotometer** libusb-win32 drivers (i1Pro, ColorMunki, SpyderPrint), **not** printer OEM drivers.
|
||||||
|
|
||||||
|
- Fetch: `scripts/fetch-argyll.mjs` copies `Argyll_V*/usb/` → `src-tauri/argyll/usb/` from the Windows zip (`ArgyllCMS_install_USB.exe`, `.inf`, `.cat`, `libusb0.sys` for x86/amd64/arm64).
|
||||||
|
- Bundle: `tauri.conf.json` `resources: ["argyll/**/*"]` + NSIS `installMode: "both"` + `installerHooks: "windows/hooks.nsh"`.
|
||||||
|
- `hooks.nsh` `NSIS_HOOK_PREINSTALL`: if elevated, `MessageBox` Yes/No "Install ArgyllCMS USB instrument drivers?".
|
||||||
|
- `NSIS_HOOK_POSTINSTALL`: `ExecWait` `$INSTDIR\argyll\usb\ArgyllCMS_install_USB.exe` or `$INSTDIR\resources\argyll\usb\...`. Not silent. Missing file → exclamation box.
|
||||||
|
- Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe` (would break other Argyll apps).
|
||||||
|
- Also maps missing HKCU shell-folder drive letters via `DefineDosDevice` to avoid "Invalid Drive" on domain profiles.
|
||||||
|
|
||||||
|
WiX/MSI has no equivalent prompt.
|
||||||
|
|
||||||
|
### Windows idiosyncrasies / bugs fixed
|
||||||
|
|
||||||
|
| Issue | Symptom | Fix |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| #25/#26 | Need ACPU-equivalent raw print | GDI + `SetICMMode(ICM_OFF)` + `DMICMMETHOD_NONE` + 24-bit BGR DIB |
|
||||||
|
| #34 | No driver dialog / trays / orientation / scaler | `DocumentPropertiesW` modal, `DeviceCapabilitiesW`, auto-fit `StretchDIBits` dest |
|
||||||
|
| #36 | Preferences (Epson Print Preview, private OEM) ignored | Cache **full** DEVMODE buffer including `dmDriverExtra`; pass that pointer to `CreateDCW` |
|
||||||
|
| #48 | "PPD Uncorrected Passthrough" shown on Windows | Hide `#cupsOptionsGroup` |
|
||||||
|
| #46 | Argyll console windows cover UI | `CREATE_NO_WINDOW` on subprocess spawn **only** — not on print |
|
||||||
|
| #67 | Duplicate `get_windows_printers` / `print_target_windows` | Collapsed to `get_printers` / `print_target_native` |
|
||||||
|
|
||||||
|
Remaining gaps:
|
||||||
|
|
||||||
|
- Default printer never flagged.
|
||||||
|
- `paper_size` not applied to `DEVMODE` (`DM_PAPERSIZE` unused).
|
||||||
|
- `PHYSICALOFFSET*` not used; scaler uses `HORZRES`/`VERTRES` only.
|
||||||
|
- `StretchDIBits` may interpolate when scaling.
|
||||||
|
- `show_printer_properties` Tauri result is always `None`, so the frontend reports cancel even on OK.
|
||||||
|
- No `PrinterProperties` / `AdvancedDocumentProperties` alternative; only `DocumentPropertiesW`.
|
||||||
|
- `GetForegroundWindow` can attach the modal to the wrong top-level window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 13 — Linux CUPS printing
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
## Linux (`src-tauri/src/print/unix.rs`)
|
||||||
|
|
||||||
|
No libcups FFI. Shell out to CUPS CLI: `lpstat`, `lpoptions`, `lp`. **`lpr` is never used.**
|
||||||
|
|
||||||
|
### Enumeration
|
||||||
|
|
||||||
|
| Command | Parser | Result |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| `lpstat -e` | `parse_lpstat_e` | destination names, one per non-empty trimmed line |
|
||||||
|
| `lpstat -p` | `parse_lpstat_p` | `(name, status)` from lines `printer <name> …`. Status: idle → Idle; printing/now printing → Printing; disabled/stopped → Stopped; else Unknown |
|
||||||
|
| `lpstat -d` | `parse_lpstat_d` | `system default destination: <name>` |
|
||||||
|
| `lpoptions -p <name>` | `extract_printer_display_name` | `printer-info=` value, quotes stripped |
|
||||||
|
|
||||||
|
`merge_printer_info`: destinations first (status from `-p` or `"Idle"`), then any `-p` names not already seen. Each entry calls `lpoptions` for `display_name` (N printers = N extra processes).
|
||||||
|
|
||||||
|
If both `-e` fails and `-p` cannot even be spawned, error `"Failed to execute 'lpstat': …"`. Otherwise partial data is OK.
|
||||||
|
|
||||||
|
### Capabilities / PPD
|
||||||
|
|
||||||
|
`lpoptions -p <printer> -l` parsed by `parse_lpoptions_l` (unix.rs:181-229):
|
||||||
|
|
||||||
|
| PPD key (before `/`) | Destination |
|
||||||
|
|----------------------|-------------|
|
||||||
|
| `InputSlot`, `MediaSource` | trays (1-based index id, `*` stripped) |
|
||||||
|
| `PageSize`, `MediaSize` | paper_sizes |
|
||||||
|
| `CNIJMediaType`, `MediaType`, `StpMediaType`, `EPIJ_Medi` | media_types (id = name = token) |
|
||||||
|
|
||||||
|
Then, if `/etc/cups/ppd/<printer>.ppd` is readable, `parse_ppd_media_types` **replaces** media_types with id/name pairs from `*CNIJMediaType `, `*MediaType `, `*StpMediaType `, `*EPIJ_Medi ` lines (`id/Human Name:`).
|
||||||
|
|
||||||
|
macOS typically has PPDs under `~/Library/Printers` or a CUPS temp copy, **not** `/etc/cups/ppd/`, so the PPD-file enrichment is Linux-centric. macOS still gets media types from `lpoptions -l`.
|
||||||
|
|
||||||
|
### Driver color bypass detection (`detect_driver_color_bypass`, unix.rs:251-272)
|
||||||
|
|
||||||
|
First-match order:
|
||||||
|
|
||||||
|
| Probe substring | Key | Value | Meaning |
|
||||||
|
|-----------------|-----|-------|---------|
|
||||||
|
| `CNIJIntent2` | `CNIJIntent2` | `4` | Canon "Off (No Color Adjustment)" |
|
||||||
|
| `CNIJIntent` | `CNIJIntent` | `4` | older Canon |
|
||||||
|
| `EPIJ_CCor` | `EPIJ_CCor` | `0` | Epson Color Settings — 0 disables correction (preferred over CMat if both exist) |
|
||||||
|
| `EPIJ_CMat` | `EPIJ_CMat` | `3` | Epson "Off (No Color Adjustment)" |
|
||||||
|
| `StpColorCorrection` | `StpColorCorrection` | `Uncorrected` | Gutenprint |
|
||||||
|
| `ColorCorrection` | `ColorCorrection` | `Uncorrected` | generic |
|
||||||
|
| `EpsonColorMode` | `EpsonColorMode` | `Off` | Epson |
|
||||||
|
|
||||||
|
`EPIJ_OSColMat` is **not** auto-detected (but is forwarded if captured on macOS). `ColorModel` is forwarded if captured, not auto-set on Linux except the Gray fallback below.
|
||||||
|
|
||||||
|
### Media type key (`detect_media_type_key`, unix.rs:274-284)
|
||||||
|
|
||||||
|
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
|
||||||
|
|
||||||
|
### Linux `build_lp_args` / `print_target` (unix.rs:362-457)
|
||||||
|
|
||||||
|
```
|
||||||
|
lp -d <printer> -t "ICCery Target - <filename>"
|
||||||
|
[-o raw] # default
|
||||||
|
OR [-o ColorModel=Gray -o cm-calibration] # ppd_uncorrected_passthrough == true
|
||||||
|
[-o orientation-requested=3|4]
|
||||||
|
[-o PageSize=<paper_size>]
|
||||||
|
[-o MediaType=<media_type>] # generic key only — does not call detect_media_type_key
|
||||||
|
<tiff_path>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`raw` queue:** CUPS skips filters (no `cgpdftoraster`, no PPD color). The TIFF is sent as the job payload. Printers that cannot consume raw TIFF need the checkbox fallback.
|
||||||
|
- **Fallback `ColorModel=Gray` + `cm-calibration`:** documented as "uncorrected CUPS passthrough" for devices that reject raw TIFF. `Gray` is a surprising choice for color targets — it is the historical CUPS "don't color-manage" trick, not a conversion of the image to grayscale at the app layer. Still a rewrite risk: a color TIFF with `ColorModel=Gray` may be wrong on some drivers.
|
||||||
|
- **No ColorSync flags** (those are macOS-only).
|
||||||
|
- **No auto driver-bypass injection** on Linux (Canon/Epson PPD keys are **not** added unless the user typed them — and there is no Linux Preferences dialog to capture them).
|
||||||
|
- `paper_source` / `cups_options` ignored.
|
||||||
|
- `lp` failure: stderr, else stdout, else exit code, wrapped as `"CUPS print job failed: …"`.
|
||||||
|
- Missing file: `"Target TIFF file not found: …"`.
|
||||||
|
|
||||||
|
### Linux Preferences
|
||||||
|
|
||||||
|
`commands.rs:1364-1368` — `show_printer_properties` is a no-op `Ok(None)`. There is no CUPS web-UI (`http://localhost:631`) launcher, no `system-config-printer`, no `gtk-print-unix-dialog`. The `#btnPrinterProperties` click therefore always looks like a cancel on Linux.
|
||||||
|
|
||||||
|
### `lp` vs `lpr`
|
||||||
|
|
||||||
|
Always `lp` (`Command::new("lp")`). `lpr` is never invoked. `lp` is the CUPS System V client and is what `-o name=value` and `-d dest` are specified against. `lpr` BSD syntax (`-P`, `-o` still works on CUPS) was not used.
|
||||||
|
|
||||||
|
### CUPS options string parser (`parse_cups_options_string`, unix.rs:286-332)
|
||||||
|
|
||||||
|
Shared with macOS. Whitespace-separated `name=value`; double quotes toggle; quotes stripped from both sides; tokens without `=` skipped; `AP_D_InputSlot=` yields `("",)` empty value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 14 — ICCery-CPU / TargetPrint (macOS companion)
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
## ICCery-CPU / TargetPrint
|
||||||
|
|
||||||
|
Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `TargetPrint`. Zero third-party deps. Universal 2. Hardened Runtime on, App Sandbox **off** (needs `cupsGetPPD`).
|
||||||
|
|
||||||
|
### Why it exists
|
||||||
|
|
||||||
|
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but:
|
||||||
|
|
||||||
|
- No 1:1 physical-size preview
|
||||||
|
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
|
||||||
|
- No AirPrint warning
|
||||||
|
- NSPrintPanel is settings-only; the job is a second step
|
||||||
|
|
||||||
|
TargetPrint is the **Adobe Color Print Utility analogue**: decode TIFF with ImageIO, draw 1:1 in PostScript points with interpolation off, present `NSPrintPanel` as the actual print operation, inject ColorSync-off **and** vendor PPD keys into `NSPrintInfo`.
|
||||||
|
|
||||||
|
### Architecture (SPEC §2)
|
||||||
|
|
||||||
|
```
|
||||||
|
ICCery (Tauri) --job /tmp/iccery_job_<uuid>.json (fire-and-forget, do not wait)
|
||||||
|
▼
|
||||||
|
TargetPrint.app (always NSApplication, never a CLI tool)
|
||||||
|
main.swift → AppDelegate → PreviewWindowController
|
||||||
|
TargetCanvasView (ImageIO, knowsPageRange/rectForPage)
|
||||||
|
PrintEngine (NSPrintOperation + NSPrintInfo + ColorMatching)
|
||||||
|
CUPSManager (libcups, AirPrint, vendor PPD)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invocation contract (SPEC §4, `main.swift`, `AppDelegate.consumeArguments`)
|
||||||
|
|
||||||
|
```
|
||||||
|
TargetPrint.app/Contents/MacOS/TargetPrint --job /tmp/iccery_job_<uuid>.json
|
||||||
|
TargetPrint --job=/path.json
|
||||||
|
TargetPrint --verbose --job … # ~/Library/Logs/TargetPrint/TargetPrint.log
|
||||||
|
# or open a .targetjob / .json via file association (UTI com.iccery.targetjob)
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes (`AppError.swift`): `0` ok/cancel, `1` invalid job, `2` unreadable TIFF, `3` CUPS, `4` fatal. Job JSON is **never deleted** by TargetPrint.
|
||||||
|
|
||||||
|
Mode B: no args / drop TIFFs / File→Open — inspector + Print….
|
||||||
|
|
||||||
|
`--job` presents `NSPrintPanel` immediately (`AppDelegate:32-33`, SPEC §4.5). Tauri must **not** `wait()`.
|
||||||
|
|
||||||
|
### JSON `TargetJob` v1 (SPEC §5, `TargetJob.swift`, `Resources/sample.targetjob`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"jobTitle": "Epson_XP55_IlfordLustre_Target_P1-2",
|
||||||
|
"files": ["/abs/page1.tif", "/abs/page2.tif"],
|
||||||
|
"printSettings": {
|
||||||
|
"printerName": "EPSON_XP_55_Series",
|
||||||
|
"mediaSize": "A4",
|
||||||
|
"mediaType": "PremiumGlossy",
|
||||||
|
"paperSource": "Auto",
|
||||||
|
"resolution": "5760x1440dpi",
|
||||||
|
"printQuality": null,
|
||||||
|
"scaling": 1.0,
|
||||||
|
"centered": true,
|
||||||
|
"forceUnmanagedColor": true
|
||||||
|
},
|
||||||
|
"uiPolicy": {
|
||||||
|
"lockColorManagement": true,
|
||||||
|
"allowBasicDriverChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required: `version==1`, `jobTitle`, non-empty `files`, `printSettings.{printerName,mediaSize,scaling,centered,forceUnmanagedColor}`, `uiPolicy.{lockColorManagement,allowBasicDriverChanges}`. Unknown keys ignored. `printQuality` is implemented in Swift beyond the spec sample.
|
||||||
|
|
||||||
|
### Geometry & pixel integrity (SPEC §6, §15)
|
||||||
|
|
||||||
|
- 72 pt = 1 in. **Never** `backingScaleFactor` (`Geometry.swift`).
|
||||||
|
- `physicalInches = pixels / dpi`; `pointSize = physical * 72 * scaling`.
|
||||||
|
- Papers: A4/A3/A5/Letter/Legal/Tabloid/4x6/5x7. Unknown names fall back to A4, with substring match for `A4.Borderless`.
|
||||||
|
- Destination origin rounded to 0.001 pt to avoid fractional-device resampling while staying inside G1 ±0.1 mm.
|
||||||
|
- ImageIO load: `kCGImageSourceShouldCache=true`, `ShouldAllowFloat=false`. DPI from `kCGImagePropertyDPIWidth/Height`, default 72.
|
||||||
|
- `TargetPage.makeUnmanagedDeviceRGB`: redraw into `CGColorSpaceCreateDeviceRGB()` with interpolation **none**, antialias **off**, `shouldInterpolate: false`. SPEC §6.3: do **not** `CGImageCreateCopyWithColorSpace` into a calibrated space.
|
||||||
|
- Print draw (`TargetCanvasView.drawPrintedPage`): `interpolationQuality = .none`, `setShouldAntialias(false)`, `setAllowsAntialiasing(false)`, `setShouldSmoothFonts(false)`, fill paper white, `context.draw(cgImage, in: dest)`. GState saved/restored so page N does not inherit page N-1's transform.
|
||||||
|
- `PixelIntegrity.scanVerticalSeam`: any pixel on a red\|green seam that is not exactly left or right fails P1 (interpolation/antialias detected).
|
||||||
|
- Pagination: `knowsPageRange` 1…N, `rectForPage` = paper size, empty header/footer.
|
||||||
|
|
||||||
|
### ColorSync disable in TargetPrint (SPEC §7) — **different keys from ICCery**
|
||||||
|
|
||||||
|
`ColorMatching.swift`:
|
||||||
|
|
||||||
|
| User mode | `PMColorMatchingMode` value |
|
||||||
|
|-----------|-----------------------------|
|
||||||
|
| Unmanaged (profiling default) | `"APCustomColorMatching"` |
|
||||||
|
| ColorSync | `"APColorSync"` |
|
||||||
|
| Driver / Vendor | `"APPrinterExtension"` |
|
||||||
|
|
||||||
|
Injected into:
|
||||||
|
|
||||||
|
- `printInfo.dictionary()["PMColorMatchingMode"]`
|
||||||
|
- `printInfo.dictionary()["PMCustomColorMatchingProfile"]` = `""` when unmanaged else `"System"`
|
||||||
|
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
|
||||||
|
- nested `com.apple.print.printSettings` dictionary, same keys
|
||||||
|
|
||||||
|
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths.
|
||||||
|
|
||||||
|
Panel policy (`ColorMatching.configurePanel`):
|
||||||
|
|
||||||
|
- Always: copies, page range, preview
|
||||||
|
- If `allowBasicDriverChanges`: paper size, orientation, scaling
|
||||||
|
- If `lockColorManagement`: `stripColorMatchingAccessories` — walk `panel.accessoryControllers`, remove any whose class/title/nib contains "color matching" / "colour matching" / "colormatch", requiring `NSPrintPanelAccessorizing`. Stripped twice (configure + immediately before `runModal`) because system accessories install lazily.
|
||||||
|
|
||||||
|
### CUPS / PPD (`CUPSManager.swift`, `Bridging-Header.h`)
|
||||||
|
|
||||||
|
Bridging header:
|
||||||
|
|
||||||
|
```objc
|
||||||
|
#import <cups/cups.h>
|
||||||
|
#import <cups/ppd.h>
|
||||||
|
static inline const char *TPCupsGetPPD(const char *name) {
|
||||||
|
return cupsGetPPD(name); // deprecated; Swift overlay marks cupsGetPPD unavailable
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cupsGetDests` / `cupsFreeDests` / `cupsGetOption` for `printer-uri-supported`, `device-uri`, `printer-make-and-model`.
|
||||||
|
- `TPCupsGetPPD` → read ISO-Latin-1 → `unlink` the temp PPD.
|
||||||
|
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
|
||||||
|
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
|
||||||
|
|
||||||
|
| Vendor | TargetPrint keys | ICCery macOS `lp` keys |
|
||||||
|
|--------|------------------|------------------------|
|
||||||
|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
|
||||||
|
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
|
||||||
|
| HP | `ColorModel=RGB`, `HPColorControl=Off` | (none auto) |
|
||||||
|
| Generic | any Color/Colour OpenUI choice in `{none,off,no,nocoloradjustment}` | `ColorCorrection=Uncorrected` / `StpColorCorrection=Uncorrected` |
|
||||||
|
|
||||||
|
A rewrite should apply **both** dictionaries (ICCery's empirically captured PDE keys **and** TargetPrint's SPEC keys).
|
||||||
|
|
||||||
|
Media/tray/quality discovery walks `*Keyword code/Title:` and `*OpenUI` translations (`media type`, `paper source`, `print quality`, …). Keywords tried:
|
||||||
|
|
||||||
|
- Media: `MediaType`, `CNIJMediaType`
|
||||||
|
- Tray: `InputSlot`, `EPIJ_FdSo`, `CNIJMediaSupply`
|
||||||
|
- Quality: `CNIJPrintQuality`, `EPIJ_Qual`, `cupsPrintQuality`, `PrintQuality`, `CNIJPrintMode2`, `Quality`, `StpQuality`
|
||||||
|
|
||||||
|
Canon quality is a triple (`CNIJPrintQuality` + `CNIJPrintMode2` + `CNIJPQualitySlider`) mapped in `PrintEngine.applyOptionalPPDKeys`.
|
||||||
|
|
||||||
|
PPD hex escapes (`<2F>` → `/`) decoded by `decodePPDString`.
|
||||||
|
|
||||||
|
### PrintEngine (`PrintEngine.swift`)
|
||||||
|
|
||||||
|
`makePrintInfo()`:
|
||||||
|
|
||||||
|
- `jobDisposition = .spool`
|
||||||
|
- margins 0, centering **off** (geometry is in the view), pagination `.clip`, `scalingFactor = 1.0`
|
||||||
|
- `NSPrinter(name: printerName)`
|
||||||
|
- paper size from `Geometry.paper(named:)`
|
||||||
|
- `ColorMatching.apply`
|
||||||
|
- `CUPSManager.namedQueue` → `vendorColorBypass` + optional media/tray/quality/resolution extras
|
||||||
|
- `canSpawnSeparateThread = false` on the operation
|
||||||
|
- `runModal(for: window, delegate:didRun:)` — exit code 0 on success **or** cancel (SPEC §4)
|
||||||
|
|
||||||
|
### How ICCery would invoke it (SPEC §16, `VENDOR.md`)
|
||||||
|
|
||||||
|
Not implemented in current ICCery Rust. Specified as:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let json = serde_json::to_string_pretty(&job)?;
|
||||||
|
let mut path = env::temp_dir();
|
||||||
|
path.push(format!("iccery_job_{}.json", uuid::Uuid::new_v4()));
|
||||||
|
fs::write(&path, json)?;
|
||||||
|
|
||||||
|
Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
|
||||||
|
// or vendored: src-tauri/targetprint/macos-{x86_64,aarch64,universal}/TargetPrint.app/Contents/MacOS/TargetPrint
|
||||||
|
.arg("--job")
|
||||||
|
.arg(&path)
|
||||||
|
.spawn()?; // fire-and-forget — do not wait
|
||||||
|
```
|
||||||
|
|
||||||
|
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
|
||||||
|
|
||||||
|
Suggested ICCery integration:
|
||||||
|
|
||||||
|
1. Keep current `lp` path as the headless/fast path (and the only path on Linux).
|
||||||
|
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
|
||||||
|
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
|
||||||
|
|
||||||
|
### TargetPrint vs ICCery macOS — decision table for the rewrite
|
||||||
|
|
||||||
|
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|
||||||
|
|---------|-------------------|-------------|------------------------|
|
||||||
|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel |
|
||||||
|
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` |
|
||||||
|
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
|
||||||
|
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
|
||||||
|
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
|
||||||
|
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) |
|
||||||
|
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges |
|
||||||
|
| AirPrint | none | detected + warned | Port detector into ICCery printer list |
|
||||||
|
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
|
||||||
|
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue cross-reference
|
||||||
|
|
||||||
|
| # | Title | Print relevance |
|
||||||
|
|---|-------|-----------------|
|
||||||
|
| 25 | Raw OS Printing Engine | Parent feature. ACPU-equivalent, no `window.print()`. |
|
||||||
|
| 26 | Windows GDI/ICM | `SetICMMode`, `DMICMMETHOD_NONE`, BGR DIB, `StretchDIBits`. |
|
||||||
|
| 27 | macOS/Linux CUPS | Originally `-o raw`. macOS later diverged to AP_* (#92/#188). |
|
||||||
|
| 28 | UI + IPC | `get_printers`, printer `<select>`, Print Target button, toasts. |
|
||||||
|
| 34 | Driver properties, trays, orientation, auto-fit | Windows dialog + scaler; Unix lpoptions; `PrintOptions`. |
|
||||||
|
| 36 | DEVMODE not applied | Full buffer + `dmDriverExtra` retained into `CreateDCW`. |
|
||||||
|
| 46 | Console windows on Windows | `CREATE_NO_WINDOW` on Argyll spawn, **not** on print. |
|
||||||
|
| 48 | Hide PPD checkbox on Windows | `#cupsOptionsGroup.hidden`. |
|
||||||
|
| 50 | Stage 2 badge cleanup | Removed ACPU / auto-fit badges. |
|
||||||
|
| 67 | Consolidate print commands | `get_printers` / `print_target_native` only. |
|
||||||
|
| 68 | printtarg JSON manifest | Gallery + per-page print consume `event: manifest`. |
|
||||||
|
| 92 | macOS CUPS raw spooler | `macos.rs` created; ColorSync research. Milestone 9. |
|
||||||
|
| 127 | Fetch Argyll + NSIS USB | Instrument USB, not printer drivers. |
|
||||||
|
| 188 | Cannot disable Epson/Canon CM on macOS | Preferences was System Settings. Now NSPrintPanel + SPI + AP_* + PPD bypass + capture. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files (absolute)
|
||||||
|
|
||||||
|
```
|
||||||
|
/tmp/ICCery/src-tauri/src/print/mod.rs
|
||||||
|
/tmp/ICCery/src-tauri/src/print/macos.rs
|
||||||
|
/tmp/ICCery/src-tauri/src/print/windows.rs
|
||||||
|
/tmp/ICCery/src-tauri/src/print/unix.rs
|
||||||
|
/tmp/ICCery/src-tauri/src/print/tests.rs
|
||||||
|
/tmp/ICCery/src-tauri/src/commands.rs # get_printers, get_printer_capabilities,
|
||||||
|
# show_printer_properties, print_target_native
|
||||||
|
/tmp/ICCery/src-tauri/src/lib.rs # PrinterDevModeStore manage + command list
|
||||||
|
/tmp/ICCery/src-tauri/src/process_manager.rs # CREATE_NO_WINDOW for Argyll
|
||||||
|
/tmp/ICCery/src-tauri/src/calibration.rs # CREATE_NO_WINDOW for printcal
|
||||||
|
/tmp/ICCery/src-tauri/Cargo.toml # objc2* / windows features
|
||||||
|
/tmp/ICCery/src-tauri/windows/hooks.nsh # NSIS USB
|
||||||
|
/tmp/ICCery/src/js/printtarg.js
|
||||||
|
/tmp/ICCery/src/index.html # #rawPrintPanel
|
||||||
|
/tmp/ICCery/AGENTS.md # #188 contract
|
||||||
|
/tmp/ICCery-CPU/SPEC.md
|
||||||
|
/tmp/ICCery-CPU/VENDOR.md
|
||||||
|
/tmp/ICCery-CPU/Bridging-Header.h
|
||||||
|
/tmp/ICCery-CPU/Sources/main.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/AppDelegate.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/AppError.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Printing/ColorMatching.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Printing/PrintEngine.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Printing/CUPSManager.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Models/TargetJob.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Models/TargetPage.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Models/Geometry.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Models/PixelIntegrity.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Views/TargetCanvasView.swift
|
||||||
|
/tmp/ICCery-CPU/Sources/Controllers/PreviewWindowController.swift
|
||||||
|
```
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 15 — Stage 3: `chartread`, averaging, swatch grid
|
||||||
|
|
||||||
|
UI: `#stage-3`. Action: `#btnStartRead` → `run_chartread`.
|
||||||
|
|
||||||
|
## argv (`build_chartread_args`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -u [-c port] [-Y l] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
| Source | Flag |
|
||||||
|
|--------|------|
|
||||||
|
| always | `-v -u` |
|
||||||
|
| `#chartreadInstrumentSelect` value | `-c port` if non-empty **and not `"1"`** (#111) |
|
||||||
|
| Settings `enable_i1pro2_leds` | `-Y l` (default **off** for stock-Argyll compatibility) (#204) |
|
||||||
|
|
||||||
|
If `enable_i1pro2_leds` is omitted in the config, backend loads it from settings.
|
||||||
|
|
||||||
|
Process id: `chartread_${basename}`.
|
||||||
|
|
||||||
|
**Do not pass instlist device index as `-c`.** `instlist.port` is already the 1-based comm port. Port `1` means "default" — omit `-c` (#111).
|
||||||
|
|
||||||
|
## `instlist`
|
||||||
|
|
||||||
|
`detect_instruments` spawns `instlist` with **no args**, id `"instlist"`, cwd inherited. Fork emits JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "devices": [ { "name": "...", "type": "...", "port": 1, ... } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend prefers JSON; falls back to regex `^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?`. XY label if name/type matches `/spectro\s?scan|i1io/i` (`data-xy="1"`).
|
||||||
|
|
||||||
|
## State machine
|
||||||
|
|
||||||
|
`STATE`: `IDLE`, `CALIBRATING`, `AWAITING_STRIP`, `READING`, `ALL_STRIPS_READ`, `WARNING`, `PROMPT_CONTINUE`, `TABLE_PLACE_SHEET`, `TABLE_ALIGN`, `ERROR`, `FINISHED`.
|
||||||
|
|
||||||
|
Classifier is a **pure function** (`classifyChartreadLine`) with 39 tests. Order of matchers matters — see [05](05-argyll-fork.md) §12.6.
|
||||||
|
|
||||||
|
XY two-line prompts: line 1 `locate patch A1 with the sight,` then line 2 `then hit return to continue`. While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, continuation lines **stay sticky** in that state (#93).
|
||||||
|
|
||||||
|
`Please remove last sheet from table` is info-only (`isRemoveSheetNotice`); do not prompt.
|
||||||
|
|
||||||
|
Cancel on XY: send `q\n` then kill so the head parks.
|
||||||
|
|
||||||
|
## Averaging (#87, bugs #109/#110)
|
||||||
|
|
||||||
|
Each completed pass: `snapshot_ti3` copies `{basename}.ti3` → `{basename}_pass{N}.ti3` and **deletes** the canonical file. Stage 4 stays locked. Finish: `run_average -v pass1 pass2 … output.ti3` then `promote_ti3` if needed.
|
||||||
|
|
||||||
|
Do **not** overwrite the same `.ti3` in place (#109). Do **not** unlock Stage 4 after the first pass (#110).
|
||||||
|
|
||||||
|
## Swatch grid (#6, #178)
|
||||||
|
|
||||||
|
Listens `process:json_row`. Each patch is a 135° diagonal split: **top-left intended**, **bottom-right measured**.
|
||||||
|
|
||||||
|
`is_pad` spacers are skipped **only** when there is no measurement **and** device values are all zero. White reference patches (`-e`) may be flagged `is_pad` but have valid Lab — **render them** (#178).
|
||||||
|
|
||||||
|
Row order A→Z, patches 1→N left-to-right / top-to-bottom to match printtarg.
|
||||||
|
|
||||||
|
ΔE₀₀ from `delta_e.js` (`computeDeltaE00`). Traffic lights from settings:
|
||||||
|
|
||||||
|
- `< delta_e_good_max` (default 2.0) Good
|
||||||
|
- `< delta_e_warning_max` (default 5.0) Warning
|
||||||
|
- else Bad
|
||||||
|
|
||||||
|
`settings-saved` reclassifies live swatches. Thresholds must be ≥ 0 and `good < warning`.
|
||||||
|
|
||||||
|
## i1Pro 2 LEDs (#204)
|
||||||
|
|
||||||
|
Fork `-Y l` (not the `-L` from the original ticket). Silent no-op on instruments without `inst_stat_leds`.
|
||||||
|
|
||||||
|
| LED | Meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| Flashing white | awaiting white-tile calibration |
|
||||||
|
| Flashing blue | ready for strip |
|
||||||
|
| Flashing red | misread |
|
||||||
|
| Flashing green | capture OK |
|
||||||
|
|
||||||
|
If an unpatched binary rejects `-Y l`, capture last stderr line and expand Process Output.
|
||||||
|
|
||||||
|
## Interactive buttons vs real keys
|
||||||
|
|
||||||
|
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 16 — Stage 4: `colprof` (#7, #56, #176)
|
||||||
|
|
||||||
|
UI: `#stage-4`. Action: `#btnCreateProfile` → `run_colprof`.
|
||||||
|
|
||||||
|
## argv (`build_colprof_args`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -a {algorithm} -q {quality} [-t intent] [-f [spec]] [-i illum] [-o obs] [-c inView] [-d outView] [-D desc] [-C copyright] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
| UI | Field | Flag | Values |
|
||||||
|
|----|-------|------|--------|
|
||||||
|
| Algorithm | `algorithm` | `-a` | typically `l` (cLUT) |
|
||||||
|
| Quality | `quality` | `-q` | `l`/`m`/`h`/`u` |
|
||||||
|
| Intent | `intent` | `-t` | omitted if empty |
|
||||||
|
| FWA/OBA | `fwa` | `-f` | `none` → omit; empty → `-f` alone; else `-f D50\|D65\|path.sp` |
|
||||||
|
| Illuminant | `illuminant` | `-i` | override D50 |
|
||||||
|
| Observer | `observer` | `-o` | override 1931 2° |
|
||||||
|
| Input viewing | `input_viewing_cond` | `-c` | skip `none` |
|
||||||
|
| Output viewing | `output_viewing_cond` | `-d` | skip `none` |
|
||||||
|
| Description | `description` | `-D` | |
|
||||||
|
| Copyright | `copyright` | `-C` | |
|
||||||
|
|
||||||
|
Process id: `colprof_${basename}`.
|
||||||
|
|
||||||
|
Fork `colprof -u` JSON progress exists; **ICCery does not pass `-u`**. Spinner/stage label is driven from stdout text.
|
||||||
|
|
||||||
|
## FWA (#176)
|
||||||
|
|
||||||
|
`#colprofFwa`: D50 (recommended modern OBA paper / ISO 3664 M1), None, D65, Custom `.sp`. Custom browse uses `select_spectrum_file` (`*.sp`) — **not** `window.__TAURI__.dialog` (#210).
|
||||||
|
|
||||||
|
## IPC mismatch that broke Stage 4 (#56)
|
||||||
|
|
||||||
|
Early JS sent `{ basename, cwd }` while Rust expected `{ ti3_path, icc_path }`, and process ids disagreed so exit listeners never fired. **Config struct and process id must match on both sides.**
|
||||||
|
|
||||||
|
## After success
|
||||||
|
|
||||||
|
If Apply Calibration is on, run `applycal -a` on the new profile. Then enable `#btnGoToVerify`. Locate profile via `get_profile_path` / `resolve_profile_extension` (#69).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 17 — Stage 5: verification, drift, install
|
||||||
|
|
||||||
|
UI: `#stage-5`. Actions: `#btnVerify` → `run_profcheck` then `extract_gamut`; `#btnInstallProfile` → `install_profile_to_system`.
|
||||||
|
|
||||||
|
## `profcheck` argv (`build_profcheck_args`)
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -k -s -u {ti3} {icc}
|
||||||
|
```
|
||||||
|
|
||||||
|
`-u` is the fork JSON ΔE report. Also swap `.icc`/`.icm` if missing (#69). Process id: `profcheck_${ti3_path}` (note: contains path — listeners must use the same string).
|
||||||
|
|
||||||
|
## Parsing (`parseProfcheckReport`)
|
||||||
|
|
||||||
|
Prefer JSON summary (`avg`, `max`, `rms`, patch count). Fall back to legacy plain-text. If nothing parses, cards show `0.00` **and** a warning is appended to the process log (#179) — never silently zero.
|
||||||
|
|
||||||
|
## Verification bands (#95) — **not** the Stage 3 swatch thresholds
|
||||||
|
|
||||||
|
Stored on each `VerificationRecord.status`:
|
||||||
|
|
||||||
|
| avg ΔE₀₀ | Status | Badge |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| < 1.0 | Excellent | `badge-excellent` |
|
||||||
|
| < 2.0 | Good | `badge-good` |
|
||||||
|
| < 3.5 | Acceptable | `badge-acceptable` |
|
||||||
|
| ≥ 3.5 | Warning | `badge-poor` |
|
||||||
|
|
||||||
|
Do **not** reuse `delta_e_good_max` / `delta_e_warning_max` (those are live swatch lights, defaults 2.0 / 5.0).
|
||||||
|
|
||||||
|
## History store (`quality_store.rs`)
|
||||||
|
|
||||||
|
Path: app data `verification_history.json`.
|
||||||
|
|
||||||
|
```
|
||||||
|
id: vr-<epoch_millis>-<seq>
|
||||||
|
profile_name, printer_name (wizardState.printerName or "Unknown")
|
||||||
|
avg_de, max_de, rms_de, patch_count, status, timestamp ISO-8601 UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
Capacity **1000** (README; some older comments said 500). Evict oldest.
|
||||||
|
|
||||||
|
**Atomic write (#213):** write `.tmp` then `rename`. Non-atomic writes corrupted history on crash.
|
||||||
|
|
||||||
|
Serde: nested structs `snake_case`; Tauri command **arguments** `camelCase` (`savePath`, `record`, `profileName`).
|
||||||
|
|
||||||
|
## Drift UI
|
||||||
|
|
||||||
|
- SVG dual-series trend with shaded ICCery bands
|
||||||
|
- Consecutive-breach alert: ≥ 2 consecutive runs with status Warning, on **distinct calendar days** or **≥ 1 hour apart**
|
||||||
|
- Printer filter `#driftPrinterFilter`
|
||||||
|
- CSV export RFC-4180 (`export_verification_history_csv`)
|
||||||
|
- Clear history command
|
||||||
|
|
||||||
|
## `iccgamut`
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -d 10 {resolved_profile}
|
||||||
|
```
|
||||||
|
|
||||||
|
`-d 10` is **surface density**, not a directory (#112). Never pass `-d 50.0` as a folder. Cwd = profile parent. Output `{stem}.gam` next to the profile.
|
||||||
|
|
||||||
|
See [18](18-gamut-viewer.md).
|
||||||
|
|
||||||
|
## Install profile (#223)
|
||||||
|
|
||||||
|
See [19](19-profile-install.md). Never move the working-directory artefact.
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
# 18 — 3D gamut viewer
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
Exhaustive reading of the current implementation for a rewrite. All citations are
|
||||||
|
`path:start-end` against `/tmp/ICCery` unless noted. Issue numbers refer to
|
||||||
|
`/tmp/iccery-research/issues/`.
|
||||||
|
|
||||||
|
**Module map**
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `src/js/gamut_viewer.js` (831 lines) | Scene, parser, mesh, lifecycle, controls |
|
||||||
|
| `src/js/gamut_viewer.test.js` | Parser + WebGL-heuristic unit tests (Node) |
|
||||||
|
| `src/js/CSS2DRenderer.js` | Vendored Three.js CSS2D addon (IIFE → `THREE.*`) |
|
||||||
|
| `src/js/OrbitControls.js` | Vendored Three r128 OrbitControls (IIFE → `THREE.OrbitControls`) |
|
||||||
|
| `src/js/vendor/quickhull.js` | Incremental 3D QuickHull fallback |
|
||||||
|
| `src/js/color_convert.js` | Lab → sRGB for per-vertex colour |
|
||||||
|
| `src/js/three.min.js` | Three.js **r128** (`const e="128"`) |
|
||||||
|
| `src/js/state.js` | `ensureGamutViewer` / `pauseGamutViewer` on stage change |
|
||||||
|
| `src/js/app.js` | GPU hints, **no** eager WebGL on `DOMContentLoaded` |
|
||||||
|
| `src/js/colprof.js` | Post-`colprof` `extract_gamut` + `loadGamutMesh` |
|
||||||
|
| `src/js/profcheck.js` | Post-verify `loadGamutMesh` |
|
||||||
|
| `src-tauri/src/commands.rs` | `build_iccgamut_args`, `extract_gamut`, `read_file_base64` |
|
||||||
|
| `src/assets/sRGB.gam` | Bundled full Argyll surface (448 verts / 892 faces) |
|
||||||
|
| `src-tauri/argyll/reference_gamuts/sRGB.gam` | Stub 8-cusp CGATS — **not** what the viewer loads |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Lifecycle (MUST NOT init WebGL on `DOMContentLoaded`)
|
||||||
|
|
||||||
|
This is the #225 contract. Eager `THREE.WebGLRenderer` + continuous rAF on a
|
||||||
|
hidden Stage 5 canvas respawns WKWebView’s GPU helper on Monterey Intel: white
|
||||||
|
flash loop, then silent exit (Web Content process death, no `ICCery` crash
|
||||||
|
report).
|
||||||
|
|
||||||
|
### 1.1 What is forbidden
|
||||||
|
|
||||||
|
`src/js/app.js:130-137` explicitly does **not** call `initGamutViewer` in the
|
||||||
|
`DOMContentLoaded` `safeInit` batch:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
|
||||||
|
// respawns WKWebView on Monterey Intel).
|
||||||
|
safeInit('Stage 1 (Targen)', initTargen);
|
||||||
|
// ...
|
||||||
|
safeInit('Stage 5 (Profcheck)', initProfcheck); // profcheck UI only, not WebGL
|
||||||
|
```
|
||||||
|
|
||||||
|
`#stage-5` starts with `class="stage hidden"` (`src/index.html:901`). Hidden
|
||||||
|
containers report `clientWidth === 0`; the old code fell back to 500×400 and
|
||||||
|
still created a GPU context (#225 §7.1).
|
||||||
|
|
||||||
|
### 1.2 `ensureGamutViewer` — only when Stage 5 is shown
|
||||||
|
|
||||||
|
`src/js/state.js:90-94`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (stageNumber === 5) {
|
||||||
|
ensureGamutViewer();
|
||||||
|
} else {
|
||||||
|
pauseGamutViewer();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the **only** production call site besides context-restore / fallback
|
||||||
|
reload. `resumeGamutViewer` is exported (`gamut_viewer.js:180-182`) but **never
|
||||||
|
imported**. Returning to Stage 5 re-enters `ensureGamutViewer`, which
|
||||||
|
`startAnimate()`s if already ready.
|
||||||
|
|
||||||
|
`ensureGamutViewer` (`gamut_viewer.js:149-174`):
|
||||||
|
|
||||||
|
1. Bail if `gamutViewerUnavailable` (WebGL feature-detect failed once — sticky).
|
||||||
|
2. If `gamutViewerReady`, just `startAnimate()` (no second renderer).
|
||||||
|
3. If `#stage-5` has class `hidden`, **return without creating WebGL**.
|
||||||
|
4. Defer actual `initGamutViewer()` by one `requestAnimationFrame` so layout has
|
||||||
|
a non-zero size after the `.hidden` class is removed.
|
||||||
|
5. Re-check hidden + ready flags inside the rAF callback.
|
||||||
|
|
||||||
|
`initGamutViewer` (`gamut_viewer.js:187-329`) is the real constructor:
|
||||||
|
|
||||||
|
- Re-entry guard: `gamutViewerReady || gamutViewerInitStarted`.
|
||||||
|
- Requires `#gamutViewerContainer` and global `THREE`.
|
||||||
|
- Feature-detects WebGL **before** `new THREE.WebGLRenderer` (`webglAvailable`,
|
||||||
|
lines 37-46): tries `webgl2`, then `webgl`, then `experimental-webgl`.
|
||||||
|
- On failure: sets `gamutViewerUnavailable = true`, logs
|
||||||
|
`WEBGL_UNAVAILABLE_MESSAGE`, paints fallback, returns. Never throws into
|
||||||
|
`safeInit`.
|
||||||
|
- Constrained-GPU path (`isLikelyConstrainedGpu`, lines 56-79 +
|
||||||
|
`setGpuHints` from `app.js:96-100`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: !lowPower,
|
||||||
|
alpha: false, // opaque; matches scene.background 0x0e0e14
|
||||||
|
powerPreference: lowPower ? 'low-power' : 'default',
|
||||||
|
failIfMajorPerformanceCaveat: false,
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
Arch preference: backend `get_app_info.arch` beats UA. Safari reports
|
||||||
|
`navigator.platform === "MacIntel"` on Apple Silicon; treating that as Intel
|
||||||
|
would wrongly drop antialias on M-series. Heuristic: `x86_64`/`x86`/`ia32`
|
||||||
|
from backend → constrained; else UA `ARM|Apple Silicon|aarch64` → not;
|
||||||
|
else Intel Mac UA + `macosMajor < 13` (or unknown major) → constrained.
|
||||||
|
|
||||||
|
- Default size if container is still 0: **500×400** (`gamut_viewer.js:214-215`).
|
||||||
|
Should not trigger if the hidden-stage guard works.
|
||||||
|
|
||||||
|
### 1.3 Pause on leave
|
||||||
|
|
||||||
|
`pauseGamutViewer` → `stopAnimate()` (`animationRunning = false`). The rAF loop
|
||||||
|
(`gamut_viewer.js:334-343`) returns immediately if the flag is false, so a lost
|
||||||
|
or hidden context does not spin:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function animate() {
|
||||||
|
if (!animationRunning) return;
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
if (contextLost || !renderer) return;
|
||||||
|
if (controls) controls.update();
|
||||||
|
if (renderer && scene && camera) {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
if (labelRenderer) labelRenderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Does not dispose** the renderer on leave. Scene, GPU context, and meshes
|
||||||
|
survive. That is intentional: re-entering Stage 5 is cheap, and `loadGamutMesh`
|
||||||
|
from Stage 4 can populate the scene if the user already visited Stage 5.
|
||||||
|
|
||||||
|
### 1.4 Context-lost handler
|
||||||
|
|
||||||
|
`gamut_viewer.js:238-252`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
renderer.domElement.addEventListener('webglcontextlost', (e) => {
|
||||||
|
e.preventDefault(); // allow restore
|
||||||
|
contextLost = true;
|
||||||
|
stopAnimate();
|
||||||
|
logger.error('WebGL context lost', 'GamutViewer');
|
||||||
|
showGamutFallback(container, '3D view lost its GPU context. Profiling stages still work.', {
|
||||||
|
reloadable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderer.domElement.addEventListener('webglcontextrestored', () => {
|
||||||
|
logger.warn('WebGL context restored — rebuilding viewer', 'GamutViewer');
|
||||||
|
contextLost = false;
|
||||||
|
disposeViewer();
|
||||||
|
ensureGamutViewer();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`disposeViewer` (`gamut_viewer.js:115-143`) disconnects `ResizeObserver`,
|
||||||
|
`renderer.dispose()`, removes both canvases, nulls scene/camera/controls/meshes,
|
||||||
|
clears `gamutViewerReady` / `gamutViewerInitStarted` / `contextLost`.
|
||||||
|
|
||||||
|
**Not reset by dispose:** `gamutViewerUnavailable`, `togglesWired`, `gpuHints`.
|
||||||
|
`togglesWired` is OK (listeners live on HTML checkboxes, not the canvas).
|
||||||
|
`gamutViewerUnavailable` means a “Reload 3D view” button after a *feature-detect*
|
||||||
|
failure would no-op — but that path does not show the button (`reloadable`
|
||||||
|
defaults false). Init-throw and context-lost do show it.
|
||||||
|
|
||||||
|
### 1.5 Monterey WKWebView (#225) — adjacent host fixes (not in the JS viewer)
|
||||||
|
|
||||||
|
Fix 3 (this module) is the crash stop. Host-side (for rewrite awareness):
|
||||||
|
|
||||||
|
- Window `visible: false` until double-rAF + `show_main_window` (`app.js:143-150`).
|
||||||
|
- Dark `#1A1A22` window / WKWebView backing (`tauri.conf.json`, `macos_webview.rs`).
|
||||||
|
- `minimumSystemVersion` 12.0; Intel Monterey < 13 gets a one-shot notice
|
||||||
|
(`app.js:30-45`).
|
||||||
|
- `visibilitychange` / `pagehide` logged (`app.js:74-79`).
|
||||||
|
|
||||||
|
Rewrite **must** keep: no WebGL until Stage 5 visible; pause rAF on leave;
|
||||||
|
feature-detect; context-lost fallback; `alpha: false`; DPR cap on constrained
|
||||||
|
GPU; do not treat MacIntel UA as Intel when backend arch is `aarch64`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scene graph
|
||||||
|
|
||||||
|
Coordinate convention (commented at `gamut_viewer.js:346-347` and `_buildGeometry`):
|
||||||
|
|
||||||
|
```
|
||||||
|
X = a* (−128 → +128) green ← → red
|
||||||
|
Y = L* (0 → 100) lightness, up
|
||||||
|
Z = b* (−128 → +128) blue ← → yellow
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Camera
|
||||||
|
|
||||||
|
- `PerspectiveCamera(45, aspect, 0.1, 2000)`
|
||||||
|
- Home position `(180, 120, 180)`
|
||||||
|
- `lookAt(0, 50, 0)` — centre of the L* axis, not the origin
|
||||||
|
- `OrbitControls.target = (0, 50, 0)`, `enableDamping = true`, `dampingFactor = 0.05`
|
||||||
|
- Touch override (`gamut_viewer.js:272-275`): `ONE: ROTATE`, `TWO: DOLLY_PAN`
|
||||||
|
(same as OrbitControls defaults; #117 says do not change this).
|
||||||
|
- `renderer.domElement.style.touchAction = 'none'` to stop the webview scrolling.
|
||||||
|
|
||||||
|
`resetCamera` (`gamut_viewer.js:676-682`) restores position + target. OrbitControls
|
||||||
|
also has its own `reset()` using `position0/target0`, but the app never calls it;
|
||||||
|
the custom function writes the same home pose.
|
||||||
|
|
||||||
|
### 2.2 Lights
|
||||||
|
|
||||||
|
No shadows, no environment map, no fog.
|
||||||
|
|
||||||
|
| Light | Colour | Intensity | Position |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AmbientLight | `0xffffff` | 0.75 | — |
|
||||||
|
| DirectionalLight | `0xffffff` | 0.80 | `(150, 250, 150)` |
|
||||||
|
| DirectionalLight | `0xffffff` | 0.35 | `(-120, -80, -120)` |
|
||||||
|
|
||||||
|
Lambert materials + these lights are what make per-vertex Lab colour readable.
|
||||||
|
A rewrite that switches to MeshBasicMaterial would lose the slight modelling;
|
||||||
|
MeshStandardMaterial would need different lighting.
|
||||||
|
|
||||||
|
### 2.3 Axis scaffold (`buildAxisScaffold`, `gamut_viewer.js:349-423`)
|
||||||
|
|
||||||
|
Replaces `THREE.AxesHelper` (#185). One `THREE.Group` (`axisScaffoldGroup`):
|
||||||
|
|
||||||
|
1. **Bounding box** — `BoxGeometry(256, 100, 256)` → `EdgesGeometry` →
|
||||||
|
`LineSegments`, colour `0x232336`, opacity 0.9, **positioned at `(0, 50, 0)`**
|
||||||
|
so it covers L* 0–100, a*/b* ±128.
|
||||||
|
2. **Ground grid** — `GridHelper(256, 16, 0x1e1e2e, 0x1a1a28)` at `y = 0`
|
||||||
|
(L*=0). #185 said “remove GridHelper”; the implementation kept a custom one.
|
||||||
|
3. **Axis lines** — L* grey `0xcccccc` `(0,0,0)→(0,100,0)`; a* and b* `0x99bbcc`
|
||||||
|
spanning ±128.
|
||||||
|
4. **Ticks** — L* at 25/50/75/100 (crosses ±4 on X and Z); a* and b* at
|
||||||
|
±128/±64 (vertical ticks ±3). **No tick at 0** on a*/b*.
|
||||||
|
5. **CSS2D labels** (only if `THREE.CSS2DObject` exists):
|
||||||
|
|
||||||
|
| Text | Position | Class |
|
||||||
|
|---|---|---|
|
||||||
|
| `L*` | `(0, 110, 0)` | `gamut-axis-primary` |
|
||||||
|
| `+a* (Red →)` | `(140, 0, 0)` | `gamut-axis-a-pos` |
|
||||||
|
| `← −a* (Green)` | `(-140, 0, 0)` | `gamut-axis-a-neg` |
|
||||||
|
| `+b* (Yellow →)` | `(0, 0, 140)` | `gamut-axis-b-pos` |
|
||||||
|
| `← −b* (Blue)` | `(0, 0, -140)` | `gamut-axis-b-neg` |
|
||||||
|
| `25` / `50` / `75` / `100` | `(10, v, 0)` | `gamut-tick-label` |
|
||||||
|
|
||||||
|
No numeric labels on a*/b* ticks. Colours are CSS (`main.css:1667-1691`):
|
||||||
|
a+ salmon, a− green, b+ yellow, b− blue.
|
||||||
|
|
||||||
|
Labels are **children of `axisScaffoldGroup`**. `toggleAxes` sets
|
||||||
|
`axisScaffoldGroup.visible`. WebGL descendants honour group visibility; the
|
||||||
|
CSS2DRenderer (`CSS2DRenderer.js:87-89`) only tests `object.visible` on the
|
||||||
|
label itself, **not ancestors**. Toggling axes off can leave CSS2D labels
|
||||||
|
on-screen. Rewrite should walk parents or hide the label DOM.
|
||||||
|
|
||||||
|
### 2.4 CSS2D overlay
|
||||||
|
|
||||||
|
`CSS2DRenderer.js` is Three.js `examples/jsm/renderers/CSS2DRenderer.js`
|
||||||
|
rewritten as an IIFE that assigns `THREE.CSS2DObject` / `THREE.CSS2DRenderer`
|
||||||
|
(`CSS2DRenderer.js:157-159`). Loaded as a classic script after `three.min.js`
|
||||||
|
(`index.html:9-11`).
|
||||||
|
|
||||||
|
Init (`gamut_viewer.js:255-263`): absolute, `top/left 0`, `pointer-events: none`
|
||||||
|
so orbit still hits the WebGL canvas. Rendered every frame after the WebGL pass.
|
||||||
|
|
||||||
|
### 2.5 Profile mesh (`currentProfileMesh`)
|
||||||
|
|
||||||
|
Solid `THREE.Mesh`, `MeshLambertMaterial({ vertexColors: true, transparent: true,
|
||||||
|
opacity: 0.88, side: THREE.DoubleSide })`. Native `.gam` faces when present;
|
||||||
|
QuickHull fallback otherwise. See §4–5.
|
||||||
|
|
||||||
|
### 2.6 sRGB reference (`sRgbGroup`)
|
||||||
|
|
||||||
|
A `THREE.Group` of two children sharing one `BufferGeometry` (#185 component 2):
|
||||||
|
|
||||||
|
1. Faint fill: `MeshLambertMaterial({ color: 0x8899bb, opacity: 0.07,
|
||||||
|
transparent, DoubleSide, depthWrite: false })`.
|
||||||
|
2. Structural outline: `EdgesGeometry(geometry, 15)` (15° coplanar threshold)
|
||||||
|
+ `LineBasicMaterial({ color: 0x6688aa, opacity: 0.55 })`.
|
||||||
|
|
||||||
|
Loaded from `fetch('assets/sRGB.gam')` at the end of `initGamutViewer`
|
||||||
|
(`loadSrgbReferenceGamut`, lines 742-752). **Adobe RGB was specified in #9 and
|
||||||
|
never shipped.**
|
||||||
|
|
||||||
|
### 2.7 Resize
|
||||||
|
|
||||||
|
`ResizeObserver` on `#gamutViewerContainer` updates camera aspect, renderer size,
|
||||||
|
and labelRenderer size. No `window.resize` listener.
|
||||||
|
|
||||||
|
### 2.8 Background
|
||||||
|
|
||||||
|
`scene.background = 0x0e0e14` matches `#gamutViewerContainer` CSS. `alpha: false`
|
||||||
|
on the renderer is required so WKWebView does not composite a white underlay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `.gam` file parser
|
||||||
|
|
||||||
|
`parseGamutFile` (`gamut_viewer.js:447-509`) is the post-#179 parser. The
|
||||||
|
previous `parseCGATS` only read the first `BEGIN_DATA` (vertices), discarded
|
||||||
|
the triangle table, then handed `{L,a,b}` objects to QuickHull which indexes
|
||||||
|
`.x/.y/.z` → empty hull → blank scene.
|
||||||
|
|
||||||
|
### 3.1 Real Argyll format (`src/assets/sRGB.gam`)
|
||||||
|
|
||||||
|
Header (not parsed; ignored because `dataStarted` is false):
|
||||||
|
|
||||||
|
```
|
||||||
|
GAMUT
|
||||||
|
DESCRIPTOR "Argyll Gamut surface poligon data"
|
||||||
|
ORIGINATOR "Argyll CMS gamut library"
|
||||||
|
COLOR_REP "LAB"
|
||||||
|
GAMUT_CENTER "50.000000 0.000000 0.000000"
|
||||||
|
CUSP_RED / CUSP_YELLOW / … / CUSP_MAGENTA
|
||||||
|
# First come the triangle verticy location
|
||||||
|
NUMBER_OF_FIELDS 4
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
VERTEX_NO LAB_L LAB_A LAB_B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
NUMBER_OF_SETS 448
|
||||||
|
BEGIN_DATA
|
||||||
|
0 53.23738 78.28787 62.14806
|
||||||
|
…
|
||||||
|
END_DATA
|
||||||
|
# And then come the triangles
|
||||||
|
NUMBER_OF_FIELDS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
VERTEX_0 VERTEX_1 VERTEX_2
|
||||||
|
END_DATA_FORMAT
|
||||||
|
NUMBER_OF_SETS 892
|
||||||
|
BEGIN_DATA
|
||||||
|
13 1 46
|
||||||
|
…
|
||||||
|
END_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
Two `BEGIN_DATA`…`END_DATA` blocks. Block 1 = vertices (`index L a b`).
|
||||||
|
Block 2+ = 0-based triangle indices. Issue #179’s printer sample
|
||||||
|
(`xp_55_koala_satin.gam`) is the same shape: 739 verts, 1474 faces.
|
||||||
|
|
||||||
|
### 3.2 Algorithm (quote)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
|
||||||
|
if (trimmed === '') continue;
|
||||||
|
|
||||||
|
if (trimmed.toUpperCase() === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
|
||||||
|
if (trimmed.toUpperCase() === 'END_DATA') { dataStarted = false; continue; }
|
||||||
|
if (!dataStarted) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/).map(Number);
|
||||||
|
|
||||||
|
if (dataBlock === 1) {
|
||||||
|
// Vertex format: index L a b (index is usually ignored)
|
||||||
|
const [_, L, a, b] = parts; // VERTEX_NO discarded
|
||||||
|
vertices.push([L, a, b]);
|
||||||
|
} else {
|
||||||
|
faces.push([parts[0], parts[1], parts[2]]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Hash comments stripped **even inside data blocks** (`# inline comment` test).
|
||||||
|
- `BEGIN_DATA` / `END_DATA` matched case-insensitively.
|
||||||
|
- Header keys (`COLOR_REP`, `NUMBER_OF_SETS`, `BEGIN_DATA_FORMAT`, …) skipped.
|
||||||
|
- Non-numeric data lines → warning, skip.
|
||||||
|
- Vertex arity < 4 or face arity < 3 → warning.
|
||||||
|
- Lab plausibility warning (does **not** drop the vertex):
|
||||||
|
`L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128`.
|
||||||
|
Bundled sRGB cusp blue is `b* = -126.4162` (inside); a printer gamut can
|
||||||
|
exceed ±128.
|
||||||
|
- Multiple face blocks are concatenated (`dataBlock >= 2`). Dual-table test
|
||||||
|
covers this.
|
||||||
|
- Vertices with no faces → warning *“will compute convex hull on the fly.”*
|
||||||
|
- Zero `BEGIN_DATA` → warning *“file may be empty or not a valid Argyll .gam”*.
|
||||||
|
|
||||||
|
`VERTEX_NO` is discarded. Faces are assumed to index **push order** (Argyll
|
||||||
|
writes 0..N-1 sequentially). A non-dense index column would silently mis-bind.
|
||||||
|
|
||||||
|
### 3.3 Lab vs XYZ
|
||||||
|
|
||||||
|
`COLOR_REP "LAB"` is **not read**. Columns are always treated as L, a, b.
|
||||||
|
`iccgamut` default is Lab. If a rewrite (or a user) ever passed `iccgamut -x`
|
||||||
|
(XYZ), the viewer would plot XYZ as if it were Lab and colour it through
|
||||||
|
`labToSrgb`. There is no XYZ path.
|
||||||
|
|
||||||
|
### 3.4 Stub vs real sRGB.gam
|
||||||
|
|
||||||
|
`src-tauri/argyll/reference_gamuts/sRGB.gam` is **not** an Argyll gamut surface:
|
||||||
|
|
||||||
|
```
|
||||||
|
CGATS.17
|
||||||
|
NUMBER_OF_FIELDS 4
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
INDEX LAB_L LAB_A LAB_B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
NUMBER_OF_SETS 8
|
||||||
|
BEGIN_DATA
|
||||||
|
0 0.0 0.0 0.0 # black
|
||||||
|
1 100.0 0.0 0.0 # white
|
||||||
|
2 53.2 80.1 67.2 # red cusp (approx)
|
||||||
|
…
|
||||||
|
END_DATA
|
||||||
|
```
|
||||||
|
|
||||||
|
8 points, **no face table**. The parser would emit 8 vertices + the hull-fallback
|
||||||
|
warning; QuickHull of 8 cusps is a coarse octahedron, not sRGB. The viewer
|
||||||
|
never loads this file. It `fetch`es `src/assets/sRGB.gam` (448/892, produced by
|
||||||
|
`iccgamut` on 2026-08-25). Rewrite: either delete the stub or generate it the
|
||||||
|
same way as the asset, and do not assume `reference_gamuts/` is the runtime
|
||||||
|
source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mesh generation: QuickHull vs ConvexGeometry
|
||||||
|
|
||||||
|
### 4.1 History
|
||||||
|
|
||||||
|
| Issue | What happened |
|
||||||
|
|---|---|
|
||||||
|
| #9 | Planned Delaunay on Lab point cloud + sRGB/AdobeRGB wireframes. |
|
||||||
|
| #57 | Viewer was a dead panel; v0.2 used 2D Delaunay on (a*, b*) flattening L*. |
|
||||||
|
| #89 | Replaced Delaunay with inlined 3D QuickHull (~270 lines in `gamut_viewer.js`); touch OrbitControls. `delaunator.min.js` deleted. |
|
||||||
|
| #117 | Proposed replacing the inline hull with Three r128 `ConvexGeometry` / `ConvexHull`, **or** vendoring `quickhull3d`. Optional. |
|
||||||
|
| #179 | Real `.gam` files already contain the surface triangulation. Parser was throwing it away and hulling `{L,a,b}` objects → empty mesh. |
|
||||||
|
|
||||||
|
**Current state:** native face indices are the primary path. QuickHull is only
|
||||||
|
the no-faces fallback. There is **no** `ConvexGeometry.js` / `ConvexHull.js` in
|
||||||
|
the tree (`src/js/vendor/` contains only `quickhull.js`). #117 was closed but
|
||||||
|
the code took the “vendor quickhull3d-like ESM” option, not Three’s addon.
|
||||||
|
|
||||||
|
### 4.2 `_buildGeometry` (`gamut_viewer.js:515-568`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (vertices.length < 4) return null;
|
||||||
|
|
||||||
|
if (faces.length > 0) {
|
||||||
|
// Native mesh: position [a, L, b] per vertex, Uint32 index from .gam
|
||||||
|
posArr[i*3] = a; // X
|
||||||
|
posArr[i*3+1] = L; // Y
|
||||||
|
posArr[i*3+2] = b; // Z
|
||||||
|
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
} else {
|
||||||
|
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // a, L, b
|
||||||
|
const hullFaces = computeQuickHull(pts);
|
||||||
|
// Dedup by `${x}_${y}_${z}` string key while expanding hull faces
|
||||||
|
// Then rewrite `vertices` from the position attribute as [L, a, b]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Critical #179 fix for the fallback: points **must** be `{x,y,z}` not `{L,a,b}`.
|
||||||
|
`quickhull.js:44` uses `pts[i].x`; undefined x made `minX === maxX` and returned `[]`.
|
||||||
|
|
||||||
|
### 4.3 `computeQuickHull` (`src/js/vendor/quickhull.js`)
|
||||||
|
|
||||||
|
Classic incremental convex hull (150 lines):
|
||||||
|
|
||||||
|
1. O(n²) duplicate filter, eps `1e-5` (#117 risk: density-50 clouds freeze here).
|
||||||
|
2. Extreme points: min/max X, then furthest from that line, then furthest from
|
||||||
|
that plane → initial tetrahedron.
|
||||||
|
3. Outward winding via tetrahedron **centroid** (`createFace` flips if the
|
||||||
|
centroid is in front). Horizon winding is recovered from this centroid, not
|
||||||
|
the growing hull (#117 risk for re-entrant sets — moot for a true convex hull).
|
||||||
|
4. For each remaining point: collect visible faces (`n·(p-a) > 1e-9`), build
|
||||||
|
horizon as edges that appear once (`uId+'_'+vId` via `pts.indexOf`, another
|
||||||
|
O(n)), deactivate visible faces, fan new faces from horizon to the point.
|
||||||
|
5. Return `faces.filter(f => f.active)` as `{a,b,c}` point references.
|
||||||
|
|
||||||
|
A convex hull of a printer gamut **fills concavities** (dark cyan/magenta
|
||||||
|
indentations that #89 wanted to preserve). That is why native `.gam` faces are
|
||||||
|
the correct primary path: `iccgamut` already computed the (possibly non-convex)
|
||||||
|
surface. Hull is only for vertex-only clouds (the 8-cusp stub, or a future
|
||||||
|
`.ti3` point cloud).
|
||||||
|
|
||||||
|
`EdgesGeometry(geometry, 15)` on the sRGB mesh strips coplanar internal
|
||||||
|
triangle edges. Threshold 15° is a magic number from #185.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Per-vertex colouring from Lab
|
||||||
|
|
||||||
|
`_renderProfileGamut` (`gamut_viewer.js:623-667`) — **profile only**, not sRGB
|
||||||
|
reference (reference stays steel-blue `0x8899bb` / `0x6688aa`).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const posAttr = geometry.getAttribute('position'); // laid out [a, L, b]
|
||||||
|
const colorArr = new Float32Array(posAttr.count * 3);
|
||||||
|
for (let i = 0; i < posAttr.count; i++) {
|
||||||
|
const a_star = posAttr.getX(i);
|
||||||
|
const L_star = posAttr.getY(i);
|
||||||
|
const b_star = posAttr.getZ(i);
|
||||||
|
const [r, g, b] = labToSrgb(L_star, a_star, b_star);
|
||||||
|
colorArr[i*3] = r / 255;
|
||||||
|
colorArr[i*3+1] = g / 255;
|
||||||
|
colorArr[i*3+2] = b / 255;
|
||||||
|
}
|
||||||
|
geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 1));
|
||||||
|
material = new THREE.MeshLambertMaterial({ vertexColors: true, opacity: 0.88, … });
|
||||||
|
```
|
||||||
|
|
||||||
|
`labToSrgb` (`color_convert.js:9-45`):
|
||||||
|
|
||||||
|
- D50 white `Xn=0.9642, Yn=1, Zn=0.8249` (ICC PCS).
|
||||||
|
- CIE Lab → XYZ (standard `δ = 6/29` piecewise cube).
|
||||||
|
- XYZ D50 → linear sRGB via a **Bradford-adapted D50→D65** matrix (not a raw
|
||||||
|
D65 sRGB matrix — correct for ICC Lab).
|
||||||
|
- sRGB gamma, clamp 0–255, `Math.round`.
|
||||||
|
|
||||||
|
Out-of-gamut Lab (printer vertices outside sRGB) clips to the sRGB cube. That
|
||||||
|
is the intended diagnostic: the blob is “true colour” where representable and
|
||||||
|
clips where the printer exceeds sRGB — which is exactly when it pokes out of
|
||||||
|
the sRGB wireframe.
|
||||||
|
|
||||||
|
`colprof.js:206` still calls `loadGamutMesh(gamFilePath, 0x3b82f6)`. The second
|
||||||
|
arg is **ignored** (flat-blue leftover from pre-#185). Rewrite: drop it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Controls
|
||||||
|
|
||||||
|
### 6.1 HTML overlay (`index.html:980-1038`)
|
||||||
|
|
||||||
|
Glassmorphic panel, `position: absolute; top/right: 12px` over
|
||||||
|
`#gamutViewerWrap`. Three layers, each a toggle + opacity slider:
|
||||||
|
|
||||||
|
| Control | Id | Default | Handler |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Profile visibility | `chkProfileGamut` | checked | `toggleProfileGamut` |
|
||||||
|
| Profile opacity | `rngProfileOpacity` | 0.88 | `setProfileOpacity` |
|
||||||
|
| sRGB visibility | `chkSrgbReference` | checked | `toggleSrgbReference` |
|
||||||
|
| sRGB opacity | `rngSrgbOpacity` | 0.55 | `setSrgbReferenceOpacity` |
|
||||||
|
| Axes visibility | `chkLabAxes` | checked | `toggleAxes` |
|
||||||
|
| Axes opacity | `rngAxisOpacity` | 0.80 | `setAxisOpacity` |
|
||||||
|
| Reset | `btnGamutResetCamera` | — | `resetCamera` |
|
||||||
|
|
||||||
|
Hint text: “Drag to rotate • Scroll to zoom • Right-drag to pan” /
|
||||||
|
“Press R to reset”.
|
||||||
|
|
||||||
|
Wired once in `_wireToggles` (`gamut_viewer.js:791-831`). `togglesWired` is
|
||||||
|
never cleared on dispose.
|
||||||
|
|
||||||
|
### 6.2 Opacity idiosyncrasy (`setSrgbReferenceOpacity`, lines 700-710)
|
||||||
|
|
||||||
|
```js
|
||||||
|
child.material.opacity = child.material.opacity >= 0.5
|
||||||
|
? Math.max(0.05, Math.min(1, opacity)) // treated as edges
|
||||||
|
: Math.max(0.02, Math.min(0.2, opacity * 0.2)); // treated as fill
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial edges 0.55 ≥ 0.5, fill 0.07 < 0.5. **If the user drags the slider below
|
||||||
|
0.5, edges flip into the fill branch permanently** (next input sees 0.1 < 0.5).
|
||||||
|
Rewrite should tag the two materials, not infer role from current opacity.
|
||||||
|
|
||||||
|
`setProfileOpacity` also sets `transparent = opacity < 1`. `setAxisOpacity`
|
||||||
|
walks all children with a `.material` (lines, grid) but CSS2D labels have none.
|
||||||
|
|
||||||
|
### 6.3 Reset (R)
|
||||||
|
|
||||||
|
Two mechanisms:
|
||||||
|
|
||||||
|
1. Button `btnGamutResetCamera`.
|
||||||
|
2. `stage5.addEventListener('keydown', …)` when key is `r`/`R` and stage is not
|
||||||
|
hidden (`gamut_viewer.js:822-829`).
|
||||||
|
|
||||||
|
`_onKeyDown` (`gamut_viewer.js:730-734`) is **dead code** — never registered.
|
||||||
|
`#stage-5` has **no `tabindex`**, so it will not receive `keydown` unless a
|
||||||
|
focusable descendant (slider, checkbox, button, or a form control earlier in
|
||||||
|
Stage 5) is focused. The button is the reliable path. Rewrite: listen on
|
||||||
|
`document` while Stage 5 is active, ignore events from text inputs.
|
||||||
|
|
||||||
|
### 6.4 Touch
|
||||||
|
|
||||||
|
Vendored `OrbitControls.js` is Three r128 (IIFE, assigns `THREE.OrbitControls`
|
||||||
|
and `THREE.MapControls` at the bottom). Touch is fully implemented
|
||||||
|
(`onTouchStart/Move/End`, `passive: false`, `preventDefault` on start).
|
||||||
|
Mapping used by the viewer:
|
||||||
|
|
||||||
|
- 1 finger → rotate
|
||||||
|
- 2 fingers → dolly + pan
|
||||||
|
|
||||||
|
Mouse: left rotate, wheel dolly, right pan. `renderer.domElement.style.touchAction
|
||||||
|
= 'none'` is required on WKWebView / mobile.
|
||||||
|
|
||||||
|
`OrbitControls` is **not** an ES module; it depends on global `THREE` from
|
||||||
|
`three.min.js` r128. A rewrite that npm-installs a newer three must vendor a
|
||||||
|
matching OrbitControls (and CSS2DRenderer) — the current files are frozen to r128.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `iccgamut` CLI flags
|
||||||
|
|
||||||
|
### 7.1 Current argv (#112 fix)
|
||||||
|
|
||||||
|
`commands.rs:684-690`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn build_iccgamut_args(resolved_path: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-v".to_string(),
|
||||||
|
"-d".to_string(),
|
||||||
|
"10".to_string(),
|
||||||
|
resolved_path.to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Locked by `test_build_iccgamut_args` (`commands.rs:1475-1478`):
|
||||||
|
`["-v", "-d", "10", "/path/to/profile.icc"]`.
|
||||||
|
|
||||||
|
Meaning:
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `-v` | Verbose (stdout into the process log via ProcessManager). |
|
||||||
|
| `-d 10` | **Surface point density**, typical useful range ~1–10. Not a directory. |
|
||||||
|
| `{profile}` | Resolved `.icc`/`.icm` path. |
|
||||||
|
| *(cwd)* | Profile parent dir, so `{basename}.gam` lands next to the profile. Set via `ProcessManager::spawn(..., cwd)`, **not** via `-d`. |
|
||||||
|
| no `-w` | VRML not required; `.gam` is the default. |
|
||||||
|
|
||||||
|
`extract_gamut` (`commands.rs:694-731`) also swaps `.icc`↔`.icm` if the given
|
||||||
|
path does not exist, process id `iccgamut_{basename}`.
|
||||||
|
|
||||||
|
### 7.2 The `-d 50.0` bug (#112)
|
||||||
|
|
||||||
|
Pre-fix argv was `-v -d 50.0 {profile}`. Density 50 produces thousands of
|
||||||
|
vertices; QuickHull’s O(n²) duplicate filter then froze the UI. There was
|
||||||
|
historical confusion that `-d` meant “directory”. Directory is `cwd`. Do **not**
|
||||||
|
pass the parent path as `-d`. Do **not** revert to 50.
|
||||||
|
|
||||||
|
#179’s real printer file was generated with `-v -d 10` (739 verts / 1474 faces,
|
||||||
|
43.6 KB) — the density we want.
|
||||||
|
|
||||||
|
### 7.3 When it runs
|
||||||
|
|
||||||
|
1. **Stage 4 success** (`colprof.js:173-174, 197-215`): `triggerGamutExtraction`
|
||||||
|
listens for `process:exit` on `iccgamut_{basename}`, then
|
||||||
|
`loadGamutMesh(cwd/basename.gam)`. This often runs **while Stage 5 is still
|
||||||
|
hidden**, so `scene` is null and `_renderProfileGamut` returns null. The
|
||||||
|
`.gam` file is still written to disk.
|
||||||
|
2. **Stage 5 verify success** (`profcheck.js:626-635`): `loadGamutMesh` again.
|
||||||
|
This is the path that actually puts the printer mesh on screen, assuming the
|
||||||
|
user has opened Stage 5 (viewer inited) and then clicked Verify.
|
||||||
|
|
||||||
|
If the user opens Stage 5 *before* verifying, they see axes + sRGB only.
|
||||||
|
Navigating to Stage 5 does **not** auto-load an existing `{basename}.gam`.
|
||||||
|
Rewrite should load it in `ensureGamutViewer` once basename/cwd are known.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Fallback UI when WebGL is missing
|
||||||
|
|
||||||
|
`showGamutFallback` (`gamut_viewer.js:81-102`) wipes
|
||||||
|
`#gamutViewerContainer.innerHTML` and inserts:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="gamut-webgl-fallback" role="status">
|
||||||
|
<p>…message…</p>
|
||||||
|
<!-- optional --> <button class="secondary btn-md">Reload 3D view</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS (`main.css:1494-1511`): flex-centred, min-height 280px, same `#0e0e14`
|
||||||
|
background, muted text. Controls panel is a **sibling** of the container
|
||||||
|
(`#gamutViewerWrap` > container + panel), so the legend remains visible over
|
||||||
|
an empty/fallback view — another rewrite nicety: hide or disable it.
|
||||||
|
|
||||||
|
Messages:
|
||||||
|
|
||||||
|
| Trigger | Copy | Reload? |
|
||||||
|
|---|---|---|
|
||||||
|
| `webglAvailable() === false` | `WEBGL_UNAVAILABLE_MESSAGE` = “3D gamut viewer requires WebGL; the rest of ICCery still works.” | no |
|
||||||
|
| `initGamutViewer` throw | same `WEBGL_UNAVAILABLE_MESSAGE` | yes |
|
||||||
|
| `webglcontextlost` | “3D view lost its GPU context. Profiling stages still work.” | yes |
|
||||||
|
|
||||||
|
Reload: `disposeViewer(); ensureGamutViewer();`. Feature-detect failure sets
|
||||||
|
`gamutViewerUnavailable`, so a hypothetical reload would no-op until a full
|
||||||
|
page reload.
|
||||||
|
|
||||||
|
Intel Monterey < 13 also gets a session-once wizard banner
|
||||||
|
(`app.js:30-45`): “On this Mac the 3D gamut view may be unavailable…”
|
||||||
|
|
||||||
|
Init catch path removes a half-attached `renderer.domElement` before showing
|
||||||
|
fallback (`gamut_viewer.js:321-327`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
`src/js/gamut_viewer.test.js` — Node-runnable ESM (`node src/js/gamut_viewer.test.js`).
|
||||||
|
|
||||||
|
#212: previously crashed with `window is not defined` because `gamut_viewer.js`
|
||||||
|
top-level-dereferenced `window.__TAURI__.core`. Now guarded
|
||||||
|
(`gamut_viewer.js:5`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
|
||||||
|
? window.__TAURI__.core.invoke : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
The test file polyfills `globalThis.window` / `document` **before** a dynamic
|
||||||
|
`await import('./gamut_viewer.js')` (static import is hoisted and would still
|
||||||
|
lose the race). Canvas `getContext` returns `null` so `webglAvailable()` is
|
||||||
|
false in Node.
|
||||||
|
|
||||||
|
Cases (`runAll`):
|
||||||
|
|
||||||
|
| Test | Asserts |
|
||||||
|
|---|---|
|
||||||
|
| `testParseGamutBasic` | 4 verts, 2 faces, 0 warnings |
|
||||||
|
| `testParseGamutDualTable` | two face `BEGIN_DATA` blocks concatenate to 2 faces |
|
||||||
|
| `testParseGamutWithComments` | `#` lines + `# inline comment` stripped |
|
||||||
|
| `testWebglAvailableFalseWithoutContext` | no context / no document → false |
|
||||||
|
| `testWebglAvailableTrueWithWebgl` | `getContext('webgl')` → true |
|
||||||
|
| `testConstrainedGpuPrefersBackendArch` | `aarch64` + MacIntel UA → **not** constrained; `x86_64` → constrained |
|
||||||
|
| `testConstrainedGpuIgnoresAppleSiliconUa` | ARM UA → not constrained |
|
||||||
|
| `testConstrainedGpuIntelMac` | Monterey Intel UA → constrained; x86_64 on Ventura still constrained |
|
||||||
|
| `testEnsureGamutViewerNoopsWithoutDom` | no Stage 5 / no THREE → `isGamutViewerReady() === false` |
|
||||||
|
| `testFallbackMessage` | copy contains “requires WebGL” |
|
||||||
|
|
||||||
|
**Not tested:** real `sRGB.gam` golden parse (448/892), Lab-bounds warnings,
|
||||||
|
empty file, XYZ, QuickHull tetrahedron/cube fixtures (#117 AC),
|
||||||
|
`labToSrgb` colours, `loadGamutMesh`, context-lost, opacity slider, R-key,
|
||||||
|
`build_iccgamut_args` is a **Rust** test not a JS one.
|
||||||
|
|
||||||
|
Auto-run:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (typeof process !== 'undefined' && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
|
||||||
|
runAll(); // throws if any fail → non-zero exit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Idiosyncrasies / rewrite traps
|
||||||
|
|
||||||
|
1. **Do not create WebGL on `DOMContentLoaded`.** Stage 5 starts `.hidden`.
|
||||||
|
`ensureGamutViewer` + one rAF + hidden-class re-check is the contract (#225).
|
||||||
|
2. **Pause rAF on leave; do not necessarily dispose.** Scene stays so Stage 4’s
|
||||||
|
late `loadGamutMesh` can work *if* Stage 5 was visited first — which it
|
||||||
|
usually is not. Safer rewrite: queue the `.gam` path and load on first
|
||||||
|
`ensureGamutViewer`.
|
||||||
|
3. **Native faces > hull.** A convex hull of Lab points is the wrong surface
|
||||||
|
for a printer gamut. Only hull when the face table is missing.
|
||||||
|
4. **Position layout is `(a, L, b)` not `(L, a, b)`.** Colour, hull fallback,
|
||||||
|
and CSS2D all depend on this. Mixing them was the #179 blank-scene bug.
|
||||||
|
5. **`VERTEX_NO` is ignored; faces are 0-based push-order.** Fine for Argyll.
|
||||||
|
6. **`COLOR_REP` is ignored.** Lab only.
|
||||||
|
7. **Two sRGB.gam files, only one is real.** Runtime = `src/assets/sRGB.gam`
|
||||||
|
(448/892). `src-tauri/argyll/reference_gamuts/sRGB.gam` is an 8-point stub.
|
||||||
|
8. **No Adobe RGB overlay** despite #9.
|
||||||
|
9. **`loadGamutMesh(path, 0x3b82f6)`** — extra colour arg is dead.
|
||||||
|
10. **sRGB opacity slider infers fill vs edges from `opacity >= 0.5`.** Breaks
|
||||||
|
after the user goes below 0.5.
|
||||||
|
11. **CSS2D labels ignore parent `visible`.** Axis toggle is incomplete.
|
||||||
|
12. **R-key is bound to `#stage-5` without `tabindex`.** `_onKeyDown` is unused.
|
||||||
|
13. **Duplicate CSS** for `#gamutViewerContainer`: `main.css:835-842`
|
||||||
|
(`height: 400px; background: #111116`) and `main.css:1480-1486`
|
||||||
|
(`min-height: 420px; background: #0e0e14`). Later rules win for
|
||||||
|
background; `height: 400px` + `min-height: 420px` → 420px.
|
||||||
|
14. **Three r128 via `<script>` + IIFE addons.** Not ESM. `gamut_viewer.js` is
|
||||||
|
ESM and talks to `window.THREE`. A bundler rewrite must keep that seam or
|
||||||
|
import matching addons.
|
||||||
|
15. **`invoke` is null outside Tauri.** `loadGamutMesh` will throw if called
|
||||||
|
from tests / a browser without the polyfill.
|
||||||
|
16. **`togglesWired` / `gamutViewerUnavailable` survive `disposeViewer`.**
|
||||||
|
17. **`GridHelper` still present** after #185 said to remove it.
|
||||||
|
18. **QuickHull O(n²) duplicate filter** — keep density at 10, never 50 (#112).
|
||||||
|
19. **`atob(read_file_base64)`** assumes the `.gam` is ASCII. It is (CGATS).
|
||||||
|
Do not switch to a UTF-16 profile dump without a TextDecoder.
|
||||||
|
20. **DoubleSide Lambert** hides winding errors in Argyll’s triangle table.
|
||||||
|
If a rewrite uses FrontSide, audit winding.
|
||||||
|
21. **`failIfMajorPerformanceCaveat: false`** — accept software GL rather than
|
||||||
|
crash Monterey.
|
||||||
|
22. **No `resumeGamutViewer` call site.** `ensureGamutViewer` already restarts
|
||||||
|
the loop when `gamutViewerReady`.
|
||||||
|
23. **Feature-detect prefers webgl2** but r128 `WebGLRenderer` still opens a
|
||||||
|
WebGL1 context unless `r128` is given `{ capability: … }`. Detecting webgl2
|
||||||
|
does not mean Three uses it.
|
||||||
|
24. **Stage 5 verify is what actually shows the printer mesh**, not Stage 4
|
||||||
|
extraction and not merely opening the viewer.
|
||||||
|
25. **Cusp vertices in the header** (`CUSP_RED` etc.) are not used. They are
|
||||||
|
duplicated as the first few `BEGIN_DATA` rows. A rewrite could label cusps
|
||||||
|
but the current viewer does not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Public API surface (for a rewrite to preserve)
|
||||||
|
|
||||||
|
```js
|
||||||
|
setGpuHints(hints) // { arch, os, macosMajor }
|
||||||
|
isGamutViewerReady()
|
||||||
|
webglAvailable(doc?)
|
||||||
|
isLikelyConstrainedGpu(hints?)
|
||||||
|
ensureGamutViewer() // Stage 5 entry
|
||||||
|
pauseGamutViewer() // Stage 5 leave
|
||||||
|
resumeGamutViewer() // unused
|
||||||
|
initGamutViewer() // internal-ish but exported
|
||||||
|
parseGamutFile(text) → { vertices, faces, warnings }
|
||||||
|
resetCamera()
|
||||||
|
setProfileOpacity(0..1)
|
||||||
|
setSrgbReferenceOpacity(0..1)
|
||||||
|
setAxisOpacity(0..1)
|
||||||
|
loadSrgbReferenceGamut()
|
||||||
|
loadGamutMesh(gamFilePath) → Mesh|null
|
||||||
|
toggleSrgbReference(visible)
|
||||||
|
toggleProfileGamut(visible)
|
||||||
|
toggleAxes(visible)
|
||||||
|
WEBGL_UNAVAILABLE_MESSAGE
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`,
|
||||||
|
`chkLabAxes`, `btnGamutResetCamera`, `rngProfileOpacity`, `rngSrgbOpacity`,
|
||||||
|
`rngAxisOpacity`, `gamutViewerContainer`, `stage-5`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Issue → code checklist
|
||||||
|
|
||||||
|
| Issue | Status in tree |
|
||||||
|
|---|---|
|
||||||
|
| #9 3D Lab viewer | Shipped; no Adobe RGB; Delaunay replaced. |
|
||||||
|
| #57 dead panel | `extract_gamut` + `loadGamutMesh` wired from colprof + profcheck. Stage 4 load still races init. |
|
||||||
|
| #89 3D hull + touch | Hull vendored; OrbitControls touch live; Delaunay gone. |
|
||||||
|
| #112 `-d 50` | Fixed to `-d 10`; Rust unit test. |
|
||||||
|
| #117 ConvexGeometry | Not done; `vendor/quickhull.js` instead. Native faces make this low-priority. |
|
||||||
|
| #179 blank mesh + 0.00 ΔE | `parseGamutFile` uses both tables; points passed as `{x,y,z}`. (Profcheck regex is out of scope here.) |
|
||||||
|
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
|
||||||
|
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
|
||||||
|
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 19 — System profile installation (#223)
|
||||||
|
|
||||||
|
Command: `install_profile_to_system(profile_path, options) → InstallResult`.
|
||||||
|
|
||||||
|
**Copies** (never moves, never deletes) the working `.icc`/`.icm` into the OS colour store. The wizard artefact in `cwd` must remain so Stage 5 / drift / re-install still work.
|
||||||
|
|
||||||
|
Also: `get_profile_install_dir(prefer_system_wide) → path` so the UI can preview the destination.
|
||||||
|
|
||||||
|
## `InstallOptions`
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `force_overwrite` | false | Treat as overwrite regardless of `collision_policy` |
|
||||||
|
| `prefer_system_wide` | false | user dir vs system dir |
|
||||||
|
| `register_with_os` | true | ColorSync / ICM / colord after copy |
|
||||||
|
| `collision_policy` | `"cancel"` | `overwrite` \| `rename` \| `cancel` |
|
||||||
|
| `open_color_panel` | false | open OS colour UI after success |
|
||||||
|
| `calibration_note` | null | echoed back in the result for the toast if applycal embedded a `.cal` |
|
||||||
|
|
||||||
|
## `InstallResult`
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `dest_path` | Final file written |
|
||||||
|
| `registered` | OS registration attempted and reported success |
|
||||||
|
| `overwritten` | Collision resolved by overwrite |
|
||||||
|
| `renamed` | Collision resolved by timestamp suffix |
|
||||||
|
| `opened_panel` | Colour UI launched |
|
||||||
|
| `message` | Human status for the banner |
|
||||||
|
| `calibration_note` | Pass-through |
|
||||||
|
|
||||||
|
## Destinations
|
||||||
|
|
||||||
|
| OS | User | System | On-disk extension |
|
||||||
|
|----|------|--------|-------------------|
|
||||||
|
| Windows | `%USERPROFILE%\AppData\Local\Microsoft\Windows\Color` | `%WINDIR%\System32\spool\drivers\color` | always `.icm` (`profile_extension_for_os`) |
|
||||||
|
| macOS | `~/Library/ColorSync/Profiles` | `/Library/ColorSync/Profiles` | always `.icc` |
|
||||||
|
| Linux | `~/.local/share/icc` | `/usr/share/color/icc` | always `.icc` |
|
||||||
|
|
||||||
|
The **source** may be `.icc` or `.icm`; the **destination filename** is `{stem}.{os_ext}`. Stem is rejected if it contains `..`, `/`, or `\`.
|
||||||
|
|
||||||
|
System-wide paths need elevation. `permission_message` must mention UAC / admin / sudo, not a generic I/O failure.
|
||||||
|
|
||||||
|
## Collision + atomic copy
|
||||||
|
|
||||||
|
`resolve_destination`:
|
||||||
|
|
||||||
|
- missing → write that name
|
||||||
|
- exists + overwrite / `force_overwrite` → same path, `overwritten=true`
|
||||||
|
- exists + rename → `{stem}-{unix_epoch}.{ext}` (`timestamped_filename`)
|
||||||
|
- exists + cancel → `Err("A profile named {filename} already exists at … Choose Overwrite, Rename, or Cancel.")`
|
||||||
|
|
||||||
|
Copy is atomic: write `{dest}.iccery-install.tmp` then `rename`. On rename failure, delete the tmp. Create parent dirs as needed.
|
||||||
|
|
||||||
|
## Source verification (`verify_source_profile`)
|
||||||
|
|
||||||
|
- Path is a file
|
||||||
|
- Extension `icc` or `icm` (case-insensitive)
|
||||||
|
- Size ≥ **128** bytes (ICC header)
|
||||||
|
|
||||||
|
## OS registration (`register_with_os=true`)
|
||||||
|
|
||||||
|
| OS | Mechanism |
|
||||||
|
|----|-----------|
|
||||||
|
| Windows | Copy into the Color folder is enough for ICM to pick it up; optional `InstallColorProfileW` when linked. Do not call `SetDeviceGammaRamp`. |
|
||||||
|
| macOS | File in ColorSync folder is sufficient. Optional open ColorSync Utility (`open -a "ColorSync Utility"`) when `open_color_panel`. |
|
||||||
|
| Linux | `colormgr import-profile` when `colormgr` exists on PATH; ignore if missing. Optional `open_color_panel` → GNOME Color / `colormgr` GUI if present. |
|
||||||
|
|
||||||
|
Never register a path that failed to copy.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
`#btnInstallProfile` → collision dialog `#profileInstallCollisionDialog` (`profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`) matching the calibration collision pattern.
|
||||||
|
|
||||||
|
Settings: `default_install_location` `user`\|`system`, `ask_before_overwrite_profile`, `open_color_panel_after_install`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 20 — CGATS dataset interop (#94, #211)
|
||||||
|
|
||||||
|
Native Rust parser `src-tauri/src/cgats.rs` plus frontend `cgats_interop.js`. Used to jump the wizard to Stage 4/5 from a lab/spectral measurement file that did not originate in this ICCery run.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Role |
|
||||||
|
|---------|------|
|
||||||
|
| `select_dataset_file` | **Open** dialog: `*.ti3, *.txt, *.cgats, *.csv` |
|
||||||
|
| `import_measurement_dataset` | Parse + write canonical `.ti3` into cwd, then `wizardState.setTarget` |
|
||||||
|
| `export_measurement_dataset` | Write canonical `.ti3` / txt |
|
||||||
|
| `inspect_dataset_preview` | Preview rows for the CGATS viewer (`DatasetSummary`) |
|
||||||
|
| `select_existing_target` | Related: open `.ti1`/`.ti2` for resume, not CGATS |
|
||||||
|
|
||||||
|
#211: import used a **save** dialog and left wizard state uninitialised. Always `pick_file` (open), then `wizardState.setTarget(basename, cwd)`. Never `save` for import.
|
||||||
|
|
||||||
|
v0.8.3 also opens an existing `.ti3` in the viewer without re-importing.
|
||||||
|
|
||||||
|
## `CgatsDataset` model
|
||||||
|
|
||||||
|
```
|
||||||
|
file_type: "CGATS.17" | "CTI3" | "ISO28178" | "CSV"
|
||||||
|
originator, created, descriptor, target_instrument
|
||||||
|
illuminant, observer, measurement_condition
|
||||||
|
color_rep: Rgb | Cmyk | Lab | DeviceN(n)
|
||||||
|
fields: [{ name, field_type }]
|
||||||
|
samples: [{ sample_id, sample_loc, device_coords[], lab[3], xyz[3], spectral[] }]
|
||||||
|
spectral_range: { start_nm, end_nm, step_nm, bands[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`FieldType`: `Id`, `DeviceCoord(i)`, `LabL/A/B`, `XyzX/Y/Z`, `Spectral(nm)`, `Custom(name)`.
|
||||||
|
|
||||||
|
`DatasetSummary` (preview): `patch_count`, `color_space`, `has_spectral`, `has_lab`, `spectral_start/end`, `illuminant`, `sample_preview` (first N rows).
|
||||||
|
|
||||||
|
## Normalisation (`to_canonical_ti3`)
|
||||||
|
|
||||||
|
Must satisfy the Argyll C CGATS parser (strict):
|
||||||
|
|
||||||
|
- Emit `CTI3` / `CGATS.17` keywords Argyll expects (`NUMBER_OF_FIELDS`, `BEGIN_DATA_FORMAT`, `BEGIN_DATA`, `COLOR_REP`, `DEVICE_CLASS`, `TARGET_INSTRUMENT` when known)
|
||||||
|
- Device coordinates 0–255 → 0–100 if the column max is > 100
|
||||||
|
- Field aliases: `SAMPLE_ID` / `SAMPLE_LOC` / `LAB_L` `LAB_A` `LAB_B` / `XYZ_*` / `SPEC_*` / `RGB_*` / `CMYK_*`
|
||||||
|
- Synthesise `COLOR_REP` and `DEVICE_CLASS` if missing
|
||||||
|
- Drop rows with no device **and** no Lab/XYZ/spectral
|
||||||
|
- Preserve spectral bands if present so `colprof -f` FWA still has data
|
||||||
|
|
||||||
|
Imported datasets can jump to Stage 4 (profile from the set) or Stage 5 (verify an existing profile against the set). Stage 1–2 stay locked unless sibling `.ti1`/`.ti2` exist.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Never `innerHTML` dataset names or originator strings (#114 was presets; same rule applies). Paths from the open dialog are the only allowed sources — no URL fetch of CGATS.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 21 — UI reference
|
||||||
|
|
||||||
|
Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **242 element ids** in `src/index.html` — complete roster at the end.
|
||||||
|
|
||||||
|
## Shell
|
||||||
|
|
||||||
|
- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, Calibrate Printer, cal status chip, stepper 1–5.
|
||||||
|
- Main: notification banner, one visible `.stage`.
|
||||||
|
- Window 1280×800, min 1100×700, hidden until paint, dark `#1A1A22`.
|
||||||
|
|
||||||
|
Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`.
|
||||||
|
|
||||||
|
Banner: `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`. Auto-hides via `wizardState.noticeTimer`.
|
||||||
|
|
||||||
|
## Design tokens (`main.css`)
|
||||||
|
|
||||||
|
```
|
||||||
|
--bg-color: #1e1e1e
|
||||||
|
--panel-color: #252526
|
||||||
|
--text-color: #d4d4d4
|
||||||
|
--accent-color: #007acc
|
||||||
|
--border-color: #333333
|
||||||
|
--btn-height-sm/md/lg: 28 / 36 / 40 px
|
||||||
|
--btn-radius-sm/md: 4 / 6 px
|
||||||
|
```
|
||||||
|
|
||||||
|
Button classes: `.btn-sm` 28px, `.btn-md` 36px, `.btn-lg` 40px primary stage actions, `.btn-icon-sq` 36×36, `.icon-btn` 28×28 header, `.btn-properties` 36px. Action rows: `.stage-actions`, `.modal-actions`, `.chartread-actions`, `.print-actions-row`, `.btn-row`, `.btn-row-sm`, `.btn-row-end`, `.input-row-sm`. No ad-hoc inline heights (#177).
|
||||||
|
|
||||||
|
Help mode: `.has-tooltip` + `.tooltip-text`. Tooltips must overlay (fixed/absolute) so they do not expand document flow (#171).
|
||||||
|
|
||||||
|
Process logs: `<details class="log-container">` expandable (#54), wrapping text (#21). All logs share one CSS class — never per-stage IDs.
|
||||||
|
|
||||||
|
## Stage 0 ids
|
||||||
|
|
||||||
|
`stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`.
|
||||||
|
|
||||||
|
Collision: `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`.
|
||||||
|
|
||||||
|
## Stage 1 ids
|
||||||
|
|
||||||
|
`btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, radios `name="colourSpace"` (no element id on each radio), `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`.
|
||||||
|
|
||||||
|
## Stage 2 ids
|
||||||
|
|
||||||
|
`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`.
|
||||||
|
|
||||||
|
`#cupsOptionsGroup` is **hidden on Windows** (#48). `#chkPpdFallback` is Linux-only ColorModel=Gray + cm-calibration.
|
||||||
|
|
||||||
|
## Stage 3 ids
|
||||||
|
|
||||||
|
`stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`.
|
||||||
|
|
||||||
|
Button labels by state — see [15](15-stage3-chartread.md). XY stepper badges light in order Place → Align → Scan → Remove.
|
||||||
|
|
||||||
|
## Stage 4 ids
|
||||||
|
|
||||||
|
`colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`.
|
||||||
|
|
||||||
|
## Stage 5 ids
|
||||||
|
|
||||||
|
`btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`.
|
||||||
|
|
||||||
|
Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable container (`tabindex`) so it works without clicking the canvas.
|
||||||
|
|
||||||
|
## Modals
|
||||||
|
|
||||||
|
| Dialog | Root id | Controls |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| Settings | `settingsDialog` | listed in [22](22-settings-presets.md) |
|
||||||
|
| About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` |
|
||||||
|
| Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` |
|
||||||
|
| Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` |
|
||||||
|
| Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel |
|
||||||
|
| Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` |
|
||||||
|
|
||||||
|
## Dialogs must go through host APIs
|
||||||
|
|
||||||
|
Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this.
|
||||||
|
|
||||||
|
## Complete `id=` roster (242)
|
||||||
|
|
||||||
|
`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 22 — Settings and presets
|
||||||
|
|
||||||
|
Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. Invalid JSON loads `AppSettings::default()`. `save_settings` calls `validate()` then writes pretty JSON and **immediately** applies `log::set_max_level`.
|
||||||
|
|
||||||
|
## `AppSettings` (`settings.rs`)
|
||||||
|
|
||||||
|
| Field | Default | Notes |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||||
|
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
||||||
|
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||||
|
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||||
|
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||||
|
| `custom_presets` | `[]` | User presets; built-ins are **not** stored here. |
|
||||||
|
| `enable_i1pro2_leds` | `false` | Adds `chartread -Y l`. Default off so stock Argyll still runs. |
|
||||||
|
| `calibration_stale_days` | `30` | Stage 0 / Stage 1 reminder. |
|
||||||
|
| `default_install_location` | `"user"` | `"user"` \| `"system"` for Stage 5. |
|
||||||
|
| `ask_before_overwrite_profile` | `true` | |
|
||||||
|
| `open_color_panel_after_install` | `false` | |
|
||||||
|
|
||||||
|
Validation error strings (surface in `#deltaEThresholdError`):
|
||||||
|
|
||||||
|
- `"ΔE thresholds cannot be negative."`
|
||||||
|
- `"Good ΔE threshold must be strictly less than the warning threshold."`
|
||||||
|
|
||||||
|
Saving dispatches a DOM `settings-saved` event so the live swatch grid reclassifies without a re-read.
|
||||||
|
|
||||||
|
Settings dialog ids: `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`.
|
||||||
|
|
||||||
|
Log helpers: `get_log_path`, `get_recent_log_excerpt`, `open_log_dir`.
|
||||||
|
|
||||||
|
## `ProfilingPreset`
|
||||||
|
|
||||||
|
Every field is optional-defaulted with `#[serde(default)]` except the required identity / Stage 1–2 core. `applyPreset` **must** set TIFF DPI (#113) — a historical bug left `#tiffDpi` at 300 when loading the 150 dpi draft preset.
|
||||||
|
|
||||||
|
| Field | Type | Stage |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id`, `name`, `description` | string | identity. Names/descriptions go through `textContent`, never `innerHTML` (#114 XSS) |
|
||||||
|
| `colour_space` | `"rgb"` \| `"cmyk"` | 1 |
|
||||||
|
| `patch_count`, `white_patches`, `black_patches` | u32 | 1 |
|
||||||
|
| `grey_steps`, `single_channel_steps`, `neutral_steps` | Option\<u32\> | 1 advanced |
|
||||||
|
| `preconditioning_profile` | Option\<path\> | 1 `-c` |
|
||||||
|
| `neutral_concentration` | Option\<f64\> | 1 `-N` |
|
||||||
|
| `ofps_high_quality` | Option\<bool\> | 1 `-G` |
|
||||||
|
| `ofps_adaptation` | Option\<f64\> | 1 `-A` |
|
||||||
|
| `full_spread_algorithm` | Option\<string\> | 1 `-t/-r/-R/-q/-Q/-i/-I` |
|
||||||
|
| `total_ink_limit` | Option\<u32\> | 1 `-l` CMYK |
|
||||||
|
| `dark_emphasis`, `device_power` | Option\<f64\> | 1 `-V` / `-p` |
|
||||||
|
| `instrument`, `page_size`, `bit_depth`, `dpi` | | 2 |
|
||||||
|
| `random_seed`, `no_randomize` | | 2 `#printtargLayoutOrder` |
|
||||||
|
| `calibration_file`, `apply_calibration` | | 0 / 2 `-K` |
|
||||||
|
| `colprof_algorithm`, `colprof_quality`, `colprof_intent` | | 4 |
|
||||||
|
| `colprof_fwa`, `colprof_illuminant`, `colprof_observer` | | 4 |
|
||||||
|
| `colprof_input_viewing_cond`, `colprof_output_viewing_cond` | | 4 |
|
||||||
|
|
||||||
|
## Built-in presets (`get_default_presets`)
|
||||||
|
|
||||||
|
Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JSON via `export_preset_json` / `import_preset_json` with schema validation (unknown keys ignored via serde default; missing required fields fail).
|
||||||
|
|
||||||
|
| id | Name | Space | Patches | Page | Bit | DPI | Quality | Extra |
|
||||||
|
|----|------|-------|---------|------|-----|-----|---------|-------|
|
||||||
|
| `preset-std-rgb` | Standard RGB Photo (800 patches) | rgb | 800, white 4 | A4 | 8 | 300 | `m` | FWA D50, seed 1, instrument i1, alg `l` |
|
||||||
|
| `preset-hq-cmyk` | High-Gamut CMYK Proofing (1500 patches) | cmyk | 1500, black 8 | A3 | 16 | 300 | `h` | ink limit 320, FWA D50, seed 1 |
|
||||||
|
| `preset-draft-rgb` | Fast RGB Draft (400 patches) | rgb | 400 | A4 | 8 | **150** | `l` | FWA D50, seed 1 |
|
||||||
|
| `preset-ultra-rgb` | Ultra Precision RGB (2500 patches) | rgb | 2500, white 6, black 6 | A3 | 16 | 300 | `u` | `ofps_high_quality=true` (`-G`), FWA D50, seed 1 |
|
||||||
|
|
||||||
|
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
||||||
|
|
||||||
|
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 23 — Graphical assets
|
||||||
|
|
||||||
|
Reuse these in the rewrite. Do not replace the cone.
|
||||||
|
|
||||||
|
## Wordmark — `src/assets/ICCery-logo.svg`
|
||||||
|
|
||||||
|
ViewBox `0 0 480 160`.
|
||||||
|
|
||||||
|
- Waffle cone path `M 50 82 L 110 82 L 80 142 Z`, fill `#FAD7A1`, stroke `#E59866`, clipped waffle grid.
|
||||||
|
- Scoops: C `#00BCEB` at (63,72) r=22; M `#EC008C` at (97,72); Y `#FFED00` at (80,48); white stroke 2.5; highlight dots.
|
||||||
|
- K cherry: stem + circle `#1E293B` at (80,24) r=7.
|
||||||
|
- Wordmark: `ICC` white, `ery` linear gradient `#00AEEF` → `#0066CC`, weight 800, size 58, x=140 y=105.
|
||||||
|
|
||||||
|
Header usage: height `4.42rem`, max-width 175px, object-position left.
|
||||||
|
|
||||||
|
## App icon — `src/assets/app-icon.svg`
|
||||||
|
|
||||||
|
Cone-only mark for window/taskbar. Raster set:
|
||||||
|
|
||||||
|
- `src-tauri/icons/32x32.png`, `64x64.png`, `128x128.png`, `128x128@2x.png`
|
||||||
|
- `icon.icns`, `icon.ico`, `icon.png`
|
||||||
|
- Store / Android / iOS variants under `icons/` (legacy Tauri generator output; rewrite may subset)
|
||||||
|
|
||||||
|
## Installer chrome
|
||||||
|
|
||||||
|
| File | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
||||||
|
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||||
|
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||||
|
|
||||||
|
## Gamut reference
|
||||||
|
|
||||||
|
- **Ship `src/assets/sRGB.gam`** — real Argyll sRGB gamut used by the viewer.
|
||||||
|
- `src-tauri/argyll/reference_gamuts/sRGB.gam` is an **8-cusp stub**. Do not use it as the overlay (#185 notes / gamut rewrite notes).
|
||||||
|
|
||||||
|
## Third-party JS (legacy)
|
||||||
|
|
||||||
|
- `three.min.js` r128 via script tag
|
||||||
|
- `OrbitControls.js`, `CSS2DRenderer.js` IIFE attaching to `THREE`
|
||||||
|
- `vendor/quickhull.js` — used when `.gam` has vertices but no faces
|
||||||
|
|
||||||
|
A rewrite may use any WebGL engine; keep Lab mapping `X=a* Y=L* Z=b*` and CSS2D axis labels.
|
||||||
|
|
||||||
|
## NSIS USB extras
|
||||||
|
|
||||||
|
`windows/hooks.nsh` offers Argyll USB instrument driver install when elevated, and maps missing user-shell-folder drive letters in the elevated session (network home drives) to avoid "Invalid Drive". Preserve the driver-offer behaviour; the DOS-device mapping is a Windows installer idiosyncrasy.
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
# 24 — Issue tickets and rewrite invariants
|
||||||
|
|
||||||
|
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
|
||||||
|
|
||||||
|
Source corpus: Gitea issues `#1`–`#225` (`/tmp/iccery-research/issues/`), `issues-index.md`, `ROADMAP.md`, `git log` on `/tmp/ICCery` (main @ `e12ef41`, tag `v0.8.5`), PR titles pages 1–2, plus sibling issue dumps `argyll-issues.json` and `cpu-issues.json`.
|
||||||
|
|
||||||
|
This is a rewrite-invariants document. Every closed bug gets a **root cause → fix → invariant**. Features are traced so a rewrite does not re-invent the same CLI/IPC contracts.
|
||||||
|
|
||||||
|
Open tickets `#223` and `#224` are **implemented on main** (see § Open issues). Won't-fix `#90` / `#96` are documented in § Won't-fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Feature genealogy (issue → ship)
|
||||||
|
|
||||||
|
The product is a **printer-profiling workstation**: vanilla JS + Tauri v2 wrapping ArgyllCMS sidecars over stdin/stdout JSON (`-u`) with a disk-artefact wizard.
|
||||||
|
|
||||||
|
| Era | Version | Issues | What landed |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Scaffold | `v0.1.0` | `#1`, `#2` | Tauri v2 shell, AGPLv3 isolation, `ProcessManager` with `process:stdout/stderr/exit` events |
|
||||||
|
| Stage 1–2 | `v0.1.3` | `#3`, `#4` | `targen` patch sets; `printtarg` layout + instrument geometry |
|
||||||
|
| Stage 3 | `v0.1.6` | `#5`, `#6` | `chartread -u` state machine + live swatch grid + ΔE₀₀ |
|
||||||
|
| Stage 4–5 | `v0.1.9` | `#7`, `#8` | `colprof` cLUT; `profcheck` peak/avg/RMS |
|
||||||
|
| Print + UI | `v0.1.10`–`0.1.13` | `#21`, `#23`, `#25`–`#28`, `#34`, `#54`, `#61` | Logo; raw GDI/CUPS print; DEVMODE prefs; collapsible logs; 1280×800 window |
|
||||||
|
| Gamut + ship | `v0.1.14`–`0.2.0` | `#9`, `#10`, `#57`, `#58`, `#60`, `#69` | `iccgamut` + Three.js; settings/packaging; artefact-gated wizard |
|
||||||
|
| Hotfix | `v0.2.1` | `#84` (from `#63` regression) | Stdin decoupled from `Child::wait()` |
|
||||||
|
| M8 | `v0.3.0` | `#86`, `#87`, `#88`, `#89` | `instlist`; multi-pass `average`; presets; 3D hull |
|
||||||
|
| Hotfixes | `v0.3.1`–`0.3.6` | `#103`, `#108`–`#116`, `#119`, `#127`, `#134`, `#137` | Browse dialogs, GLIBC, averaging snapshots, comm-port vs index, density vs dir, XSS/DPI, fetch-argyll, `ARGYLL_NOT_INTERACTIVE`, Accept/Override |
|
||||||
|
| macOS print | `v0.4.0` | `#91`, `#92` | Universal dmg path + CUPS raw spooler |
|
||||||
|
| Production | `v0.5.0`–`0.5.5` | `#139`–`#141`, `#147`/`#149`, `#151`, `#158`–`#165` | Logging, Stage 3 resume from `.ti2`, advanced `targen`, `kill_all` on exit, deterministic `-R 1`, universal sidecars |
|
||||||
|
| Enterprise colour | `v0.6.0`–`0.7.4` | `#94`, `#171`, `#172`, `#175`–`#179`, `#184`, `#185`, `#188`, `#189` | CGATS import, overlay tooltips, profile picker, `d`/`u` chartread keys, OBA/FWA, button system, swatch split, `.gam` dual-table parse, ΔE thresholds, ColorSync/vendor bypass, DMG background |
|
||||||
|
| Hardware + analytics | `v0.8.1`–`0.8.2` | `#204`, `#93`, `#95` | i1Pro2 `-Y l`; XY tables; drift history |
|
||||||
|
| Reliability | `v0.8.3`–`0.8.4` | `#210`–`#215`, `#225` | Native file pickers, Node-safe gamut tests, atomic history, frontend CI, Monterey WKWebView survival |
|
||||||
|
| Cal + install | `v0.8.5` | `#224`, `#223` (tickets still *open*) | Stage 0 `printcal`/`applycal`; OS profile install |
|
||||||
|
|
||||||
|
ArgyllCMS fork genealogy (why ICCery's CLI looks the way it does):
|
||||||
|
|
||||||
|
| Argyll issue | Flag / contract ICCery depends on |
|
||||||
|
|---|---|
|
||||||
|
| argyll `#1` | `chartread -u` → `ROW_COLORS_JSON:` per row |
|
||||||
|
| argyll `#2` | `targen -u` JSON progress |
|
||||||
|
| argyll `#3` | `printtarg -u` page manifest JSON |
|
||||||
|
| argyll `#4` | `colprof -u` JSON progress |
|
||||||
|
| argyll `#5` | `profcheck -u` JSON ΔE report |
|
||||||
|
| argyll `#6` | `instlist` JSON `{event:"instruments", devices:[{port,name,type}]}` |
|
||||||
|
| argyll `#19` | `printtarg -d "<label>"` custom chart legend (ICCery `#119`) |
|
||||||
|
| argyll `#24` | Windows anonymous-pipe `PeekNamedPipe` so `chartread` does not deadlock on `ReadFile` |
|
||||||
|
| argyll `#32` | ad-hoc `codesign -s -` of Mach-O sidecars (ICCery `#165`) |
|
||||||
|
| argyll `#37` | i1Pro2 LED — ICCery shipped as `chartread -Y l` (`#204`), **not** the originally proposed `-L` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Closed bugs — root cause → fix → rewrite invariant
|
||||||
|
|
||||||
|
Grouped by subsystem. Issue numbers are always cited.
|
||||||
|
|
||||||
|
### 2.1 Process manager / IPC
|
||||||
|
|
||||||
|
**`#21` Process logs spill horizontally**
|
||||||
|
- **Root cause:** `#colprofLog` / `#profcheckLog` had no CSS; even stages with `white-space: pre-wrap` could not wrap Argyll's space-less progress dots.
|
||||||
|
- **Fix:** Class selector `.log-container pre { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 200px; }` (PR `#22`). Later `#54` made boxes collapsible.
|
||||||
|
- **Invariant:** All subprocess consoles share one CSS class. Never style per-stage IDs. Argyll stdout is not English prose; it is long runs of `.` and must wrap *anywhere*.
|
||||||
|
|
||||||
|
**`#46` Visible cmd.exe on every Argyll spawn (Windows)**
|
||||||
|
- **Root cause:** Sidecars are `IMAGE_SUBSYSTEM_WINDOWS_CUI`. `tokio::process::Command` without creation flags allocates a console.
|
||||||
|
- **Fix:** `command.creation_flags(0x08000000)` (`CREATE_NO_WINDOW`) in `ProcessManager::spawn` (`#[cfg(windows)]`).
|
||||||
|
- **Invariant:** Every Windows spawn of an Argyll CUI binary **must** set `CREATE_NO_WINDOW`. Do not "fix" this by rebuilding Argyll as WINDOWS_GUI — that would also kill stdin/stdout.
|
||||||
|
|
||||||
|
**`#63` Zombie children (enhancement that caused `#84`)**
|
||||||
|
- **Root cause:** 100 ms `try_wait` polling never removed finished `Child` handles.
|
||||||
|
- **Fix (PR `#77`):** `child.wait().await` and reap from the map.
|
||||||
|
- **Invariant:** Reap is required. But **never** `wait()` while still holding the `Mutex<Child>` that `send_stdin` needs — see `#84`.
|
||||||
|
|
||||||
|
**`#84` P0: `Child::wait()` deadlocks `send_stdin` and closes stdin (v0.2.1)**
|
||||||
|
- **Root cause:** PR `#77` awaited `c.wait()` under `Arc<Mutex<Child>>`. Tokio's `Child::wait` **drops stdin** to avoid parent/child deadlock. Interactive `chartread` therefore (1) could not acquire the mutex for Calibrate/Retry/Skip/Cancel, and (2) had its pipe closed immediately after spawn.
|
||||||
|
- **Fix (PR `#83`, v0.2.1):** `take()` stdin/stdout/stderr at spawn; store `ChildStdin` in a separate `stdins: HashMap<String, Arc<Mutex<ChildStdin>>>`; `tokio::select!` between `child.wait()` and a oneshot `kill_rx`.
|
||||||
|
- **Invariant:** Interactive subprocess lifetime has **three** independent handles: wait task, stdin mutex, kill oneshot. `send_stdin` must never take the `Child` lock. Never call `wait()` before `take()`-ing stdin.
|
||||||
|
|
||||||
|
**`#85` `resolve_binary` ignores `.exe`**
|
||||||
|
- **Root cause:** Path joins used `"targen"`; on disk the file is `targen.exe`. `exists()` returned false for custom dirs and bundled resources.
|
||||||
|
- **Fix (PR `#97`):** On Windows, try `{name}` and `{name}.exe` for both settings dir and resource resolution. Unit-test both.
|
||||||
|
- **Invariant:** Windows binary lookup is always a two-candidate search. Do not assume Unix names in `tauri.conf.json` resources.
|
||||||
|
|
||||||
|
**`#116` Duplicate spawn ids steal the live child's maps**
|
||||||
|
- **Root cause:** `stdins.insert(id)` / `killers.insert(id)` with no occupancy check. Averaging reuses `chartread_${basename}`. Old wait-task `remove()`s the **new** child's entries → "Process not found" on Calibrate.
|
||||||
|
- **Fix (PR `#123`):** Hard-error if `id` is occupied; disable Start / Measure Another Sheet until `process:exit`. Tokio test: spawn, reject duplicate, stdin still works, kill, reuse.
|
||||||
|
- **Invariant:** Process IDs are exclusive leases. Prefer a loud error over silently replacing the occupant. After exit, the id is reusable.
|
||||||
|
|
||||||
|
**`#134` Calibrate button does nothing; `instlist` shows nothing**
|
||||||
|
- **Root cause:** (1) Without `ARGYLL_NOT_INTERACTIVE=1`, Argyll on Windows uses `_getch()` / `ReadConsoleInput()`, ignoring piped stdin — especially combined with `#46`'s hidden console. (2) Forked `instlist` emits JSON; frontend only had a text regex.
|
||||||
|
- **Fix (PR `#135`, v0.3.5):** `command.env("ARGYLL_NOT_INTERACTIVE", "1")` on every spawn; parse `event === "instruments"` JSON with text fallback.
|
||||||
|
- **Invariant:** All Argyll children get `ARGYLL_NOT_INTERACTIVE=1`. Frontend instrument lists parse JSON first. Related upstream: argyll `#24` (`PeekNamedPipe` before `ReadFile` on anonymous pipes) — the env var is necessary but not sufficient on Windows.
|
||||||
|
|
||||||
|
**`#147` / `#149` Orphan `chartread` after window close**
|
||||||
|
- **Root cause:** Only Stage 3 Cancel called `kill_process`. `lib.rs` used bare `.run(...)` with no `RunEvent::Exit` / `CloseRequested`. Spectrophotometer stays USB-locked.
|
||||||
|
- **Fix:** `ProcessManager::kill_all` (drop stdins, signal killers, `start_kill`); hook app/window exit.
|
||||||
|
- **Invariant:** Host exit **always** drains the process table. Interactive instruments are the scary case, but `colprof`/`printtarg` must die too.
|
||||||
|
|
||||||
|
**`#158` Settings log level is decorative**
|
||||||
|
- **Root cause:** `tauri-plugin-log` filter hardcoded `Debug` vs `Info` from `cfg!(debug_assertions)`. `AppSettings.log_level` was persisted and ignored.
|
||||||
|
- **Fix:** Map stored string → `LevelFilter` at startup and on Save.
|
||||||
|
- **Invariant:** A settings control that does not change observable behaviour is a bug. Log level is runtime state, not a compile-time constant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Print (Win / Mac / Linux)
|
||||||
|
|
||||||
|
**`#33` Windows CI: unresolved GDI imports**
|
||||||
|
- **Root cause:** `windows` crate 0.58 does not export `SetICMMode` / `ICM_OFF` under `Win32::Graphics::Gdi` as used in `print/windows.rs`. GitHub `windows-2025` runner compiled the print backend and died with `error[E0432]`.
|
||||||
|
- **Fix:** Declare the GDI printing functions/structs used (`CreateDCW`, ICM off, `StretchDIBits`, …) directly rather than depending on incomplete crate bindings (`a9802ad`, `c9e16ab`). Later also: runner label, native rustup, `shell: powershell`.
|
||||||
|
- **Invariant:** Colour-management bypass (`SetICMMode(ICM_OFF)`) is a **link-time** contract. Pin/vend the FFI; do not assume `windows` crate surface area. CI on the real Windows target is the only proof.
|
||||||
|
|
||||||
|
**`#36` DEVMODE preferences not applied**
|
||||||
|
- **Root cause:** OEM dialog (`DocumentPropertiesW`) writes public `DEVMODEW` **plus** `dmDriverExtra` private bytes. The print path was not forwarding the full buffer (and mishandled the anonymous union for `dmDefaultSource` / `dmOrientation`) into `CreateDCW`/`StartDocW`. Epson "Print Preview" etc. therefore vanished.
|
||||||
|
- **Fix:** Retain the entire DEVMODE allocation including `dmDriverExtra`; fix union field access (`19449e3`).
|
||||||
|
- **Invariant:** Windows driver settings are a **blob**, not a parsed struct. Never memcpy only `dmSize` bytes. Never reconstruct DEVMODE from a handful of public fields.
|
||||||
|
|
||||||
|
**`#48` CUPS/PPD "Uncorrected Passthrough" shown on Windows**
|
||||||
|
- **Root cause:** `#cupsOptionsGroup` rendered unconditionally.
|
||||||
|
- **Fix:** Hide on `navigator.userAgent` Windows (v0.1.7).
|
||||||
|
- **Invariant:** Print UI is per-backend. Windows = DEVMODE; Unix = PPD/CUPS. Do not show controls the active spooler cannot honour.
|
||||||
|
|
||||||
|
**`#67` Duplicate print IPC (cleanup, not a user bug)**
|
||||||
|
- **Fix:** Collapse `get_windows_printers`/`get_cups_printers`/`print_target_*` into `get_printers` / `print_target`.
|
||||||
|
- **Invariant:** One IPC surface, platform switch inside Rust.
|
||||||
|
|
||||||
|
**`#188` macOS: Preferences opens System Settings; no way to disable driver CM (Epson XP-55, Canon Pro 9500 II)**
|
||||||
|
- **Root cause:** "Preferences" deep-linked to the OS Printers & Scanners pane, not the selected queue's driver panel. ColorSync plus vendor filters (`CNIJIntent2`, Epson `ColorCorrection`) re-managed the raw target. Follow-on: `PMSessionSetColorMatchingMode` 3-arg SPI **crashed**.
|
||||||
|
- **Fix (v0.7.2–0.7.3):** Native `NSPrintPanel` bound to the current `PMPrinter`; PPD/`lpoptions` media-type extraction; vendor uncorrected-color keys; `AP_ColorMatchingMode=AP_ApplicationColorMatching`; **remove** the crashing 3-arg SPI and use the validated 2-arg / lock sequence (`8b01c98`). Windows media_types added in the same stack so CI still built.
|
||||||
|
- **Invariant:** Raw print means **three** layers must all be off: OS ColorSync, driver colour, and ICM/CUPS filters. Opening the generic Printers pane is not a preferences dialog. Never call private Core Printing SPIs whose arity is guessed.
|
||||||
|
|
||||||
|
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
|
||||||
|
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
|
||||||
|
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
|
||||||
|
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Stage 1 — targen
|
||||||
|
|
||||||
|
**`#44` Patch count ignored → always 836**
|
||||||
|
- **Root cause:** Frontend sent `patch_count`; Rust `build_targen_args` only read `total_patches`. `-f` omitted → Argyll's built-in default **836** RGB patches.
|
||||||
|
- **Fix (v0.1.5, PR `#45`):** Honour `patch_count` (fallback `total_patches`); unit-test argv.
|
||||||
|
- **Invariant:** `targen` without `-f` is not "use the UI value". Default 836 is an Argyll formula, not an ICCery default. Serde field names must match across JS and Rust **and** be tested.
|
||||||
|
|
||||||
|
**`#59` Empty working directory**
|
||||||
|
- **Root cause:** Basename typed without Browse → `cwd: ""` → process launch dir (read-only / undefined). Same hole in later stages.
|
||||||
|
- **Fix (v0.1.12):** Init `currentWorkingDir` to `document_dir`/`home_dir`; disable Generate until both basename and cwd exist; backend also refuses empty cwd.
|
||||||
|
- **Invariant:** Empty cwd is illegal. Frontend guard **and** backend fallback. Never spawn Argyll in the install/resource directory.
|
||||||
|
|
||||||
|
**`#103` Stage 1 Browse does nothing; Generate stays disabled**
|
||||||
|
- **Root cause:** Frontend called `window.__TAURI__.dialog` (Tauri v1 API). With `withGlobalTauri: true` on Tauri v2, `window.__TAURI__.dialog` is **undefined**. Same class of bug as `#210`/`#211`.
|
||||||
|
- **Fix (v0.3.1, PR `#104`):** Backend command `select_target_file` using `app.dialog()`.
|
||||||
|
- **Invariant:** **Never** use `window.__TAURI__.dialog`. All file pickers are Rust commands with explicit filters. See also `#172`, `#210`, `#211`.
|
||||||
|
|
||||||
|
**`#162` Advanced options misaligned**
|
||||||
|
- **Root cause:** Mixed control heights (number vs range vs checkbox-without-label) plus a full-width `-c` row breaking the 2-col grid; flex-end hacks.
|
||||||
|
- **Fix (v0.5.5 / v0.6.1):** Semantic sub-groups, normalized row heights, flexbox auto-margins.
|
||||||
|
- **Invariant:** Advanced `targen` controls are a 2-column grid of equal-height rows. Checkboxes get a label column. Do not use `justify-content: flex-end` to fake alignment.
|
||||||
|
|
||||||
|
**`#172` Preconditioning browse filters `.ti1/.ti2/.ti3` instead of `.icc/.icm/.mpp`**
|
||||||
|
- **Root cause:** Reused `select_target_file` (target artefacts) for `targen -c`.
|
||||||
|
- **Fix:** Dedicated `select_profile_file`.
|
||||||
|
- **Invariant:** Each browse button has its **own** backend command and extension filter. Sharing "the dialog command" is how you open a Save-`.ti1` dialog for an ICC.
|
||||||
|
|
||||||
|
**`#211` CGATS Import opens a Save `.ti1` dialog and jumps to Stage 4 with empty basename**
|
||||||
|
- **Root cause:** `handleImport` invoked `select_target_file` (`save_file`, filter `ti1`). Empty `cwd`/`basename` were forwarded into `import_measurement_dataset`; `wizardState.setTarget` never ran.
|
||||||
|
- **Fix (v0.8.3):** `select_dataset_file` (open, filters `ti3/txt/cgats/csv`); sync wizard target dir+basename from the imported artefact.
|
||||||
|
- **Invariant:** Import = **open** dialog. After import, `basename`/`cwd` are initialized from the dataset before any stage jump. Do not reuse the target-save picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Stage 2 — printtarg
|
||||||
|
|
||||||
|
**`#50` Redundant Stage 2 badges (cleanup)**
|
||||||
|
- **Fix:** Remove duplicate status text. Not a functional invariant beyond "one source of layout truth".
|
||||||
|
|
||||||
|
**`#58` / `#70` TIFF previews fail in WebView; `image` crate Windows-only**
|
||||||
|
- **Root cause:** Browsers/WKWebView/WebView2 do not decode TIFF. `img.src = base64(tiff)` is a blank. `image` was under `[target.'cfg(windows)'.dependencies]` (GDI print), so Linux/macOS could not even compile a converter.
|
||||||
|
- **Fix:** Move `image` (png+tiff features) to common deps; `read_tiff_preview_png` decodes, caps ~1200 px, returns PNG base64. Keep `read_file_base64` for **text** artefacts (`.gam`).
|
||||||
|
- **Invariant:** Never feed TIFF to `<img>`. Preview pipeline is always TIFF→PNG in Rust. The `image` crate is a **common** dependency, not Windows-only.
|
||||||
|
|
||||||
|
**`#68` printtarg JSON parsed by hunting for a brace**
|
||||||
|
- **Root cause:** String-index search for `{\n "event": "manifest"` instead of a structured object.
|
||||||
|
- **Fix:** Parse a single JSON event (Argyll `#3` `-u` manifest).
|
||||||
|
- **Invariant:** `-u` tools emit one JSON object per event. Accumulate stdout, `JSON.parse` whole objects; do not `indexOf` pretty-printed fragments.
|
||||||
|
|
||||||
|
**`#163` Regenerating Stage 2 shuffles patches (printed sheet ≠ `.ti2`)**
|
||||||
|
- **Root cause:** `printtarg` randomizes layout unless `-R <seed>` or `-r`. `build_printtarg_args` passed neither.
|
||||||
|
- **Fix (v0.5.5):** Default `-R 1`; optional custom seed and raster-order `-r`.
|
||||||
|
- **Invariant:** Printed charts are not reproducible without an explicit seed. Default is **deterministic**. Re-running Stage 2 on the same `.ti1` must produce the same `.ti2`/`.tif` or `chartread` will score the wrong patches.
|
||||||
|
|
||||||
|
**`#119` (feature, not bug) `printtarg -d` is a *label*, not a directory**
|
||||||
|
- See idiosyncrasies. Do not confuse with `iccgamut -d` (`#112`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 Stage 3 — chartread / averaging / XY
|
||||||
|
|
||||||
|
**`#109` Multi-pass averaging overwrites the same `.ti3`**
|
||||||
|
- **Root cause:** `#87` UI shipped, but every `chartread` wrote `{basename}.ti3`. Pass 2 clobbered pass 1. `average` was invoked as `foo.ti3 foo.ti3 foo.ti3`.
|
||||||
|
- **Fix:** After each successful exit, snapshot `{basename}.ti3` → `{basename}_pass{N}.ti3`. Canonical `{basename}.ti3` is written only at Finish (copy of pass1, or `average -v pass1 pass2 … basename.ti3`). Do **not** change the `chartread` inoutfile (still must match `.ti2` basename).
|
||||||
|
- **Invariant:** Measurement identity ≠ averaging identity. `chartread` always uses the chart basename. Pass files are snapshots. Stage 4 never sees a pass file unless Finish has run.
|
||||||
|
|
||||||
|
**`#110` Stage 4 unlocks on first pass, before Average**
|
||||||
|
- **Root cause:** `process:exit` of pass 1 called `wizardState.setStage3Result`. `verify_stage_artefacts` saw `{basename}.ti3` and enabled Stage 4 while "Measure Another Sheet" was still offered.
|
||||||
|
- **Fix:** Same PR as `#109`. `setStage3Result` only from **Finish and Average** / Accept-single-pass. Prefer the canonical-file rule over a new `stage3_accepted` flag.
|
||||||
|
- **Invariant:** Wizard gates on **disk artefacts with defined meaning**. `{basename}.ti3` means "accepted measurement", not "a pass happened".
|
||||||
|
|
||||||
|
**`#111` `instlist` ordinal passed as `chartread -c`**
|
||||||
|
- **Root cause:** Dropdown `value = inst.index` (1-based instrument list). `build_chartread_args` emitted `-c {index}`. Argyll `-c` is a **communication port** list (`chartread -??`), a different enumeration. Works by coincidence for a single USB i1Pro on port 1.
|
||||||
|
- **Fix:** Do not assume ordinal equality. Use `instlist` as presence/health; pass `-c` only when the value is a documented comm-port. Tighten regex to require instrument tokens.
|
||||||
|
- **Invariant:** `instlist` index ≠ `-c` port. After JSON `instlist` (argyll `#6` / ICCery `#134`), the field to pass is `device.port`, never the array index, and only if it is a real comm port.
|
||||||
|
|
||||||
|
**`#137` (feature) Accept/Override + multi-sheet prompts**
|
||||||
|
- Chartread emits multi-key warnings. UI must send the exact key (`n`/`y`/…) the prompt asks for, not just Enter.
|
||||||
|
|
||||||
|
**`#175` Stage 3 cannot complete — never sends `d`**
|
||||||
|
- **Root cause:** After the last strip, `chartread` waits for `d` (Done) before writing `.ti3`. ICCery never sent it. Follow-on: `snapshot_ti3` IPC used `passIndex` vs the deserializer's expected key.
|
||||||
|
- **Fix (v0.6.7–0.6.8):** Explicit **Done & Save .ti3** → `d\n`; **Undo Strip** → `u\n`; completion state machine; `invoke("snapshot_ti3", { passIndex })` camelCase to match Tauri v2 serde rename.
|
||||||
|
- **Invariant:** `chartread` is not finished when the last row JSON arrives. Done is a **keystroke**. Tauri v2 IPC argument names are camelCase on the JS side. Always add a completion control rather than inferring EOF.
|
||||||
|
|
||||||
|
**`#93` XY tables (SpectroScan / i1iO)** — feature, but its invariants are bug-shaped: classify **multi-line** prompts; fiducial alignment is a 4-step checklist; cancel must **park the head**.
|
||||||
|
|
||||||
|
**`#204` i1Pro2 LEDs** — pass `-Y l` (not `-L`). Silent no-op on instruments without LEDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Stage 4 — colprof
|
||||||
|
|
||||||
|
**`#56` Stage 4 is broken (IPC schema / process-id mismatch)**
|
||||||
|
- **Root cause:** `colprof.js` sent `{ basename, cwd }`. Rust expected `{ ti3_path, icc_path }`. Process IDs disagreed (`colprof_${basename}` vs `colprof_{icc_path}`), so UI listeners never fired even on a successful spawn.
|
||||||
|
- **Fix (PR `#64`, v0.1.11):** Align struct, argv builder, and process id with the frontend.
|
||||||
|
- **Invariant:** Spawn id is a **protocol**. Frontend listen-filters and backend spawn ids are the same string. Serde structs are tested against the JS payload, not against a parallel imagined schema.
|
||||||
|
|
||||||
|
**`#69` Profile extension `.icm` (Windows) vs `.icc` (Unix)**
|
||||||
|
- **Root cause:** Hardcoded `${basename}.icc`. Argyll `colprof` writes `.icm` on Windows. `profcheck` / `iccgamut` then "cannot find profile".
|
||||||
|
- **Fix:** Platform-aware resolver, or existence-check both extensions. Used by Stage 5 and gamut (`#57`).
|
||||||
|
- **Invariant:** Never hardcode `.icc`. Resolve `{icc, icm}` on disk. This also applies to install (`#223`) and preconditioning (`#172`).
|
||||||
|
|
||||||
|
**`#176` OBA/FWA + viewing conditions** — feature. `colprof -c` / `-d` here are **illuminant / viewing condition**, not directories and not `printtarg -d` labels.
|
||||||
|
|
||||||
|
**`#210` Custom `.sp` Browse: `window.__TAURI__.dialog` TypeError**
|
||||||
|
- **Root cause:** Identical to `#103`. No `select_spectrum_file` command.
|
||||||
|
- **Fix (v0.8.3):** Native `select_spectrum_file` (filter `.sp`).
|
||||||
|
- **Invariant:** Same as `#103`. Third time this class of bug shipped (`#103`, `#210`, `#211`). A rewrite should have a single `pick_file { filters }` command and never touch `window.__TAURI__.dialog`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 Stage 5 — gamut / profcheck / history
|
||||||
|
|
||||||
|
**`#57` 3D gamut is a dead panel**
|
||||||
|
- **Root cause:** `extract_gamut` / `loadGamutMesh` existed but nothing called them after `colprof`. `initGamutViewer` never stored meshes. Blocked by `#56` and `#69`.
|
||||||
|
- **Fix (v0.1.14):** Run `iccgamut` after successful profile; load printer `.gam` + bundled `sRGB.gam`; dispose previous geometries; `ResizeObserver` on the hidden Stage 5 canvas.
|
||||||
|
- **Invariant:** Gamut is a **post-colprof artefact**, not a demo mesh. Viewer must not allocate WebGL until the stage is shown (`#225`).
|
||||||
|
|
||||||
|
**`#112` `iccgamut -d 50.0` is surface density, not a directory**
|
||||||
|
- **Root cause:** `extract_gamut` passed `-d 50.0`. In Argyll, `iccgamut -d s` is **surface point density** (useful ~1–10). Density 50 yields thousands of vertices and freezes the O(n²) hull filter. Cwd is already set on the spawn.
|
||||||
|
- **Fix (PR `#121`):** `-d 10`; extract `build_iccgamut_args`; unit-test argv. Do not pass `-w` (VRML) unless required.
|
||||||
|
- **Invariant:** `-d` is **not** "directory" on `iccgamut`. Cwd is a ProcessManager concern. Density default is 10. Argv builders exist so this cannot silently regress.
|
||||||
|
|
||||||
|
**`#179` Mesh empty; profcheck cards show 0.00**
|
||||||
|
- **Root cause:** `.gam` has **two** CGATS tables (vertices `VERTEX_NO LAB_L LAB_A LAB_B`, then faces `VERTEX_0 VERTEX_1 VERTEX_2`). Parser kept only the first `BEGIN_DATA` and produced `{L,a,b}`. QuickHull indexed `p.x/p.y/p.z` → all undefined → empty hull. Profcheck regex expected one wording of `errors(CIEDE2000): max. =`; real output variants yielded 0.
|
||||||
|
- **Fix (v0.7.0):** Dual-table parse; map `x=a*, y=L*, z=b*`; use file faces when present; broader max/avg/rms regexes + user-visible parse warnings.
|
||||||
|
- **Invariant:** `.gam` is two tables. Lab is not XYZ-style `{x,y,z}` until remapped. Profcheck text is not a stable API — prefer `profcheck -u` JSON (argyll `#5`) and keep regex as fallback only.
|
||||||
|
|
||||||
|
**`#117` Inlined QuickHull** — modularize under `src/js/vendor/quickhull.js` (and later prefer file faces over recomputing hulls).
|
||||||
|
|
||||||
|
**`#185` Gamut rework** — CIELAB axes + CSS2D labels, `EdgesGeometry` sRGB reference, per-vertex `labToSrgb()`, layer toggles. Invariant: reference overlay is edges, not a competing solid that hides the profile.
|
||||||
|
|
||||||
|
**`#212` `gamut_viewer.test.js` : `window is not defined`**
|
||||||
|
- **Root cause:** Module top-level `const { invoke } = window.__TAURI__.core`. Node test import evaluates that before any polyfill. Unlike `profcheck.test.js` / `chartread.test.js`, no `globalThis.window` mock and no CLI auto-run.
|
||||||
|
- **Fix:** Guard `typeof window !== 'undefined'`; polyfill in the test; `node src/js/gamut_viewer.test.js` must exit non-zero on failure. Later `#215` runs this in CI.
|
||||||
|
- **Invariant:** Frontend modules that `import` in Node tests cannot touch `window` at load time. Tests are first-class; if AGENTS.md documents `node src/js/….test.js`, CI must run it (`#215`).
|
||||||
|
|
||||||
|
**`#213` `verification_history.json` non-atomic write**
|
||||||
|
- **Root cause:** `fs::write` in place. Crash ⇒ truncated JSON. `load_history_file` treats parse failure as **empty vec**, so the next save wipes the archive. Contradicted AGENTS.md's own atomic-write rule.
|
||||||
|
- **Fix (v0.8.4):** Write `.tmp`, flush/sync, `rename`.
|
||||||
|
- **Invariant:** History files are append-only user data. Write temp+rename. Parse failure must **not** be indistinguishable from "no history".
|
||||||
|
|
||||||
|
**`#95` Drift analytics** — 1,000-record cap, SVG dual-series, consecutive-breach card, RFC-4180 CSV. Relies on `#213`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 macOS window / WebKit
|
||||||
|
|
||||||
|
**`#61` Default window 800×600**
|
||||||
|
- **Fix:** 1280×800 default, min 1100×700.
|
||||||
|
- **Invariant:** The wizard + TIFF cards + WebGL viewer are not a mobile layout. Set min bounds in `tauri.conf.json`.
|
||||||
|
|
||||||
|
**`#148` Align `minimumSystemVersion` with Argyll**
|
||||||
|
- Argyll macOS builds and Tauri 2 / WebGL do not support 10.15 in practice.
|
||||||
|
|
||||||
|
**`#165` Unsigned Argyll sidecars `SIGKILL` on Apple Silicon**
|
||||||
|
- **Root cause:** arm64 requires at least ad-hoc signature. `fetch-argyll.mjs` staged Jam-built unsigned Mach-Os into Resources. Kernel kills with `Killed: 9`, no useful error.
|
||||||
|
- **Fix:** Upstream argyll `#32` (`v3.5.0-ICCery.1.5`) `codesign -f -s -` in `makepackagebin.sh` + CI `codesign -dvv`, including `lipo` fat binaries.
|
||||||
|
- **Invariant:** Every shipped Mach-O sidecar is signed (ad-hoc at minimum). Fetch pipeline must verify signatures, not assume "it ran on Intel".
|
||||||
|
|
||||||
|
**`#164` Universal binary (Intel + arm64)**
|
||||||
|
- Need universal Argyll archive (argyll `#27`), runtime fallback if a slice is missing, CI `--no-run` when cross-compiling tests for the other arch.
|
||||||
|
|
||||||
|
**`#225` Monterey white-flash loop then silent exit (v0.8.4)**
|
||||||
|
- **Root cause:** Main window `visible: true` with no dark backing. `initGamutViewer()` created `THREE.WebGLRenderer({ antialias: true, alpha: true })` and started rAF at `DOMContentLoaded` **while Stage 5 was hidden**. On macOS 12 (esp. Intel GPU) the Web Content/GPU helper died; WKWebView respawned its **white** default store; after a few cycles the host tore the window down. No Crash Reporter on `ICCery.app`. `minimumSystemVersion` still claimed 10.15.
|
||||||
|
- **Fix:** Defer WebGL until the gamut viewer is shown; survive context-lost; hide window until first paint; dark WKWebView `backgroundColor`; log unexpected window destroy / Web Content death; raise `minimumSystemVersion` to **12.0**.
|
||||||
|
- **Invariant:** Do not create a WebGL context at startup. Do not show a window before first paint on macOS. Helper-process death ≠ app crash — log it. Support matrix must match WKWebView/WebGL reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 Packaging / CI
|
||||||
|
|
||||||
|
**`#38` Unused / duplicated crates** — audit deps; don't let Windows-only crates leak (inverse of `#70`).
|
||||||
|
|
||||||
|
**`#40` Linux CI too slow**
|
||||||
|
- **Fix (PR `#41`):** Allow strip (`NO_STRIP` off), rust-cache `workspaces: "src-tauri -> target"`, apt archive cache, `--bundles deb,appimage` (skip rpm), upload-artifact v4.
|
||||||
|
|
||||||
|
**`#42` Windows MSI/NSIS branding.**
|
||||||
|
|
||||||
|
**`#52` Strip foreign-platform Argyll binaries from each OS bundle.**
|
||||||
|
- **Invariant:** A Windows installer must not ship `linux-x86_64/` and `macOS/` trees. Fetch-at-build (`#127`) makes this a packaging filter, not a git-lfs problem.
|
||||||
|
|
||||||
|
**`#80` Mermaid diagram does not render**
|
||||||
|
- **Root cause:** Node label `Raw Print Subsystem (GDI / CUPS)` — parentheses parsed as shape syntax.
|
||||||
|
- **Fix:** Quote / drop parens.
|
||||||
|
- **Invariant:** Mermaid node text with `()` must be quoted.
|
||||||
|
|
||||||
|
**`#91` Universal `.dmg` + codesign pipeline (still evolving through `#189`).**
|
||||||
|
|
||||||
|
**`#108` `.deb` requires GLIBC_2.39 (won't run on Ubuntu 22.04)**
|
||||||
|
- **Root cause:** Linux CI runner newer than the support target. Binary linked against glibc 2.39; 22.04 ships 2.35.
|
||||||
|
- **Fix (v0.3.2):** Build on **Ubuntu 22.04 LTS**.
|
||||||
|
- **Invariant:** The CI image **is** the minimum glibc. If you support 22.04, you build on 22.04 (or older), not `ubuntu-latest`.
|
||||||
|
|
||||||
|
**`#127` Fetch Argyll at build time + Windows USB driver offer**
|
||||||
|
- Removed vendored binaries. NSIS hook for `ArgyllCMS_install_USB.exe`. Follow-on Windows NSIS bugs in the same train: LogicLib admin check, domain-user install path must be a local fixed disk, shell-folder drive mapping, skip WebView2 bootstrapper `msiexec` invalid-drive.
|
||||||
|
- **Invariant:** Sidecars are release artefacts of `gronod/argyllcms`, not git blobs. Windows USB instruments need the libusb0 INF. Packaging scripts must `tr -d '\r'` manifest lists (argyll `#21` — CRLF in `binfiles` dropped drivers/docs from the zip).
|
||||||
|
|
||||||
|
**`#189` DMG has no background image**
|
||||||
|
- AppleScript Finder decoration does not run headlessly. Multiple CI attempts (`#196`, `#208`, `#219`) then **dmgbuild** (`#222`, commit `6976ced`).
|
||||||
|
- **Invariant:** Do not use Finder AppleScript to style a DMG on a headless runner. Use `dmgbuild`.
|
||||||
|
|
||||||
|
**`#215` Frontend tests not in CI**
|
||||||
|
- Added `npm test` (profcheck, chartread, gamut_viewer) to macOS/Linux/Windows workflows. Prevents `#212`-class breakage.
|
||||||
|
|
||||||
|
**`#214` Docs drift** — AGENTS/README/licenses vs code. Keep ROADMAP version = Cargo version (`#115`, `#152`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.10 Security (innerHTML presets)
|
||||||
|
|
||||||
|
**`#114` Preset manager XSS via `innerHTML`**
|
||||||
|
- **Root cause:** `renderManagePresetsList` interpolated `p.name` / `p.description` into `innerHTML`. Imported JSON is attacker-controlled. Tauri WebView is a privileged surface (`<img src=x onerror=…>` runs with backend IPC).
|
||||||
|
- **Fix (PR `#122`, with `#113`):** `createElement` + `textContent`. Optional Rust reject of `<` / control chars. **No** HTML sanitizer.
|
||||||
|
- **Invariant:** Untrusted strings (preset import, CGATS descriptors, chart labels, log excerpts) never go through `innerHTML`. `textContent` only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.11 Settings / presets / wizard chrome
|
||||||
|
|
||||||
|
**`#113` `applyPreset` never applies TIFF DPI**
|
||||||
|
- **Root cause:** Schema had `dpi`; Fast RGB Draft is 150. `applyPreset` skipped `#tiffDpi`; `collectCurrentSettingsAsPreset` hardcoded `dpi: 300`.
|
||||||
|
- **Fix:** Round-trip `#tiffDpi` (72–600); show DPI in manage-presets subtitle.
|
||||||
|
- **Invariant:** Every `ProfilingPreset` field that exists on disk has a DOM binding in both directions. Adding a Stage 1/2/4 control without a preset field (or vice versa) is a bug. (v0.7.0 also broke the build by adding fields without updating the struct — `c917f05`.)
|
||||||
|
|
||||||
|
**`#60` Wizard is not a wizard**
|
||||||
|
- **Root cause:** Stepper allowed clicking Stage 5 with no files. Modules fell back to `"test_target"`.
|
||||||
|
- **Fix (v0.1.15 / v0.2.0):** Gate `.ti1` → `.ti2`+`.tif` → `.ti3` → `.icc`/`.icm`. Remove every `|| "test_target"`. Completed stages remain clickable backwards.
|
||||||
|
- **Invariant:** No placeholder basenames. Navigation = disk. `#151` re-validates artefacts on stage entry and app start (deleted files must re-lock).
|
||||||
|
|
||||||
|
**`#171` Tooltips reflow the layout**
|
||||||
|
- **Root cause:** Tooltip content inserted in-flow.
|
||||||
|
- **Fix (v0.6.2):** Absolute overlay on hover/focus; in-flow hints only when the global "show tooltip hints" toggle is on.
|
||||||
|
- **Invariant:** Hover must not change document flow.
|
||||||
|
|
||||||
|
**`#184` Configurable ΔE₀₀ thresholds** — settings persistence applied to the Stage 3 traffic lights. Same "settings must actually apply" rule as `#158`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.12 UI leftovers that still encode invariants
|
||||||
|
|
||||||
|
**`#178` Swatch grid:** honour `is_pad` (padding patches, `id == "0"`); render white patches; diagonal split intended vs measured; `printtarg` row/column order is canonical after bi-directional reverse. Device channels are 0–100%; XYZ in JSON is 0–100 and **must be /100** before `icmXYZ2Lab`.
|
||||||
|
|
||||||
|
**`#177` Buttons:** `.btn-sm/.btn-md/.btn-lg/.btn-icon-sq` only. No inline sizes. `#223` install button must use this hierarchy.
|
||||||
|
|
||||||
|
**`#140` Resume:** opening a `.ti2` jumps to Stage 3; `.ti1` to Stage 2. Parse header (instrument, patch count, colorants, pages). Do not regenerate `#163`-style random layouts over a chart that was already printed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Open issues — implemented on main (`v0.8.5`)
|
||||||
|
|
||||||
|
Gitea still shows these as **open**. Git on `main` (merge `e12ef41`, tag `v0.8.5`) already contains the work. ROADMAP "Printer Calibration Release (`v0.8.5`)" matches.
|
||||||
|
|
||||||
|
| Ticket | State in tracker | Evidence on `main` | What shipped |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **`#223` System-wide ICC/ICM install** | open | `980b44b` `feat(stage5): install generated ICC/ICM profiles into the OS (#223)` ; PR `#228` → development; PR `#229` → main | Stage 5 "Install Profile to System": copy into OS colour store (user or system), overwrite/rename/cancel, elevation guidance, note when printcal curves were applied |
|
||||||
|
| **`#224` printcal / applycal** | open | `24318bc` `feat(cal): printer linearization via printcal / applycal (#224)` ; PR `#227` → development; PR `#229` → main | Optional Stage 0: `CAL_` artefacts, Apply Calibration toggle → `printtarg -K` + `applycal`, channel-response plots, stale-cal warnings, project/library persistence |
|
||||||
|
|
||||||
|
Rewrite must treat Stage 0 calibration and Stage 5 install as **shipped** contracts, not backlog. Close the tickets when convenient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Won't-fix
|
||||||
|
|
||||||
|
### `#90` Display wizard (`dispwin` / `dispread`) — Reviewed/Won't Fix
|
||||||
|
|
||||||
|
Asked for a second wizard mode: emissive monitor profiling (white point, gamma, cd/m², colorimeter loop, `dispwin -I` install).
|
||||||
|
|
||||||
|
**Why not:**
|
||||||
|
- ICCery's architecture, print engine, artefact chain (`.ti1`→`.tif`→`.ti3`→`.icc`), and raw-print colour-bypass work are **reflective printer** problems. Display calibration is a different product (full-screen test patches, VCGT, per-monitor ColorSync/ICC install).
|
||||||
|
- `dispwin`/`dispread` already *are* interactive GUIs; wrapping them duplicates Argyll's own display tools and DisplayCAL without sharing the print pipeline.
|
||||||
|
- Hardware, prompts, and OS install APIs do not reuse Stage 3's strip-reader state machine.
|
||||||
|
- Priority/Low, closed the same day it was filed during M8 triage. Printer-only scope is the product.
|
||||||
|
|
||||||
|
Do not quietly add a "Print vs Display" toggle in a rewrite. If display profiling happens, it is a separate app.
|
||||||
|
|
||||||
|
### `#96` i18n (en/de/fr/ja) — Reviewed/Won't Fix
|
||||||
|
|
||||||
|
ROADMAP: *Closed — Won't Fix (English UI retained as standard color-management terminology).*
|
||||||
|
|
||||||
|
**Why not:**
|
||||||
|
- The domain language **is** English: ΔE₀₀, cLUT, CGATS, Lab, FWA, OFPS, `targen -f`. Translating chrome without translating Argyll's English prompts (`Place the instrument on its reflective white reference…`) produces a mixed UI. Translating prompt matchers (`#137`, `#93`, `#175`) is how Stage 3 silently deadlocks.
|
||||||
|
- Argyll itself is English-only; `ARGYLL_NOT_INTERACTIVE` keystroke protocol is letter keys (`d`, `u`, `n`), not localized buttons.
|
||||||
|
- Cost of four complete dictionaries + locale-aware regex across every prompt classifier exceeded the value for a specialist tool.
|
||||||
|
|
||||||
|
Keep the UI English. Do not introduce `t()` until prompt classification is table-driven **and** Argyll grows locale support — which it will not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Idiosyncrasies (do not rediscover)
|
||||||
|
|
||||||
|
These are the sharp edges issues burned in. A rewrite that "simplifies" any of them will re-open the ticket.
|
||||||
|
|
||||||
|
### 5.1 Argyll CLI is a minefield of one-letter flags
|
||||||
|
|
||||||
|
| Tool | Flag | Means | Ticket |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `targen` | `-f N` | patch count; omit ⇒ **836** RGB | `#44` |
|
||||||
|
| `targen` | `-c file` | preconditioning **profile** (`.icc/.icm/.mpp`) | `#172` |
|
||||||
|
| `targen` | `-u` | JSON progress | argyll `#2` |
|
||||||
|
| `printtarg` | `-d "str"` | **custom label** on the chart | `#119` / argyll `#19` |
|
||||||
|
| `printtarg` | `-D` | 8-bit TIFF dither (different letter) | argyll `#19` |
|
||||||
|
| `printtarg` | `-R N` | PRNG seed; omit ⇒ non-deterministic layout | `#163` |
|
||||||
|
| `printtarg` | `-r` | raster order, no randomize | `#163` |
|
||||||
|
| `printtarg` | `-K` | apply calibration (Stage 0) | `#224` |
|
||||||
|
| `printtarg` | `-u` | JSON page manifest | `#68` / argyll `#3` |
|
||||||
|
| `chartread` | `-u` | `ROW_COLORS_JSON:` stream | `#5` / argyll `#1` |
|
||||||
|
| `chartread` | `-c N` | **comm port**, not instlist index | `#111` |
|
||||||
|
| `chartread` | `-d` | **display** read mode (not a directory, not a label) | argyll `#37` audit |
|
||||||
|
| `chartread` | `-Y l` | i1Pro2 LED (not `-L`) | `#204` |
|
||||||
|
| `chartread` | stdin `d` | **Done**, write `.ti3` | `#175` |
|
||||||
|
| `chartread` | stdin `u` | undo last strip | `#175` |
|
||||||
|
| `iccgamut` | `-d s` | **surface density** (use 10, not 50, not a path) | `#112` |
|
||||||
|
| `colprof` | `-d` | viewing condition | `#176` |
|
||||||
|
| `colprof` | `-c` | illuminant / FWA | `#176` |
|
||||||
|
| `colprof` | `-u` | JSON progress | argyll `#4` |
|
||||||
|
| `profcheck` | `-u` | JSON ΔE report | argyll `#5` |
|
||||||
|
| `instlist` | stdout JSON | `{event:"instruments", devices:[{port,name,type}]}` | `#134` / argyll `#6` |
|
||||||
|
|
||||||
|
**Same letter, four meanings of `-d`:** printtarg label, iccgamut density, chartread display mode, colprof viewing condition. Never name a helper `dash_d` without the tool.
|
||||||
|
|
||||||
|
### 5.2 Process / Windows / stdin
|
||||||
|
|
||||||
|
- Argyll binaries are **console** subsystem → `CREATE_NO_WINDOW` (`0x08000000`) (`#46`).
|
||||||
|
- `ARGYLL_NOT_INTERACTIVE=1` is mandatory or Windows ignores pipes (`#134`).
|
||||||
|
- Even then, Argyll's `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes**; upstream `PeekNamedPipe` (argyll `#24`) is required or the instrument trigger never fires.
|
||||||
|
- `Child::wait()` drops stdin (`#84`). Take streams first; store stdin separately; kill via oneshot.
|
||||||
|
- Spawn ids are exclusive (`#116`).
|
||||||
|
- App exit must `kill_all` (`#147`, `#149`) or the spectro stays locked.
|
||||||
|
- Windows resolve: `targen` **and** `targen.exe` (`#85`).
|
||||||
|
- `tokio::process` + GDI print FFI: don't trust `windows` crate completeness (`#33`).
|
||||||
|
|
||||||
|
### 5.3 Files, extensions, artefacts
|
||||||
|
|
||||||
|
- `colprof` writes **`.icm` on Windows, `.icc` on Unix** (`#69`). Always probe both.
|
||||||
|
- Wizard chain: `.ti1` → `.ti2` + `.tif` → `.ti3` → `.icc`/`.icm` (+ `.gam`) (`#60`, `#151`).
|
||||||
|
- `{basename}.ti3` is the **accepted** measurement; passes live in `{basename}_passN.ti3` (`#109`, `#110`).
|
||||||
|
- Calibration artefacts use `CAL_` prefix and `.cal` (`#224`).
|
||||||
|
- TIFF is not a web image (`#58`). Convert in Rust.
|
||||||
|
- `.gam` = vertex table **plus** face table (`#179`).
|
||||||
|
- History JSON: atomic rename or you delete the user's drift archive (`#213`).
|
||||||
|
- Empty cwd is forbidden (`#59`).
|
||||||
|
|
||||||
|
### 5.4 Frontend / Tauri v2
|
||||||
|
|
||||||
|
- `window.__TAURI__.dialog` **does not exist**. Three tickets (`#103`, `#210`, `#211`). File dialogs are Rust commands with dedicated filters.
|
||||||
|
- Tauri v2 invoke args are **camelCase** (`passIndex`, not `pass_index`) (`#175`).
|
||||||
|
- JS payload field names must match Rust (`patch_count` vs `total_patches` — `#44`; `basename/cwd` vs `ti3_path/icc_path` — `#56`).
|
||||||
|
- Spawn id strings are part of the UI protocol (`colprof_${basename}`).
|
||||||
|
- Do not `innerHTML` untrusted preset/CGATS text (`#114`).
|
||||||
|
- Do not read `window` at module top-level if Node tests import the file (`#212`).
|
||||||
|
- Do not create WebGL on `DOMContentLoaded` (`#225`).
|
||||||
|
- Tooltips are overlays (`#171`).
|
||||||
|
|
||||||
|
### 5.5 Colour / print pipeline
|
||||||
|
|
||||||
|
- Raw print = unmanaged raster. Bypass **OS + driver + ICM** (`#25`–`#28`, `#36`, `#188`).
|
||||||
|
- Windows: full DEVMODE blob including `dmDriverExtra` (`#36`); `SetICMMode(ICM_OFF)` (`#33`).
|
||||||
|
- macOS: `AP_ApplicationColorMatching` + vendor keys (`CNIJIntent2`, Epson `ColorCorrection`); never crash-call 3-arg `PMSessionSetColorMatchingMode` (`#188`).
|
||||||
|
- PPD: send **choice codes**, display **titles**; vendor keywords differ (`InputSlot` / `EPIJ_FdSo` / `CNIJMediaSupply`; `MediaType` / `CNIJMediaType`) — CPU `#1`/`#2`.
|
||||||
|
- Hide CUPS widgets on Windows (`#48`).
|
||||||
|
- Default printtarg seed `-R 1` (`#163`).
|
||||||
|
- Device JSON is 0–100%; XYZ 0–100 must be divided by 100 before Lab (`ROW_COLORS_JSON` contract, argyll `#1`).
|
||||||
|
- Padding patches: `is_pad` / `id == "0"` (`#178`).
|
||||||
|
|
||||||
|
### 5.6 Packaging
|
||||||
|
|
||||||
|
- Linux: build on the oldest glibc you support (`#108` = Ubuntu 22.04).
|
||||||
|
- Strip foreign Argyll trees (`#52`).
|
||||||
|
- Fetch sidecars from `gronod/argyllcms` releases (`#127`); verify macOS ad-hoc signatures (`#165`).
|
||||||
|
- Manifest copy loops: strip CR (`\r`) or Windows zips silently omit USB drivers (argyll `#21`).
|
||||||
|
- Headless DMG: `dmgbuild`, not Finder AppleScript (`#189`).
|
||||||
|
- `minimumSystemVersion` is 12.0 after Monterey WebKit reality (`#225`), not 10.15.
|
||||||
|
|
||||||
|
### 5.7 Defaults people will hardcode wrong
|
||||||
|
|
||||||
|
- Patch count default if `-f` missing: **836**, not 0, not 1000 (`#44`).
|
||||||
|
- TIFF DPI built-in Standard = 300; Fast RGB Draft preset = **150** (`#113`).
|
||||||
|
- `iccgamut -d` = **10** (`#112`).
|
||||||
|
- Window 1280×800, min 1100×700 (`#61`).
|
||||||
|
- Log level is a setting, not `cfg!(debug_assertions)` (`#158`).
|
||||||
|
- LED flag is `-Y l` (`#204`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Suggested rewrite test locks (from the tickets)
|
||||||
|
|
||||||
|
If the rewrite has tests, these are the ones the history says you will regret omitting:
|
||||||
|
|
||||||
|
1. `build_targen_args` includes `-f` from `patch_count` (`#44`).
|
||||||
|
2. `build_printtarg_args` includes `-R 1` (or explicit seed) (`#163`).
|
||||||
|
3. `build_iccgamut_args` is `-v -d 10 {profile}` — never a path, never 50 (`#112`).
|
||||||
|
4. `build_chartread_args` does not pass instlist ordinal as `-c` (`#111`).
|
||||||
|
5. Windows `resolve_binary("targen")` finds `targen.exe` (`#85`).
|
||||||
|
6. ProcessManager: duplicate id errors; stdin works during wait; kill_all on drop (`#84`, `#116`, `#149`).
|
||||||
|
7. Averaging: two snapshots, `average` argv unique files, Stage 4 locked until Finish (`#109`, `#110`).
|
||||||
|
8. Preset round-trip includes DPI; imported `<img onerror>` is text (`#113`, `#114`).
|
||||||
|
9. Profile resolver accepts `.icc` and `.icm` (`#69`).
|
||||||
|
10. `.gam` dual-table parse; profcheck JSON then regex fallback (`#179`).
|
||||||
|
11. History write is temp+rename (`#213`).
|
||||||
|
12. Node can `import` gamut/profcheck/chartread modules (`#212`, `#215`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. PR title index (page 2, remaining)
|
||||||
|
|
||||||
|
Page 2 of `pulls?state=all&limit=50&page=2` (older half of the genealogy):
|
||||||
|
|
||||||
|
`#144` Stage 1 advanced targen (`#141`) · `#143` resume `.ti2` (`#140`) · `#142` logging (`#139`) · `#138` Accept/Override (`#137`) · `#136`/`#135` v0.3.5 `ARGYLL_NOT_INTERACTIVE` (`#134`) · `#133` v0.3.4 · `#132` printtarg `-d` (`#119`) · `#131` v0.4.0 · `#130` macOS CUPS (`#92`) · `#129`/`#128` fetch-argyll (`#127`) · `#126` v0.3.3 · `#125` QuickHull module (`#117`) · `#124` docs v0.3.2 (`#115`) · `#123` duplicate spawn (`#116`) · `#122` preset DPI+XSS (`#113`,`#114`) · `#121` iccgamut density (`#112`) · `#120` comm port (`#111`) · `#118` averaging unique ti3 (`#109`,`#110`) · `#107` release asset naming · `#105`/`#104` Stage 1 browse (`#103`) · `#102` v0.3.0 · `#101` 3D hull (`#89`) · `#100` presets (`#88`) · `#99` averaging (`#87`) · `#98` instlist (`#86`) · `#97` `.exe` (`#85`) · `#83` process deadlock v0.2.1 (`#84`) · `#81` mermaid (`#80`) · `#79` print IPC consolidate (`#67`,`#68`) · `#77` wait/reap (`#63`) · `#75` wizard gating (`#60`) · `#74` gamut+extension (`#57`,`#69`) · `#73` TIFF PNG + window (`#58`,`#61`) · `#72` default cwd (`#59`) · `#64` colprof IPC (`#56`) · `#55` collapsible logs (`#54`) · `#53` strip foreign binaries (`#52`) · `#51` Stage 2 cleanup (`#50`) · `#49` hide CUPS on Windows (`#48`) · `#47` `CREATE_NO_WINDOW` (`#46`) · `#45` targen `-f` (`#44`) · `#43` installer art (`#42`) · `#41` Linux CI (`#40`)
|
||||||
|
|
||||||
|
Recent page-1 merges of note: `#229` v0.8.5 (`#223`+`#224`) · `#228` install · `#227` printcal · `#226` Monterey (`#225`) · `#222` dmgbuild (`#189`).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# 25 — Rewrite notes and idiosyncrasies
|
||||||
|
|
||||||
|
The new ICCery is **not** Tauri. Keep the product name, cone artwork, unmanaged-print semantics, and Argyll process boundary.
|
||||||
|
|
||||||
|
## Four different meanings of `-d`
|
||||||
|
|
||||||
|
| Tool | `-d` means |
|
||||||
|
|------|------------|
|
||||||
|
| `targen` | colourant combination (`2` RGB, `4` CMYK) |
|
||||||
|
| `printtarg` | custom **label** string (fork) |
|
||||||
|
| `iccgamut` | surface **density** (number, e.g. `10`) |
|
||||||
|
| `colprof` | output viewing condition |
|
||||||
|
|
||||||
|
Passing the wrong `-d` is the #1 class of silent misconfiguration (#112).
|
||||||
|
|
||||||
|
## Flags that look similar
|
||||||
|
|
||||||
|
- printtarg `-r` = no randomize / raster layout; targen `-r` = full-spread algorithm
|
||||||
|
- printtarg `-R seed` vs targen `-R` algorithm
|
||||||
|
- applycal `-u` = **unapply** curves, **not** JSON
|
||||||
|
- chartread `-Y l` (letter L) = LEDs; original ticket proposed `-L`
|
||||||
|
|
||||||
|
## JSON prefixes are inconsistent (fork)
|
||||||
|
|
||||||
|
| Tool | `-u` stdout |
|
||||||
|
|------|-------------|
|
||||||
|
| `chartread` | **`ROW_COLORS_JSON: `** + compact JSON |
|
||||||
|
| `printtarg` | pretty-printed **bare** JSON manifest |
|
||||||
|
| `profcheck` | one compact **bare** JSON line |
|
||||||
|
| `targen` / `colprof` | compact JSON (fork) — ICCery currently **does not** pass `-u` |
|
||||||
|
| `instlist` | pretty JSON, no `-u` needed |
|
||||||
|
|
||||||
|
Only chartread lines are intercepted by ProcessManager. Manifest/profcheck JSON arrives as `process:stdout` and must be parsed from the accumulator.
|
||||||
|
|
||||||
|
## Windows musts
|
||||||
|
|
||||||
|
- `CREATE_NO_WINDOW` (`0x08000000`) on every Argyll spawn
|
||||||
|
- `.exe` candidate first (`resolve_binary`)
|
||||||
|
- `.icm` default extension
|
||||||
|
- GDI `SetICMMode(ICM_OFF)` + `DMICMMETHOD_NONE`
|
||||||
|
- Cache full DEVMODE bytes from DocumentProperties (#36) — applying fields onto a fresh DEVMODE drops driver extras
|
||||||
|
- Hide CUPS checkbox (`#cupsOptionsGroup`)
|
||||||
|
- PeekNamedPipe fork + `ARGYLL_NOT_INTERACTIVE=1`
|
||||||
|
- TIFF→PNG in host, not in the webview
|
||||||
|
|
||||||
|
## macOS musts
|
||||||
|
|
||||||
|
- Preferences = **NSPrintPanel on the AppKit main thread**, never System Settings URL, never cupsctl web UI (#188 comments 1–5)
|
||||||
|
- `PMPrinterCreateFromPrinterID(CUPS id)` then `display_name` → `NSPrinter::printerWithName`
|
||||||
|
- Private SPI via `dlsym`: **2-argument** `(PMPrintSession, *const CFString) -> i32`. Passing integer `1` as a lock flag **SIGSEGV**. Try Lock, then Mode, then NoLock. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching` only
|
||||||
|
- Dual keys `AP_ColorMatchingMode` and dotted `AP.ColorMatchingMode`
|
||||||
|
- Mirror into `NSPrintInfo.printSettings` dictionary (PDEs read this)
|
||||||
|
- Pre-select Canon `CNIJIntent2=4`, Epson `EPIJ_CMat=3` (or `EPIJ_CCor=0`), Gutenprint `StpColorCorrection=Uncorrected`
|
||||||
|
- `lp` always includes both AP_* options + captured cups_options + bypass if missing
|
||||||
|
- Cancel = `Ok(None)`, not an error
|
||||||
|
- Dark WKWebView: `drawsBackground=NO`, under-page colour, window hidden until paint
|
||||||
|
- `minimumSystemVersion` 12.0
|
||||||
|
- Ad-hoc sign Argyll Mach-O sidecars on Apple Silicon (#165)
|
||||||
|
- TargetPrint companion uses **different** ColorSync keys (`APCustomColorMatching`). Do not mix the two dictionaries.
|
||||||
|
|
||||||
|
## Linux musts
|
||||||
|
|
||||||
|
- `lp` not `lpr`
|
||||||
|
- Default `-o raw`; fallback `ColorModel=Gray` + `cm-calibration` if `#chkPpdFallback`
|
||||||
|
- Enumerate `lpstat -e/-p/-d` + `lpoptions -p -l` + `/etc/cups/ppd/{name}.ppd`
|
||||||
|
|
||||||
|
## Frontend musts
|
||||||
|
|
||||||
|
- No WebGL until Stage 5 visible; pause rAF on leave; survive context lost
|
||||||
|
- `iccgamut -d 10` density
|
||||||
|
- Average snapshots delete canonical `.ti3`
|
||||||
|
- Open dialogs for import; save dialogs only when saving
|
||||||
|
- No `innerHTML` for user-supplied preset/dataset names
|
||||||
|
- Atomic history writes
|
||||||
|
- Deterministic printtarg `-R 1`
|
||||||
|
- Honour patch count `-f`
|
||||||
|
|
||||||
|
## Suggested characterisation tests (from tickets)
|
||||||
|
|
||||||
|
Lock these before rewriting UI:
|
||||||
|
|
||||||
|
1. `build_*_args` golden vectors (already in Rust unit tests — port them)
|
||||||
|
2. `classifyChartreadLine` 39 cases
|
||||||
|
3. `parseGamutFile` multi-`BEGIN_DATA`, comments, OOB vertices
|
||||||
|
4. `parseProfcheckReport` JSON + legacy + empty→warning
|
||||||
|
5. Color bypass detector: Canon/Epson/Gutenprint samples
|
||||||
|
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
|
||||||
|
7. `build_lp_args` always emits both AP_* keys
|
||||||
|
8. Threshold validation `good < warning`
|
||||||
|
9. `snapshot_ti3` 1-based and removes canonical
|
||||||
|
10. DEVMODE round-trip size
|
||||||
|
|
||||||
|
## Host command list (complete)
|
||||||
|
|
||||||
|
From `lib.rs` `generate_handler!` — a rewrite should provide equivalents with the **same argument names** (Tauri command arguments are `camelCase` at the IPC boundary, Rust fields `snake_case` inside structs).
|
||||||
|
|
||||||
|
### Process / platform
|
||||||
|
|
||||||
|
`spawn_process`, `send_stdin`, `kill_process`, `kill_all_processes`, `resolve_binary`, `get_app_info`, `get_os_info`, `show_main_window`, `get_default_working_dir`, `get_log_path`, `get_recent_log_excerpt`, `log_frontend_message`, `open_log_dir`
|
||||||
|
|
||||||
|
### Files / dialogs
|
||||||
|
|
||||||
|
`select_existing_target`, `select_profile_file`, `select_spectrum_file`, `select_dataset_file`, `select_target_file`, `select_directory`, `select_csv_save_path`, `read_file_base64`, `read_tiff_preview_png`, `parse_ti2_header`
|
||||||
|
|
||||||
|
### Wizard / Argyll
|
||||||
|
|
||||||
|
`detect_instruments`, `get_profile_path`, `verify_stage_artefacts`, `extract_gamut`, `run_targen`, `run_printtarg`, `run_chartread`, `snapshot_ti3`, `promote_ti3`, `run_average`, `run_colprof`, `run_profcheck`
|
||||||
|
|
||||||
|
### Print
|
||||||
|
|
||||||
|
`get_printers`, `get_printer_capabilities`, `show_printer_properties`, `print_target_native`
|
||||||
|
|
||||||
|
### Calibration (9)
|
||||||
|
|
||||||
|
`generate_calibration_target`, `compute_calibration_curves`, `apply_calibration`, `parse_cal_file_cmd`, `list_saved_calibrations`, `save_calibration_to_library`, `select_cal_file`, `load_project_calibration`, `save_project_calibration`
|
||||||
|
|
||||||
|
### Profile install
|
||||||
|
|
||||||
|
`install_profile_to_system`, `get_profile_install_dir`
|
||||||
|
|
||||||
|
### Quality store (4)
|
||||||
|
|
||||||
|
`save_verification_record`, `get_verification_history`, `clear_verification_history`, `export_verification_history_csv`
|
||||||
|
|
||||||
|
### Settings / presets (7)
|
||||||
|
|
||||||
|
`load_settings`, `save_settings`, `get_all_presets`, `save_preset`, `delete_preset`, `export_preset_json`, `import_preset_json`
|
||||||
|
|
||||||
|
### CGATS
|
||||||
|
|
||||||
|
`import_measurement_dataset`, `export_measurement_dataset`, `inspect_dataset_preview`
|
||||||
|
|
||||||
|
## Licence / AGPL
|
||||||
|
|
||||||
|
GUI: proprietary EULA (`LICENCE.md`). Argyll sidecars: AGPLv3, subprocess-only. Third-party: Three.js r128 MIT, OrbitControls, CSS2DRenderer, quickhull. Rewrite may swap the renderer but must not link Argyll.
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
# 26 — ICCery v2 Mac: reviewed Gitea ticket plan
|
||||||
|
|
||||||
|
**Status:** reviewed, executable. This is the plan to file — not the draft reviewed in chat.
|
||||||
|
**Product:** ICCery v2, macOS-only SwiftUI/AppKit rewrite.
|
||||||
|
**Repo:** `gronod/iccery-v2-mac` on git.i3omb.com.
|
||||||
|
**Spec:** chapters [01](01-overview.md)–[25](25-rewrite-notes.md) (vendor these into the new repo; do not treat the live spec site as archival).
|
||||||
|
**Agent:** Devin (`gitea` MCP: `label_write`, `milestone_write`, `issue_write`). Local git for bootstrap files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes from the 2026-09-08 draft
|
||||||
|
|
||||||
|
| Draft | Reviewed plan |
|
||||||
|
|-------|----------------|
|
||||||
|
| macOS 12.0 + `@Observable` | **macOS 14.0 Sonoma.** `@Observable` is 14+; SwiftUI 12 is not a viable wizard shell. v0.8.5’s 12.0 floor was WKWebView/#225, which is gone. |
|
||||||
|
| Bundle id `com.gronod.iccery` | **`com.gronod.iccery2`** until v1 is retired (settings, logs, Launch Services collide). |
|
||||||
|
| M3 exit = `lp` **and** Quartz/TargetPrint | **M3 = v0.8.5 `lp` path only** (issues 12–15, 17). TargetPrint is **v2.1** (issue 16, milestone Later). Do not mix ColorSync dictionaries. |
|
||||||
|
| Issue 2 = streaming spawn only | Issue 2 also ships **`runCaptured`** (`printcal`/`applycal`). |
|
||||||
|
| Settings dialog in M6 (issue 31) | **Settings UI in M1 (issue 5).** Issue 31 is About + help overlays only. |
|
||||||
|
| Calibrate Printer live in issue 1 | **Disabled/hidden** until issue 29. |
|
||||||
|
| `colprof -u` / `targen -u` “document later” | **Do not pass `-u` in v2.0** (parity; `colprof -u` collides with upstream white-point `-u`). |
|
||||||
|
| Skip/Undo `s`/`u` “verify later” | **Drop mock keys.** Real strip mode treats unknown letters as trigger. |
|
||||||
|
| Issue 3 refuses unsigned sidecars | Fetch script **ad-hoc signs** (`codesign -s -`) then `codesign -dvv` must succeed (#165). |
|
||||||
|
| Gitea-hosted `macos-latest` | **Self-hosted Mac runner** (or GitHub Actions mirror). `xcodebuild -destination macOS` is host-arch, not universal. |
|
||||||
|
| Gamut “SceneKit or Metal” | **SceneKit.** |
|
||||||
|
| Bootstrap via MCP `create_or_update_file` | **Git commit locally, then push.** Do not also MCP-write the same files. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locked product decisions
|
||||||
|
|
||||||
|
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
|
||||||
|
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
|
||||||
|
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
|
||||||
|
4. **Print v2.0:** NSPrintPanel (settings capture, “Use Settings”) → `lp` with **both** `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and `-o AP.ColorMatchingMode=AP_ApplicationColorMatching`. **Never `-o raw` on macOS.**
|
||||||
|
5. **Print v2.1:** `ICCeryPrintKit` (TargetPrint folded), own ColorSync vocabulary (`PMColorMatchingMode=APCustomColorMatching`). Public API remains `TargetJob` JSON + `--job`.
|
||||||
|
6. **Identity:** product name ICCery, original cone SVG/app icon. Bundle `com.gronod.iccery2`.
|
||||||
|
7. **App Sandbox: OFF.** USB instruments, CUPS/`lp`, ColorSync install, `dlsym` of private PrintCore SPI. Hardened Runtime on; entitlements documented in issues 1 and 32.
|
||||||
|
8. **No Windows/Linux. No display cal (#90). No i18n (#96).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution sequence
|
||||||
|
|
||||||
|
1. In `~/Projects/iccery-v2-mac`: `git push -u origin main` (existing root commit).
|
||||||
|
2. Create `develop` from `main`. Commit `AGENTS.md`, `BUILD-PLAN.md`, `LICENCE.md`, vendored `docs/` (this spec set), `brand/ICCery-logo.svg` + `app-icon.svg`. Push `develop`.
|
||||||
|
3. MCP `label_write` → create every label. **Save ids.**
|
||||||
|
4. MCP `milestone_write` → create M1–M6 + **Later**. No `due_on`. **Save ids.**
|
||||||
|
5. MCP `issue_write` → create issues **in the order below**. Pass `milestone` id + `labels` ids. `issue_read` uses `issue_number`, not `index`.
|
||||||
|
6. If Gitea numbering does not start at 1, patch `BUILD-PLAN.md` dependency numbers to match.
|
||||||
|
7. Verify: `milestone_read list` (7 milestones), spot-check 3 issue bodies.
|
||||||
|
|
||||||
|
Do not create issues until labels and milestones exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo bootstrap
|
||||||
|
|
||||||
|
### `AGENTS.md`
|
||||||
|
|
||||||
|
- SwiftUI + AppKit; min **14.0**; universal; bundle `com.gronod.iccery2`.
|
||||||
|
- **AGPL boundary:** never link Argyll; spawn only; `ARGYLL_NOT_INTERACTIVE=1`.
|
||||||
|
- Two spawn APIs: streaming `ProcessManager` (actor) and `runCaptured` (printcal/applycal only).
|
||||||
|
- No blocking subprocess I/O on `@MainActor`. Do not hop to main per stdout line (colprof dots).
|
||||||
|
- Argyll flag cheat-sheet: spec [25](25-rewrite-notes.md) (`-d`/`-r`/`-R`/`-u`/`-Y` differ per tool).
|
||||||
|
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
||||||
|
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
||||||
|
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
|
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
|
||||||
|
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|
||||||
|
### `BUILD-PLAN.md`
|
||||||
|
|
||||||
|
- Milestone map below + mermaid branch taxonomy (quoted node labels).
|
||||||
|
- Pointer to vendored `docs/`.
|
||||||
|
- Each milestone: **CI/mock gate** and **hardware gate** listed separately.
|
||||||
|
|
||||||
|
### `LICENCE.md`
|
||||||
|
|
||||||
|
- GUI: proprietary EULA (copy from v0.8.5).
|
||||||
|
- Bundled Argyll sidecars: AGPLv3, subprocess-only, `License.txt` shipped beside binaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
Create exactly these (MacMonitor taxonomy):
|
||||||
|
|
||||||
|
| Name | Color | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `Project/ICCery-v2` | `#007acc` | every issue/PR |
|
||||||
|
| `Feature/Architecture` | `#7057ff` | |
|
||||||
|
| `Feature/Backend` | `#008672` | |
|
||||||
|
| `Feature/UI` | `#d73a49` | |
|
||||||
|
| `Feature/DevOps` | `#fbca04` | |
|
||||||
|
| `Bug/Architecture` | `#b60205` | future |
|
||||||
|
| `Bug/Backend` | `#b60205` | future |
|
||||||
|
| `Bug/UI` | `#b60205` | future |
|
||||||
|
| `Bug/DevOps` | `#b60205` | future |
|
||||||
|
| `Priority/Critical` | `#d73a49` | |
|
||||||
|
| `Priority/High` | `#e99695` | |
|
||||||
|
| `Priority/Medium` | `#fef2c0` | |
|
||||||
|
| `Priority/Low` | `#c2e0c6` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
Each milestone has an **interactive test gate**. Split **CI/mock** vs **hardware**. Do not start the next milestone until both gates for the current one pass (hardware may be “N/A” only for M1).
|
||||||
|
|
||||||
|
### M1 — Foundation & process core
|
||||||
|
|
||||||
|
**CI/mock:** app launches; wizard shell; ProcessManager spawn `instlist` (mock or real) with live log; artefact gating unit tests; settings persist + dialog.
|
||||||
|
**Hardware:** none required.
|
||||||
|
Issues 1–6.
|
||||||
|
|
||||||
|
### M2 — Target generation & layout (Stages 1–2)
|
||||||
|
|
||||||
|
**CI/mock:** targen → `.ti1`; printtarg → `.ti2` + TIFFs; manifest + gallery; resume; presets. Printing stubbed.
|
||||||
|
**Hardware:** none.
|
||||||
|
Issues 7–11.
|
||||||
|
|
||||||
|
### M3 — macOS unmanaged printing (`lp` path)
|
||||||
|
|
||||||
|
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
|
||||||
|
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
|
||||||
|
Issues 12–15, 17. **Not issue 16.**
|
||||||
|
|
||||||
|
### M4 — Measurement (Stage 3)
|
||||||
|
|
||||||
|
**CI/mock:** `chartread.mock` end-to-end; classifier 39+ fixtures; swatch ΔE₀₀; snapshot/average.
|
||||||
|
**Hardware:** Detect real instrument; one strip (or XY) through Done & Save → `.ti3`.
|
||||||
|
Issues 18–22.
|
||||||
|
|
||||||
|
### M5 — Profile, verification & install (Stages 4–5)
|
||||||
|
|
||||||
|
**CI/mock:** colprof → `.icc`; profcheck JSON parse; history atomic write; install copy into a temp ColorSync-like dir.
|
||||||
|
**Hardware:** full `.ti1`→`.icc` on a real chart; profile visible in ColorSync Utility.
|
||||||
|
Issues 23–27.
|
||||||
|
|
||||||
|
### M6 — Gamut, Stage 0, CGATS, polish & release
|
||||||
|
|
||||||
|
**CI/mock:** `.gam` parser fixtures; cal argv builders; CGATS round-trip; `xcodebuild test`; fetch-argyll + ad-hoc sign; dmgbuild.
|
||||||
|
**Hardware:** Stage 0 on a real printer; gamut view of a real profile.
|
||||||
|
Issues 28–32.
|
||||||
|
|
||||||
|
### Later — Quartz / TargetPrint (v2.1)
|
||||||
|
|
||||||
|
Not a v2.0 exit gate. Issue 16 only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket body template
|
||||||
|
|
||||||
|
Every issue body uses this structure (fill all sections):
|
||||||
|
|
||||||
|
```
|
||||||
|
## Summary
|
||||||
|
## Spec refs
|
||||||
|
## Scope
|
||||||
|
## Implementation notes
|
||||||
|
## Rewrite invariants
|
||||||
|
## Dependencies
|
||||||
|
## Test
|
||||||
|
- CI/mock:
|
||||||
|
- Hardware:
|
||||||
|
## Acceptance criteria
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue tickets
|
||||||
|
|
||||||
|
Create in this order. Titles are the Gitea titles.
|
||||||
|
|
||||||
|
### M1
|
||||||
|
|
||||||
|
**Issue 1 — App scaffold & wizard shell**
|
||||||
|
Labels: `Project/ICCery-v2`, `Feature/Architecture`, `Feature/UI`, `Priority/Critical`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- Xcode project `ICCery`, bundle id **`com.gronod.iccery2`**, SwiftUI App lifecycle, **macOS 14.0**, universal `arm64`+`x86_64`.
|
||||||
|
- Dark tokens from v0.8.5: bg `#1e1e1e`, panel `#252526`, text `#d4d4d4`, accent `#007acc`, border `#333`. Window 1280×800, min 1100×700.
|
||||||
|
- **App Sandbox OFF.** Hardened Runtime ON. Entitlements: USB (`com.apple.security.device.usb`), Apple Events / `open` for ColorSync Utility later, no network client required at M1. Document in `TargetPrint.entitlements`-style file `ICCery.entitlements`.
|
||||||
|
- Sidebar 270px: original `ICCery-logo.svg` (do not redraw), settings/about buttons, **disabled** preset select, **disabled** Calibrate Printer + cal chip (enable in 11 / 29), stepper 1–5.
|
||||||
|
- Main: notification banner + one visible stage. Stage 0 is not in the stepper.
|
||||||
|
- First paint: native dark `NSAppearance` — hidden-until-paint is not required (no WebView).
|
||||||
|
- Spec: [01](01-overview.md), [02](02-architecture.md), [21](21-ui-reference.md), [23](23-assets.md).
|
||||||
|
- Invariants: none of #225 WebView workarounds.
|
||||||
|
- Deps: none.
|
||||||
|
- Test CI: `xcodebuild build`; window min-size; only Stage 1 enabled. Hardware: N/A.
|
||||||
|
- AC: launches dark; stepper 5 stages; logo matches v0.8.5 SVG; Calibrate Printer disabled; sandbox disabled in entitlements plist.
|
||||||
|
|
||||||
|
**Issue 2 — ProcessManager: spawn / stdin / kill / captured / event bus**
|
||||||
|
Labels: `Feature/Architecture`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- `ProcessManager` **actor**.
|
||||||
|
- **Streaming:** `spawn(id, binary, args, cwd)` — reject duplicate live ids; pipe stdin/stdout/stderr; `ARGYLL_NOT_INTERACTIVE=1`; take handles at spawn; line-split stdout; lines starting **`ROW_COLORS_JSON: `** (17 chars, space after colon) → `jsonRow` (prefix stripped), never also as `stdout`; `sendStdin(id, Data)` exact bytes + flush; `kill(id)` close stdin then terminate; `killAll()`; reap → `exit(id, code)` with natural `unwrap_or(0)` / killed `unwrap_or(1)`.
|
||||||
|
- **Captured:** `runCaptured(binary, args, cwd) -> (code, stdout, stderr)` — same env, same `resolve_binary`, same `killAll` coverage. Used **only** by `printcal` / `applycal` (issue 29 / 24). No event bus.
|
||||||
|
- Events: `AsyncStream` — `stdout`, `stderr`, `jsonRow`, `exit`, `error`. Do **not** hop to `@MainActor` per line.
|
||||||
|
- ProcessManager does **not** JSON.parse pretty-printed `instlist` / `printtarg` / `profcheck` output. Callers accumulate stdout.
|
||||||
|
- `killAll` on `NSApplication.willTerminate` and last-window close (#147, #149).
|
||||||
|
- Log argv with home rewritten to `~`.
|
||||||
|
- Spec: [03](03-ipc-and-process-manager.md), [04](04-argyll-binaries.md) §0.3–0.5.
|
||||||
|
- Invariants: **#84** stdin independent of wait; **#116** exclusive ids; **#134** env var; **#147/#149** killAll on quit.
|
||||||
|
- Deps: 1 (app target), 3 (resolve — may stub paths in tests).
|
||||||
|
- Test CI: duplicate-id; stdin survives while wait runs; json-row isolation; killAll N children; captured returns full stdout. Hardware: N/A.
|
||||||
|
- AC: unit tests listed above pass.
|
||||||
|
|
||||||
|
**Issue 3 — Argyll sidecar fetch & binary resolution**
|
||||||
|
Labels: `Feature/DevOps`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- `fetch-argyll` pulls Gronod tag `v3.5.0-ICCery.1.x` into `Resources/argyll/{macos-universal,macos-x86_64,macos-aarch64}/` + `mocks/` + `reference_gamuts/` + `License.txt`. **Not git blobs** (#127).
|
||||||
|
- After fetch: **`codesign -s -` every Mach-O**, then `codesign -dvv` must succeed. Fail the script if still unsigned (#165 `Killed: 9`).
|
||||||
|
- `resolve_binary(name)`: settings `argyll_binary_dir` if the file exists → bundled dir; prefer `macos-universal` **only when that folder contains `instlist`**. Do not search `$PATH`.
|
||||||
|
- Ship **real** `sRGB.gam` from v0.8.5 `src/assets/sRGB.gam` as an app resource (not the 8-cusp stub in `reference_gamuts/`) — consumed by issue 28 (#185).
|
||||||
|
- Mocks (`chartread.mock`, `colprof.mock`, `profcheck.mock`) are first-class; M4/M5 CI must run them.
|
||||||
|
- Spec: [04](04-argyll-binaries.md) §0.1/§0.6, [05](05-argyll-fork.md) §8–10.
|
||||||
|
- Deps: 1.
|
||||||
|
- Test CI: fetch (or fixture) + resolve override + unsigned binary fails check. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 4 — Wizard state machine & artefact gating**
|
||||||
|
Labels: `Feature/Architecture`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- `WizardState`: `currentStage`, `basename`, `cwd`, `printerName`, `sessionMode`, `profileBasename`.
|
||||||
|
- `verifyStageArtefacts` from disk: `.ti1` / `.ti2` / `.ti3` / `.icc|.icm`. Forward gated; back always; re-validate on window focus + stage entry (#151).
|
||||||
|
- Basename: no `/`, `\`, `..`. Empty cwd illegal → Documents → Home → app-data (#59). No placeholder basenames (#60).
|
||||||
|
- Stage 4 is **not** unlocked by `.ti2`. Canonical `.ti3` means **accepted** measurement only (#109/#110).
|
||||||
|
- Spec: [06](06-wizard-and-artefacts.md), [02](02-architecture.md).
|
||||||
|
- Deps: 1.
|
||||||
|
- Test CI: gating matrix; focus re-lock. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 5 — Settings store, logging & settings dialog**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- `settings.json` under app-data for `com.gronod.iccery2` (do not read v1’s folder unless a future migration ticket says so).
|
||||||
|
- `AppSettings` defaults: `argyll_binary_dir` nil, `default_instrument` nil (**stored, never applied to argv**), `log_level` nil, `delta_e_good_max` 2.0, `delta_e_warning_max` 5.0 (`good < warning`, both ≥ 0), `custom_presets` [], `enable_i1pro2_leds` **false**, `calibration_stale_days` 30, `default_install_location` `user`, `ask_before_overwrite_profile` true, `open_color_panel_after_install` false. Invalid JSON → defaults.
|
||||||
|
- Logger: `~/Library/Logs/com.gronod.iccery2/iccery.log`, 5 MiB × 5. Level applied at startup **and** on save (#158).
|
||||||
|
- **Settings dialog in this ticket** (not M6): argyll dir picker, default instrument (display-only caveat), i1Pro2 LEDs, ΔE fields + inline error, stale days, install location, log level, open-log-folder / copy-path / copy-excerpt.
|
||||||
|
- Spec: [22](22-settings-presets.md), [02](02-architecture.md) logging, [21](21-ui-reference.md) settings ids.
|
||||||
|
- Deps: 1, 6 (directory picker — 6 may land in parallel; stub if needed).
|
||||||
|
- Test CI: validation strings; live log-level change; dialog bindings persist. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 6 — File dialogs & artefact helpers**
|
||||||
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M1
|
||||||
|
|
||||||
|
- **One NSOpenPanel/NSSavePanel wrapper per purpose** — never a shared picker:
|
||||||
|
- `selectTargetFile` save `.ti1`
|
||||||
|
- `selectExistingTarget` open `.ti1/.ti2`
|
||||||
|
- `selectProfileFile` `.icc/.icm/.mpp` (**not** `.ti*`, #172)
|
||||||
|
- `selectSpectrumFile` `.sp`
|
||||||
|
- `selectDatasetFile` **open** `.ti3/.txt/.cgats/.csv` (#211)
|
||||||
|
- `selectDirectory`, `selectCsvSavePath`, `selectCalFile`
|
||||||
|
- `readTiffPreviewPng`: host-side TIFF→PNG, max edge 1200, high-quality. **Never** feed TIFF to `NSImageView` as the gallery source (#58).
|
||||||
|
- `parseTi2Header`: `TARGET_INSTRUMENT`, `NUMBER_OF_SETS`, `NUMBER_OF_PAGES`, sibling `.ti1`.
|
||||||
|
- `resolveSafeCwd`, `getDefaultWorkingDir`, `getAppInfo`, `getProfilePath` (existing `.icm` else `.icc`, macOS default `.icc`, #69).
|
||||||
|
- Spec: [04](04-argyll-binaries.md) §0.2/§13, [24](24-issues-invariants.md) #103/#210/#211.
|
||||||
|
- Deps: 1.
|
||||||
|
- Test CI: filter + mode per picker; TIFF→PNG; ti2 fixtures. Hardware: N/A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M2
|
||||||
|
|
||||||
|
**Issue 7 — Stage 1: targen UI & argv builder**
|
||||||
|
Labels: `Feature/UI`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M2
|
||||||
|
|
||||||
|
- Always `-v -d {2|4}`. RGB `-d 2`, CMYK `-d 4`. **Do not pass `targen -u`.**
|
||||||
|
- Patch presets 400 / **800 default** / 1500 / custom; honour `-f N` (#44 — omitting yields Argyll 836).
|
||||||
|
- White `-e`, black `-B` (RGB default 4, CMYK 0 on colour-space switch).
|
||||||
|
- Advanced: `-g` `-s` `-n`; `-N` skip if ≈0.50; `-c` profile picker; `-G`; `-A` **even at 0.10** (no default-skip); algorithm `-t|-r|-R|-q|-Q|-i|-I` with **`ofps` / default = no extra flag**; CMYK-only `-l` 1–400; `-V` skip ≈1.0; `-p` skip ≈1.0 and >0.
|
||||||
|
- Basename + cwd required. Process id `targen_{basename}`.
|
||||||
|
- Spec: [08](08-stage1-targen.md), [04](04-argyll-binaries.md) §1.2.
|
||||||
|
- Deps: 2, 3, 4, 6.
|
||||||
|
- Test CI: argv golden vectors every flag combo. Hardware: N/A.
|
||||||
|
- AC: exit 0 → `.ti1` → Stage 2 unlocks.
|
||||||
|
|
||||||
|
**Issue 8 — Stage 1: resume existing target**
|
||||||
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M2
|
||||||
|
|
||||||
|
- Open dialog `.ti1/.ti2` (#103, #140). Jump: `.ti1`→Stage 2, `.ti2`→Stage 3 + “Resumed from .ti2”.
|
||||||
|
- Spec: [06](06-wizard-and-artefacts.md), [08](08-stage1-targen.md).
|
||||||
|
- Deps: 4, 6, 7.
|
||||||
|
- Test CI: resume fixtures. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 9 — Stage 2: printtarg UI & argv builder**
|
||||||
|
Labels: `Feature/UI`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M2
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always `-v -u`. Default layout **`-R 1`** (#163). Raster is `-r` (not `-R`). Custom seed `-R N` N≥1.
|
||||||
|
- `-d` is the **label** (fork #19 / ICCery #119), not colour space, not density. Auto: `ICCery - {basename} - {printer} - {ink} - {driverPaper} - {actualPaper} - DD/MM/YYYY HH:MM`.
|
||||||
|
- `-K`/`-I` **must exist on the builder in M2** (Stage 0 reuses it). UI toggle may wait for 29. **Never `-K` a `CAL_` basename.**
|
||||||
|
- Process id `printtarg_{basename}`. CM warning banner.
|
||||||
|
- Spec: [09](09-stage2-printtarg.md), [04](04-argyll-binaries.md) §2.2, [05](05-argyll-fork.md) §2.3.
|
||||||
|
- Deps: 2, 4, 7.
|
||||||
|
- Test CI: argv goldens including deterministic re-run identical `.ti2`. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 10 — Stage 2: JSON manifest & TIFF gallery**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M2
|
||||||
|
|
||||||
|
- Accumulate **all** stdout; `JSON.parse` the pretty-printed object (not brace-hunting, #68):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"event":"manifest","pages":[{"filename":"target.tif","patches":800,"width_mm":210,"height_mm":297}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Gallery: `readTiffPreviewPng` per page. Per-page Print buttons **stubbed** until M3.
|
||||||
|
- Spec: [09](09-stage2-printtarg.md), [05](05-argyll-fork.md) §2.3.
|
||||||
|
- Deps: 6, 9.
|
||||||
|
- Test CI: single + multi-page fixtures; non-zero exit stays on Stage 2. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 11 — Presets engine & built-ins**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/Medium`
|
||||||
|
Milestone: M2
|
||||||
|
|
||||||
|
- `ProfilingPreset` Codable: all Stage 1/2/4 fields in [22](22-settings-presets.md).
|
||||||
|
- Built-ins (all i1, FWA D50, seed 1, algorithm `l`):
|
||||||
|
1. `preset-std-rgb` — 800, A4, 8-bit, 300 dpi, quality `m`
|
||||||
|
2. `preset-hq-cmyk` — 1500, A3, 16-bit, quality `h`, ink 320, black 8
|
||||||
|
3. `preset-draft-rgb` — 400, A4, 8-bit, **150 dpi**, quality `l`
|
||||||
|
4. `preset-ultra-rgb` — 2500, A3, 16-bit, quality `u`, `-G`, white/black 6
|
||||||
|
- Apply/save/manage/delete-custom/import/export. Built-ins undeletable. Names via `Text` only (#114). DPI must bind (#113). Enable the M1 preset select.
|
||||||
|
- Deps: 5, 7, 9 (Stage 4 fields stored now, applied in 23).
|
||||||
|
- Test CI: round-trip; draft → 150 dpi; import schema. Hardware: N/A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M3
|
||||||
|
|
||||||
|
**Issue 12 — Printer enumeration & capabilities**
|
||||||
|
Labels: `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M3
|
||||||
|
|
||||||
|
- `lpstat -e` / `-p` / `-d`; `lpoptions -p` display name (`printer-info`); `lpoptions -p -l` trays/sizes/media; PPD `*Key id/Human:` enrichment.
|
||||||
|
- Models as draft (`Printer`, `PrinterTray`, `PrinterPaperSize`, `PrinterMediaType`, `PrinterCapabilities`).
|
||||||
|
- **Cite spec [10](10-print-system.md) + [11](11-print-macos.md).** Doc [13](13-print-linux.md) is Linux `lp -o raw` — **enumeration only, do not copy `print_target`.**
|
||||||
|
- Deps: 2 (optional CLI spawn) or Foundation `Process`.
|
||||||
|
- Test CI: recorded `lpstat`/`lpoptions` fixtures; empty list = success. Hardware: optional live queue list.
|
||||||
|
|
||||||
|
**Issue 13 — Native NSPrintPanel bound to selected queue**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/Critical`
|
||||||
|
Milestone: M3
|
||||||
|
|
||||||
|
- Preferences → **`NSPrintPanel` on the AppKit main thread**, bound with `PMPrinterCreateFromPrinterID(CUPS queue id)` → `PMSessionSetCurrentPMPrinter` → default settings/page format. Fallback `NSPrinter(name: displayName)`.
|
||||||
|
- Default button **"Use Settings"** (capture, not print). Cancel → `nil`, not error. `PMRelease` on every path.
|
||||||
|
- **Never** System Settings, CUPS web UI, or `NSWorkspace` open of the printer (#188).
|
||||||
|
- Spec: [11](11-print-macos.md).
|
||||||
|
- Deps: 12.
|
||||||
|
- Test CI: cancel path. Hardware: panel shows Epson/Canon **driver PDE**.
|
||||||
|
|
||||||
|
**Issue 14 — ColorSync suppression engine**
|
||||||
|
Labels: `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M3
|
||||||
|
|
||||||
|
Six layers (spec [11](11-print-macos.md) roster):
|
||||||
|
|
||||||
|
1. Session binding (issue 13).
|
||||||
|
2. `dlsym(RTLD_DEFAULT)`: `PMSessionSetColorMatchingModeLock` → `PMSessionSetColorMatchingMode` → `…NoLock`. **Signature `(PMPrintSession, CFStringRef) -> OSStatus`.** Modes `AP_ApplicationColorMatching` then `ApplicationColorMatching`. First `(symbol, mode)` returning 0 wins. **Never pass integer `1` (SIGSEGV).** Forbidden: `AP_ColorSyncMatching`, `AP_VendorColorMatching`.
|
||||||
|
3. `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, **locked**.
|
||||||
|
4. Driver bypass from `lpoptions -l`, **unlocked**: Canon `CNIJIntent2=4` else `CNIJIntent=4`; Epson `EPIJ_CCor=0` **if that key exists** else `EPIJ_CMat=3`; Gutenprint `StpColorCorrection=Uncorrected`; generic `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`.
|
||||||
|
5. Mirror into `NSPrintInfo.printSettings`.
|
||||||
|
6. After OK: `PMPrintSettingsToOptions` → filter (drop `com.apple.*`, collate/copies/job-sheets, empty values, **both AP_* keys** — issue 15 always re-adds them) → `capturedCupsOptions` + media type.
|
||||||
|
|
||||||
|
- Deps: 12, 13.
|
||||||
|
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
|
||||||
|
|
||||||
|
**Issue 15 — `lp` spool path**
|
||||||
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M3
|
||||||
|
|
||||||
|
```
|
||||||
|
lp -d <queue> -t "ICCery Target - <file>"
|
||||||
|
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
… captured options …
|
||||||
|
<tiff>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Never `-o raw`.** Raw skips the raster filter that honours `AP_*`.
|
||||||
|
- Then captured cups_options (sanitise keys/values — no `;`, newlines, or shell metacharacters; pass as `Process` args, never a shell string). Then media_type if absent, then driver bypass if no bypass key yet (**not** gated on a checkbox), then `orientation-requested=3|4`, then `PageSize`.
|
||||||
|
- Last argv is the TIFF path.
|
||||||
|
- Spec: [11](11-print-macos.md) `build_lp_args`.
|
||||||
|
- Deps: 14.
|
||||||
|
- Test CI: argv goldens — both AP_* always present; captured wins; sanitise rejects `;`. Hardware: one unmanaged page.
|
||||||
|
|
||||||
|
**Issue 17 — Stage 2 print panel UI**
|
||||||
|
Labels: `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M3
|
||||||
|
|
||||||
|
- After manifest: printer select + refresh, status badge, Preferences, tray, media type, orientation, Print All + per-page, CM banner, per-printer `capturedCupsOptions` cache. Cancel → info, not error. No CUPS “PPD fallback” checkbox (Windows/Linux only, #48).
|
||||||
|
- Spec: [09](09-stage2-printtarg.md), [10](10-print-system.md).
|
||||||
|
- Deps: 10, 12, 13, 15.
|
||||||
|
- Test CI: captured options replayed in argv. Hardware: layout→Preferences→Print All.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4
|
||||||
|
|
||||||
|
**Issue 18 — Instrument detection (`instlist`)**
|
||||||
|
Labels: `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M4
|
||||||
|
|
||||||
|
- Spawn `instlist` no args, id `instlist`, cwd inherited. Accumulate stdout; parse pretty JSON `{event:"instruments",devices:[{port,name,type}]}`. Regex fallback requiring known-instrument tokens.
|
||||||
|
- `port` is **1-based comm port** for `chartread -c`. Port `1` / Auto → **omit `-c`** (#111). Never pass array index. XY if name/type matches `/spectro\s?scan|i1io/i`.
|
||||||
|
- Duplicate Detect while running → exclusive-id error (#116).
|
||||||
|
- Spec: [15](15-stage3-chartread.md), [05](05-argyll-fork.md) §4.
|
||||||
|
- Deps: 2, 3.
|
||||||
|
- Test CI: JSON + regex fixtures; empty list. Hardware: Detect finds a real device.
|
||||||
|
|
||||||
|
**Issue 19 — chartread session & prompt classifier**
|
||||||
|
Labels: `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M4
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -u [-c port] [-Y l] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
- Process id `chartread_{basename}`. **Do not pass `chartread -u` omission — `-u` is required** (JSON rows).
|
||||||
|
- `-Y l` only when settings `enable_i1pro2_leds` (default **false**, #204). Letter L, not `-L`.
|
||||||
|
- Classifier: 11 states, priority matchers on **real C prompt strings** (spec [05](05-argyll-fork.md) §12.4). Sticky `TABLE_*` (#93). “Remove last sheet” info-only.
|
||||||
|
- stdin: `" \n"` / `"\n"` trigger/accept; `"d\n"` done+save (#175, **not EOF**); `"q\n"` abort. **No Skip/Undo sending `s`/`u`.** If a back-strip control is needed, map to real keys (`f`/`b`/`n`) after reading fork `chartread.c`.
|
||||||
|
- XY cancel: `q\n`, ~500ms, then kill (#147).
|
||||||
|
- CI must run `chartread.mock`.
|
||||||
|
- Spec: [15](15-stage3-chartread.md), [03](03-ipc-and-process-manager.md) stdin table.
|
||||||
|
- Deps: 2, 4, 18, 5 (LED flag).
|
||||||
|
- Test CI: port 39 classifier cases + real-prompt fixtures; Done → `.ti3`. Hardware: one real strip.
|
||||||
|
|
||||||
|
**Issue 20 — Live swatch grid & ΔE₀₀**
|
||||||
|
Labels: `Feature/UI`, `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M4
|
||||||
|
|
||||||
|
- `jsonRow` `event:row_complete`. Grid A→Z, 1→N LTR. 135° split TL intended / BR measured (#178). `is_pad` skip only if no measured **and** all-zero device (white `-e` still renders). XYZ 0–100 → /100 before Lab. Traffic lights from settings 2.0 / 5.0; reclassify on settings change. Distinct from Stage 5 bands (#95).
|
||||||
|
- Spec: [15](15-stage3-chartread.md), [05](05-argyll-fork.md) §3.
|
||||||
|
- Deps: 19, 5.
|
||||||
|
- Test CI: ΔE₀₀ vectors; pad/white cases. Hardware: N/A (uses json rows).
|
||||||
|
|
||||||
|
**Issue 21 — Multi-pass averaging**
|
||||||
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M4
|
||||||
|
|
||||||
|
- After chartread exit 0: `snapshot_ti3` copies to `{basename}_passN.ti3` (1-based) and **deletes** canonical (#109). Stage 4 stays locked (#110). Finish: 1 pass `promote_ti3`; ≥2 `average -v` relative names in project cwd then canonical `.ti3`.
|
||||||
|
- Deps: 2, 4, 19.
|
||||||
|
- Test CI: snapshot/promote; average argv; gating locked between passes. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 22 — XY-table flow UI**
|
||||||
|
Labels: `Feature/UI`, `Priority/Medium`
|
||||||
|
Milestone: M4
|
||||||
|
|
||||||
|
- Badges Place → Align → Scan → Remove from classifier. Cancel parks head (`q\n` then kill).
|
||||||
|
- Spec: [15](15-stage3-chartread.md) #93.
|
||||||
|
- Deps: 19.
|
||||||
|
- Test CI: simulated prompt script. Hardware: optional i1iO / SpectroScan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M5
|
||||||
|
|
||||||
|
**Issue 23 — Stage 4: colprof UI & argv builder**
|
||||||
|
Labels: `Feature/UI`, `Feature/Backend`, `Priority/Critical`
|
||||||
|
Milestone: M5
|
||||||
|
|
||||||
|
```
|
||||||
|
-v -a {l|x|X|m} -q {l|m|h|u} [-t intent] [-f [D50|D65|path.sp]] [-i] [-o] [-c inView] [-d outView] [-D] [-C] basename
|
||||||
|
```
|
||||||
|
|
||||||
|
- Default `-a l` (Lab cLUT), `-q m`. FWA: none omit / empty → bare `-f` / D50 / D65 / custom `.sp` via `selectSpectrumFile` (#210, #176). Viewing `-c`/`-d` skip `"none"` — these are **not** directories/labels.
|
||||||
|
- **Do not pass `colprof -u`.** Progress from stdout text. If someone later adds `-u`, it must be a **bare** flag (upstream `-u <token>` is white-point).
|
||||||
|
- Process id `colprof_{basename}` — payload/id parity (#56). Then `getProfilePath` (#69).
|
||||||
|
- Spec: [16](16-stage4-colprof.md), [04](04-argyll-binaries.md) §6.
|
||||||
|
- Deps: 2, 4, 6, 11.
|
||||||
|
- Test CI: argv goldens; mock colprof; exit 0 → Stage 5. Hardware: N/A (or full pipe).
|
||||||
|
|
||||||
|
**Issue 24 — Post-profile chain: applycal + iccgamut**
|
||||||
|
Labels: `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M5
|
||||||
|
|
||||||
|
- If Apply Calibration + `.cal`: `runCaptured` `applycal -v -a {cal} {icc}` via `{input}.applycal.tmp` + rename. **`applycal -u` means unapply — never send.**
|
||||||
|
- Then `iccgamut -v -d 10 {resolvedProfile}` (density, **not** a directory, #112). cwd = profile parent, id `iccgamut_{stem}` → `{stem}.gam`.
|
||||||
|
- Spec: [04](04-argyll-binaries.md) §7/§10, [17](17-stage5-verification.md).
|
||||||
|
- Deps: 2 (captured), 23.
|
||||||
|
- Test CI: applycal argv + tmp; iccgamut `-d 10`. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 25 — Stage 5: profcheck verification**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/Critical`
|
||||||
|
Milestone: M5
|
||||||
|
|
||||||
|
- `profcheck -v -k -s -u {ti3} {icc}`, id `profcheck_{ti3_path}`. Prefer JSON `event:report` / `*_de2000`; legacy text fallback; **unparseable → warning, never silent 0.00** (#179).
|
||||||
|
- Bands on **avg** ΔE₀₀: <1 Excellent, <2 Good, <3.5 Acceptable, ≥3.5 Warning — **not** swatch 2.0/5.0 (#95).
|
||||||
|
- Spec: [17](17-stage5-verification.md), [05](05-argyll-fork.md) §2.5.
|
||||||
|
- Deps: 2, 23.
|
||||||
|
- Test CI: JSON/text/garbage fixtures. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 26 — Verification history & drift analytics**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M5
|
||||||
|
|
||||||
|
- `verification_history.json`, cap 1000, **atomic `.tmp`+rename** (#213). Record fields as spec [17](17-stage5-verification.md). Consecutive-breach: ≥2 Warning on distinct days or ≥1h apart. RFC-4180 CSV.
|
||||||
|
- Deps: 25, 5.
|
||||||
|
- Test CI: crash-during-write keeps prior file; cap; CSV quotes; alert logic. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 27 — System profile installation**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M5
|
||||||
|
|
||||||
|
- **Copy, never move.** User `~/Library/ColorSync/Profiles`; system `/Library/ColorSync/Profiles` (error text must mention admin rights). Filename `{stem}.icc`; reject stem `.. / \`. Source file, `.icc/.icm`, ≥128 bytes. Collision overwrite | rename `{stem}-{epoch}.icc` | cancel. Atomic `{dest}.iccery-install.tmp`+rename. `open -a "ColorSync Utility"` if requested. `InstallResult` as spec [19](19-profile-install.md).
|
||||||
|
- Deps: 6, 5, 23.
|
||||||
|
- Test CI: dest matrix in temp dirs; collision; failed copy does not register. Hardware: profile appears in ColorSync Utility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M6
|
||||||
|
|
||||||
|
**Issue 28 — 3D gamut viewer (SceneKit)**
|
||||||
|
Labels: `Feature/UI`, `Feature/Backend`, `Priority/High`
|
||||||
|
Milestone: M6
|
||||||
|
|
||||||
|
- Parser: two CGATS `BEGIN_DATA` blocks — vertices `VERTEX_NO L a b` (discard index, push-order), then faces 0-based. `#` comments; native faces **>** convex hull; vertex-only → hull fallback. ASCII only.
|
||||||
|
- SceneKit (not Metal, not Three.js): X=a* ±128, Y=L* 0–100, Z=b* ±128; camera home (180,120,180) lookAt (0,50,0). Bundled **real** `sRGB.gam`. R resets camera on a **focusable** container (do not port dead-key / opacity-inference traps — spec [18](18-gamut-viewer.md) §10: **fix**, don’t clone).
|
||||||
|
- Load on Stage 5 entry + post-verify. No WebGL at boot (#225 class).
|
||||||
|
- Spec: [18](18-gamut-viewer.md), [23](23-assets.md) #185.
|
||||||
|
- Deps: 3 (asset), 24 (`.gam`).
|
||||||
|
- Test CI: dual-table / comments / OOB / stub→hull fixtures. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 29 — Stage 0: printer calibration**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: M6
|
||||||
|
|
||||||
|
- Enable Calibrate Printer. Basename `CAL_{run}` (never double-prefix). Flow: cal `targen` (`-f 0`, steps 11–51 default 21, `-e 4`, CMYK `-l` 200–400 default 320) → Stage 2 layout/print (**never `-K` the cal chart**) → Stage 3 measure → `runCaptured` `printcal -v -e [-I] [-z] [-a] [-m] [-xC…] -o out.cal CAL_{basename}` → Apply toggle drives profiling `printtarg -K` and post-colprof `applycal -a`.
|
||||||
|
- Collision Overwrite / Rename `{base}_{ISO}.cal` / Cancel. Stale: `calibration_stale_days` or printer mismatch. Curve SVG solid vs dashed identity.
|
||||||
|
- Spec: [07](07-stage0-calibration.md), [04](04-argyll-binaries.md) §1.3/§7/§8. Invariant **#224**.
|
||||||
|
- Deps: 2 (captured), 5, 7, 9, 15, 17, 19, 23, 24.
|
||||||
|
- Test CI: argv builders; `.cal` parse; toggle gates `-K`. Hardware: one cal loop on a real printer.
|
||||||
|
|
||||||
|
**Issue 30 — CGATS dataset interop**
|
||||||
|
Labels: `Feature/Backend`, `Priority/Medium`
|
||||||
|
Milestone: M6
|
||||||
|
|
||||||
|
- Import = **open** dialog + `setTarget` before jump (#211). Write canonical `.ti3` Argyll-strict. Device 0–255→0–100. Jump Stage 4/5; Stages 1–2 stay locked without `.ti1/.ti2`. User strings via `Text` only.
|
||||||
|
- Spec: [20](20-cgats-interop.md) #94/#211.
|
||||||
|
- Deps: 4, 6.
|
||||||
|
- Test CI: parse→write→reparse. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 31 — About & help chrome**
|
||||||
|
Labels: `Feature/UI`, `Priority/Medium`
|
||||||
|
Milestone: M6
|
||||||
|
|
||||||
|
- About: version + build date from `getAppInfo`. Global help: **overlay** tooltips that do not reflow (#171). Notification banner auto-hide. Settings dialog already shipped in issue 5.
|
||||||
|
- Spec: [21](21-ui-reference.md), [22](22-settings-presets.md).
|
||||||
|
- Deps: 1, 5.
|
||||||
|
- Test CI: tooltips do not change layout height. Hardware: N/A.
|
||||||
|
|
||||||
|
**Issue 32 — Packaging, signing & CI**
|
||||||
|
Labels: `Feature/DevOps`, `Priority/High`
|
||||||
|
Milestone: M6
|
||||||
|
|
||||||
|
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
||||||
|
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||||
|
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
||||||
|
- Confirm entitlements: sandbox **false**.
|
||||||
|
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
||||||
|
- Deps: all v2.0 issues 1–15, 17–31.
|
||||||
|
- Test CI: unsigned sidecar fails; tests run; dmg produced. Hardware: Gatekeeper-open the notarized dmg on a clean Mac.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Later (v2.1) — do not put on M3
|
||||||
|
|
||||||
|
**Issue 16 — Quartz print module (folded TargetPrint)**
|
||||||
|
Labels: `Feature/Backend`, `Feature/UI`, `Priority/High`
|
||||||
|
Milestone: **Later**
|
||||||
|
|
||||||
|
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
|
||||||
|
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
|
||||||
|
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.**
|
||||||
|
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
|
||||||
|
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
|
||||||
|
- AirPrint detection → persistent warning.
|
||||||
|
- Spec: [14](14-iccery-cpu-targetprint.md).
|
||||||
|
- Deps: 13, 17 (UI hook). Not required for M3 or M6 exit.
|
||||||
|
- Test CI: seam-integrity; module compiles standalone. Hardware: 1:1 on paper vs TIFF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency graph (issue numbers)
|
||||||
|
|
||||||
|
```
|
||||||
|
1
|
||||||
|
├─ 2 ─ 3
|
||||||
|
│ └─ 4 ─ 6 ─ 5
|
||||||
|
│ ├─ 7 ─ 8
|
||||||
|
│ │ └─ 9 ─ 10 ─ 11
|
||||||
|
│ │ └─ 12 ─ 13 ─ 14 ─ 15 ─ 17
|
||||||
|
│ └─ 18 ─ 19 ─ 20
|
||||||
|
│ ├─ 21, 22
|
||||||
|
│ └─ 23 ─ 24 ─ 25 ─ 26
|
||||||
|
│ └─ 27
|
||||||
|
├─ 28 ← 24
|
||||||
|
├─ 29 ← 7, 9, 15, 17, 19, 23, 24
|
||||||
|
├─ 30 ← 4, 6
|
||||||
|
├─ 31 ← 5
|
||||||
|
└─ 32 ← 1–15, 17–31
|
||||||
|
|
||||||
|
16 (Later) ← 13, 17
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Windows GDI / DEVMODE / `CREATE_NO_WINDOW`; Linux `lp -o raw` / udev; NSIS/WiX.
|
||||||
|
- Display calibration (#90), i18n (#96).
|
||||||
|
- Linking Argyll. WKWebView/WebGL.
|
||||||
|
- Shipping TargetPrint as the v2.0 spooler.
|
||||||
|
- Migrating v0.8.5 `settings.json` / history (new bundle id on purpose). Add a ticket later if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification after filing
|
||||||
|
|
||||||
|
1. 7 milestones (M1–M6 + Later), all open.
|
||||||
|
2. 32 issues, all with `Project/ICCery-v2` + Feature + Priority + a milestone.
|
||||||
|
3. Issue 16 is on **Later**, not M3.
|
||||||
|
4. Issue 2 body contains `runCaptured`.
|
||||||
|
5. Issue 5 body contains the settings **dialog**.
|
||||||
|
6. Issue 1 min OS is **14.0** and bundle id is **`com.gronod.iccery2`**.
|
||||||
|
7. Spot-check issues 14, 15, 19, 24 for ColorSync SPI arity, no `-o raw`, no `s`/`u`, `iccgamut -d 10`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ICCery Rewrite Specification
|
||||||
|
|
||||||
|
**Product:** ICCery — printer ICC/ICM profiling frontend for ArgyllCMS
|
||||||
|
**Source analysed:** [git.i3omb.com/gronod/ICCery](https://git.i3omb.com/gronod/ICCery) `v0.8.5` (`main`, merge #229)
|
||||||
|
**Related:** [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) (fork of ArgyllCMS 3.5.0), [gronod/ICCery-CPU](https://git.i3omb.com/gronod/ICCery-CPU) (`TargetPrint`)
|
||||||
|
**Current stack (do not reuse):** Tauri v2 + Rust host + vanilla JS + Three.js
|
||||||
|
**Licence:** ICCery GUI is proprietary EULA; ArgyllCMS binaries are AGPLv3 and **must remain subprocess-isolated** (stdin/stdout/stderr only — never link)
|
||||||
|
|
||||||
|
This folder is a complete functional specification of the existing application so it can be reimplemented in a different stack under the same name, using the same graphical assets (CMY ice-cream-cone wordmark).
|
||||||
|
|
||||||
|
## How to use these documents
|
||||||
|
|
||||||
|
Read in this order if you are implementing:
|
||||||
|
|
||||||
|
1. [01-overview.md](01-overview.md) — product, wizard, platforms
|
||||||
|
2. [02-architecture.md](02-architecture.md) — process isolation, modules, events
|
||||||
|
3. [03-ipc-and-process-manager.md](03-ipc-and-process-manager.md) — spawn / stdin / kill contract
|
||||||
|
4. [04-argyll-binaries.md](04-argyll-binaries.md) — **every** Argyll CLI invocation
|
||||||
|
5. [05-argyll-fork.md](05-argyll-fork.md) — `-u` JSON protocols, `instlist`, `-Y l`, Windows pipe fix
|
||||||
|
6. Wizard stages: [06](06-wizard-and-artefacts.md) → [07](07-stage0-calibration.md) → [08](08-stage1-targen.md) → [09](09-stage2-printtarg.md) → [15](15-stage3-chartread.md) → [16](16-stage4-colprof.md) → [17](17-stage5-verification.md)
|
||||||
|
7. Print (critical): [10](10-print-system.md) → [11-print-macos.md](11-print-macos.md) → [12](12-print-windows.md) → [13](13-print-linux.md) → [14](14-iccery-cpu-targetprint.md)
|
||||||
|
8. [18-gamut-viewer.md](18-gamut-viewer.md)
|
||||||
|
9. [21-ui-reference.md](21-ui-reference.md) — every control id
|
||||||
|
10. [24-issues-invariants.md](24-issues-invariants.md) — bugs that must not be repeated
|
||||||
|
11. [25-rewrite-notes.md](25-rewrite-notes.md) — stack-agnostic idiosyncrasies
|
||||||
|
12. [26-v2-mac-ticket-plan.md](26-v2-mac-ticket-plan.md) — **reviewed** Gitea milestone/ticket plan for the macOS SwiftUI rewrite (executable; supersedes the 2026-09-08 draft)
|
||||||
|
|
||||||
|
## Non-goals of the original (do not reintroduce)
|
||||||
|
|
||||||
|
- Display calibration wizard (`dispwin` / `dispread`) — won't-fix (#90)
|
||||||
|
- i18n (en/de/fr/ja) — won't-fix (#96); Argyll diagnostics are English-only
|
||||||
|
- Linking against Argyll as a library (AGPL contamination)
|
||||||
|
|
||||||
|
## Source map (legacy Tauri tree)
|
||||||
|
|
||||||
|
| Area | Path |
|
||||||
|
|------|------|
|
||||||
|
| Wizard HTML | `src/index.html` |
|
||||||
|
| Frontend modules | `src/js/*.js` |
|
||||||
|
| Styles | `src/styles/main.css` |
|
||||||
|
| Logo | `src/assets/ICCery-logo.svg` |
|
||||||
|
| Rust host | `src-tauri/src/` |
|
||||||
|
| Print backends | `src-tauri/src/print/{mod,macos,windows,unix}.rs` |
|
||||||
|
| Argyll sidecars | `src-tauri/argyll/<platform>/` (not in git; `npm run fetch-argyll`) |
|
||||||
|
| Bundled sRGB gamut | `src/assets/sRGB.gam` |
|
||||||
|
|
||||||
|
Analysed at 2026-09-08 from Gitea (`109` ICCery issues, `13` Argyll-fork issues, `3` ICCery-CPU issues) plus a full source read of `v0.8.5`.
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "README",
|
||||||
|
"file": "README.md",
|
||||||
|
"title": "ICCery Rewrite Specification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "01-overview",
|
||||||
|
"file": "01-overview.md",
|
||||||
|
"title": "01 \u2014 Product overview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "02-architecture",
|
||||||
|
"file": "02-architecture.md",
|
||||||
|
"title": "02 \u2014 Architecture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "03-ipc-and-process-manager",
|
||||||
|
"file": "03-ipc-and-process-manager.md",
|
||||||
|
"title": "03 \u2014 IPC and process manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "04-argyll-binaries",
|
||||||
|
"file": "04-argyll-binaries.md",
|
||||||
|
"title": "04 \u2014 Argyll binary invocations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "05-argyll-fork",
|
||||||
|
"file": "05-argyll-fork.md",
|
||||||
|
"title": "05 \u2014 Gronod ArgyllCMS fork protocols"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "06-wizard-and-artefacts",
|
||||||
|
"file": "06-wizard-and-artefacts.md",
|
||||||
|
"title": "06 \u2014 Wizard, artefact gating, resume"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "07-stage0-calibration",
|
||||||
|
"file": "07-stage0-calibration.md",
|
||||||
|
"title": "07 \u2014 Stage 0: printer calibration (`printcal` / `applycal`) (#224)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "08-stage1-targen",
|
||||||
|
"file": "08-stage1-targen.md",
|
||||||
|
"title": "08 \u2014 Stage 1: `targen`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "09-stage2-printtarg",
|
||||||
|
"file": "09-stage2-printtarg.md",
|
||||||
|
"title": "09 \u2014 Stage 2: `printtarg` and target layout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "10-print-system",
|
||||||
|
"file": "10-print-system.md",
|
||||||
|
"title": "10 \u2014 Print system (shared types & UI)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "11-print-macos",
|
||||||
|
"file": "11-print-macos.md",
|
||||||
|
"title": "11 \u2014 macOS printing and ColorSync suppression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "12-print-windows",
|
||||||
|
"file": "12-print-windows.md",
|
||||||
|
"title": "12 \u2014 Windows GDI raw printing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "13-print-linux",
|
||||||
|
"file": "13-print-linux.md",
|
||||||
|
"title": "13 \u2014 Linux CUPS printing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "14-iccery-cpu-targetprint",
|
||||||
|
"file": "14-iccery-cpu-targetprint.md",
|
||||||
|
"title": "14 \u2014 ICCery-CPU / TargetPrint (macOS companion)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "15-stage3-chartread",
|
||||||
|
"file": "15-stage3-chartread.md",
|
||||||
|
"title": "15 \u2014 Stage 3: `chartread`, averaging, swatch grid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "16-stage4-colprof",
|
||||||
|
"file": "16-stage4-colprof.md",
|
||||||
|
"title": "16 \u2014 Stage 4: `colprof` (#7, #56, #176)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "17-stage5-verification",
|
||||||
|
"file": "17-stage5-verification.md",
|
||||||
|
"title": "17 \u2014 Stage 5: verification, drift, install"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "18-gamut-viewer",
|
||||||
|
"file": "18-gamut-viewer.md",
|
||||||
|
"title": "18 \u2014 3D gamut viewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "19-profile-install",
|
||||||
|
"file": "19-profile-install.md",
|
||||||
|
"title": "19 \u2014 System profile installation (#223)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "20-cgats-interop",
|
||||||
|
"file": "20-cgats-interop.md",
|
||||||
|
"title": "20 \u2014 CGATS dataset interop (#94, #211)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "21-ui-reference",
|
||||||
|
"file": "21-ui-reference.md",
|
||||||
|
"title": "21 \u2014 UI reference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "22-settings-presets",
|
||||||
|
"file": "22-settings-presets.md",
|
||||||
|
"title": "22 \u2014 Settings and presets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "23-assets",
|
||||||
|
"file": "23-assets.md",
|
||||||
|
"title": "23 \u2014 Graphical assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "24-issues-invariants",
|
||||||
|
"file": "24-issues-invariants.md",
|
||||||
|
"title": "24 \u2014 Issue tickets and rewrite invariants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "25-rewrite-notes",
|
||||||
|
"file": "25-rewrite-notes.md",
|
||||||
|
"title": "25 \u2014 Rewrite notes and idiosyncrasies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "26-v2-mac-ticket-plan",
|
||||||
|
"file": "26-v2-mac-ticket-plan.md",
|
||||||
|
"title": "26 \u2014 v2 Mac Gitea ticket plan (reviewed)"
|
||||||
|
}
|
||||||
|
]
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
name: ICCery
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.gronod
|
||||||
|
deploymentTarget:
|
||||||
|
macOS: "14.0"
|
||||||
|
groupSortPosition: top
|
||||||
|
|
||||||
|
packages:
|
||||||
|
ICCeryCore:
|
||||||
|
path: Packages/ICCeryCore
|
||||||
|
|
||||||
|
targets:
|
||||||
|
ICCery:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
sources:
|
||||||
|
- path: Sources/ICCery
|
||||||
|
- path: Resources
|
||||||
|
excludes:
|
||||||
|
- ICCery.entitlements
|
||||||
|
- Argyll
|
||||||
|
- path: Resources/Argyll
|
||||||
|
type: folder
|
||||||
|
dependencies:
|
||||||
|
- package: ICCeryCore
|
||||||
|
product: ICCeryCore
|
||||||
|
postBuildScripts:
|
||||||
|
- name: Copy Argyll sidecars
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
SRC="${SRCROOT}/Vendor/Argyll"
|
||||||
|
DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll"
|
||||||
|
if [ -d "$SRC" ]; then
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
rsync -a "$SRC/" "$DEST/"
|
||||||
|
else
|
||||||
|
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||||
|
fi
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||||
|
PRODUCT_NAME: ICCery
|
||||||
|
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
||||||
|
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||||
|
MARKETING_VERSION: "2.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
ENABLE_HARDENED_RUNTIME: YES
|
||||||
|
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
||||||
|
CODE_SIGN_IDENTITY: "-"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
ENABLE_APP_SANDBOX: NO
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
ARCHS: "$(ARCHS_STANDARD)"
|
||||||
|
|
||||||
|
ICCeryCoreTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
sources:
|
||||||
|
- path: Tests/ICCeryCoreTests
|
||||||
|
dependencies:
|
||||||
|
- package: ICCeryCore
|
||||||
|
product: ICCeryCore
|
||||||
|
- target: ICCery
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
CODE_SIGN_IDENTITY: "-"
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
ICCery:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
ICCery: all
|
||||||
|
ICCeryCoreTests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
gatherCoverageData: false
|
||||||
|
targets:
|
||||||
|
- ICCeryCoreTests
|
||||||
Executable
+140
@@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# scripts/fetch-argyll.sh
|
||||||
|
#
|
||||||
|
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
|
||||||
|
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
|
||||||
|
#
|
||||||
|
# Env overrides (parity with v1 fetch-argyll.mjs):
|
||||||
|
# ARGYLL_SERVER_URL default https://git.i3omb.com
|
||||||
|
# ARGYLL_REPO default gronod/argyllcms
|
||||||
|
# ARGYLL_RELEASE_TAG default: latest release
|
||||||
|
# GITEA_TOKEN optional, for private repos
|
||||||
|
#
|
||||||
|
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
|
||||||
|
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
|
||||||
|
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
|
||||||
|
# they ship in git, not in the release tarball.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
|
||||||
|
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
|
||||||
|
TAG="${ARGYLL_RELEASE_TAG:-}"
|
||||||
|
SUFFIX="_macOS_universal_bin.tgz"
|
||||||
|
PLATFORM_DIR="macos-universal"
|
||||||
|
MARKER="instlist"
|
||||||
|
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) FORCE=1 ;;
|
||||||
|
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
|
||||||
|
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUTH_HEADER=""
|
||||||
|
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_get() {
|
||||||
|
if [ -n "$AUTH_HEADER" ]; then
|
||||||
|
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
|
||||||
|
else
|
||||||
|
curl -fsSL -H 'Accept: application/json' "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$TAG" ]; then
|
||||||
|
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
|
||||||
|
else
|
||||||
|
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Fetching release info from $API_URL"
|
||||||
|
RELEASE_JSON="$(api_get "$API_URL")" || {
|
||||||
|
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the macOS universal asset's browser_download_url without jq.
|
||||||
|
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
|
||||||
|
| tr ',' '\n' \
|
||||||
|
| grep '"browser_download_url"' \
|
||||||
|
| grep "$SUFFIX" \
|
||||||
|
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
|
||||||
|
| head -n 1)"
|
||||||
|
|
||||||
|
if [ -z "$ASSET_URL" ]; then
|
||||||
|
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
|
||||||
|
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading $ASSET_URL"
|
||||||
|
TMPDIR_FETCH="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
|
||||||
|
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
|
||||||
|
|
||||||
|
if [ -n "$AUTH_HEADER" ]; then
|
||||||
|
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
|
||||||
|
else
|
||||||
|
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXTRACT="$TMPDIR_FETCH/extract"
|
||||||
|
mkdir -p "$EXTRACT"
|
||||||
|
tar -xzf "$ARCHIVE" -C "$EXTRACT"
|
||||||
|
|
||||||
|
# Archive contains Argyll_V*/bin/ (or a bare bin/).
|
||||||
|
BIN_DIR=""
|
||||||
|
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
|
||||||
|
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
|
||||||
|
done
|
||||||
|
if [ -z "$BIN_DIR" ]; then
|
||||||
|
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cp -R "$BIN_DIR"/. "$DEST"/
|
||||||
|
find "$DEST" -type f -exec chmod 0755 {} +
|
||||||
|
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||||
|
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||||
|
# verify — an unsigned sidecar fails the script.
|
||||||
|
for f in "$DEST"/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
if file -b "$f" | grep -q 'Mach-O'; then
|
||||||
|
codesign -f -s - "$f" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
UNSIGNED=""
|
||||||
|
for f in "$DEST"/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
if file -b "$f" | grep -q 'Mach-O'; then
|
||||||
|
if ! codesign -dvv "$f" >/dev/null 2>&1; then
|
||||||
|
UNSIGNED="$UNSIGNED $f"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$UNSIGNED" ]; then
|
||||||
|
echo "error: unsigned binaries remain:$UNSIGNED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$DEST/$MARKER" ]; then
|
||||||
|
echo "error: marker binary $MARKER missing after extraction" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"
|
||||||
Reference in New Issue
Block a user