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"