diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift
new file mode 100644
index 0000000..d7c07f6
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift
@@ -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 `
/`
+/// exists there.
+/// 2. Bundled `/Resources/Argyll//`.
+/// 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)
+ }
+}
diff --git a/Resources/Argyll/mocks/chartread.mock b/Resources/Argyll/mocks/chartread.mock
new file mode 100755
index 0000000..35d2b08
--- /dev/null
+++ b/Resources/Argyll/mocks/chartread.mock
@@ -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
diff --git a/Resources/Argyll/mocks/colprof.mock b/Resources/Argyll/mocks/colprof.mock
new file mode 100755
index 0000000..8bbd22c
--- /dev/null
+++ b/Resources/Argyll/mocks/colprof.mock
@@ -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
diff --git a/Resources/Argyll/mocks/profcheck.mock b/Resources/Argyll/mocks/profcheck.mock
new file mode 100755
index 0000000..c424509
--- /dev/null
+++ b/Resources/Argyll/mocks/profcheck.mock
@@ -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
diff --git a/Resources/Argyll/reference_gamuts/sRGB.gam b/Resources/Argyll/reference_gamuts/sRGB.gam
new file mode 100644
index 0000000..9826a02
--- /dev/null
+++ b/Resources/Argyll/reference_gamuts/sRGB.gam
@@ -0,0 +1,16 @@
+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
+1 100.0 0.0 0.0
+2 53.2 80.1 67.2
+3 87.7 -86.2 83.2
+4 97.1 -21.6 94.5
+5 32.3 79.2 -107.9
+6 60.3 98.2 -60.8
+7 91.1 -48.1 -14.1
+END_DATA
diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift
index 9dba46a..e43c41b 100644
--- a/Sources/ICCery/ICCeryApp.swift
+++ b/Sources/ICCery/ICCeryApp.swift
@@ -1,4 +1,5 @@
import AppKit
+import ICCeryCore
import SwiftUI
@main
@@ -19,15 +20,24 @@ struct ICCeryApp: App {
}
}
-/// AppDelegate: quit when the single window closes, and give later
-/// milestones a hook to `killAll` Argyll children before teardown
-/// (#147/#149 — wired once ProcessManager exists in #2).
+/// 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 applicationWillTerminate(_ notification: Notification) {
- // Issue #2+: ProcessManager.shared.killAll()
+ 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
}
}
diff --git a/Tests/ICCeryCoreTests/BinaryResolverTests.swift b/Tests/ICCeryCoreTests/BinaryResolverTests.swift
new file mode 100644
index 0000000..c1c7c76
--- /dev/null
+++ b/Tests/ICCeryCoreTests/BinaryResolverTests.swift
@@ -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")
+ }
+}
diff --git a/project.yml b/project.yml
index 4618da8..1afb884 100644
--- a/project.yml
+++ b/project.yml
@@ -19,9 +19,25 @@ targets:
- 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
diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh
new file mode 100755
index 0000000..1a44c89
--- /dev/null
+++ b/scripts/fetch-argyll.sh
@@ -0,0 +1,118 @@
+#!/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/ # 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__$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
+
+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"