diff --git a/.gitignore b/.gitignore
index 6e57a6a..d131153 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,7 +13,10 @@ DerivedData/
Package.resolved
# Fetched Argyll sidecars (release artefacts, not git blobs — #127)
-Resources/argyll/
+Vendor/Argyll/
+
+# XcodeGen output (regenerate with `make gen`)
+ICCery.xcodeproj/
# macOS
.DS_Store
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..70e8da4
--- /dev/null
+++ b/Makefile
@@ -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
diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift
new file mode 100644
index 0000000..7de992e
--- /dev/null
+++ b/Packages/ICCeryCore/Package.swift
@@ -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"),
+ ]
+)
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/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift
new file mode 100644
index 0000000..48303ab
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift
@@ -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"
+ )
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift
new file mode 100644
index 0000000..94f62b0
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
+public struct StageArtefacts: Sendable, Equatable {
+ /// `.ti1` exists (Stage 1 done → unlocks Stage 2).
+ public var stage1Complete = false
+ /// `.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
+ public var stage2Complete = false
+ /// `.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
+ }
+
+ /// `/.` — 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_` namespace.
+ /// Multi-page TIFFs match `.tif`, `.1.tif` … and
+ /// `_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 = ["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)
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift
new file mode 100644
index 0000000..40a29b0
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift
@@ -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 `.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)
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift
new file mode 100644
index 0000000..3b158a5
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift
@@ -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
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift
new file mode 100644
index 0000000..79560e9
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift
@@ -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 `.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[.. 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
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift
new file mode 100644
index 0000000..c3cfdc7
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift
@@ -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()) }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift
new file mode 100644
index 0000000..a5d54e0
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift
@@ -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: " ")
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift
new file mode 100644
index 0000000..231c30a
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift
@@ -0,0 +1,127 @@
+import Foundation
+import OSLog
+
+/// Rolling file sink for `AppLogger` — `~/Library/Logs//
+/// 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)
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift
new file mode 100644
index 0000000..24a280f
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift
@@ -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//`
+/// - Log file: `~/Library/Logs//iccery.log`
+/// - Bundled Argyll tools: `/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)
+ }
+
+ /// `/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)
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift
new file mode 100644
index 0000000..40e2c23
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift
@@ -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(_ 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
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift
new file mode 100644
index 0000000..d04acf3
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift
@@ -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)
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift
new file mode 100644
index 0000000..8377a51
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift
@@ -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)" }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift
new file mode 100644
index 0000000..ab79ff6
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift
@@ -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)
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift
new file mode 100644
index 0000000..1e130af
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift
@@ -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.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 {
+ 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.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))
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift
new file mode 100644
index 0000000..2464ac9
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift
@@ -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 }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift
new file mode 100644
index 0000000..f4a679c
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift
@@ -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])
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift
new file mode 100644
index 0000000..91973d0
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift
@@ -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
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift
new file mode 100644
index 0000000..c34c8a0
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift
@@ -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]
+ }
+}
diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift
new file mode 100644
index 0000000..85da63e
--- /dev/null
+++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift
@@ -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)
+ }
+}
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..993c5b5
--- /dev/null
+++ b/Resources/Argyll/reference_gamuts/sRGB.gam
@@ -0,0 +1,1379 @@
+GAMUT
+
+DESCRIPTOR "Argyll Gamut surface poligon data"
+ORIGINATOR "Argyll CMS gamut library"
+CREATED "Tue Aug 25 10:22:46 2026"
+COLOR_REP "LAB"
+GAMUT_CENTER "50.000000 0.000000 0.000000"
+CSPACE_WHITE "100.000000 -2.387816 -19.404026"
+GAMUT_WHITE "99.998874 -2.387789 -19.403808"
+CSPACE_BLACK "0.000000 0.000000 0.000000"
+GAMUT_BLACK "0.000000 0.000000 0.000000"
+CUSP_RED "53.237382 78.287871 62.148058"
+CUSP_YELLOW "97.137321 -23.769791 84.721311"
+CUSP_GREEN "87.733928 -87.887785 73.898355"
+CUSP_CYAN "91.113320 -50.031832 -33.437063"
+CUSP_BLUE "32.301170 77.838134 -126.416203"
+CUSP_MAGENTA "60.323069 96.208467 -79.513768"
+# 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
+1 87.73393 -87.88779 73.89835
+2 32.30117 77.83813 -126.4162
+3 97.13732 -23.76979 84.72131
+4 60.32307 96.20847 -79.51377
+5 92.08525 -14.24625 81.54220
+6 53.35447 78.60413 50.79253
+7 83.87564 2.280649 76.53540
+8 80.69498 9.066293 74.66362
+9 77.59264 15.90655 72.88418
+10 57.93092 64.97051 63.46530
+11 58.76878 62.66166 63.75963
+12 53.57668 79.20211 36.15187
+13 89.34786 -74.05441 75.78451
+14 71.68937 29.56313 69.65578
+15 65.07949 45.92003 66.37764
+16 73.12190 26.16928 70.41736
+17 66.31765 42.76656 66.95748
+18 53.65394 79.40935 32.13248
+19 90.79613 -62.92803 77.46661
+20 91.08723 -60.81567 77.80354
+21 53.94195 80.17896 19.76368
+22 92.04631 -54.11558 78.91087
+23 54.05752 80.48649 15.59671
+24 92.39458 -51.77448 79.31196
+25 95.22381 -34.29738 82.55042
+26 54.46471 81.56421 3.147035
+27 55.15354 83.36736 -13.13741
+28 58.23555 91.15715 -58.67024
+29 56.25383 86.19822 -32.74892
+30 43.82204 44.39999 -107.3457
+31 88.00240 -84.44335 56.17783
+32 53.79934 90.68751 -90.31915
+33 52.74215 89.83989 -92.07732
+34 88.28748 -80.88480 41.84045
+35 91.11332 -50.03183 -33.43706
+36 50.66167 88.21685 -95.54287
+37 55.47044 15.53048 -88.43226
+38 70.25635 -15.11766 -65.05040
+39 88.51724 -78.08693 32.21627
+40 49.64029 87.44353 -97.24689
+41 50.24043 27.91848 -96.87445
+42 90.56657 -55.46975 -23.06920
+43 12.35755 45.69866 -74.21878
+44 13.42559 47.41983 -77.01411
+45 90.39594 -57.21271 -19.59352
+46 67.01897 -70.33719 59.14136
+47 89.11856 -71.03844 11.82216
+48 74.03954 -22.07350 -59.19191
+49 90.07208 -60.58426 -12.62126
+50 49.46805 -55.46731 46.63837
+51 64.65829 -3.983664 -73.81222
+52 75.93542 -25.44286 -56.27611
+53 62.76463 -0.333769 -76.80409
+54 5.923683 34.08038 -56.46335
+55 89.62939 -65.33530 -2.130596
+56 2.837725 19.48753 -41.27759
+57 15.14731 -26.38932 20.02350
+58 28.15269 -37.40803 31.45365
+59 17.84328 -28.67346 22.94881
+60 30.63103 -39.50779 33.21918
+61 85.42860 -41.27479 -41.88407
+62 2.264561 15.55144 -36.92225
+63 87.32555 -44.25171 -39.05084
+64 3.487739 23.76854 -45.40603
+65 35.09873 57.77819 45.86665
+66 26.18016 47.69382 36.88900
+67 9.565408 -19.61366 13.12312
+68 1.765340 12.12314 -32.33317
+69 24.64917 45.96270 35.11541
+70 99.99887 -2.368094 -19.42243
+71 0.00000 0.00000 0.00000
+72 73.84328 58.41718 -58.35473
+73 89.01410 21.35955 -35.43485
+74 90.53891 17.92010 -33.18221
+75 92.08034 14.49254 -30.91473
+76 77.62019 48.68730 -52.56424
+77 13.46979 33.32198 20.41046
+78 86.02128 28.25508 -39.88383
+79 11.79010 31.42273 17.96859
+80 0.976169 6.703653 -22.41022
+81 78.94902 45.34606 -50.54002
+82 97.40326 -21.64644 65.83800
+83 84.55614 31.70238 -42.07507
+84 97.87024 -17.99013 42.20060
+85 99.53319 -5.650714 -9.059997
+86 99.24797 -7.696724 -2.125014
+87 99.11290 -8.675347 1.345406
+88 98.62197 -12.28656 15.20945
+89 99.38806 -6.688337 -5.594113
+90 98.98277 -9.624138 4.816620
+91 98.73735 -11.43000 11.75069
+92 98.85759 -10.54257 8.286179
+93 98.39120 0.957317 -21.73439
+94 0.679866 4.668850 -17.05062
+95 1.282928 -2.661895 1.764970
+96 0.129513 0.889405 -3.341660
+97 6.734616 -13.97338 9.265051
+98 5.199688 21.73802 7.977318
+99 99.52551 -4.488846 -20.16416
+100 98.18088 -10.71721 -22.27412
+101 10.15105 -14.53865 -3.319695
+102 3.938488 17.17106 6.042395
+103 1.664400 -0.998752 2.350220
+104 9.951148 -16.30966 1.630954
+105 93.99286 -32.42990 -28.87434
+106 10.39826 -12.29228 -8.240623
+107 93.73185 -33.92167 -29.28710
+108 0.825811 4.714559 -10.87949
+109 9.796380 -17.65318 6.241842
+110 8.071554 -4.789824 -17.08635
+111 1.547389 -0.845764 -5.058574
+112 7.646280 28.27220 -11.70984
+113 7.710785 -7.269724 -12.26130
+114 4.531049 -8.243161 2.713690
+115 10.19545 29.79051 12.06211
+116 8.489888 -1.807740 -21.73665
+117 10.69333 -9.569632 -13.07176
+118 15.40892 -23.12967 8.665202
+119 2.591700 3.044100 3.772876
+120 5.703828 26.49037 -25.86414
+121 3.285427 6.068621 4.837187
+122 3.595860 1.268164 -18.47215
+123 6.203050 28.34728 -30.40435
+124 4.382828 20.02936 -5.422344
+125 1.438284 6.595400 -1.333755
+126 20.91115 -7.754914 26.97127
+127 20.27872 -11.23640 26.16018
+128 13.07359 -16.15192 -4.356306
+129 19.75035 24.39195 -61.99173
+130 20.43478 27.07432 -65.41167
+131 7.513566 -10.57730 10.46011
+132 5.081402 -4.463716 -10.68033
+133 5.377705 -2.428913 -15.78637
+134 21.61150 -4.222775 27.85812
+135 7.464250 30.09177 -28.31670
+136 23.48754 27.82838 -69.36887
+137 19.23150 -17.78240 24.79616
+138 30.72092 34.40688 -83.56651
+139 21.24832 7.098725 28.18333
+140 22.37545 -0.689668 28.81160
+141 28.48420 11.23173 -56.81067
+142 5.221417 14.44958 7.807366
+143 27.77366 37.12635 37.13350
+144 3.555104 17.20435 -12.53467
+145 19.39693 11.38805 26.46904
+146 47.69490 -47.30841 18.78004
+147 6.482617 18.55770 9.742288
+148 36.73982 11.47773 -65.27615
+149 4.202949 18.92348 -0.781149
+150 62.93561 -64.00783 42.28715
+151 31.67616 21.19027 38.39296
+152 65.12867 -65.33676 41.60032
+153 4.665997 -7.316435 -0.768194
+154 17.39142 22.45460 24.82553
+155 20.90425 -25.78611 6.307888
+156 30.74844 -38.00092 25.43964
+157 26.66549 -0.0907351 -41.71491
+158 13.91397 34.50998 3.577564
+159 20.33466 3.625928 27.01590
+160 4.068001 18.04872 2.700735
+161 15.90574 -20.09466 21.08254
+162 30.98258 -35.10897 14.50448
+163 35.98428 -37.59082 12.34234
+164 11.88289 -5.858106 16.57264
+165 10.67036 -13.02915 14.77963
+166 33.49846 -36.37075 13.41920
+167 32.29113 14.26994 38.16461
+168 47.73146 -1.699098 -60.36746
+169 30.09398 36.68870 39.02219
+170 26.37535 -26.46001 -0.308204
+171 28.98639 -30.35030 32.42729
+172 15.46523 8.927534 21.70760
+173 20.77851 -27.26990 10.70253
+174 45.39094 -0.217804 -59.91525
+175 17.88513 -0.883742 -32.12110
+176 14.74711 23.14890 21.53339
+177 72.35878 -60.47868 11.85376
+178 91.67496 -32.25284 -32.33429
+179 92.20045 -29.29361 -31.50100
+180 92.48407 -27.72691 -31.05153
+181 77.63971 34.10040 -52.78188
+182 83.30741 27.60819 -44.07625
+183 13.76159 34.10536 8.718749
+184 75.54892 35.67697 -56.03791
+185 43.72381 -36.17902 -2.844653
+186 76.39551 30.21868 -54.79453
+187 76.21464 20.90006 -55.22162
+188 13.64415 33.79146 13.43158
+189 78.87199 21.14692 -51.06609
+190 87.32002 15.09810 -38.13399
+191 67.26989 26.66099 -69.28892
+192 38.66394 62.86364 10.75828
+193 35.19656 58.04239 36.57309
+194 83.65310 20.03624 -43.67447
+195 90.66601 -12.51473 -33.53344
+196 66.21972 13.60448 -71.12634
+197 89.57944 -17.91095 -35.26060
+198 37.26335 61.33582 8.675190
+199 23.91933 -23.30291 -3.733546
+200 2.682365 13.39937 -13.90632
+201 98.13709 -14.63988 14.48116
+202 74.35996 -7.603405 -58.51742
+203 74.68159 15.36010 -57.70693
+204 37.13105 60.98447 13.13676
+205 35.71721 59.43591 11.05708
+206 75.17492 17.17678 -56.90661
+207 27.77639 49.62372 31.59393
+208 67.91145 44.21902 -68.02449
+209 65.35662 20.31670 -72.42599
+210 11.42291 6.317206 16.36066
+211 88.37362 9.567093 -36.62598
+212 3.319578 15.58691 -7.053573
+213 88.56494 -11.00468 -36.68412
+214 74.63991 -6.294046 -58.06294
+215 27.98927 50.19579 18.84953
+216 31.00037 -16.59208 34.74828
+217 65.71297 30.38908 -71.73081
+218 36.82309 60.16166 26.36287
+219 67.26234 49.39310 -68.98901
+220 88.19055 -12.81586 -37.28068
+221 74.59533 4.811492 -57.98787
+222 74.20986 3.198571 -58.61370
+223 16.10752 19.55477 23.06607
+224 37.41096 61.72633 4.231548
+225 13.31752 -13.69400 -9.151914
+226 26.39913 48.28369 21.36279
+227 87.86746 7.522807 -37.43259
+228 98.90170 -7.761468 -17.43958
+229 35.24765 62.42859 -36.12594
+230 31.75068 -3.266419 -42.71249
+231 28.01680 52.51032 -19.93533
+232 59.44113 -50.08606 6.299467
+233 33.94732 61.18875 -38.24646
+234 96.00062 -29.01119 61.31600
+235 88.10144 -1.914353 -37.23781
+236 94.56869 -35.07831 27.10537
+237 95.28485 -31.26178 31.65535
+238 23.35841 44.90028 17.21548
+239 2.266959 10.54665 -3.751327
+240 28.09646 50.48229 14.21560
+241 34.34219 60.15362 -23.87512
+242 37.29636 63.54341 -23.72363
+243 26.33149 50.38084 -17.82716
+244 95.98191 -27.88023 39.51939
+245 2.446838 11.78193 -8.392522
+246 13.92629 -8.181595 -18.46406
+247 94.87371 -33.59099 31.05815
+248 98.29312 -11.03405 -14.68680
+249 95.37605 -30.49052 28.28770
+250 94.77740 -35.63092 53.89733
+251 27.50338 51.19254 -11.10843
+252 98.45214 -9.864210 -18.14280
+253 29.98711 53.20877 2.465737
+254 28.53656 51.64811 0.250181
+255 32.13856 -0.522276 -46.54584
+256 48.32618 -40.06980 -0.680740
+257 11.85507 -0.428976 -26.77594
+258 9.504890 10.87540 13.86657
+259 93.81622 -39.51807 26.00116
+260 74.52452 -61.48449 10.94735
+261 97.70015 -14.30301 -11.90641
+262 31.55372 -13.38783 35.37864
+263 10.42073 2.579694 14.87232
+264 97.44691 -17.41806 6.142709
+265 97.55030 -15.43866 -8.437139
+266 94.36246 -38.06815 53.33448
+267 13.04887 34.71996 -13.88170
+268 12.75360 33.96459 -9.020249
+269 93.39587 -43.35149 42.52101
+270 33.25497 17.34379 39.19856
+271 8.715568 -5.149986 12.30012
+272 97.78216 -16.07696 10.30740
+273 20.20364 -5.467953 -28.48208
+274 97.40563 -16.54334 -4.963766
+275 26.61730 51.10743 -22.18650
+276 34.30220 62.06052 -42.18343
+277 91.00758 -56.66178 11.02375
+278 70.70544 -53.57729 -1.864092
+279 14.10349 35.00912 -1.521011
+280 30.98682 57.86378 -38.52871
+281 9.512529 -1.287457 13.50842
+282 93.32611 -43.99792 45.67200
+283 94.01887 -37.70669 19.16257
+284 13.60171 -11.02093 -13.85885
+285 97.27410 -16.40380 -12.56932
+286 8.043309 -8.267045 11.27282
+287 93.43482 -40.88541 14.67685
+288 93.77435 -38.83760 15.18622
+289 90.89224 -57.84543 14.48903
+290 97.26612 -17.61638 -1.488146
+291 92.20520 -50.48310 34.11207
+292 91.36553 -55.25143 22.38978
+293 93.03680 -42.60741 6.773126
+294 92.86103 -42.47785 -4.573553
+295 96.97734 -18.69401 -5.625079
+296 24.91315 48.95145 -20.10400
+297 93.22558 -41.81436 10.72090
+298 92.74040 -42.78198 -8.474425
+299 97.13171 -18.65765 1.989240
+300 91.12239 -56.93396 22.03022
+301 92.69355 -47.79108 41.53138
+302 92.89173 -41.39279 -11.95982
+303 93.21108 -38.50587 -18.91533
+304 97.00239 -19.66648 5.465989
+305 93.04862 -39.96704 -15.44071
+306 91.52506 -54.47515 26.16916
+307 33.23674 -12.70974 -31.45839
+308 34.01268 10.28814 39.10684
+309 33.49186 -3.503987 37.56308
+310 11.50229 33.18357 -16.37708
+311 29.30833 55.73847 -36.66815
+312 8.335318 7.298841 12.08963
+313 91.90437 -52.48325 33.67877
+314 24.12544 -21.15564 -8.183776
+315 96.86083 -9.866071 16.50597
+316 77.95635 -13.90732 70.77213
+317 9.421928 32.61559 -30.32216
+318 62.64612 -31.14253 57.93254
+319 60.40093 -39.80946 -16.46213
+320 48.42654 -15.59406 -42.63749
+321 6.404035 -0.402050 9.127567
+322 7.276775 3.402146 10.46652
+323 48.84672 -34.55524 -12.53862
+324 61.81548 -36.45738 56.97924
+325 59.94608 24.49026 60.21842
+326 6.558180 11.46464 9.646398
+327 61.42550 11.70671 60.16647
+328 48.14355 -45.40177 46.12738
+329 49.32816 -19.97056 48.49804
+330 69.33594 -43.88677 -19.45580
+331 48.82496 -22.91644 47.92431
+332 47.73441 19.14651 50.39953
+333 49.07231 31.10492 52.63467
+334 49.32683 15.43808 51.26434
+335 45.27055 19.94972 48.60077
+336 39.82562 28.05534 45.30432
+337 48.13794 -18.00573 -38.88927
+338 70.36580 -33.95143 -37.58748
+339 38.79589 25.27193 44.22368
+340 25.20039 -11.21379 -25.49234
+341 49.77231 -4.182637 49.95564
+342 31.38055 -33.09592 34.09503
+343 22.54416 -9.789864 -24.85476
+344 35.74211 -14.05042 -32.06197
+345 37.37400 28.84882 43.53951
+346 47.71964 34.51697 51.98748
+347 34.91799 -21.38088 -19.94736
+348 49.04452 -32.55371 -16.46482
+349 34.26401 -13.90936 37.41112
+350 38.45770 31.53684 44.66277
+351 70.14358 -36.02027 -33.99700
+352 37.41040 -22.57751 -20.67675
+353 49.71131 -26.14210 -28.10082
+354 24.35665 -18.85029 -12.59794
+355 24.89436 -13.85676 -21.26108
+356 49.47680 -28.34128 -24.24881
+357 22.22683 -12.49970 -20.56104
+358 24.61284 -16.41120 -16.95794
+359 35.44970 -16.55937 -28.07054
+360 21.07184 47.77452 -45.56465
+361 23.34524 51.15712 -51.13708
+362 35.17491 -19.00693 -24.03086
+363 87.06072 9.370494 7.187899
+364 88.52195 5.174662 12.87112
+365 90.12211 1.800273 14.97607
+366 87.61671 12.88924 -7.151265
+367 82.81879 9.180387 41.93159
+368 85.62945 13.62353 1.468319
+369 86.93636 8.570999 10.77074
+370 31.85722 61.79949 -59.60587
+371 89.33123 10.42784 -8.524011
+372 87.46891 11.96257 -3.571796
+373 85.49520 12.78818 5.074162
+374 88.76889 6.798596 5.741566
+375 85.76969 14.49072 -2.135386
+376 85.36696 11.98546 8.677448
+377 92.22258 1.787498 2.973154
+378 90.62339 5.158576 0.784580
+379 91.96858 0.0543591 10.04090
+380 80.27421 19.38626 17.09611
+381 79.28559 25.73400 0.430351
+382 86.81771 7.803842 14.34869
+383 91.73611 -1.550446 17.08429
+384 81.21472 24.81315 -8.495535
+385 91.21404 9.016163 -13.38990
+386 82.75019 8.740551 45.22326
+387 81.18401 12.15713 43.52679
+388 92.09286 0.905013 6.508738
+389 78.77087 22.82519 15.14776
+390 92.35770 2.700996 -0.561588
+391 79.58249 27.38259 -6.915997
+392 81.50122 14.12340 29.79385
+393 83.37224 25.16023 -20.83636
+394 85.91600 15.38956 -5.735542
+395 83.94803 16.21828 2.971552
+396 83.14824 11.27243 28.15276
+397 79.74100 28.25421 -10.57715
+398 71.32551 47.57174 -18.68964
+399 90.90744 7.026770 -6.313611
+400 81.54185 26.64941 -15.76232
+401 78.88980 23.50318 11.47322
+402 85.04753 22.73359 -22.24328
+403 93.96598 -0.651517 1.647721
+404 93.47278 -4.094151 15.70088
+405 70.07438 50.78030 -20.50606
+406 85.22966 23.78332 -25.80187
+407 83.55337 26.18182 -24.41524
+408 12.49315 37.63989 -40.46678
+409 93.11594 7.722502 -18.17200
+410 79.01517 24.21397 7.793819
+411 89.90427 0.315521 22.02200
+412 92.95320 6.659334 -14.66383
+413 81.59492 14.69861 26.24226
+414 69.07035 46.34499 2.218811
+415 78.65839 22.18071 18.80973
+416 94.70243 4.342196 -15.90200
+417 77.68103 28.39026 2.114921
+418 81.41337 13.58186 33.30720
+419 80.16523 18.74256 20.72951
+420 69.53654 48.42463 -9.213966
+421 87.93016 14.83435 -14.28997
+422 87.83105 0.527343 37.31729
+423 70.49374 43.77068 0.154891
+424 94.46153 -12.13477 41.60092
+425 69.85356 40.76189 19.17483
+426 65.82409 56.94787 -10.42383
+427 85.87591 1.556436 51.72802
+428 74.82919 24.13317 53.16703
+429 8.603005 16.24850 -42.11586
+430 76.36188 20.99314 51.57010
+431 68.79887 45.11718 9.878961
+432 69.96660 41.29868 15.38241
+433 63.04901 60.16254 2.151702
+434 72.28298 32.67149 33.56793
+435 63.53064 61.96164 -9.575327
+436 73.19295 46.96005 -27.89996
+437 62.77183 59.11453 10.03247
+438 20.37149 48.00729 -51.43156
+439 73.40029 47.90326 -31.55172
+440 72.06443 31.53639 44.02758
+441 68.34758 43.04775 25.12152
+442 70.90211 36.02996 31.97182
+443 62.64618 58.63630 13.97813
+444 70.82030 35.62091 35.58479
+445 63.48416 54.93669 23.14435
+446 62.42032 57.77172 21.84206
+447 64.73492 66.34472 -32.42670
+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
+6 0 65
+4 32 72
+32 33 72
+25 3 82
+70 93 99
+99 93 100
+95 71 103
+63 35 107
+97 67 109
+104 97 109
+96 71 111
+108 96 111
+97 104 114
+104 101 114
+79 98 115
+110 113 117
+113 106 117
+67 57 118
+109 67 118
+104 109 118
+103 71 119
+62 56 120
+68 62 120
+119 71 121
+80 94 122
+116 80 122
+110 116 122
+64 54 123
+56 64 123
+120 56 123
+71 96 125
+96 108 125
+121 71 125
+102 121 125
+101 104 128
+106 101 128
+43 54 129
+54 64 129
+44 43 130
+43 129 130
+97 114 131
+106 113 132
+94 108 132
+108 111 132
+110 122 133
+122 94 133
+94 132 133
+113 110 133
+132 113 133
+120 123 135
+2 44 136
+44 130 136
+130 129 136
+59 57 137
+30 2 138
+2 136 138
+136 129 141
+138 136 141
+121 102 142
+69 66 143
+68 120 144
+120 112 144
+112 124 144
+60 50 146
+102 98 147
+142 102 147
+37 41 148
+41 30 148
+30 138 148
+138 141 148
+124 98 149
+50 46 150
+146 50 150
+1 31 152
+46 1 152
+150 46 152
+31 34 152
+34 146 152
+146 150 152
+71 95 153
+111 71 153
+101 106 153
+114 101 153
+95 114 153
+132 111 153
+106 132 153
+77 69 154
+69 143 154
+143 151 154
+151 145 154
+58 60 156
+141 129 157
+115 98 158
+139 140 159
+140 134 159
+98 102 160
+149 98 160
+127 137 161
+57 67 161
+137 57 161
+59 58 162
+58 156 162
+60 146 163
+156 60 163
+126 127 164
+134 126 164
+159 134 164
+67 97 165
+97 131 165
+127 161 165
+164 127 165
+161 67 165
+162 156 166
+156 163 166
+139 145 167
+145 151 167
+51 53 168
+53 37 168
+66 65 169
+143 66 169
+151 143 169
+162 166 170
+166 163 170
+60 58 171
+58 59 171
+59 137 171
+145 139 172
+139 159 172
+57 59 173
+118 57 173
+155 118 173
+59 162 173
+162 170 173
+170 155 173
+37 148 174
+168 37 174
+62 68 175
+56 62 175
+98 79 176
+147 98 176
+79 77 176
+77 154 176
+39 47 177
+61 63 178
+63 107 178
+107 105 178
+61 178 179
+178 105 179
+105 100 180
+179 105 180
+83 81 181
+78 83 182
+79 115 183
+115 158 183
+81 76 184
+181 81 184
+170 163 185
+83 181 186
+181 184 186
+77 79 188
+79 183 188
+182 83 189
+83 186 189
+186 187 189
+74 73 190
+187 186 191
+23 21 192
+12 6 193
+6 65 193
+65 66 193
+73 78 194
+78 182 194
+182 189 194
+190 73 194
+100 93 195
+37 53 196
+61 179 197
+179 180 197
+180 100 197
+100 195 197
+52 61 197
+26 23 198
+23 192 198
+104 118 199
+118 155 199
+128 104 199
+155 170 199
+94 80 200
+108 94 200
+80 68 200
+68 144 200
+38 48 202
+194 189 203
+21 18 204
+192 21 204
+198 192 205
+192 204 205
+187 191 206
+191 203 206
+189 187 206
+203 189 206
+193 66 207
+184 76 208
+2 30 208
+186 184 208
+41 37 209
+191 41 209
+37 196 209
+196 203 209
+203 191 209
+75 74 211
+74 190 211
+124 149 212
+144 124 212
+51 38 214
+38 202 214
+202 48 214
+137 127 216
+171 137 216
+30 41 217
+41 191 217
+208 30 217
+191 186 217
+186 208 217
+18 12 218
+12 193 218
+204 18 218
+215 204 218
+193 207 218
+36 40 219
+33 36 219
+72 33 219
+76 72 219
+208 76 219
+40 2 219
+2 208 219
+48 52 220
+52 197 220
+197 195 220
+195 213 220
+213 214 220
+214 48 220
+196 53 221
+203 196 221
+53 51 222
+221 53 222
+51 214 222
+154 145 223
+145 172 223
+176 154 223
+27 26 224
+26 198 224
+106 128 225
+66 69 226
+207 66 226
+215 218 226
+218 207 226
+75 211 227
+190 194 227
+211 190 227
+194 203 227
+203 221 227
+28 29 229
+34 39 232
+146 34 232
+39 177 232
+25 82 234
+82 84 234
+195 93 235
+213 195 235
+221 222 235
+222 214 235
+93 75 235
+75 227 235
+214 213 235
+227 221 235
+183 158 238
+188 183 238
+215 226 238
+69 77 238
+226 69 238
+77 188 238
+102 125 239
+160 102 239
+149 160 239
+212 149 239
+125 108 239
+205 204 240
+204 215 240
+215 238 240
+29 27 242
+229 29 242
+241 229 242
+84 237 244
+200 144 245
+144 212 245
+108 200 245
+212 239 245
+239 108 245
+116 110 246
+110 117 246
+85 70 248
+70 228 248
+88 201 249
+84 88 249
+237 84 249
+236 247 249
+247 237 249
+24 25 250
+25 234 250
+234 84 250
+84 244 250
+27 224 251
+242 27 251
+231 241 251
+241 242 251
+243 231 251
+70 99 252
+228 70 252
+99 100 252
+100 248 252
+248 228 252
+198 205 253
+224 198 253
+205 240 253
+238 158 254
+240 238 254
+253 240 254
+251 224 254
+224 253 254
+148 141 255
+141 157 255
+157 230 255
+174 148 255
+230 174 255
+163 146 256
+185 163 256
+146 232 256
+68 80 257
+80 116 257
+175 68 257
+116 246 257
+172 210 258
+223 172 258
+147 176 258
+176 223 258
+247 236 259
+177 47 260
+85 248 261
+127 126 262
+216 127 262
+126 134 262
+159 164 263
+172 159 263
+210 172 263
+92 90 264
+91 92 264
+89 85 265
+85 261 265
+22 24 266
+24 250 266
+250 244 266
+112 267 268
+267 251 268
+244 237 269
+237 247 269
+247 259 269
+167 151 270
+88 91 272
+91 264 272
+201 88 272
+249 201 272
+157 175 273
+175 257 273
+257 246 273
+86 89 274
+89 265 274
+241 231 275
+231 243 275
+4 28 276
+28 229 276
+229 233 276
+32 4 276
+55 47 277
+55 49 278
+47 55 278
+256 232 278
+260 47 278
+232 177 278
+177 260 278
+98 124 279
+158 98 279
+124 112 279
+251 254 279
+254 158 279
+112 268 279
+268 251 279
+276 233 280
+263 164 281
+164 271 281
+13 19 282
+19 20 282
+20 22 282
+22 266 282
+266 244 282
+244 269 282
+259 236 283
+236 249 283
+249 272 283
+117 106 284
+106 225 284
+246 117 284
+100 105 285
+248 100 285
+261 248 285
+265 261 285
+164 165 286
+165 131 286
+114 95 286
+95 271 286
+131 114 286
+271 164 286
+287 283 288
+277 47 289
+87 86 290
+86 274 290
+31 1 291
+287 289 292
+55 277 293
+49 55 294
+55 293 294
+274 265 295
+265 285 295
+290 274 295
+243 251 296
+251 267 296
+275 243 296
+277 289 297
+289 287 297
+293 277 297
+287 288 297
+45 49 298
+49 294 298
+90 87 299
+87 290 299
+264 90 299
+290 295 299
+295 294 299
+47 39 300
+39 292 300
+289 47 300
+292 289 300
+1 13 301
+13 282 301
+282 269 301
+269 259 301
+259 291 301
+291 1 301
+45 298 302
+294 295 302
+298 294 302
+35 42 303
+107 35 303
+105 107 303
+285 105 303
+295 285 303
+272 264 304
+283 272 304
+288 283 304
+294 293 304
+299 294 304
+264 299 304
+297 288 304
+293 297 304
+42 45 305
+45 302 305
+303 42 305
+302 295 305
+295 303 305
+39 34 306
+259 283 306
+283 287 306
+287 292 306
+291 259 306
+292 39 306
+230 157 307
+157 273 307
+140 139 308
+139 167 308
+167 270 308
+134 140 309
+262 134 309
+140 308 309
+112 120 310
+120 135 310
+267 112 310
+296 267 310
+229 241 311
+241 275 311
+233 229 311
+280 233 311
+258 210 312
+210 263 312
+34 31 313
+31 291 313
+291 306 313
+306 34 313
+199 170 314
+128 199 314
+225 128 314
+88 84 315
+5 3 316
+7 5 316
+123 54 317
+135 123 317
+296 310 317
+310 135 317
+3 25 318
+316 3 318
+25 24 318
+24 22 318
+278 49 319
+256 278 319
+38 51 320
+51 168 320
+168 174 320
+174 230 320
+95 103 321
+271 95 321
+281 271 321
+103 119 321
+119 121 322
+321 119 322
+263 281 322
+281 321 322
+312 263 322
+121 312 322
+185 256 323
+256 319 323
+19 13 324
+13 46 324
+46 50 324
+20 19 324
+22 20 324
+318 22 324
+17 14 325
+142 147 326
+147 258 326
+121 142 326
+312 121 326
+258 312 326
+9 8 327
+16 9 327
+50 60 328
+324 50 328
+316 318 329
+42 35 330
+45 42 330
+49 45 330
+319 49 330
+318 324 331
+329 318 331
+324 328 331
+17 325 332
+15 17 333
+17 332 333
+14 16 334
+325 14 334
+16 327 334
+332 325 334
+308 270 335
+333 332 335
+332 334 335
+334 308 335
+333 335 336
+52 48 337
+48 38 337
+38 320 337
+61 52 338
+270 151 339
+335 270 339
+336 335 339
+307 273 340
+8 7 341
+327 8 341
+7 316 341
+316 329 341
+334 327 341
+309 308 341
+308 334 341
+60 171 342
+171 216 342
+328 60 342
+331 328 342
+273 246 343
+340 273 343
+230 307 344
+320 230 344
+337 320 344
+151 169 345
+339 151 345
+336 339 345
+11 15 346
+15 333 346
+333 336 346
+170 185 347
+314 170 347
+323 319 348
+262 309 349
+216 262 349
+329 331 349
+309 341 349
+341 329 349
+331 342 349
+342 216 349
+65 0 350
+336 345 350
+169 65 350
+345 169 350
+0 10 350
+346 336 350
+10 11 350
+11 346 350
+35 63 351
+330 35 351
+63 61 351
+61 338 351
+319 330 351
+185 323 352
+347 185 352
+323 348 352
+52 337 353
+338 52 353
+351 338 353
+284 225 354
+225 314 354
+314 347 354
+340 343 355
+348 319 356
+319 351 356
+351 353 356
+352 348 356
+353 352 356
+246 284 357
+343 246 357
+355 343 357
+354 347 358
+347 355 358
+284 354 358
+357 284 358
+355 357 358
+344 307 359
+337 344 359
+353 337 359
+307 340 359
+275 296 360
+311 275 360
+44 2 361
+280 311 361
+311 360 361
+347 352 362
+352 353 362
+353 359 362
+340 355 362
+359 340 362
+355 347 362
+33 32 370
+32 276 370
+36 33 370
+40 36 370
+2 40 370
+361 2 370
+276 280 370
+280 361 370
+366 371 372
+364 369 374
+369 363 374
+363 373 374
+373 368 374
+372 368 375
+363 369 376
+373 363 376
+368 372 378
+374 368 378
+377 374 378
+92 91 379
+365 364 379
+364 374 379
+364 365 382
+369 364 382
+376 369 382
+380 376 382
+7 8 386
+8 9 387
+386 8 387
+367 386 387
+90 92 388
+92 379 388
+374 377 388
+379 374 388
+89 86 390
+85 89 390
+377 378 390
+384 381 391
+366 372 394
+372 375 394
+375 381 394
+381 384 394
+368 373 395
+375 368 395
+381 375 395
+373 376 395
+384 391 397
+70 85 399
+372 371 399
+378 372 399
+371 385 399
+85 390 399
+390 378 399
+384 397 400
+376 380 401
+395 376 401
+380 389 401
+73 74 402
+74 75 402
+400 393 402
+86 87 403
+87 90 403
+90 388 403
+388 377 403
+377 390 403
+390 86 403
+88 315 404
+315 383 404
+91 88 404
+379 91 404
+365 379 404
+383 365 404
+78 73 406
+73 402 406
+83 78 407
+393 400 407
+78 406 407
+402 393 407
+406 402 407
+54 43 408
+43 360 408
+317 54 408
+296 317 408
+360 296 408
+75 93 409
+381 395 410
+395 401 410
+315 84 411
+383 315 411
+382 365 411
+396 382 411
+365 383 411
+399 385 412
+385 409 412
+409 93 412
+382 396 413
+396 392 413
+389 380 415
+93 70 416
+412 93 416
+70 399 416
+399 412 416
+391 381 417
+381 410 417
+367 387 418
+392 396 418
+396 367 418
+380 382 419
+382 413 419
+413 392 419
+415 380 419
+397 391 420
+398 397 420
+405 398 420
+371 366 421
+385 371 421
+402 75 421
+366 394 421
+394 384 421
+384 400 421
+400 402 421
+75 409 421
+409 385 421
+3 5 422
+367 396 422
+396 411 422
+391 417 423
+420 391 423
+414 420 423
+82 3 424
+3 422 424
+84 82 424
+411 84 424
+422 411 424
+401 389 425
+389 415 425
+27 29 426
+29 405 426
+405 420 426
+420 414 426
+5 7 427
+422 5 427
+7 386 427
+386 367 427
+367 422 427
+64 56 429
+129 64 429
+157 129 429
+56 175 429
+175 157 429
+9 16 430
+387 9 430
+16 14 430
+14 428 430
+418 387 430
+414 423 431
+423 417 431
+410 401 432
+401 425 432
+417 410 432
+431 417 432
+26 27 433
+426 414 433
+415 419 434
+419 392 434
+27 426 435
+426 433 435
+433 27 435
+397 398 436
+400 397 436
+398 405 436
+407 400 436
+23 26 437
+26 433 437
+414 431 437
+433 414 437
+43 44 438
+44 361 438
+360 43 438
+361 360 438
+28 4 439
+436 28 439
+4 72 439
+81 83 439
+83 407 439
+407 436 439
+72 76 439
+76 81 439
+17 15 440
+14 17 440
+428 14 440
+392 418 440
+434 392 440
+418 430 440
+430 428 440
+0 6 441
+6 12 441
+425 415 442
+415 434 442
+10 0 442
+0 441 442
+441 425 442
+11 10 442
+21 23 443
+23 437 443
+437 431 443
+434 440 444
+440 15 444
+15 11 444
+11 442 444
+442 434 444
+12 18 445
+432 425 445
+425 441 445
+441 12 445
+431 432 445
+18 21 446
+445 18 446
+21 443 446
+443 431 446
+431 445 446
+29 28 447
+405 29 447
+28 436 447
+436 405 447
+END_DATA
diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..64563b3
--- /dev/null
+++ b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..3f00db4
--- /dev/null
+++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -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
+ }
+}
diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json
new file mode 100644
index 0000000..a1ec0b2
--- /dev/null
+++ b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json
@@ -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"
+ }
+}
diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg
new file mode 100644
index 0000000..2b7b7e2
--- /dev/null
+++ b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/Resources/ICCery.entitlements b/Resources/ICCery.entitlements
new file mode 100644
index 0000000..d8671ee
--- /dev/null
+++ b/Resources/ICCery.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+
+ com.apple.security.device.usb
+
+
+
diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift
new file mode 100644
index 0000000..d7b7a33
--- /dev/null
+++ b/Sources/ICCery/FileDialogService.swift
@@ -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
+ }
+}
diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift
new file mode 100644
index 0000000..a61a29d
--- /dev/null
+++ b/Sources/ICCery/ICCeryApp.swift
@@ -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
+ }
+}
diff --git a/Sources/ICCery/NoticeBanner.swift b/Sources/ICCery/NoticeBanner.swift
new file mode 100644
index 0000000..c0bfb67
--- /dev/null
+++ b/Sources/ICCery/NoticeBanner.swift
@@ -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
+ )
+ }
+}
diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift
new file mode 100644
index 0000000..3a38f5c
--- /dev/null
+++ b/Sources/ICCery/RootView.swift
@@ -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.")
+ }
+ }
+}
diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift
new file mode 100644
index 0000000..085e7ff
--- /dev/null
+++ b/Sources/ICCery/SettingsView.swift
@@ -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)
+ }
+}
diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift
new file mode 100644
index 0000000..2f81970
--- /dev/null
+++ b/Sources/ICCery/SettingsViewModel.swift
@@ -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)
+ }
+}
diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift
new file mode 100644
index 0000000..7774bed
--- /dev/null
+++ b/Sources/ICCery/SidebarView.swift
@@ -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)
+ )
+ }
+}
diff --git a/Sources/ICCery/StagePlaceholderView.swift b/Sources/ICCery/StagePlaceholderView.swift
new file mode 100644
index 0000000..ae92b96
--- /dev/null
+++ b/Sources/ICCery/StagePlaceholderView.swift
@@ -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)
+ }
+}
diff --git a/Sources/ICCery/Theme.swift b/Sources/ICCery/Theme.swift
new file mode 100644
index 0000000..c65ca0c
--- /dev/null
+++ b/Sources/ICCery/Theme.swift
@@ -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
+ }
+}
diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift
new file mode 100644
index 0000000..2b0b9dc
--- /dev/null
+++ b/Sources/ICCery/WizardViewModel.swift
@@ -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?
+
+ 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)
+ }
+}
diff --git a/Tests/ICCeryCoreTests/AppPathsTests.swift b/Tests/ICCeryCoreTests/AppPathsTests.swift
new file mode 100644
index 0000000..2316056
--- /dev/null
+++ b/Tests/ICCeryCoreTests/AppPathsTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift
new file mode 100644
index 0000000..9a8b591
--- /dev/null
+++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift
@@ -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
+ ))
+ }
+}
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/Tests/ICCeryCoreTests/FileHelpersTests.swift b/Tests/ICCeryCoreTests/FileHelpersTests.swift
new file mode 100644
index 0000000..0854f8c
--- /dev/null
+++ b/Tests/ICCeryCoreTests/FileHelpersTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift
new file mode 100644
index 0000000..3473d74
--- /dev/null
+++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift
@@ -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")
+ }
+}
diff --git a/Tests/ICCeryCoreTests/SettingsTests.swift b/Tests/ICCeryCoreTests/SettingsTests.swift
new file mode 100644
index 0000000..5fed795
--- /dev/null
+++ b/Tests/ICCeryCoreTests/SettingsTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ICCeryCoreTests/WizardGatingTests.swift b/Tests/ICCeryCoreTests/WizardGatingTests.swift
new file mode 100644
index 0000000..4ebf930
--- /dev/null
+++ b/Tests/ICCeryCoreTests/WizardGatingTests.swift
@@ -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)
+ }
+}
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..1afb884
--- /dev/null
+++ b/project.yml
@@ -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
diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh
new file mode 100755
index 0000000..cc2b0c8
--- /dev/null
+++ b/scripts/fetch-argyll.sh
@@ -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/ # 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
+
+# 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"