M1 — Foundation & process core #40

Merged
gronod merged 15 commits from milestone/m1-foundation into develop 2026-09-08 19:32:36 +01:00
53 changed files with 5197 additions and 1 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ DerivedData/
Package.resolved Package.resolved
# Fetched Argyll sidecars (release artefacts, not git blobs — #127) # Fetched Argyll sidecars (release artefacts, not git blobs — #127)
Resources/argyll/ Vendor/Argyll/
# XcodeGen output (regenerate with `make gen`)
ICCery.xcodeproj/
# macOS # macOS
.DS_Store .DS_Store
+23
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v14)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
targets: [
.target(name: "ICCeryCore"),
]
)
@@ -0,0 +1,100 @@
import Foundation
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
///
/// Order:
/// 1. Settings `argyll_binary_dir` override only if `<dir>/<name>`
/// exists there.
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
/// 3. If nothing exists the *constructed* bundled path is still returned
/// a missing binary surfaces later as `process:error` on spawn,
/// matching v1 semantics.
public struct BinaryResolver: Sendable {
/// Root that contains the platform dirs `Bundle.resource/Argyll` in
/// the app, a fixture dir in tests.
public let bundledRoot: URL
/// `settings.argyll_binary_dir`, already expanded to a URL.
public let overrideDir: URL?
/// Host architecture directory names, universal preferred.
public let archDirs: [String]
public init(
bundledRoot: URL = AppPaths.bundledArgyllDir,
overrideDir: URL? = nil,
archDirs: [String]? = nil
) {
self.bundledRoot = bundledRoot
self.overrideDir = overrideDir
#if arch(arm64)
let fallback = ["macos-arm64", "macos-aarch64"]
#else
let fallback = ["macos-x86_64"]
#endif
self.archDirs = archDirs ?? ["macos-universal"] + fallback
}
/// Marker used to decide whether `macos-universal` is usable.
public static let markerBinary = "instlist"
/// Resolves a tool name to an absolute URL (never throws see type
/// docs). `name` is the bare tool name, e.g. `"targen"`.
public func resolve(_ name: String) -> URL {
let fm = FileManager.default
if let dir = overrideDir {
let candidate = dir.appendingPathComponent(name)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return bundledRoot
.appendingPathComponent(platformDir(), isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// The bundled platform directory that resolution will use.
public func platformDir() -> String {
let fm = FileManager.default
let universal = bundledRoot.appendingPathComponent("macos-universal")
if fm.fileExists(
atPath: universal.appendingPathComponent(Self.markerBinary).path
) {
return "macos-universal"
}
for dir in archDirs where dir != "macos-universal" {
if fm.fileExists(
atPath: bundledRoot
.appendingPathComponent(dir)
.appendingPathComponent(Self.markerBinary).path
) {
return dir
}
}
// Nothing present still return the preferred dir so the error
// message points at where the user should drop binaries.
return archDirs.first ?? "macos-universal"
}
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
public func mock(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("mocks", isDirectory: true)
.appendingPathComponent("\(name).mock", isDirectory: false)
}
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// Whether the resolved path exists and is executable.
public func exists(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Small host-side file helpers (issue #6).
public enum ArtefactFiles {
/// `get_default_working_dir` `resolveSafeCwd(nil)`.
public static func defaultWorkingDirectory() -> URL {
PathSecurity.resolveSafeCwd(nil)
}
/// `read_file_base64` for **text artefacts** the UI needs verbatim
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
/// go through `TiffPreview` instead.
public static func readBase64(_ url: URL) throws -> String {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version + build for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
}
}
@@ -0,0 +1,112 @@
import Foundation
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
public struct StageArtefacts: Sendable, Equatable {
/// `<basename>.ti1` exists (Stage 1 done unlocks Stage 2).
public var stage1Complete = false
/// `<basename>.ti2` exists (Stage 2 done with ti1, unlocks Stage 3).
public var stage2Complete = false
/// `<basename>.ti3` exists (Stage 3 done unlocks Stage 4).
public var stage3Complete = false
/// `.icc`/`.icm` exists (Stage 4 done with ti3, unlocks Stage 5).
public var stage4Complete = false
/// Absolute path of the profile file when present.
public var profilePath: URL?
public init(
stage1Complete: Bool = false,
stage2Complete: Bool = false,
stage3Complete: Bool = false,
stage4Complete: Bool = false,
profilePath: URL? = nil
) {
self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete
self.profilePath = profilePath
}
}
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
public enum ArtefactProbe {
/// `verify_stage_artefacts` the gating truth source.
public static func verify(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> StageArtefacts {
var out = StageArtefacts()
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true
out.profilePath = profile
}
return out
}
/// `<cwd>/<basename>.<ext>` the canonical artefact URL.
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
}
/// Profile extension resolution (#69): existing `.icm` wins over
/// `.icc`; when neither exists the macOS default is `.icc`.
/// (`profcheck`/`iccgamut` swap extension when the requested path is
/// missing.)
public static func resolveProfile(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> URL? {
let icm = artefact(basename, "icm", cwd)
if exists(icm, fm: fileManager) { return icm }
let icc = artefact(basename, "icc", cwd)
if exists(icc, fm: fileManager) { return icc }
return nil
}
/// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc"
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` and
/// `<basename>_NN.tif` (manifest naming).
public static func existingArtefacts(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefixes = [basename + ".", "CAL_" + basename + "."]
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
let passPrefix = basename + "_pass"
let tifStemPrefix = basename + "_"
let calPrefix = "CAL_" + basename
return entries.filter { url in
let name = url.lastPathComponent
let ext = url.pathExtension.lowercased()
guard suffixes.contains(ext) else { return false }
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
if name.hasPrefix(calPrefix) { return true }
return false
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
private static func exists(_ url: URL, fm: FileManager) -> Bool {
fm.fileExists(atPath: url.path)
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Atomic `.tmp`-then-rename file writes the convention used by
/// settings.json, verification_history.json and wizard_state.json
/// (docs/02 §Persistence, #213).
public enum AtomicFileWriter {
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
public static func write(_ data: Data, to url: URL) throws {
let fm = FileManager.default
let dir = url.deletingLastPathComponent()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let tmp = url.appendingPathExtension("tmp")
do {
try data.write(to: tmp, options: [])
// replaceItemAt handles same-volume atomic swap and removes
// the destination cleanly; fall back to remove+move.
if fm.fileExists(atPath: url.path) {
_ = try fm.replaceItemAt(url, withItemAt: tmp)
} else {
try fm.moveItem(at: tmp, to: url)
}
} catch {
try? fm.removeItem(at: tmp)
throw error
}
}
public static func write(_ text: String, to url: URL) throws {
try write(Data(text.utf8), to: url)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// Basename sanitisation and safe working-directory resolution
/// (docs/02 §Working directory, docs/06 §Empty cwd).
public enum PathSecurity {
public enum Error: Swift.Error, Equatable, Sendable {
case invalidBasename(String)
}
/// Basenames must not contain `/`, `\`, or `..` and must be
/// non-empty. Never invent a default basename (#60).
public static func isValidBasename(_ name: String) -> Bool {
guard !name.isEmpty else { return false }
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
}
@discardableResult
public static func sanitizeBasename(_ name: String) throws -> String {
guard isValidBasename(name) else {
throw Error.invalidBasename(name)
}
return name
}
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory
/// Documents Home app-data. Never returns an empty/nil cwd.
public static func resolveSafeCwd(
_ explicit: URL?,
fileManager: FileManager = .default
) -> URL {
if let explicit,
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
return explicit
}
let candidates: [URL?] = [
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
fileManager.homeDirectoryForCurrentUser,
AppPaths.appDataDir,
]
for candidate in candidates {
guard let url = candidate else { continue }
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
return url
}
}
// Last resort: app-data, created unconditionally.
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
return AppPaths.appDataDir
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
/// `parse_ti2_header` reads only CGATS keyword lines the data grid
/// itself belongs to issue #30.
public struct Ti2Header: Sendable, Equatable {
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
public var instrument: String?
/// `NUMBER_OF_SETS` the patch count. Note: `NUMBER_OF_FIELDS` is
/// the CGATS column count, *not* the patch count.
public var patchCount: Int?
/// `NUMBER_OF_PAGES`.
public var pageCount: Int?
/// A sibling `<stem>.ti1` exists next to the parsed file.
public var hasSiblingTi1 = false
public static func parse(
_ url: URL,
fileManager: FileManager = .default
) -> Ti2Header {
var header = Ti2Header()
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
return header
}
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
break
}
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
guard let space = line.firstIndex(of: " ") else { continue }
let key = String(line[..<space])
let value = String(line[line.index(after: space)...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "TARGET_INSTRUMENT":
header.instrument = value
case "NUMBER_OF_SETS":
header.patchCount = Int(value)
case "NUMBER_OF_PAGES":
header.pageCount = Int(value)
default:
continue
}
}
let stem = url.deletingPathExtension()
header.hasSiblingTi1 = fileManager.fileExists(
atPath: stem.appendingPathExtension("ti1").path
)
return header
}
}
@@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// TIFF PNG preview for the Stage 2 gallery (#58): decode on the host
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
/// bytes to the UI.
public enum TiffPreview {
public static let maxEdge: Int = 1200
/// Returns PNG data for the first page of a TIFF, or `nil` when the
/// file cannot be decoded.
public static func previewPNG(
tiff url: URL,
maxEdge: Int = Self.maxEdge
) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { return nil }
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { return nil }
return out as Data
}
}
@@ -0,0 +1,62 @@
import Foundation
import OSLog
/// Severity levels, matching the v1 `log_level` setting values.
public enum LogLevel: String, Codable, Sendable, CaseIterable {
case error, warn, info, debug, trace
var osType: OSLogType {
switch self {
case .error: return .error
case .warn: return .default
case .info: return .info
case .debug: return .debug
case .trace: return .debug
}
}
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
var rank: Int {
switch self {
case .error: return 0
case .warn: return 1
case .info: return 2
case .debug: return 3
case .trace: return 4
}
}
}
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
/// gated at write time so a settings save takes effect immediately
/// (#158).
public struct AppLogger: Sendable {
public static let shared = AppLogger(category: "app")
private let osLog: Logger
private let sink: LogSink
public let category: String
public init(category: String, sink: LogSink = .shared) {
self.category = category
self.sink = sink
self.osLog = Logger(
subsystem: AppPaths.bundleIdentifier,
category: category
)
}
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
let text = LogSanitizer.sanitize(message())
if level.rank <= sink.level.rank {
osLog.log(level: level.osType, "\(text, privacy: .public)")
}
sink.write(level: level, category: category, message: text)
}
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
}
@@ -0,0 +1,17 @@
import Foundation
/// Rewrites the user's home directory to `~` in log output
/// (docs/03 §Logging hygiene `sanitize_arg_for_logging`).
public enum LogSanitizer {
/// Replaces every occurrence of the current user's home path with `~`.
public static func sanitize(_ text: String) -> String {
let home = NSHomeDirectory()
guard !home.isEmpty else { return text }
return text.replacingOccurrences(of: home, with: "~")
}
/// Sanitizes an argv list for display.
public static func sanitizeArgs(_ args: [String]) -> String {
args.map(sanitize).joined(separator: " ")
}
}
@@ -0,0 +1,127 @@
import Foundation
import OSLog
/// Rolling file sink for `AppLogger` `~/Library/Logs/<bundle>/
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
/// (`iccery.log.1` `iccery.log.5`).
///
/// The minimum level is **runtime state** (#158): `setLevel` takes
/// effect immediately at startup and on every settings save.
public final class LogSink: @unchecked Sendable {
public static let shared = LogSink(fileURL: AppPaths.logFile)
private let lock = NSLock()
private let fileURL: URL
private var minimumLevel: LogLevel
private var handle: FileHandle?
/// 5 MiB per segment, 5 historical segments kept.
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
public static let keptSegments = 5
public init(
fileURL: URL = AppPaths.logFile,
minimumLevel: LogLevel? = nil
) {
self.fileURL = fileURL
#if DEBUG
self.minimumLevel = minimumLevel ?? .debug
#else
self.minimumLevel = minimumLevel ?? .info
#endif
}
public var level: LogLevel {
lock.lock()
defer { lock.unlock() }
return minimumLevel
}
/// Applied at startup AND on every settings save (issue #5, #158).
public func setLevel(_ level: LogLevel) {
lock.lock()
minimumLevel = level
lock.unlock()
}
/// `nil` DEBUG-build default (.debug) / release (.info).
public func applySettings(_ settings: AppSettings) {
setLevel(settings.effectiveLogLevel)
}
public func shouldLog(_ level: LogLevel) -> Bool {
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
}
// MARK: - Writing
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
/// rotating first when the active segment exceeds 5 MiB.
public func write(level: LogLevel, category: String, message: String) {
guard shouldLog(level) else { return }
lock.lock()
defer { lock.unlock() }
rotateIfNeeded()
openIfNeeded()
let stamp = Self.timestamp()
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
if let data = line.data(using: .utf8) {
handle?.write(data)
}
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static func timestamp() -> String {
formatter.string(from: Date())
}
private func openIfNeeded() {
guard handle == nil else { return }
try? FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
handle = try? FileHandle(forWritingTo: fileURL)
try? handle?.seekToEnd()
}
/// Shifts `iccery.log.4.5`, `.3.4`, , `.log.1` and resets the
/// writer. Oldest segment is deleted.
private func rotateIfNeeded() {
guard FileManager.default.fileExists(atPath: fileURL.path),
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let size = attrs[.size] as? UInt64,
size >= Self.maxSegmentBytes
else { return }
try? handle?.close()
handle = nil
let fm = FileManager.default
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
try? fm.removeItem(at: oldest)
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
let src = fileURL.appendingPathExtension("\(i)")
let dst = fileURL.appendingPathExtension("\(i + 1)")
if fm.fileExists(atPath: src.path) {
try? fm.moveItem(at: src, to: dst)
}
}
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
}
/// Tail of the active log for the settings dialog's "copy excerpt".
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
guard let data = try? Data(contentsOf: fileURL) else { return "" }
let slice = data.suffix(maxBytes)
return String(decoding: slice, as: UTF8.self)
}
}
@@ -0,0 +1,51 @@
import Foundation
/// Well-known filesystem locations for the ICCery host process.
///
/// macOS paths (docs/02 §Persistence):
/// - App data: `~/Library/Application Support/<bundle-id>/`
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
public enum AppPaths {
/// `com.gronod.iccery2` read from the main bundle so tests can override.
public static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
}
/// `~/Library/Application Support/com.gronod.iccery2`
public static var appDataDir: URL {
FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
/// `~/Library/Logs/com.gronod.iccery2`
public static var logDir: URL {
FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
public static var logFile: URL {
logDir.appendingPathComponent("iccery.log", isDirectory: false)
}
/// `<app>/Contents/Resources/Argyll` bundled sidecar root.
public static var bundledArgyllDir: URL {
Bundle.main.resourceURL?
.appendingPathComponent("Argyll", isDirectory: true)
?? URL(fileURLWithPath: "/nonexistent")
}
/// Creates the app data and log directories if missing.
@discardableResult
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
let fm = FileManager.default
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
return (appDataDir, logDir)
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Accumulates stdout lines into a complete JSON document.
///
/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest)
/// emit pretty-printed multi-line JSON on stdout. Individual lines are
/// *not* valid JSON only the whole block is so callers route stdout
/// lines here and get `Data` back once the buffer parses.
///
/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager
/// diverts them to `jsonRow` events first.
public struct JSONAccumulator: Sendable {
private var buffer = Data()
public init() {}
/// Appends one stdout line. Returns the complete document bytes when
/// the accumulated buffer forms valid JSON, otherwise `nil`.
public mutating func feed(line: String) -> Data? {
buffer.append(Data(line.utf8))
buffer.append(0x0A)
return tryParse()
}
/// Attempts to decode the accumulated buffer; clears it on success.
public mutating func decode<T: Decodable>(_ type: T.Type) -> T? {
guard let data = tryParse() else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
/// Raw buffer when it parses, `nil` while still incomplete.
public var completeData: Data? {
var copy = self
return copy.tryParse()
}
public mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
public var isEmpty: Bool { buffer.isEmpty }
private mutating func tryParse() -> Data? {
// Cheap gate: JSON documents start with { or [.
guard let first = buffer.first(where: { !$0.isJSONWhitespace }),
first == UInt8(ascii: "{") || first == UInt8(ascii: "[")
else { return nil }
guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil }
let out = buffer
buffer.removeAll(keepingCapacity: false)
return out
}
}
private extension UInt8 {
var isJSONWhitespace: Bool {
self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D
}
}
@@ -0,0 +1,36 @@
import Foundation
/// Events on the process bus the v2 equivalent of the v1 Tauri events
/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus).
public enum ProcessEvent: Sendable, Equatable {
/// Non-JSON stdout line. (`process:stdout`)
case stdout(id: String, line: String)
/// stderr line. (`process:stderr`)
case stderr(id: String, line: String)
/// Child exited; 0 = success. (`process:exit`)
case exit(id: String, code: Int32)
/// Spawn failure. (`process:error`)
case error(id: String, message: String)
/// Stdout line began with `ROW_COLORS_JSON: ` prefix stripped,
/// payload is the remaining raw bytes. (`process:json_row`)
case jsonRow(id: String, payload: Data)
public var id: String {
switch self {
case .stdout(let id, _), .stderr(let id, _), .exit(let id, _),
.error(let id, _), .jsonRow(let id, _):
return id
}
}
}
public enum ProcessError: Error, Equatable, Sendable {
/// A child with this id is still running (#116).
case duplicateID(String)
/// No child registered under this id.
case unknownID(String)
/// Process refused to launch.
case spawnFailed(String)
/// stdin write failed (pipe closed / process gone).
case stdinFailed(String)
}
@@ -0,0 +1,17 @@
import Foundation
/// Deterministic process ids (docs/02 §Event bus). Listeners must always
/// filter events on `id` historical bug #56 was an id mismatch.
public enum ProcessID {
public static let instlist = "instlist"
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
public static func chartread(_ basename: String) -> String { "chartread_\(basename)" }
public static func average(_ basename: String) -> String { "average_\(basename)" }
public static func colprof(_ basename: String) -> String { "colprof_\(basename)" }
public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" }
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
}
@@ -0,0 +1,41 @@
import Foundation
/// Incremental byteline decoder for process pipes.
///
/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never
/// appear inside a multi-byte UTF-8 sequence (continuation bytes are
/// 0x80), so splitting bytes at `\n` is always scalar-safe; each line
/// is then decoded with a lossy fallback for non-UTF-8 output.
public struct ProcessLineDecoder: Sendable {
public private(set) var pending = Data()
public init() {}
/// Feeds a chunk; returns every complete line found (without `\n`).
public mutating func feed(_ chunk: Data) -> [String] {
guard !chunk.isEmpty else { return [] }
pending.append(chunk)
var lines: [String] = []
while let nl = pending.firstIndex(of: 0x0A) {
var slice = pending.prefix(upTo: nl)
pending = pending.suffix(from: pending.index(after: nl))
// Tolerate CRLF output.
if slice.last == 0x0D { slice = slice.dropLast() }
lines.append(Self.decode(slice))
}
return lines
}
/// Flushes any unterminated remainder at EOF. Returns `nil` when empty.
public mutating func finish() -> String? {
guard !pending.isEmpty else { return nil }
var rest = pending
pending.removeAll(keepingCapacity: false)
if rest.last == 0x0D { rest = rest.dropLast() }
return rest.isEmpty ? nil : Self.decode(rest)
}
private static func decode(_ bytes: Data.SubSequence) -> String {
String(decoding: bytes, as: UTF8.self)
}
}
@@ -0,0 +1,354 @@
import Foundation
/// Captured output from `runCaptured` (used by printcal/applycal
/// the only tools whose results arrive as one-shot output).
public struct CapturedResult: Sendable, Equatable {
public let stdout: String
public let stderr: String
public let exitCode: Int32
}
/// Spawn / stdin / kill / event bus for Argyll sidecar children
/// (docs/02 §Event bus, docs/03 §Process manager).
///
/// Invariants:
/// - Duplicate `id` while a child runs is rejected (#116).
/// - The stdin handle lives in its own map, independent of wait, so
/// `sendStdin` never blocks on process exit (#84).
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
/// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills.
/// - `kill` drops the stdin handle so writers fail fast.
public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
public static let shared = ProcessManager()
// MARK: - Event bus (multicast)
private var subscribers: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
/// Subscribe to the event bus. Each call returns an independent
/// stream; every event is delivered to every live subscriber.
public nonisolated func events() -> AsyncStream<ProcessEvent> {
AsyncStream { continuation in
let token = UUID()
Task { await self.addSubscriber(continuation, token: token) }
continuation.onTermination = { _ in
Task { await self.removeSubscriber(token) }
}
}
}
private func addSubscriber(
_ continuation: AsyncStream<ProcessEvent>.Continuation,
token: UUID
) {
subscribers[token] = continuation
}
private func removeSubscriber(_ token: UUID) {
subscribers.removeValue(forKey: token)
}
private func emit(_ event: ProcessEvent) {
for continuation in subscribers.values {
continuation.yield(event)
}
}
// MARK: - Child registry
private struct RunningChild {
let process: Process
/// stdin lives in its own slot, independent of process wait (#84).
var stdin: FileHandle?
var stdoutDecoder: ProcessLineDecoder
var stderrDecoder: ProcessLineDecoder
var stdoutEOF = false
var stderrEOF = false
/// Set by the termination handler; `exit` is emitted once both
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:]
/// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
public func isRunning(_ id: String) -> Bool {
children[id] != nil || captured[id] != nil
}
// MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for
/// `exit(id:)` events never assume the return means the tool
/// finished (docs/03).
public func runStreaming(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
children[id] = RunningChild(
process: process,
stdin: stdinPipe.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
do {
try process.run()
} catch {
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
}
stderrHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
process.terminationHandler = { [weak self] proc in
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
}
// MARK: - Spawn (captured)
/// Runs a child to completion and returns all output. Reads stdout
/// and stderr concurrently so a full pipe buffer can never deadlock
/// the child. Used by `printcal` / `applycal` (docs/03).
public func runCaptured(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register before run() so a concurrent duplicate spawn fails.
captured[id] = process
do {
try process.run()
} catch {
captured.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
async let errData = Task.detached {
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
}
}
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: code))
}
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
}
// MARK: - stdin
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
/// and flushes (docs/03 §stdin protocol).
public func sendStdin(id: String, bytes: Data) throws {
guard let child = children[id] else { throw ProcessError.unknownID(id) }
guard let handle = child.stdin else {
throw ProcessError.stdinFailed("stdin closed for \(id)")
}
do {
try handle.write(contentsOf: bytes)
} catch {
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
}
}
public func sendStdin(id: String, text: String) throws {
try sendStdin(id: id, bytes: Data(text.utf8))
}
// MARK: - Kill
/// Terminates a child. The `exit` event still fires exactly once.
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
public func kill(id: String) {
if var child = children[id] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: process.terminationStatus))
}
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() -> Int {
let ids = Array(children.keys) + Array(captured.keys)
for id in ids { kill(id: id) }
return ids.count
}
// MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1"
for (key, value) in extra { env[key] = value }
return env
}
private func ingestOutput(
_ data: Data,
id: String,
isStderr: Bool,
handle: FileHandle
) {
guard var child = children[id] else { return }
if data.isEmpty {
// EOF on this pipe.
handle.readabilityHandler = nil
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
children[id] = child
maybeFinalize(id: id)
return
}
let lines: [String] = isStderr
? child.stderrDecoder.feed(data)
: child.stdoutDecoder.feed(data)
children[id] = child
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
private func didTerminate(id: String, code: Int32) {
guard var child = children[id], !child.finalized else { return }
child.pendingExitCode = code
try? child.stdin?.close()
child.stdin = nil
children[id] = child
maybeFinalize(id: id)
}
/// Emits `exit` once the child has terminated *and* both pipes have
/// drained to EOF, so no buffered output is lost.
private func maybeFinalize(id: String) {
guard var child = children[id],
let code = child.pendingExitCode,
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
children.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
emit(.stderr(id: id, line: tail))
}
emit(.exit(id: id, code: code))
}
}
@@ -0,0 +1,115 @@
import Foundation
/// A saved wizard preset slot (docs/22 §Presets). The preset *engine*
/// lands in issue #11; for M1 the store only needs a Codable container.
public struct CustomPreset: Codable, Equatable, Sendable {
public var name: String
/// Opaque per-stage form values keyed by field id.
public var values: [String: String]
public init(name: String, values: [String: String] = [:]) {
self.name = name
self.values = values
}
}
/// Where `install_profile` drops finished profiles (docs/22).
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
case user
case system
}
/// `settings.json` model (docs/22). snake_case keys match the v1 file
/// so field names stay identical across rewrites.
public struct AppSettings: Codable, Equatable, Sendable {
/// User override for Argyll binaries; `nil` bundled sidecars.
public var argyllBinaryDir: String?
/// Stored but **never applied to argv** Stage 2's own instrument
/// select is the live source (docs/04 §0.1).
public var defaultInstrument: String?
/// `nil` `.debug` in debug builds, `.info` in release (#158).
public var logLevel: LogLevel?
public var deltaEGoodMax: Double
public var deltaEWarningMax: Double
public var customPresets: [CustomPreset]
public var enableI1Pro2Leds: Bool
public var calibrationStaleDays: Int
public var defaultInstallLocation: InstallLocation
public var askBeforeOverwriteProfile: Bool
public var openColorPanelAfterInstall: Bool
public init(
argyllBinaryDir: String? = nil,
defaultInstrument: String? = nil,
logLevel: LogLevel? = nil,
deltaEGoodMax: Double = 2.0,
deltaEWarningMax: Double = 5.0,
customPresets: [CustomPreset] = [],
enableI1Pro2Leds: Bool = false,
calibrationStaleDays: Int = 30,
defaultInstallLocation: InstallLocation = .user,
askBeforeOverwriteProfile: Bool = true,
openColorPanelAfterInstall: Bool = false
) {
self.argyllBinaryDir = argyllBinaryDir
self.defaultInstrument = defaultInstrument
self.logLevel = logLevel
self.deltaEGoodMax = deltaEGoodMax
self.deltaEWarningMax = deltaEWarningMax
self.customPresets = customPresets
self.enableI1Pro2Leds = enableI1Pro2Leds
self.calibrationStaleDays = calibrationStaleDays
self.defaultInstallLocation = defaultInstallLocation
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
self.openColorPanelAfterInstall = openColorPanelAfterInstall
}
public static let `default` = AppSettings()
/// Effective log level runtime state, not just persistence (#158).
public var effectiveLogLevel: LogLevel {
if let logLevel { return logLevel }
#if DEBUG
return .debug
#else
return .info
#endif
}
enum CodingKeys: String, CodingKey {
case argyllBinaryDir = "argyll_binary_dir"
case defaultInstrument = "default_instrument"
case logLevel = "log_level"
case deltaEGoodMax = "delta_e_good_max"
case deltaEWarningMax = "delta_e_warning_max"
case customPresets = "custom_presets"
case enableI1Pro2Leds = "enable_i1pro2_leds"
case calibrationStaleDays = "calibration_stale_days"
case defaultInstallLocation = "default_install_location"
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
case openColorPanelAfterInstall = "open_color_panel_after_install"
}
/// UI-facing validation. Strings are part of the contract (issue #5).
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
public static let errorThresholdOrder =
"Good ΔE threshold must be strictly less than the warning threshold."
/// All validation errors, in declaration order. Empty = valid.
public func validate() -> [String] {
var errors: [String] = []
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
errors.append(Self.errorNegativeDeltaE)
}
if deltaEGoodMax >= deltaEWarningMax {
errors.append(Self.errorThresholdOrder)
}
return errors
}
public var isValid: Bool { validate().isEmpty }
}
@@ -0,0 +1,47 @@
import Foundation
/// Persists `AppSettings` to
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
/// Posted on `NotificationCenter.default` after every successful save.
public static let settingsDidChange =
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
}
public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
}
/// Validates before persisting throws `SettingsError` listing
/// every violation; nothing is written on failure.
public func save(_ settings: AppSettings) throws {
let errors = settings.validate()
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
public enum SettingsError: Error, Equatable {
case validationFailed([String])
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
///
/// Navigation is *disk*, not buttons: a stage unlocks only when its
/// predecessor artefacts exist. Forward moves are gated; backward is
/// always allowed. Gating is re-evaluated on window focus and on stage
/// entry (#151 files can disappear in Finder).
public enum WizardGating {
/// Whether `stage` is reachable given the probed artefacts.
///
/// - Stage 0 (calibrate): always it is out-of-band, not gated.
/// - Stage 1: always.
/// - Stage 2: `.ti1` exists.
/// - Stage 3: `.ti1` **and** `.ti2`.
/// - Stage 4: `.ti3` exists (accepted measurement only a `.ti2`
/// alone never unlocks it; #109/#110).
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
public static func isUnlocked(
_ stage: WizardStage,
artefacts: StageArtefacts
) -> Bool {
switch stage {
case .calibrate: return true
case .generate: return true
case .layOutPrint: return artefacts.stage1Complete
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
case .buildProfile: return artefacts.stage3Complete
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
}
}
/// Whether `go(to:)` may proceed. Backward moves and the current
/// stage are always allowed; forward moves must be unlocked.
public static func canNavigate(
to target: WizardStage,
from current: WizardStage,
artefacts: StageArtefacts
) -> Bool {
if target == current { return true }
if target == .calibrate || current == .calibrate {
// Stage 0 is a side-trip, not stepper navigation.
return true
}
if target.rawValue < current.rawValue { return true }
return isUnlocked(target, artefacts: artefacts)
}
/// The deepest unlocked stepper stage used when revalidation
/// locks the current stage (#151).
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
for stage in WizardStage.stepperStages.reversed()
where isUnlocked(stage, artefacts: artefacts) {
return stage
}
return .generate
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The five wizard stages plus Stage 0 (calibration), matching the v1
/// `data-stage` contract in docs/21. Stepper buttons 15 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 (15); `nil` for the out-of-band calibrate stage.
public var stepperIndex: Int? {
self == .calibrate ? nil : rawValue
}
public var title: String {
switch self {
case .calibrate: return "Printer Calibration"
case .generate: return "Generate Target"
case .layOutPrint: return "Lay Out & Print"
case .measure: return "Measure Chart"
case .buildProfile: return "Build Profile"
case .verifyInstall: return "Verify & Install"
}
}
public var symbolName: String {
switch self {
case .calibrate: return "slider.horizontal.3"
case .generate: return "square.grid.3x3"
case .layOutPrint: return "printer"
case .measure: return "eyedropper.halffull"
case .buildProfile: return "paintpalette"
case .verifyInstall: return "checkmark.seal"
}
}
/// Stages shown in the sidebar stepper, in order.
public static var stepperStages: [WizardStage] {
[.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall]
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Session mode (docs/06 §wizardState). `"calibration"` is set while
/// Stage 0 is driving a `CAL_` chart through the same pipeline.
public enum SessionMode: String, Codable, Sendable {
case profile
case calibration
}
/// Persisted wizard state (docs/06 §wizardState fields)
/// `wizard_state.json` in app data.
public struct WizardState: Codable, Equatable, Sendable {
/// 05 (`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)
}
}
+75
View File
@@ -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
+20
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Mock script for profcheck
# Simulates real ArgyllCMS profcheck -v -k -s -u output
echo "profcheck: Checking profile accuracy..."
echo "No of test patches = 52"
sleep 1
cat << 'EOF'
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
EOF
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
exit 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.800",
"green" : "0.478",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ICCery-logo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "original"
}
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
<defs>
<clipPath id="coneClip">
<path d="M 50 82 L 110 82 L 80 142 Z" />
</clipPath>
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00AEEF" />
<stop offset="100%" stop-color="#0066CC" />
</linearGradient>
</defs>
<!-- Drop Shadow -->
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
<!-- Ice Cream Cone (Waffle) -->
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
<line x1="40" y1="80" x2="120" y2="160" />
<line x1="55" y1="80" x2="135" y2="160" />
<line x1="70" y1="80" x2="150" y2="160" />
<line x1="85" y1="80" x2="165" y2="160" />
<line x1="120" y1="80" x2="40" y2="160" />
<line x1="105" y1="80" x2="25" y2="160" />
<line x1="90" y1="80" x2="10" y2="160" />
<line x1="75" y1="80" x2="-5" y2="160" />
</g>
<!-- CMYK Scoops (C, M, Y) -->
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
<!-- Black (Key) Cherry -->
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
<!-- Highlights on scoops for 3D effect -->
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
<!-- Text: ICCery -->
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
read/write user-chosen working directories, and talk to lp/CUPS. -->
<key>com.apple.security.device.usb</key>
<true/>
</dict>
</plist>
+112
View File
@@ -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
}
}
+50
View File
@@ -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
}
}
+62
View File
@@ -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
)
}
}
+48
View File
@@ -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.")
}
}
}
+169
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
+113
View File
@@ -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 15 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 15.
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)
)
}
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (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)
}
}
+21
View File
@@ -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
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
import Observation
import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06).
///
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons.
@MainActor
@Observable
final class WizardViewModel {
// MARK: - wizardState fields (persisted)
var stage: WizardStage {
didSet { if stage != oldValue { persist() } }
}
/// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } }
}
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
}
var printerName: String? {
didSet { if printerName != oldValue { persist() } }
}
var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } }
}
/// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } }
}
// MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts()
private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>?
init(stateStore: WizardStateStore = WizardStateStore()) {
self.stateStore = stateStore
let s = stateStore.load()
self.stage = s.stage
self.basename = s.basename
self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd)
self.printerName = s.printerName
self.sessionMode = s.sessionMode
self.profileBasename = s.profileBasename
refreshGating()
// A restored stage may have been locked since (#151).
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
}
}
// MARK: - Gating
/// `isUnlocked` for the sidebar stepper.
func isUnlocked(_ stage: WizardStage) -> Bool {
WizardGating.isUnlocked(stage, artefacts: artefacts)
}
/// `true` while Stage 0 (printer calibration) is shown.
var isCalibrating: Bool { stage == .calibrate }
/// Re-probes the artefact directory and re-locks (#151). Called on
/// window focus, stage entry, and basename/cwd changes.
func refreshGating() {
guard !basename.isEmpty, let dir = effectiveWorkingDirectory else {
artefacts = StageArtefacts()
return
}
artefacts = ArtefactProbe.verify(basename: basename, cwd: dir)
}
/// `setTarget(basename, cwd)` validates the basename (no `/`, `\`,
/// `..`; no placeholders #60) and resolves the cwd (#59).
func setTarget(basename: String, workingDirectory: URL?) {
do {
self.basename = try PathSecurity.sanitizeBasename(basename)
} catch {
showNotice("Invalid target name.", kind: .error)
return
}
self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory)
}
/// cwd never stays empty once a basename exists (#59).
var effectiveWorkingDirectory: URL? {
if let workingDirectory { return workingDirectory }
return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil)
}
// MARK: - Navigation
/// `navigateToStage(n)` refuses locked forward moves with a
/// warning banner; backward is always allowed (docs/06).
func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return }
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
stage = target
} else {
showNotice(
"Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.",
kind: .warning
)
}
}
func enterCalibration() {
sessionMode = .calibration
stage = .calibrate
}
func exitCalibration() {
sessionMode = .profile
stage = .generate
}
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
/// If the current stage re-locked, fall back to the deepest unlocked.
func windowDidBecomeKey() {
refreshGating()
if stage != .calibrate,
!WizardGating.isUnlocked(stage, artefacts: artefacts) {
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
}
}
// MARK: - Notice
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
noticeDismissTask?.cancel()
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
}
}
}
}
func dismissNotice() {
noticeDismissTask?.cancel()
notice = nil
}
// MARK: - Persistence
private func persist() {
let state = WizardState(
currentStage: stage.rawValue,
basename: basename,
cwd: workingDirectory?.path ?? "",
printerName: printerName,
sessionMode: sessionMode,
profileBasename: profileBasename
)
try? stateStore.save(state)
}
}
+27
View File
@@ -0,0 +1,27 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
}
}
@@ -0,0 +1,124 @@
import Testing
import Foundation
import ImageIO
import UniformTypeIdentifiers
@testable import ICCeryCore
private func tempURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try """
CTI2
TARGET_INSTRUMENT "i1iO"
NUMBER_OF_FIELDS 9
NUMBER_OF_SETS 800
NUMBER_OF_PAGES 3
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
try "CGATS".write(
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
@Test func numberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
let image = ctx.makeImage()!
guard let dest = CGImageDestinationCreateWithURL(
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
) else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
return url
}
@Test func producesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
// PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
}
@Test func nonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try body(root)
return root
}
private func touch(_ url: URL, executable: Bool = true) throws {
FileManager.default.createFile(atPath: url.path, contents: Data())
if executable {
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path
)
}
}
@Test func overrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
}
@Test func overrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
}
@Test func universalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
}
let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal")
}
@Test func fallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
let r = BinaryResolver(
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
#expect(r.platformDir() == "macos-x86_64")
}
@Test func missingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
}
}
@@ -0,0 +1,129 @@
import Testing
import Foundation
@testable import ICCeryCore
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-files-\(name)")
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
}
}
}
@Test func acceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good))
}
}
@Test func resolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
}
@Test func createsParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
@Test func icmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm")
}
@Test func enumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam",
"CAL_t.ti1", "CAL_t.cal",
// must NOT match:
"other.ti1", "t.txt", "CAL_other.ti1",
] { try touch(dir.appendingPathComponent(name)) }
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
.map(\.lastPathComponent)
for expected in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
#expect(names.contains(expected), "missing \(expected)")
}
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
@@ -0,0 +1,262 @@
import Testing
import Foundation
@testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized)
struct ProcessManagerTests {
// MARK: - Fixture plumbing
private static let fixtureDir: URL = {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
/// Writes a shell script fixture and returns its executable URL.
private func script(_ name: String, _ body: String) throws -> URL {
let url = Self.fixtureDir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path
)
return url
}
/// Collects events for `id` until `.exit`, `timeout` seconds max.
private func collect(
_ manager: ProcessManager,
id: String,
timeout: TimeInterval = 10
) async -> [ProcessEvent] {
await withCheckedContinuation { cont in
let box = Box()
Task {
for await event in manager.events() {
guard event.id == id else { continue }
box.append(event)
if case .exit = event { break }
}
if box.finish() { cont.resume(returning: box.events) }
}
Task {
try? await Task.sleep(for: .seconds(timeout))
if box.finish() { cont.resume(returning: box.events) }
}
}
}
private final class Box: @unchecked Sendable {
private let lock = NSLock()
private var _events: [ProcessEvent] = []
private var finished = false
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
}
// MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1")
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
let evs = await events
let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil
}
#expect(lines == ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0)))
}
@Test func routesStderrSeparately() async throws {
let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops")))
}
@Test func stripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager()
let bin = try script(
"rows.sh",
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
)
async let evs = collect(pm, id: "t3")
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
let events = await evs
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout.
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
}
@Test func unterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
}
@Test func stdinRoundTrip() async throws {
let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern.
let bin = try script(
"echo.sh",
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
)
async let evs = collect(pm, id: "t5")
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
}
@Test func duplicateIDRejected() async throws {
let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await #expect(throws: ProcessError.duplicateID("t6")) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
}
await pm.kill(id: "t6")
}
@Test func killEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7")
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
await pm.kill(id: "t7")
let events = await evs
// exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1)
await #expect(throws: ProcessError.unknownID("t7")) {
try await pm.sendStdin(id: "t7", text: "d\n")
}
}
@Test func killAllCountsSignaled() async throws {
let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll()
#expect(count == 2)
}
@Test func capturedRunReturnsBothStreams() async throws {
let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data"))
#expect(result.exitCode == 3)
}
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script(
"big.sh",
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
)
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999"))
}
@Test func argyllEnvVarIsSet() async throws {
let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
}
@Test func unknownIDStdinThrows() async throws {
let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) {
try await pm.sendStdin(id: "nope", text: "d\n")
}
}
}
@Suite("ProcessLineDecoder")
struct ProcessLineDecoderTests {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
#expect(d.finish() == nil)
}
@Test func crlfIsStripped() {
var d = ProcessLineDecoder()
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
}
@Test func finishReturnsRemainder() {
var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8))
#expect(d.finish() == "x")
#expect(d.finish() == nil)
}
}
@Suite("JSONAccumulator")
struct JSONAccumulatorTests {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil)
#expect(acc.feed(line: " \"k\": 1") == nil)
let done = acc.feed(line: "}")
#expect(done != nil)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
#expect(obj?["k"] == 1)
}
@Test func nonJSONLinesIgnored() {
var acc = JSONAccumulator()
#expect(acc.feed(line: "Reading instrument...") == nil)
#expect(acc.feed(line: "still text") == nil)
#expect(acc.completeData == nil)
}
@Test func decodeTyped() {
struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator()
// Split so the doc completes on the second feed.
#expect(acc.feed(line: "{\"n\":") == nil)
let data = acc.feed(line: "7}")
#expect(data != nil)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7)
#expect(acc.isEmpty)
}
}
@Suite("LogSanitizer")
struct LogSanitizerTests {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
}
}
+164
View File
@@ -0,0 +1,164 @@
import Testing
import Foundation
@testable import ICCeryCore
private func tempStoreURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-settings-\(UUID().uuidString)")
.appendingPathComponent("settings.json")
}
@Suite("AppSettings")
struct AppSettingsTests {
@Test func defaults() {
let s = AppSettings.default
#expect(s.argyllBinaryDir == nil)
#expect(s.defaultInstrument == nil)
#expect(s.logLevel == nil)
#expect(s.deltaEGoodMax == 2.0)
#expect(s.deltaEWarningMax == 5.0)
#expect(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30)
#expect(s.defaultInstallLocation == .user)
#expect(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall)
#expect(s.isValid)
}
@Test func negativeThresholds() {
var s = AppSettings.default
s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [
AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder,
])
}
@Test func goodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default
s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9
#expect(s.isValid)
}
@Test func snakeCaseKeys() throws {
let s = AppSettings.default
let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\""))
}
}
@Suite("SettingsStore")
struct SettingsStoreTests {
@Test func roundTrip() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3"
try store.save(s)
#expect(store.load() == s)
}
@Test func corruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
#expect(SettingsStore(fileURL: url).load() == .default)
}
@Test func missingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
}
@Test func invalidSettingsNotPersisted() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func savePostsNotification() async throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var fired = false
let token = NotificationCenter.default.addObserver(
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default)
#expect(fired)
}
}
@Suite("LogSink")
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
.appendingPathComponent("iccery.log")
return (url, LogSink(fileURL: url))
}
@Test func writesFormattedLines() {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(content.contains("[INFO] test: hello"))
}
@Test func levelFilteringIsLive() {
let (url, sink) = tempLog()
sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden"))
#expect(content.contains("shown"))
}
@Test func rotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog()
sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it.
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes))
try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path
))
// Active log is small again.
let size = try FileManager.default.attributesOfItem(
atPath: url.path
)[.size] as? UInt64
#expect((size ?? 0) < 1024)
}
@Test func tailExcerptCaps() throws {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
}
}
@@ -0,0 +1,127 @@
import Testing
import Foundation
@testable import ICCeryCore
private func artefacts(
ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false
) -> StageArtefacts {
var a = StageArtefacts()
a.stage1Complete = ti1
a.stage2Complete = ti2
a.stage3Complete = ti3
a.stage4Complete = profile
if profile {
a.profilePath = URL(fileURLWithPath: "/x/t.icc")
}
return a
}
@Suite("WizardGating matrix")
struct WizardGatingTests {
@Test func emptyProjectOnlyStage1() {
let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
}
}
@Test func ti1UnlocksStage2Only() {
let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
}
@Test func stage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
}
@Test func stage4NeedsTi3NotTi2() {
// #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
}
@Test func stage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
))
}
@Test func forwardGatedBackwardFree() {
let a = artefacts()
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
// Backward always allowed even when artefacts vanished.
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
// Same stage is a no-op.
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
// Stage 0 is a side-trip, never gated.
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
}
@Test func deepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
#expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true)
) == .measure)
#expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall)
}
}
@Suite("WizardStateStore")
struct WizardStateStoreTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json")
}
@Test func roundTrip() throws {
let url = tempURL()
let store = WizardStateStore(fileURL: url)
var s = WizardState()
s.currentStage = 3
s.basename = "run-42"
s.cwd = "/tmp/charts"
s.sessionMode = .calibration
s.profileBasename = "imported"
try store.save(s)
#expect(store.load() == s)
}
@Test func missingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default)
#expect(s.stage == .generate)
#expect(s.sessionMode == .profile)
}
@Test func corruptStageFallsBackToGenerate() throws {
let url = tempURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
}
@Test func sessionModeCalibrationRoundTrips() throws {
var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration)
s.sessionMode = .profile
#expect(s.sessionMode == .profile)
}
}
+96
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# scripts/fetch-argyll.sh
#
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
#
# Env overrides (parity with v1 fetch-argyll.mjs):
# ARGYLL_SERVER_URL default https://git.i3omb.com
# ARGYLL_REPO default gronod/argyllcms
# ARGYLL_RELEASE_TAG default: latest release
# GITEA_TOKEN optional, for private repos
#
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
# they ship in git, not in the release tarball.
set -eu
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
TAG="${ARGYLL_RELEASE_TAG:-}"
SUFFIX="_macOS_universal_bin.tgz"
PLATFORM_DIR="macos-universal"
MARKER="instlist"
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
FORCE=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
esac
done
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
exit 0
fi
AUTH_HEADER=""
if [ -n "${GITEA_TOKEN:-}" ]; then
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
fi
api_get() {
if [ -n "$AUTH_HEADER" ]; then
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
else
curl -fsSL -H 'Accept: application/json' "$1"
fi
}
if [ -n "$TAG" ]; then
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
else
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
fi
echo "Fetching release info from $API_URL"
RELEASE_JSON="$(api_get "$API_URL")" || {
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
exit 1
}
# Find the macOS universal asset's browser_download_url without jq.
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
| tr ',' '\n' \
| grep '"browser_download_url"' \
| grep "$SUFFIX" \
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
| head -n 1)"
if [ -z "$ASSET_URL" ]; then
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
exit 1
fi
echo "Downloading $ASSET_URL"
TMPDIR_FETCH="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
if [ -n "$AUTH_HEADER" ]; then
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
else
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
fi
EXTRACT="$TMPDIR_FETCH/extract"
mkdir -p "$EXTRACT"
tar -xzf "$ARCHIVE" -C "$EXTRACT"
# Archive contains Argyll_V*/bin/ (or a bare bin/).
BIN_DIR=""
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
done
if [ -z "$BIN_DIR" ]; then
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
exit 1
fi
mkdir -p "$DEST"
cp -R "$BIN_DIR"/. "$DEST"/
find "$DEST" -type f -exec chmod 0755 {} +
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
# verify — an unsigned sidecar fails the script.
for f in "$DEST"/*; do
[ -f "$f" ] || continue
if file -b "$f" | grep -q 'Mach-O'; then
codesign -f -s - "$f" 2>/dev/null || true
fi
done
UNSIGNED=""
for f in "$DEST"/*; do
[ -f "$f" ] || continue
if file -b "$f" | grep -q 'Mach-O'; then
if ! codesign -dvv "$f" >/dev/null 2>&1; then
UNSIGNED="$UNSIGNED $f"
fi
fi
done
if [ -n "$UNSIGNED" ]; then
echo "error: unsigned binaries remain:$UNSIGNED" >&2
exit 1
fi
if [ ! -x "$DEST/$MARKER" ]; then
echo "error: marker binary $MARKER missing after extraction" >&2
exit 1
fi
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"