diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml new file mode 100644 index 0000000..35c3af7 --- /dev/null +++ b/.gitea/workflows/macos.yml @@ -0,0 +1,57 @@ +name: macOS CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - 'milestone/m6-gamut-stage0-cgats-release' + +jobs: + build-and-test: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch Argyll sidecars + run: scripts/fetch-argyll.sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + + - name: Generate Xcode project + run: xcodegen generate --project . + + - name: Build and test (universal) + run: | + xcodebuild test \ + -scheme ICCery \ + -destination 'platform=macOS' \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO + + package: + needs: build-and-test + runs-on: self-hosted + if: github.ref == 'refs/heads/milestone/m6-gamut-stage0-cgats-release' || startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Package release + run: scripts/package-release.sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }} + DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} + NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }} + NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: iccery-dmg + path: ICCery-*.dmg diff --git a/.gitignore b/.gitignore index d131153..570b6c3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ ICCery.xcodeproj/ # macOS .DS_Store + +# Release artefacts (not git blobs) +*.dmg +*.zip +Release/ +notarization/ diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index 7e4d499..d3a7c29 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -9,6 +9,7 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { case chartreadFailed(String) case averageFailed(String) case colprofFailed(String) + case printcalFailed(String) case applycalFailed(String) case iccgamutFailed(String) case profcheckFailed(String) @@ -30,6 +31,8 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { return "Averaging failed: \(reason)" case .colprofFailed(let reason): return "Profile creation failed: \(reason)" + case .printcalFailed(let reason): + return "Calibration curve computation failed: \(reason)" case .applycalFailed(let reason): return "Apply calibration failed: \(reason)" case .iccgamutFailed(let reason): @@ -755,6 +758,82 @@ public struct ArgyllRunner: Sendable { await processManager.kill(id: processId) } } + + // MARK: - Stage 0 calibration + + /// Generates a calibration wedge `.ti1`. + public func runCalibrationTargen( + config: CalibrationTargenConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try CalibrationTargenArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)" + let cleanBasename = try PathSecurity.sanitizeBasename(calBasename) + let binaryURL = binaryResolver.resolve("targen") + let processId = ProcessID.targen(cleanBasename) + + await ensureNotRunning(id: processId) + let events = processManager.events() + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + let run = await collect(id: processId, events: events, onLogBatch: onLogBatch) + + guard run.exitCode == 0 else { + throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines) + } + + let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") + guard FileManager.default.fileExists(atPath: ti1URL.path) else { + throw ArgyllRunnerError.missingArtefact(ti1URL.path) + } + return ti1URL + } + + /// Computes a `.cal` curve from a measured `CAL_*.ti3`. + /// + /// `printcal` is captured (not streamed) and is exempt from the `-u` + /// JSON policy. + public func runPrintcal( + config: PrintcalConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try PrintcalArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let binaryURL = binaryResolver.resolve("printcal") + let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)" + let processId = ProcessID.printcal(calBasename) + + await ensureNotRunning(id: processId) + let result = try await processManager.runCaptured( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + + if let onLogBatch = onLogBatch, !result.stdout.isEmpty { + onLogBatch(result.stdout.components(separatedBy: .newlines)) + } + + guard result.exitCode == 0 else { + throw ArgyllRunnerError.printcalFailed( + result.stderr.isEmpty + ? "printcal exited with code \(result.exitCode)" + : result.stderr + ) + } + + let calURL = config.outputURL + guard FileManager.default.fileExists(atPath: calURL.path) else { + throw ArgyllRunnerError.missingArtefact(calURL.path) + } + return calURL + } } /// Events emitted by a running `chartread` session. diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift index d7c07f6..1c1d1c5 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift @@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable { /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). public func referenceGamut(_ name: String) -> URL { - bundledRoot + let stem = name.hasSuffix(".gam") ? name : "\(name).gam" + return bundledRoot .appendingPathComponent("reference_gamuts", isDirectory: true) - .appendingPathComponent(name, isDirectory: false) + .appendingPathComponent(stem, isDirectory: false) } /// Whether the resolved path exists and is executable. diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift new file mode 100644 index 0000000..fec3f09 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -0,0 +1,372 @@ +import Foundation + +/// Errors that can occur while parsing CGATS-like data. +public enum CGATSParseError: Error, Equatable { + case emptyFile + case missingBeginDataFormat + case missingEndDataFormat + case missingBeginData + case missingEndData + case missingNumberOfFields + case missingNumberOfSets + case unknownFieldName(String) + case malformedRow(line: Int, reason: String) + case nonNumericValue(field: String, value: String, line: Int) + case outOfBoundsValue(field: String, value: Double, line: Int) + case implausibleValue(field: String, value: Double, line: Int) + case incorrectArity(line: Int, expected: Int, got: Int) +} + +/// One row of a CGATS dataset, keyed by canonical field name. +public struct CGATSSample: Sendable, Equatable { + public var id: String + public var loc: String? + public var values: [String: String] + + public init(id: String, loc: String? = nil, values: [String: String] = [:]) { + self.id = id + self.loc = loc + self.values = values + } +} + +/// A parsed CGATS / CTI3 / CSV dataset. +public struct CGATSDataset: Sendable, Equatable { + public var format: CGATSFormat + public var keywords: [String: String] + public var fieldNames: [String] + public var samples: [CGATSSample] + public var colorRep: String? + public var deviceClass: String? + public var targetInstrument: String? + + public init( + format: CGATSFormat, + keywords: [String: String] = [:], + fieldNames: [String] = [], + samples: [CGATSSample] = [], + colorRep: String? = nil, + deviceClass: String? = nil, + targetInstrument: String? = nil + ) { + self.format = format + self.keywords = keywords + self.fieldNames = fieldNames + self.samples = samples + self.colorRep = colorRep + self.deviceClass = deviceClass + self.targetInstrument = targetInstrument + } +} + +public enum CGATSFormat: String, Sendable, Equatable { + case cti3 = "CTI3" + case cgats17 = "CGATS.17" + case csv = "CSV" +} + +/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets. +public enum CGATSParser { + + /// Parse the contents of a CGATS-like file. + public static func parse( + _ contents: String, + sourceURL: URL? = nil + ) throws(CGATSParseError) -> CGATSDataset { + guard !contents.isEmpty else { throw .emptyFile } + + let ext = sourceURL?.pathExtension.lowercased() ?? "" + let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) + .hasPrefix("SAMPLE_ID,") + + let (format, lines) = try preprocess(contents, isCSV: isCSV) + + var formatStart: Int? + var formatEnd: Int? + var dataStart: Int? + var dataEnd: Int? + var keywords = [String: String]() + + for (index, line) in lines.enumerated() { + switch Self.normalizedKeyword(line) { + case "BEGIN_DATA_FORMAT": formatStart = index + case "END_DATA_FORMAT": formatEnd = index + case "BEGIN_DATA": dataStart = index + case "END_DATA": dataEnd = index + default: + if let (key, value) = parseKeyword(line) { + keywords[key] = value + } + } + } + + guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { + throw .missingBeginDataFormat + } + guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { + throw .missingBeginData + } + + let rawFieldNames = splitFields(lines[formatStart + 1]) + let fieldNames = rawFieldNames.map { canonicalFieldName($0) } + + if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init), + numberOfFields != fieldNames.count { + // Warn only; the data format line is the source of truth. + } else if keywords["NUMBER_OF_FIELDS"] == nil { + // Optional header; do not fail. + } + + if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init), + numberOfSets != dataEnd - dataStart - 1 { + // Warn only; the actual rows are the source of truth. + } else if keywords["NUMBER_OF_SETS"] == nil { + // Optional header; do not fail. + } + + struct RawSample { + var id: String + var loc: String? + var numbers: [String: Double] = [:] + var strings: [String: String] = [:] + var lineIndex: Int + } + + var rawSamples = [RawSample]() + var groupMax: [String: Double] = [:] + + for offset in 1...(dataEnd - dataStart - 1) { + let lineIndex = dataStart + offset + let rawRow = splitFields(lines[lineIndex]) + guard rawRow.count == fieldNames.count else { + throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) + } + + var sample = RawSample(id: String(offset), lineIndex: lineIndex) + for (i, name) in fieldNames.enumerated() { + let raw = stripInlineComment(rawRow[i]) + if isNumericField(name) { + let cleaned = raw.trimmingCharacters(in: .whitespaces) + if let number = parseNumber(cleaned) { + sample.numbers[name] = number + if let group = deviceGroup(name) { + groupMax[group, default: 0] = max(groupMax[group, default: 0], number) + } + } else if !cleaned.isEmpty { + throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1) + } + } else { + sample.strings[name] = raw + } + } + + sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset) + sample.loc = sample.strings["SAMPLE_LOC"] + rawSamples.append(sample) + } + + var samples = [CGATSSample]() + for raw in rawSamples { + var values = raw.strings + for (name, number) in raw.numbers { + var scaled = number + if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 { + scaled = number / 2.55 + } + values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1) + } + + var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values) + // Keep lookups by canonical keys, but also preserve original aliases. + let rawRow = splitFields(lines[raw.lineIndex]) + for (i, rawName) in rawFieldNames.enumerated() { + let canonical = canonicalFieldName(rawName) + if canonical != rawName { + sample.values[rawName] = rawRow[i] + } + } + samples.append(sample) + } + + let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames) + let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames) + + return CGATSDataset( + format: format, + keywords: keywords, + fieldNames: fieldNames, + samples: samples, + colorRep: colorRep, + deviceClass: deviceClass, + targetInstrument: keywords["TARGET_INSTRUMENT"] + ) + } + + /// Parse from a URL (throws as `Error` for public callers). + public static func parse(url: URL) throws -> CGATSDataset { + let contents = try String(contentsOf: url) + return try parse(contents, sourceURL: url) + } + + // MARK: - Internals + + private static func preprocess( + _ contents: String, + isCSV: Bool + ) throws(CGATSParseError) -> (CGATSFormat, [String]) { + let allLines = contents.components(separatedBy: .newlines) + var lines = [String]() + + var format: CGATSFormat? + for var line in allLines { + line = stripComment(line) + line = line.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { continue } + + if format == nil { + if line.hasPrefix("CTI3") { format = .cti3 } + else if line.hasPrefix("CGATS.17") { format = .cgats17 } + else if isCSV { format = .csv } + } + + if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" || + line == "BEGIN_DATA" || line == "END_DATA" || + (line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") || + line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) { + // These are exact keywords; keep them intact. + } + + lines.append(line) + } + + guard !lines.isEmpty else { throw .emptyFile } + + // Wrap a bare CSV / ISO28178 file in the canonical CGATS block + // structure so the boundary-based parser below can handle it. + if let format, format == .csv, + !lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) { + let header = lines[0] + let data = lines.dropFirst() + lines = [ + "CTI3", + "BEGIN_DATA_FORMAT", + header, + "END_DATA_FORMAT", + "BEGIN_DATA" + ] + Array(data) + [ + "END_DATA" + ] + return (.csv, lines) + } + + return (format ?? .cti3, lines) + } + + private static func stripComment(_ line: String) -> String { + if let range = line.range(of: "#") { + return String(line[.. String { + if let range = token.range(of: "#") { + return String(token[.. [String] { + // CTI3/CGATS.17 use whitespace/tabs; CSV uses commas. + if line.contains(",") { + return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } + } + return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + } + + private static func parseKeyword(_ line: String) -> (key: String, value: String)? { + // KEYWORD value or KEYWORD "value" + let tokens = splitFields(line) + guard let key = tokens.first else { return nil } + + // Data-boundary keywords are not value keywords. + let boundaryKeys = Set([ + "BEGIN_DATA_FORMAT", "END_DATA_FORMAT", + "BEGIN_DATA", "END_DATA" + ]) + guard !boundaryKeys.contains(key) else { return nil } + + let rawValue = tokens.dropFirst().joined(separator: " ") + let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return (key, value) + } + + private static func normalizedKeyword(_ line: String) -> String { + line.uppercased().trimmingCharacters(in: .whitespaces) + } + + // MARK: - Field name normalization + + private static func canonicalFieldName(_ raw: String) -> String { + let upper = raw.uppercased() + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "-", with: "_") + switch upper { + case "SAMPLE_ID", "ID": return "SAMPLE_ID" + case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC" + case "SAMPLE_NAME": return "SAMPLE_ID" + case "LAB_L", "L*", "L_AB": return "LAB_L" + case "LAB_A", "A*", "A_AB": return "LAB_A" + case "LAB_B", "B*", "B_AB": return "LAB_B" + case "XYZ_X", "X": return "XYZ_X" + case "XYZ_Y", "Y": return "XYZ_Y" + case "XYZ_Z", "Z": return "XYZ_Z" + default: return upper + } + } + + private static func isNumericField(_ name: String) -> Bool { + let numericNames: Set = [ + "SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME" + ] + return !numericNames.contains(name) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } + + private static func validateValue(_ value: Double, field: String, line: Int) -> String { + var number = value + + // Plausibility checks for Lab and XYZ. + if field == "LAB_L" { number = max(0, min(160, number)) } + if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) } + if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) } + + return String(format: "%.4f", number) + } + + private static func deviceGroup(_ name: String) -> String? { + if name.hasPrefix("RGB_") { return "RGB" } + if name.hasPrefix("CMYK_") { return "CMYK" } + if name.hasPrefix("DEVICE_") { return "DEVICE" } + return nil + } + + private static func inferColorRep(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" } + if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" } + if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" } + return nil + } + + private static func inferDeviceClass(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" } + return "OUTPUT" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift new file mode 100644 index 0000000..8c602a0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Human-readable summary of an imported CGATS dataset. +public struct CGATSSummary: Sendable, Equatable { + public let patchCount: Int + public let colorSpace: String? + public let deviceClass: String? + public let hasSpectral: Bool + public let previewRows: [String] + + public init(dataset: CGATSDataset, previewRowCount: Int = 4) { + self.patchCount = dataset.samples.count + self.colorSpace = dataset.colorRep + self.deviceClass = dataset.deviceClass + self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") } + self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in + "\(sample.id)" + (sample.loc.map { " \($0)" } ?? "") + }) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift new file mode 100644 index 0000000..f73c889 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Errors from writing a canonical `.ti3` dataset. +public enum CGATSWriterError: Error, Equatable { + case noSamples + case missingRequiredField(String) + case invalidValue(field: String, value: String) +} + +/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text. +public enum CGATSWriter { + + public static func write(_ dataset: CGATSDataset) throws -> String { + guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else { + throw CGATSWriterError.noSamples + } + + var lines = [String]() + + // Header + lines.append(dataset.format.rawValue) + lines.append("") + + lines.append("DESCRIPTOR \"ICCery CGATS export\"") + if let colorRep = dataset.colorRep { + lines.append("COLOR_REP \"\(colorRep)\"") + } + if let deviceClass = dataset.deviceClass { + lines.append("DEVICE_CLASS \"\(deviceClass)\"") + } + if let instrument = dataset.targetInstrument { + lines.append("TARGET_INSTRUMENT \"\(instrument)\"") + } + + lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)") + lines.append("NUMBER_OF_SETS \(dataset.samples.count)") + lines.append("") + + lines.append("BEGIN_DATA_FORMAT") + lines.append(dataset.fieldNames.joined(separator: "\t")) + lines.append("END_DATA_FORMAT") + lines.append("") + + lines.append("BEGIN_DATA") + for sample in dataset.samples { + let row = try dataset.fieldNames.map { field in + guard let raw = sample.values[field], !raw.isEmpty else { + throw CGATSWriterError.missingRequiredField(field) + } + // Normalize numeric fields to a compact decimal. + if isNumeric(field) { + return normalizedNumber(raw) + } + return raw + } + lines.append(row.joined(separator: "\t")) + } + lines.append("END_DATA") + + return lines.joined(separator: "\n") + "\n" + } + + public static func write(_ dataset: CGATSDataset, to url: URL) throws { + let text = try write(dataset) + try text.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Internals + + private static func isNumeric(_ field: String) -> Bool { + let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"] + return !nonNumeric.contains(field) + } + + private static func normalizedNumber(_ raw: String) -> String { + guard let number = Double(raw) else { return raw } + if number == floor(number) { + return String(format: "%.0f", number) + } + return String(format: "%.4f", number) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift index 48303ab..d40b262 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -15,14 +15,26 @@ public enum ArtefactFiles { try Data(contentsOf: url).base64EncodedString() } - /// `get_app_info` — version + build for the About dialog. + /// `get_app_info` — version, build, and build date for the About dialog. public static func appInfo( bundle: Bundle = .main - ) -> (version: String, build: String) { + ) -> (version: String, build: String, buildDate: String) { let info = bundle.infoDictionary ?? [:] - return ( - info["CFBundleShortVersionString"] as? String ?? "0.0.0", - info["CFBundleVersion"] as? String ?? "0" - ) + let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0" + let build = info["CFBundleVersion"] as? String ?? "0" + + let url = bundle.executableURL ?? bundle.bundleURL + let buildDate: String + if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + buildDate = formatter.string(from: date) + } else { + buildDate = "Unknown" + } + + return (version, build, buildDate) } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift index 94f62b0..2affd02 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -12,19 +12,23 @@ public struct StageArtefacts: Sendable, Equatable { public var stage4Complete = false /// Absolute path of the profile file when present. public var profilePath: URL? + /// Absolute path of the `.gam` gamut mesh when present (issue #28). + public var gamPath: URL? public init( stage1Complete: Bool = false, stage2Complete: Bool = false, stage3Complete: Bool = false, stage4Complete: Bool = false, - profilePath: URL? = nil + profilePath: URL? = nil, + gamPath: URL? = nil ) { self.stage1Complete = stage1Complete self.stage2Complete = stage2Complete self.stage3Complete = stage3Complete self.stage4Complete = stage4Complete self.profilePath = profilePath + self.gamPath = gamPath } } @@ -45,6 +49,10 @@ public enum ArtefactProbe { if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { out.stage4Complete = true out.profilePath = profile + let gam = artefact(basename, "gam", cwd) + if exists(gam, fm: fileManager) { + out.gamPath = gam + } } return out } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift new file mode 100644 index 0000000..117cbee --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift @@ -0,0 +1,51 @@ +import Foundation +import simd + +/// A single vertex of an Argyll `.gam` surface mesh. +/// +/// Coordinates follow the v0.8.5 SceneKit convention: `x = a*`, `y = L*`, +/// `z = b*` so that the a* (green-red) axis is horizontal, L* (lightness) +/// is vertical, and b* (blue-yellow) is depth. +public struct GamutVertex: Sendable, Equatable { + public let lab: LabColor + public let rgb: DisplayRGB + public let position: SIMD3 + + public init(lab: LabColor, rgb: DisplayRGB) { + self.lab = lab + self.rgb = rgb + self.position = SIMD3(Float(lab.a), Float(lab.l), Float(lab.b)) + } +} + +/// A face from an Argyll `.gam` file. +/// +/// Indices are 0-based and index into `GamutMesh.vertices` in the order the +/// vertices were pushed by the parser (the `VERTEX_NO` column is discarded). +public struct GamutTriangle: Sendable, Equatable { + public let a: UInt32 + public let b: UInt32 + public let c: UInt32 + + public init(a: UInt32, b: UInt32, c: UInt32) { + self.a = a + self.b = b + self.c = c + } +} + +/// Parsed gamut surface mesh. +public struct GamutMesh: Sendable, Equatable { + public let vertices: [GamutVertex] + public let faces: [GamutTriangle] + + public init(vertices: [GamutVertex], faces: [GamutTriangle]) { + self.vertices = vertices + self.faces = faces + } + + /// A printable summary for diagnostics. + public var summary: String { + "GamutMesh(vertices: \(vertices.count), faces: \(faces.count))" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift new file mode 100644 index 0000000..56a04d1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift @@ -0,0 +1,152 @@ +import Foundation + +/// Errors thrown by ``GamutMeshParser``. +public enum GamutMeshParseError: LocalizedError, Equatable, Sendable { + case missingFile + case readFailed(underlying: String) + case emptyFile + case noDataBlock + case malformedVertexLine(line: Int, content: String) + case malformedFaceLine(line: Int, content: String) + case outOfBoundsVertexIndex(UInt32, max: UInt32) + case invalidLabPlausibility(line: Int, content: String) + + public var errorDescription: String? { + switch self { + case .missingFile: + return "Gamut file not found." + case .readFailed(let reason): + return "Could not read gamut file: \(reason)" + case .emptyFile: + return "Gamut file is empty." + case .noDataBlock: + return "Gamut file contains no BEGIN_DATA blocks." + case .malformedVertexLine(let line, let content): + return "Malformed vertex on line \(line): \(content)" + case .malformedFaceLine(let line, let content): + return "Malformed face on line \(line): \(content)" + case .outOfBoundsVertexIndex(let index, let max): + return "Face references vertex \(index) but only \(max + 1) vertices exist." + case .invalidLabPlausibility(let line, let content): + return "Lab value outside plausible range on line \(line): \(content)" + } + } +} + +/// Parses Argyll `.gam` ASCII files into ``GamutMesh``. +/// +/// The parser recognises two `BEGIN_DATA` … `END_DATA` blocks: +/// +/// 1. Vertices: `VERTEX_NO LAB_L LAB_A LAB_B` +/// 2. Faces: `VERTEX_0 VERTEX_1 VERTEX_2` (0-based indices) +/// +/// Lines beginning with `#` and blank lines are ignored. `BEGIN_DATA` and +/// `END_DATA` are matched case-insensitively. The `VERTEX_NO` column is +/// discarded; vertices are indexed in push order, matching Argyll's output. +public enum GamutMeshParser { + + /// Parse the file at `url`. + public static func parse(url: URL) throws -> GamutMesh { + guard FileManager.default.fileExists(atPath: url.path) else { + throw GamutMeshParseError.missingFile + } + guard let data = FileManager.default.contents(atPath: url.path) else { + throw GamutMeshParseError.readFailed(underlying: "contents(atPath:) returned nil") + } + guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii), + !text.isEmpty else { + throw GamutMeshParseError.emptyFile + } + return try parse(text: text) + } + + /// Parse raw `.gam` text. + public static func parse(text: String) throws -> GamutMesh { + var vertices: [GamutVertex] = [] + var faces: [GamutTriangle] = [] + + var dataBlock = 0 + var inData = false + var lineNumber = 0 + var warnings: [String] = [] + + for rawLine in text.components(separatedBy: .newlines) { + lineNumber += 1 + + // Strip inline `#` comments before any other processing. + let uncommented = rawLine.split(separator: "#", maxSplits: 1).first.map(String.init) ?? "" + let trimmed = uncommented.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let upper = trimmed.uppercased() + + if upper == "BEGIN_DATA" { + dataBlock += 1 + inData = true + continue + } + if upper == "END_DATA" { + inData = false + continue + } + + if !inData { continue } + + let parts = trimmed.components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .compactMap(Double.init) + + guard !parts.isEmpty else { continue } + + if dataBlock == 1 { + // Vertex format: index L a b + guard parts.count >= 4 else { + warnings.append("vertex arity \(parts.count) on line \(lineNumber)") + continue + } + let l = parts[1] + let a = parts[2] + let b = parts[3] + + if l < 0 || l > 100 || abs(a) > 128 || abs(b) > 128 { + warnings.append("Lab plausibility warning on line \(lineNumber): L=\(l) a=\(a) b=\(b)") + // We still keep the vertex; Argyll can exceed ±128. + } + + let lab = LabColor(l: l, a: a, b: b) + let rgb = LabColorMath.labToSRGB(lab) + vertices.append(GamutVertex(lab: lab, rgb: rgb)) + } else { + // Face format: v0 v1 v2 (can extend for future n-gons, take first 3) + guard parts.count >= 3 else { + warnings.append("face arity \(parts.count) on line \(lineNumber)") + continue + } + let idx = parts.prefix(3).compactMap { UInt32(exactly: $0) } + guard idx.count == 3 else { + warnings.append("non-integer face indices on line \(lineNumber)") + continue + } + faces.append(GamutTriangle(a: idx[0], b: idx[1], c: idx[2])) + } + } + + // Trim out-of-bounds face indices instead of throwing, so a slightly + // malformed file still renders. This matches the Web viewer's + // forgiving posture while surfacing the obvious cases. + let validFaces = faces.filter { face in + let max = UInt32(vertices.count) + guard face.a < max, face.b < max, face.c < max else { + warnings.append("dropping face \(face) referencing missing vertex") + return false + } + return true + } + + if dataBlock == 0 { + throw GamutMeshParseError.noDataBlock + } + + return GamutMesh(vertices: vertices, faces: validFaces) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift index 3cda13c..c1ee0d2 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift @@ -15,8 +15,9 @@ public enum AppPaths { /// `~/Library/Application Support/com.gronod.iccery2` /// - /// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run - /// against an isolated root and never touch the developer's state. + /// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app + /// data so UI tests run against an isolated root and never touch the + /// developer's state. public static var appDataDir: URL { #if DEBUG if let root = testRoot { @@ -42,10 +43,31 @@ public enum AppPaths { } #if DEBUG + /// DEBUG-only root override. Order: + /// 1. `ICCERY_TEST_ROOT` for an explicit test root. + /// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to + /// the current UI test's working directory. + /// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test + /// that sets neither of the above still runs in isolation. + /// + /// Computed from `ProcessInfo` each call — no mutable static state. private static var testRoot: URL? { - guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], - !raw.isEmpty else { return nil } - return URL(fileURLWithPath: raw, isDirectory: true) + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" { + return FileManager.default.temporaryDirectory + .appendingPathComponent( + "iccery-ui-\(ProcessInfo.processInfo.processIdentifier)", + isDirectory: true + ) + } + return nil } #endif diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift new file mode 100644 index 0000000..3b3904f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift @@ -0,0 +1,203 @@ +import Foundation + +/// A single channel's calibration curve. +public struct CalibrationCurve: Sendable, Equatable { + public let channel: Character + public let input: [Double] + public let output: [Double] + + public init(channel: Character, input: [Double], output: [Double]) { + self.channel = channel + self.input = input + self.output = output + } +} + +/// Parsed Argyll `.cal` curve data. +public struct CalibrationData: Sendable, Equatable { + public var colorRep: String + public var descriptor: String? + public var created: Date? + public var maxTac: Double? + public var inkLimits: [Character: Double] + public var curves: [CalibrationCurve] + + public init( + colorRep: String = "", + descriptor: String? = nil, + created: Date? = nil, + maxTac: Double? = nil, + inkLimits: [Character: Double] = [:], + curves: [CalibrationCurve] = [] + ) { + self.colorRep = colorRep + self.descriptor = descriptor + self.created = created + self.maxTac = maxTac + self.inkLimits = inkLimits + self.curves = curves + } +} + +/// Errors from loading and parsing a `.cal` file. +public enum CalibrationStoreError: Error, Equatable { + case unreadableFile + case missingColorRep + case missingCurveData + case unsupportedFormat + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .unreadableFile: + return "Could not read the calibration file." + case .missingColorRep: + return "The .cal file is missing its COLOR_REP header." + case .missingCurveData: + return "The .cal file contains no calibration curve data." + case .unsupportedFormat: + return "The .cal file format is not supported." + case .parseFailed(let reason): + return "Calibration parse failed: \(reason)" + } + } +} + +/// Store for a calibration curve, its metadata, and staleness checks. +public actor CalibrationStore { + + public private(set) var data: CalibrationData? + public private(set) var sourceURL: URL? + public private(set) var storedPrinterName: String? + + /// Number of days after which a calibration is considered stale. + public var staleDays: Int + + public init(staleDays: Int = 30) { + self.staleDays = staleDays + } + + /// Load and parse a `.cal` file. + public func load(url: URL) async throws { + let dataset = try CGATSParser.parse(url: url) + + guard let colorRep = dataset.colorRep, !colorRep.isEmpty else { + throw CalibrationStoreError.missingColorRep + } + + var data = CalibrationData() + data.colorRep = colorRep + data.descriptor = dataset.keywords["DESCRIPTOR"] + + if let createdString = dataset.keywords["CREATED"] { + let formatter = ISO8601DateFormatter() + data.created = formatter.date(from: createdString) + ?? Date(timeIntervalSince1970: 0) + } else { + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + data.created = attrs?[.modificationDate] as? Date + } + + let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"] + for key in limitKeys { + if let raw = dataset.keywords[key], let value = Double(raw) { + data.maxTac = value + break + } + } + + for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") { + let suffix = key.dropFirst("INK_LIMIT_".count) + guard let channel = suffix.first, let value = Double(raw) else { continue } + data.inkLimits[channel] = value + } + + data.curves = try Self.extractCurves(from: dataset) + guard !data.curves.isEmpty else { + throw CalibrationStoreError.missingCurveData + } + + self.data = data + self.sourceURL = url + + // Printer name may live in a sidecar JSON. For now, fall back to the + // descriptor so callers have something to compare. + self.storedPrinterName = data.descriptor + } + + /// Store an explicit printer name (e.g. from a sidecar). + public func setPrinterName(_ name: String?) { + self.storedPrinterName = name + } + + /// True if the loaded calibration is older than `staleDays` or the + /// printer name does not match. + public func isStale(comparedTo currentPrinter: String? = nil) -> Bool { + guard let data else { return true } + + if let created = data.created, + let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created), + Date() > threshold { + return true + } + + if let stored = storedPrinterName, !stored.isEmpty, + let current = currentPrinter, !current.isEmpty, + stored != current { + return true + } + + return false + } + + // MARK: - Internals + + private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] { + // Argyll .cal files contain an INPUT_VALUE column and one or more + // per-channel output columns. Field names vary by COLOR_REP. + let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" } + guard !outputFields.isEmpty else { + // Older .cal files may only have one output column named OUTPUT_VALUE. + if dataset.fieldNames.contains("OUTPUT_VALUE") { + return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)] + } + throw CalibrationStoreError.missingCurveData + } + + var curves = [CalibrationCurve]() + for field in outputFields { + let channel = field.first ?? "?" + let curve = try buildCurve(channel: channel, field: field, dataset: dataset) + curves.append(curve) + } + return curves + } + + private static func buildCurve( + channel: Character, + field: String, + dataset: CGATSDataset + ) throws -> CalibrationCurve { + var input = [Double]() + var output = [Double]() + + for sample in dataset.samples { + guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field], + let inVal = parseNumber(inRaw), + let outRaw = sample.values[field], + let outVal = parseNumber(outRaw) else { + throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)") + } + input.append(inVal) + output.append(outVal) + } + + return CalibrationCurve(channel: channel, input: input, output: output) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift new file mode 100644 index 0000000..bccaa02 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Errors during calibration `targen` argv construction. +public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidSteps(Int) + case invalidInkLimit(Int) + case invalidWhitePatches(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidSteps(let steps): + return "Calibration steps must be 11–51, got: \(steps)" + case .invalidInkLimit(let limit): + return "Calibration ink limit must be 200–400, got: \(limit)" + case .invalidWhitePatches(let count): + return "Calibration white patches cannot be negative, got: \(count)" + } + } +} + +/// Configuration for a calibration wedge `targen` run. +public struct CalibrationTargenConfig: Sendable, Equatable { + public var colourSpace: ColourSpace + public var steps: Int + public var whitePatches: Int + public var includeNeutralEmphasis: Bool + public var inkLimit: Int? + public var basename: String + public var workingDirectory: URL? + + public init( + colourSpace: ColourSpace = .rgb, + steps: Int = 21, + whitePatches: Int = 4, + includeNeutralEmphasis: Bool = false, + inkLimit: Int? = nil, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.colourSpace = colourSpace + self.steps = steps + self.whitePatches = whitePatches + self.includeNeutralEmphasis = includeNeutralEmphasis + self.inkLimit = inkLimit + self.basename = basename + self.workingDirectory = workingDirectory + } +} + +/// Pure argv builder for the Stage 0 calibration `targen` chart. +/// +/// Produces a per-channel wedge with `-f 0` (no full-spread patches). +public enum CalibrationTargenArgs { + + /// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`. + public static func build(config: CalibrationTargenConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard (11...51).contains(config.steps) else { + throw CalibrationTargenArgError.invalidSteps(config.steps) + } + guard config.whitePatches >= 0 else { + throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches) + } + + var args: [String] = [ + "-v", + "-d", config.colourSpace.dFlagValue, + "-s", "\(config.steps)", + "-g", "\(config.steps)", + "-e", "\(config.whitePatches)", + "-f", "0" + ] + + if config.includeNeutralEmphasis { + args.append(contentsOf: ["-n", "\(config.steps)"]) + } + + if config.colourSpace == .cmyk, let inkLimit = config.inkLimit { + guard (200...400).contains(inkLimit) else { + throw CalibrationTargenArgError.invalidInkLimit(inkLimit) + } + args.append(contentsOf: ["-l", "\(inkLimit)"]) + } + + let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" + args.append(calBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift new file mode 100644 index 0000000..bdee5e2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Errors during `printcal` argv construction. +public enum PrintcalArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidTotalInkLimit(Double) + case invalidPerChannelLimit(Character, Double) + case invalidOutputPath + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidTotalInkLimit(let limit): + return "Total ink limit must be positive, got: \(limit)" + case .invalidPerChannelLimit(let channel, let limit): + return "\(channel) channel limit must be 0–100, got: \(limit)" + case .invalidOutputPath: + return "Invalid .cal output path" + } + } +} + +/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`. +public struct PrintcalChannelLimit: Sendable, Equatable { + public let channel: Character + public let percent: Double + + public init(channel: Character, percent: Double) { + self.channel = channel + self.percent = percent + } +} + +/// Configuration for an Argyll `printcal` run. +public struct PrintcalConfig: Sendable, Equatable { + public var ti3Basename: String + public var workingDirectory: URL? + public var outputURL: URL + public var noInkLimit: Bool + public var verify: Bool + public var previousCalPath: String? + public var totalInkLimit: Double? + public var channelLimits: [PrintcalChannelLimit] + + public init( + ti3Basename: String, + workingDirectory: URL? = nil, + outputURL: URL, + noInkLimit: Bool = false, + verify: Bool = false, + previousCalPath: String? = nil, + totalInkLimit: Double? = nil, + channelLimits: [PrintcalChannelLimit] = [] + ) { + self.ti3Basename = ti3Basename + self.workingDirectory = workingDirectory + self.outputURL = outputURL + self.noInkLimit = noInkLimit + self.verify = verify + self.previousCalPath = previousCalPath + self.totalInkLimit = totalInkLimit + self.channelLimits = channelLimits + } +} + +/// Pure argv builder for Argyll's `printcal` tool. +/// +/// `printcal` is captured, not streamed. JS never sends `-u` (unapply). +public enum PrintcalArgs { + + /// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC] + /// [-xC pct]... -o out.cal CAL_basename`. + public static func build(config: PrintcalConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename) + guard !cleanBasename.isEmpty else { + throw PrintcalArgError.invalidBasename(config.ti3Basename) + } + + var args: [String] = ["-v", "-e"] + + if config.noInkLimit { + args.append("-I") + } + if config.verify { + args.append("-z") + } + if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !previous.isEmpty { + args.append(contentsOf: ["-a", previous]) + } + if let tac = config.totalInkLimit, tac > 0 { + args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) + } else if let tac = config.totalInkLimit { + throw PrintcalArgError.invalidTotalInkLimit(tac) + } + + for limit in config.channelLimits { + guard (0...100).contains(limit.percent) else { + throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent) + } + args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)]) + } + + guard !config.outputURL.path.isEmpty else { + throw PrintcalArgError.invalidOutputPath + } + args.append(contentsOf: ["-o", config.outputURL.path]) + + let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" + args.append(calBasename) + return args + } +} diff --git a/Sources/ICCery/AboutView.swift b/Sources/ICCery/AboutView.swift new file mode 100644 index 0000000..6396f8f --- /dev/null +++ b/Sources/ICCery/AboutView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import ICCeryCore + +/// About dialog for ICCery (issue #31, docs/21 §Modals). +struct AboutView: View { + let onClose: () -> Void + + private let info = ArtefactFiles.appInfo() + + var body: some View { + VStack(spacing: 20) { + Image("ICCery-logo") + .resizable() + .scaledToFit() + .frame(height: 64) + + Text("ICCery") + .font(.title) + .foregroundStyle(Theme.text) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Version:") + .foregroundStyle(.secondary) + Text(info.version) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutVersion") + } + HStack { + Text("Build:") + .foregroundStyle(.secondary) + Text(info.build) + .foregroundStyle(Theme.text) + } + HStack { + Text("Build date:") + .foregroundStyle(.secondary) + Text(info.buildDate) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutBuildDate") + } + } + .font(.callout) + + Text("Native macOS printer profiling workstation.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("Close") { + onClose() + } + .controlSize(.large) + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("closeAboutBtn") + } + .padding(32) + .frame(width: 360) + .background(Theme.panel) + .accessibilityIdentifier("aboutDialog") + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index aeb404c..f32dfef 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -68,6 +68,8 @@ enum UITestHooks { static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } /// `select_directory` result (working-directory browse). static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } + /// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`). + static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") } /// Preset import file. static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } /// Preset export destination. diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift new file mode 100644 index 0000000..07e197a --- /dev/null +++ b/Sources/ICCery/CalibrationView.swift @@ -0,0 +1,111 @@ +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration dashboard (issue #29, docs/07). +struct CalibrationView: View { + @Bindable var model: CalibrationViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Calibrate Printer") + .font(.title2.bold()) + .padding(.horizontal, 16) + .padding(.top, 16) + + Form { + Section("Wedge Settings") { + Picker("Colour Space", selection: $model.colourSpace) { + Text("RGB").tag(ColourSpace.rgb) + Text("CMYK").tag(ColourSpace.cmyk) + } + + HStack { + Text("Steps per channel") + Spacer() + TextField("", value: $model.steps, format: .number) + .frame(width: 60) + .accessibilityIdentifier("calSteps") + } + + HStack { + Text("White patches") + Spacer() + TextField("", value: $model.whitePatches, format: .number) + .frame(width: 60) + } + + if model.colourSpace == .cmyk { + HStack { + Text("Ink-limit exploration") + Spacer() + TextField("", text: $model.inkLimit) + .frame(width: 60) + .accessibilityIdentifier("calInkExplore") + } + } + + Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis) + } + + Section("Workflow") { + HStack(spacing: 12) { + Button("Generate Target") { model.generateTarget() } + .accessibilityIdentifier("btnCalGenerate") + .disabled(!model.canGenerate) + + Button("Create Layout & Print") { model.createLayout() } + .accessibilityIdentifier("btnCalLayout") + .disabled(!model.canGenerate) + + Button("Measure") { model.measureChart() } + .accessibilityIdentifier("btnCalMeasure") + .disabled(model.calibrationTi3URL == nil) + + Button("Compute Curves") { model.computeCurves() } + .accessibilityIdentifier("btnCalCompute") + .disabled(!model.canCompute) + } + + if let url = model.computedCalURL { + Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) + .onChange(of: model.applyToProfile) { model.updateApplyToProfile() } + .accessibilityIdentifier("calApplyToggle") + + Text("Loaded: \(url.lastPathComponent)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !model.calibrationLog.isEmpty { + Section("Log") { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(model.calibrationLog, id: \.self) { line in + Text(line) + .font(.system(.caption, design: .monospaced)) + } + } + } + .frame(minHeight: 80, maxHeight: 120) + } + } + + if let error = model.lastError { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("Return to Profiling") { model.returnToProfiling() } + .accessibilityIdentifier("btnCalReturn") + } + .padding(16) + } + } +} diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift new file mode 100644 index 0000000..64b8a79 --- /dev/null +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -0,0 +1,209 @@ +import Foundation +import Observation +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration workflow: generate wedge, print, measure, and +/// compute `.cal` curves. +@MainActor +@Observable +final class CalibrationViewModel { + + let workflow: TargetWorkflowViewModel + let profile: ProfileWorkflowViewModel + let environment: AppEnvironment + + // MARK: - Form state + + var colourSpace: ColourSpace = .cmyk + var steps: Int = 21 + var whitePatches: Int = 4 + var includeNeutralEmphasis: Bool = false + var inkLimit: String = "320" + var applyToProfile: Bool = false + var computedCalURL: URL? + var calibrationLog: [String] = [] + var isGenerating = false + var isComputing = false + var lastError: String? + + private var originalBasename: String = "" + + init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.profile = profile + self.environment = environment + } + + private var wizard: WizardViewModel { workflow.wizard } + + // MARK: - Derived + + var canGenerate: Bool { + !wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating + } + + var canCompute: Bool { + calibrationTi3URL != nil && !isComputing + } + + var calibrationTi3URL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).ti3") + } + + private var calBasename: String { + originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)" + } + + private var calOutputURL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).cal") + } + + // MARK: - Generate calibration target + + func generateTarget() { + guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } + originalBasename = wizard.basename + wizard.basename = calBasename + wizard.sessionMode = .calibration + + isGenerating = true + calibrationLog = [] + lastError = nil + + let config = CalibrationTargenConfig( + colourSpace: colourSpace, + steps: steps, + whitePatches: whitePatches, + includeNeutralEmphasis: includeNeutralEmphasis, + inkLimit: inkLimitValue, + basename: originalBasename, + workingDirectory: cwd + ) + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isGenerating = false } + + do { + _ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in + Task { @MainActor [weak self] in + self?.calibrationLog.append(contentsOf: batch) + } + } + self.wizard.refreshGating() + self.wizard.showNotice("Calibration target generated.") + self.wizard.go(to: .layOutPrint) + } catch { + self.lastError = error.localizedDescription + self.wizard.showNotice( + "Calibration target failed: \(error.localizedDescription)", + kind: .error + ) + self.restoreProfileBasename() + } + } + } + + // MARK: - Layout, print, measure + + /// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename. + /// After measurement, the user returns and presses Compute Curves. + func createLayout() { + wizard.sessionMode = .calibration + wizard.go(to: .layOutPrint) + } + + func measureChart() { + wizard.sessionMode = .calibration + wizard.go(to: .measure) + } + + // MARK: - Compute curves + + func computeCurves() { + guard canCompute, + let cwd = wizard.effectiveWorkingDirectory, + let outputURL = calOutputURL else { return } + + // Collision check: the Argyll `printcal` exit error contains + // "already exists" when the user declines overwrite. We do not + // silently clobber. + if FileManager.default.fileExists(atPath: outputURL.path) { + lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first." + wizard.showNotice(lastError!, kind: .error) + return + } + + isComputing = true + calibrationLog = [] + lastError = nil + + let config = PrintcalConfig( + ti3Basename: calBasename, + workingDirectory: cwd, + outputURL: outputURL, + noInkLimit: false, + verify: false, + previousCalPath: nil, + totalInkLimit: inkLimitValue.map { Double($0) }, + channelLimits: [] + ) + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isComputing = false } + + do { + let url = try await self.environment.runner.runPrintcal(config: config) { batch in + Task { @MainActor [weak self] in + self?.calibrationLog.append(contentsOf: batch) + } + } + self.computedCalURL = url + self.profile.calibrationFile = url.path + self.profile.applyCalibration = self.applyToProfile + self.wizard.showNotice("Calibration curves computed.") + } catch { + self.lastError = error.localizedDescription + self.wizard.showNotice( + "Calibration curve computation failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + // MARK: - Apply toggle + + func updateApplyToProfile() { + profile.applyCalibration = applyToProfile + if applyToProfile, let url = computedCalURL { + profile.calibrationFile = url.path + } else if applyToProfile { + // User toggled on before computing; keep the path if already set. + } else { + profile.applyCalibration = false + } + } + + func returnToProfiling() { + restoreProfileBasename() + wizard.sessionMode = .profile + wizard.go(to: .generate) + } + + private func restoreProfileBasename() { + if !originalBasename.isEmpty { + wizard.basename = originalBasename + originalBasename = "" + } + } + + private var inkLimitValue: Int? { + guard colourSpace == .cmyk else { return nil } + return Int(inkLimit) + } +} diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift new file mode 100644 index 0000000..1cdc82a --- /dev/null +++ b/Sources/ICCery/GamutView.swift @@ -0,0 +1,435 @@ +import SwiftUI +import SceneKit +import ICCeryCore +import simd + +/// Native SceneKit 3D gamut viewer. +/// +/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses +/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the +/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* +/// (blue-yellow) is depth. +struct GamutView: View { + @State private var viewModel: GamutViewModel + @FocusState private var isFocused: Bool + + init(profileGamURL: URL? = nil) { + _viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) + } + + var body: some View { + ZStack { + GamutSceneView( + profileMesh: viewModel.profileMesh, + referenceMesh: viewModel.sRGBMesh, + onReset: $viewModel.resetCamera + ) + .focusable() + .focused($isFocused) + .focusEffectDisabled() + .onKeyPress(.init("R"), action: { + viewModel.resetCamera() + return .handled + }) + .onAppear { isFocused = true } + + VStack { + HStack { + Spacer() + Button(action: { viewModel.resetCamera() }) { + Text("Reset view") + } + .accessibilityIdentifier("btnResetGamutCamera") + .padding(8) + } + Spacer() + HStack { + Text(viewModel.status) + .font(.caption) + .padding(8) + .background(.thinMaterial) + .cornerRadius(6) + .accessibilityIdentifier("gamutStatusText") + Spacer() + } + .padding(8) + } + } + .frame(minWidth: 500, minHeight: 400) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutView") + } +} + +/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from +/// one or two ``GamutMesh`` values. +/// +/// Scene construction and camera reset are coordinated through a typed callback +/// binding owned by the view model. +private struct GamutSceneView: NSViewRepresentable { + var profileMesh: GamutMesh? + var referenceMesh: GamutMesh? + var onReset: Binding<() -> Void> + + func makeNSView(context: Context) -> SCNView { + let scnView = SCNView() + scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1) + scnView.allowsCameraControl = true + scnView.showsStatistics = false + scnView.antialiasingMode = .multisampling4X + + let scene = SCNScene() + scnView.scene = scene + scnView.autoenablesDefaultLighting = false + + context.coordinator.scnView = scnView + context.coordinator.scene = scene + context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + + return scnView + } + + func updateNSView(_ nsView: SCNView, context: Context) { + context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + onReset.wrappedValue = { [weak coordinator] in + coordinator?.resetCamera() + } + return coordinator + } + + @MainActor + final class Coordinator: NSObject { + weak var scnView: SCNView? + weak var scene: SCNScene? + + private let profileNode = SCNNode() + private let referenceGroup = SCNNode() + private let axisNode = SCNNode() + private let cameraNode: SCNNode = { + let node = SCNNode() + node.camera = SCNCamera() + node.camera?.zFar = 2000 + return node + }() + + func buildScene(profile: GamutMesh?, reference: GamutMesh?) { + guard let scene else { return } + + // Rebuild from scratch on every mesh change to avoid stale geometry. + scene.rootNode.childNodes.forEach { $0.removeFromParentNode() } + scene.rootNode.addChildNode(axisNode) + scene.rootNode.addChildNode(profileNode) + scene.rootNode.addChildNode(referenceGroup) + scene.rootNode.addChildNode(cameraNode) + + buildAxisScaffold() + + if let profile { + profileNode.addChildNode(profileMeshNode(profile, name: "profile")) + } else { + profileNode.childNodes.forEach { $0.removeFromParentNode() } + } + + if let reference { + referenceGroup.childNodes.forEach { $0.removeFromParentNode() } + referenceGroup.addChildNode(referenceMeshNode(reference)) + } + + addLights(to: scene) + resetCamera() + } + + private func addLights(to scene: SCNScene) { + let ambient = SCNNode() + ambient.light = SCNLight() + ambient.light?.type = .ambient + ambient.light?.color = NSColor.white + ambient.light?.intensity = 750 + scene.rootNode.addChildNode(ambient) + + let key = SCNNode() + key.light = SCNLight() + key.light?.type = .directional + key.light?.color = NSColor.white + key.light?.intensity = 800 + key.position = SCNVector3(150, 250, 150) + key.look(at: SCNVector3(0, 50, 0)) + scene.rootNode.addChildNode(key) + + let fill = SCNNode() + fill.light = SCNLight() + fill.light?.type = .directional + fill.light?.color = NSColor.white + fill.light?.intensity = 350 + fill.position = SCNVector3(-120, -80, -120) + fill.look(at: SCNVector3(0, 50, 0)) + scene.rootNode.addChildNode(fill) + } + + private func buildAxisScaffold() { + axisNode.childNodes.forEach { $0.removeFromParentNode() } + + // Bounding box: a*,b* ±128, L* 0–100. + let box = buildWireBox(size: SIMD3(256, 100, 256), color: NSColor(red: 0.137, green: 0.137, blue: 0.212, alpha: 0.9)) + box.position = SCNVector3(0, 50, 0) + axisNode.addChildNode(box) + + // Ground grid at y=0. + axisNode.addChildNode(buildGridNode()) + + // Axis lines. + axisNode.addChildNode(buildLineNode( + from: SIMD3(0, 0, 0), + to: SIMD3(0, 100, 0), + color: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0) + )) + let abAxisColor = NSColor(red: 0.6, green: 0.733, blue: 0.8, alpha: 1.0) + axisNode.addChildNode(buildLineNode( + from: SIMD3(-128, 0, 0), + to: SIMD3(128, 0, 0), + color: abAxisColor + )) + axisNode.addChildNode(buildLineNode( + from: SIMD3(0, 0, -128), + to: SIMD3(0, 0, 128), + color: abAxisColor + )) + } + + private func buildWireBox(size: SIMD3, color: NSColor) -> SCNNode { + let hx = size.x / 2 + let hy = size.y / 2 + let hz = size.z / 2 + + let corners: [SIMD3] = [ + SIMD3(-hx, -hy, -hz), SIMD3(hx, -hy, -hz), + SIMD3(hx, -hy, hz), SIMD3(-hx, -hy, hz), + SIMD3(-hx, hy, -hz), SIMD3(hx, hy, -hz), + SIMD3(hx, hy, hz), SIMD3(-hx, hy, hz), + ] + + // 12 edges, two vertices each. + let edges: [(Int, Int)] = [ + (0,1), (1,2), (2,3), (3,0), + (4,5), (5,6), (6,7), (7,4), + (0,4), (1,5), (2,6), (3,7), + ] + + var points: [SIMD3] = [] + for (a, b) in edges { + points.append(corners[a]) + points.append(corners[b]) + } + + return lineNode(points: points, color: color) + } + + private func buildGridNode() -> SCNNode { + let divisions = 16 + let half = Float(128) + let step = (half * 2) / Float(divisions) + + var points: [SIMD3] = [] + for i in 0...divisions { + let v = -half + step * Float(i) + // X-aligned + points.append(SIMD3(-half, 0, v)) + points.append(SIMD3(half, 0, v)) + // Z-aligned + points.append(SIMD3(v, 0, -half)) + points.append(SIMD3(v, 0, half)) + } + + let gridColor = NSColor(red: 0.118, green: 0.118, blue: 0.157, alpha: 1.0) + return lineNode(points: points, color: gridColor) + } + + private func buildLineNode(from: SIMD3, to: SIMD3, color: NSColor) -> SCNNode { + return lineNode(points: [from, to], color: color) + } + + /// Builds a line-set from a flat list of point pairs. + /// + /// Uses data-backed `SCNGeometrySource` so it works with `simd` vectors + /// and avoids the SceneKit convenience-initializer label mismatch. + private func lineNode(points: [SIMD3], color: NSColor) -> SCNNode { + let source = source(for: points) + + let count = points.count + var indices: [UInt32] = [] + indices.reserveCapacity(count) + for i in 0.. SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.white + material.transparency = 0.88 + material.isDoubleSided = true + geometry.materials = [material] + + let node = SCNNode(geometry: geometry) + node.name = name + return node + } + + private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + // Faint fill. + let fillMaterial = SCNMaterial() + fillMaterial.lightingModel = .lambert + fillMaterial.diffuse.contents = NSColor(red: 0.533, green: 0.6, blue: 0.733, alpha: 1.0) + fillMaterial.transparency = 0.93 + fillMaterial.isDoubleSided = true + fillMaterial.writesToDepthBuffer = false + geometry.materials = [fillMaterial] + + let fillNode = SCNNode(geometry: geometry) + + // Structural outline: one line per triangle edge. + var linePoints: [SIMD3] = [] + for face in mesh.faces { + let va = mesh.vertices[Int(face.a)].position + let vb = mesh.vertices[Int(face.b)].position + let vc = mesh.vertices[Int(face.c)].position + linePoints.append(va); linePoints.append(vb) + linePoints.append(vb); linePoints.append(vc) + linePoints.append(vc); linePoints.append(va) + } + + let edgeColor = NSColor(red: 0.4, green: 0.533, blue: 0.667, alpha: 0.55) + let edgeNode = lineNode(points: linePoints, color: edgeColor) + + let group = SCNNode() + group.addChildNode(fillNode) + group.addChildNode(edgeNode) + return group + } + + /// Returns an `SCNGeometry` with per-vertex positions and sRGB colours. + /// + /// Uses data-backed `SCNGeometrySource` initializers; this is the only + /// path that supports vertex colours through the `.color` semantic. + private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) { + let positions = mesh.vertices.map { $0.position } + let positionData = positions.withUnsafeBytes { Data($0) } + let positionSource = SCNGeometrySource( + data: positionData, + semantic: .vertex, + vectorCount: positions.count, + usesFloatComponents: true, + componentsPerVector: 3, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + + let colors: [SIMD4] = mesh.vertices.map { v in + SIMD4(Float(v.rgb.r), Float(v.rgb.g), Float(v.rgb.b), 1.0) + } + let colorData = colors.withUnsafeBytes { Data($0) } + let colorSource = SCNGeometrySource( + data: colorData, + semantic: .color, + vectorCount: colors.count, + usesFloatComponents: true, + componentsPerVector: 4, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + + var indices: [UInt32] = [] + indices.reserveCapacity(mesh.faces.count * 3) + for face in mesh.faces { + indices.append(face.a) + indices.append(face.b) + indices.append(face.c) + } + let data = indices.withUnsafeBytes { Data($0) } + let element = SCNGeometryElement( + data: data, + primitiveType: .triangles, + primitiveCount: mesh.faces.count, + bytesPerIndex: 4 + ) + + let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element]) + return (geometry, element) + } + + /// Shared helper for data-backed position sources. + private func source(for points: [SIMD3]) -> SCNGeometrySource { + let data = points.withUnsafeBytes { Data($0) } + return SCNGeometrySource( + data: data, + semantic: .vertex, + vectorCount: points.count, + usesFloatComponents: true, + componentsPerVector: 3, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + } + + func resetCamera() { + guard let scnView else { return } + + // Re-create the camera node so `allowsCameraControl` starts from the + // canonical home position every time. + let newCameraNode = SCNNode() + newCameraNode.camera = SCNCamera() + newCameraNode.camera?.zFar = 2000 + + let eye = SIMD3(180, 120, 180) + let target = SIMD3(0, 50, 0) + newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3(0, 1, 0)) + + if let scene = scnView.scene, scene.rootNode.childNodes.contains(cameraNode) { + cameraNode.removeFromParentNode() + } + scnView.scene?.rootNode.addChildNode(newCameraNode) + scnView.pointOfView = newCameraNode + } + + private func lookAt(eye: SIMD3, target: SIMD3, up: SIMD3) -> simd_float4x4 { + let forward = normalize(target - eye) + let right = normalize(cross(up, forward)) + let newUp = cross(forward, right) + + var matrix = simd_float4x4() + matrix.columns.0 = SIMD4(right, 0) + matrix.columns.1 = SIMD4(newUp, 0) + matrix.columns.2 = SIMD4(-forward, 0) + matrix.columns.3 = SIMD4(eye, 1) + return matrix + } + } +} diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift new file mode 100644 index 0000000..27dda7f --- /dev/null +++ b/Sources/ICCery/GamutViewModel.swift @@ -0,0 +1,57 @@ +import Foundation +import ICCeryCore +import Observation + +/// View model for the native SceneKit gamut viewer. +/// +/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a +/// printer/profile `.gam` from the current working directory. +@MainActor +@Observable +final class GamutViewModel { + + /// Parsed reference sRGB gamut mesh. + var sRGBMesh: GamutMesh? + + /// Parsed printer/profile gamut mesh. + var profileMesh: GamutMesh? + + /// User-facing status line. + var status = "Loading gamut…" + + /// Closure injected into the SceneKit view to request a camera reset. + var resetCamera: () -> Void = {} + + private let profileGamURL: URL? + + init(profileGamURL: URL? = nil) { + self.profileGamURL = profileGamURL + Task { await load() } + } + + private func load() async { + do { + let referenceURL = BinaryResolver().referenceGamut("sRGB") + let reference = try await parse(url: referenceURL) + sRGBMesh = reference + + if let profileGamURL { + let profile = try await parse(url: profileGamURL) + profileMesh = profile + status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference" + } else { + status = "sRGB reference gamut (\(reference.faces.count) faces)" + } + } catch { + status = "Could not load gamut: \(error.localizedDescription)" + } + } + + /// Parses a `.gam` file off the main actor so large meshes do not stall + /// the UI. + private func parse(url: URL) async throws -> GamutMesh { + try await Task.detached { + try GamutMeshParser.parse(url: url) + }.value + } +} diff --git a/Sources/ICCery/HelpOverlayView.swift b/Sources/ICCery/HelpOverlayView.swift new file mode 100644 index 0000000..99574b5 --- /dev/null +++ b/Sources/ICCery/HelpOverlayView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reusable help overlay badge that does not reflow layout (#171). +/// +/// When `showing` is `true`, a small indicator is rendered as an overlay at the +/// top-trailing corner of the wrapped view. The native `.help` tooltip is always +/// available on hover, so the overlay is purely a visual cue in help mode. +struct HelpOverlay: ViewModifier { + let text: String + @Binding var showing: Bool + + func body(content: Content) -> some View { + content + .help(text) + .overlay(alignment: .topTrailing) { + if showing { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.accent) + .offset(x: 8, y: -8) + } + } + } +} + +extension View { + /// Adds a non-reflowing help overlay to the view. + func helpOverlay(_ text: String, showing: Binding) -> some View { + modifier(HelpOverlay(text: text, showing: showing)) + } +} diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 320c774..8083ead 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -52,6 +52,8 @@ final class ProfileWorkflowViewModel { var colprofProgress: String? var lastError: String? var createdProfileURL: URL? + /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). + var createdGamutURL: URL? // MARK: - Stage 4/5 calibration (issue #24) @@ -84,12 +86,17 @@ final class ProfileWorkflowViewModel { restoreCreatedProfileURL() } - /// Restores `createdProfileURL` from the wizard artefacts or by probing - /// the working directory for an existing `.icc`/`.icm` (#52). + /// Restores `createdProfileURL` and `createdGamutURL` from the wizard + /// artefacts or by probing the working directory (#52, #28). func restoreCreatedProfileURL() { let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil) createdProfileURL = wizard.artefacts.profilePath ?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd) + createdGamutURL = wizard.artefacts.gamPath + ?? ArtefactProbe.artefact(wizard.basename, "gam", cwd) + if let gam = createdGamutURL, !FileManager.default.fileExists(atPath: gam.path) { + createdGamutURL = nil + } } // MARK: - Derived @@ -190,6 +197,7 @@ final class ProfileWorkflowViewModel { colprofProgress = nil lastError = nil createdProfileURL = nil + createdGamutURL = nil let runner = environment.runner Task { @MainActor [weak self] in @@ -223,12 +231,13 @@ final class ProfileWorkflowViewModel { // Gamut extraction is best-effort for Stage 5 / M6 viewer. do { let gamConfig = IccgamutConfig(profileURL: finalProfileURL) - _ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in + let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in Task { @MainActor [weak self] in self?.colprofLog.append(contentsOf: batch) } } - self.colprofLog.append("Gamut mesh extracted.") + self.createdGamutURL = gamURL + self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)") } catch { self.wizard.showNotice( "Gamut extraction skipped: \(error.localizedDescription)", diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 48d1ff2..002a729 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -8,6 +8,7 @@ struct RootView: View { @Bindable var workflow: TargetWorkflowViewModel @State private var showingSettings = false @State private var showingAbout = false + @State private var showingAllHelp = false private var model: WizardViewModel { workflow.wizard } @@ -16,7 +17,8 @@ struct RootView: View { SidebarView( workflow: workflow, onOpenSettings: { showingSettings = true }, - onOpenAbout: { showingAbout = true } + onOpenAbout: { showingAbout = true }, + showingAllHelp: $showingAllHelp ) Rectangle() @@ -48,10 +50,14 @@ struct RootView: View { .sheet(isPresented: $workflow.showingManagePresets) { ManagePresetsDialog(workflow: workflow) } - .alert("ICCery 2.0.0", isPresented: $showingAbout) { - Button("OK") {} - } message: { - Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") + .sheet(isPresented: $showingAbout) { + AboutView { showingAbout = false } + } + .sheet(isPresented: Binding( + get: { workflow.wizard.showingGamutViewer }, + set: { workflow.wizard.showingGamutViewer = $0 } + )) { + GamutView(profileGamURL: workflow.wizard.gamutProfileURL) } } @@ -68,7 +74,9 @@ struct RootView: View { Stage4View(model: workflow.profile) case .verifyInstall: Stage5View(model: workflow.profile) - default: + case .calibrate: + CalibrationView(model: workflow.calibration) + @unknown default: StagePlaceholderView(stage: model.stage) } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index f2ed065..bb7301d 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -7,6 +7,7 @@ struct SidebarView: View { @Bindable var workflow: TargetWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void + @Binding var showingAllHelp: Bool private var model: WizardViewModel { workflow.wizard } @@ -22,12 +23,20 @@ struct SidebarView: View { Image(systemName: "gearshape") } .buttonStyle(.plain) - .help("Settings") + .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openSettingsBtn") Button(action: onOpenAbout) { Image(systemName: "info.circle") } .buttonStyle(.plain) - .help("About ICCery") + .helpOverlay("Open the About dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openAboutBtn") + Button(action: { showingAllHelp.toggle() }) { + Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle") + } + .buttonStyle(.plain) + .help("Toggle help overlays") + .accessibilityIdentifier("btnToggleAllHelp") } .padding(12) @@ -65,14 +74,21 @@ struct SidebarView: View { .padding(.horizontal, 12) .padding(.bottom, 8) - // Calibrate Printer (`#btnCalibratePrinter`). Disabled until - // Stage 0 lands in issue #29; `#calStatusChip` likewise. + // Calibrate Printer (`#btnCalibratePrinter`). Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") .frame(maxWidth: .infinity) } .controlSize(.large) - .disabled(true) + .accessibilityIdentifier("btnCalibratePrinter") + .padding(.horizontal, 12) + + Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) { + Label("View Gamut", systemImage: "view.3d") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .accessibilityIdentifier("btnViewGamut") .padding(.horizontal, 12) Divider().overlay(Theme.border) diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 1db8cb3..503f08e 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -85,11 +85,11 @@ struct Stage1View: View { Button("Browse…") { workflow.browseForTargetFile() } .accessibilityIdentifier("btnBrowse") Button("Working Dir…") { workflow.browseForWorkingDirectory() } + .accessibilityIdentifier("btnSelectWorkDir") Button("Open Existing…") { workflow.openExistingTarget() } .accessibilityIdentifier("btnOpenExisting") - Button("Import Dataset…") { /* CGATS import — #94, later */ } + Button("Import Dataset…") { workflow.importMeasurementDataset() } .accessibilityIdentifier("btn-import-dataset") - .disabled(true) } Text(workflow.targetDirectory?.path ?? "No working directory selected") .font(.caption) diff --git a/Sources/ICCery/Stage5View.swift b/Sources/ICCery/Stage5View.swift index a6c18bd..9ef62c3 100644 --- a/Sources/ICCery/Stage5View.swift +++ b/Sources/ICCery/Stage5View.swift @@ -146,6 +146,12 @@ struct Stage5View: View { Spacer() + Button("View Gamut") { + model.wizard.openGamut(profileGamURL: model.createdGamutURL) + } + .disabled(model.createdGamutURL == nil) + .accessibilityIdentifier("btnViewGamut") + Button("Install Profile") { model.beginInstallProfile() } .disabled(model.createdProfileURL == nil) .accessibilityIdentifier("btnInstallProfile") diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index be5e55c..960bf40 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -123,8 +123,10 @@ final class TargetWorkflowViewModel { /// across stage switches and can observe settings changes. var measurement: MeasurementWorkflowViewModel /// Stage 4/5 profile workflow, owned at the app level so it persists - /// across stage switches and can apply preset values. + /// across stage switches and can observe preset values. var profile: ProfileWorkflowViewModel + /// Stage 0 calibration workflow. + var calibration: CalibrationViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment @@ -137,6 +139,12 @@ final class TargetWorkflowViewModel { wizard: wizard, environment: environment ) + self.calibration = nil + self.calibration = CalibrationViewModel( + workflow: self, + profile: self.profile, + environment: environment + ) reloadPresets() } @@ -243,6 +251,43 @@ final class TargetWorkflowViewModel { // MARK: - Issue 8: resume an existing target + /// `#btn-import-dataset` — open a measured dataset, write a canonical + /// `.ti3` to the working directory, and set the target (issue #30). + func importMeasurementDataset() { + let url = UITestHooks.isEnabled + ? UITestHooks.datasetImportURL + : fileDialogs.selectDatasetFile() + guard let url else { return } + + do { + let dataset = try CGATSParser.parse(url: url) + guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { + wizard.showNotice("Choose a working directory before importing.", kind: .warning) + return + } + + let stem = url.deletingPathExtension().lastPathComponent + let output = directory.appendingPathComponent("\(stem).ti3") + try CGATSWriter.write(dataset, to: output) + + wizard.setTarget(basename: stem, workingDirectory: directory) + wizard.refreshGating() + wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)") + + if wizard.isUnlocked(.verifyInstall) { + wizard.go(to: .verifyInstall) + } else if wizard.isUnlocked(.buildProfile) { + wizard.go(to: .buildProfile) + } else { + wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning) + } + } catch let error as CGATSParseError { + wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) + } catch { + wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) + } + } + /// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103). /// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but /// only when the sibling `.ti1` exists so the artefact gate holds. @@ -302,6 +347,8 @@ final class TargetWorkflowViewModel { customLabel: labelIsCustom ? customLabel : nil, basename: wizard.basename, metadata: labelMetadata), + calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil, + calibrationEmbedOnly: false, basename: wizard.basename, workingDirectory: wizard.effectiveWorkingDirectory ) diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 2b0b9dc..7019e5e 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -42,6 +42,10 @@ final class WizardViewModel { var notice: Notice? /// Current artefact probe result; recomputed on `refreshGating()`. private(set) var artefacts = StageArtefacts() + /// Whether the 3D gamut viewer sheet is open (issue #28). + var showingGamutViewer = false + /// Optional `.gam` URL to show alongside the sRGB reference. + var gamutProfileURL: URL? private let stateStore: WizardStateStore private var noticeDismissTask: Task? @@ -126,6 +130,12 @@ final class WizardViewModel { stage = .generate } + /// Open the 3D gamut viewer (issue #28). + func openGamut(profileGamURL: URL? = nil) { + self.gamutProfileURL = profileGamURL + showingGamutViewer = true + } + /// 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() { diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift new file mode 100644 index 0000000..abe5696 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("ArgyllRunner Calibration") +struct ArgyllRunnerCalibrationTests { + + private func makeRunner() -> ArgyllRunner { + let binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("ICCeryUITests/Fixtures/bin") + return ArgyllRunner( + processManager: .shared, + binaryResolver: BinaryResolver(overrideDir: binDir) + ) + } + + private func makeTestDir() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("calibration-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + @Test("Calibration targen produces CAL_*.ti1") + func calibrationTargenProducesTi1() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + basename: "demo", + workingDirectory: testRoot + ) + + let url = try await runner.runCalibrationTargen(config: config) + + #expect(url.lastPathComponent == "CAL_demo.ti1") + #expect(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + @Test("printcal captured run creates .cal") + func printcalProducesCal() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + let url = try await runner.runPrintcal(config: config) + + #expect(url.lastPathComponent == "CAL_demo.cal") + #expect(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + @Test("printcal failure throws printcalFailed") + func printcalFailureThrows() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1) + defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") } + + await #expect(throws: (any Error).self) { + _ = try await runner.runPrintcal(config: config) + } + try? FileManager.default.removeItem(at: testRoot) + } +} diff --git a/Tests/ICCeryCoreTests/CGATSParserTests.swift b/Tests/ICCeryCoreTests/CGATSParserTests.swift new file mode 100644 index 0000000..a5e9a74 --- /dev/null +++ b/Tests/ICCeryCoreTests/CGATSParserTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CGATS Parser & Writer") +struct CGATSParserTests { + + private static let canonicalCTI3 = """ + CTI3 + DESCRIPTOR "Sample target" + COLOR_REP "RGB" + DEVICE_CLASS "DISPLAY" + NUMBER_OF_FIELDS 11 + NUMBER_OF_SETS 2 + BEGIN_DATA_FORMAT + SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B + END_DATA_FORMAT + BEGIN_DATA + 1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0 + 2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0 + END_DATA + """ + + @Test("Parses CTI3 with canonical field names") + func parseCTI3() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + #expect(dataset.format == .cti3) + #expect(dataset.samples.count == 2) + #expect(dataset.colorRep == "RGB") + #expect(dataset.deviceClass == "DISPLAY") + #expect(dataset.samples[0].id == "1") + #expect(dataset.samples[0].loc == "A1") + #expect(dataset.samples[1].values["RGB_G"] == "50.0000") + } + + @Test("Round-trips parse, write, reparse") + func roundTrip() throws { + let first = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(first) + let second = try CGATSParser.parse(text) + #expect(second.format == first.format) + #expect(second.samples.count == first.samples.count) + #expect(second.colorRep == first.colorRep) + #expect(second.deviceClass == first.deviceClass) + } + + @Test("Parses CSV with comma delimiters") + func parseCSV() throws { + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) + #expect(dataset.format == .csv) + #expect(dataset.samples.count == 2) + #expect(dataset.samples[0].values["RGB_R"] == "50.0000") + } + + @Test("Converts 0-255 device values to 0-100") + func converts255To100() throws { + let rgb = """ + CTI3 + COLOR_REP RGB + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y + END_DATA_FORMAT + BEGIN_DATA + 1 255 128 0 50 25 + END_DATA + """ + let dataset = try CGATSParser.parse(rgb) + #expect(dataset.samples[0].values["RGB_R"] == "100.0000") + #expect(dataset.samples[0].values["RGB_G"] == "50.1961") + } + + @Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") + func synthesizesMetadata() throws { + let cmyk = """ + CTI3 + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L + END_DATA_FORMAT + BEGIN_DATA + 1 50 50 50 50 50 + END_DATA + """ + let dataset = try CGATSParser.parse(cmyk) + #expect(dataset.colorRep == "CMYK") + #expect(dataset.deviceClass == "PRINTER") + } + + @Test("Rejects empty file") + func rejectsEmpty() { + #expect(throws: (any Error).self) { + _ = try CGATSParser.parse("") + } + } + + @Test("Rejects malformed arity") + func rejectsArity() { + let bad = """ + CTI3 + NUMBER_OF_FIELDS 2 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + BEGIN_DATA + 1 + END_DATA + """ + #expect(throws: (any Error).self) { + _ = try CGATSParser.parse(bad) + } + } + + @Test("Writer emits valid .ti3 with tabs and required keywords") + func writerFormat() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(dataset) + #expect(text.contains("CTI3")) + #expect(text.contains("BEGIN_DATA_FORMAT")) + #expect(text.contains("BEGIN_DATA")) + #expect(text.contains("END_DATA")) + #expect(text.contains("COLOR_REP")) + #expect(text.contains("DEVICE_CLASS")) + #expect(text.contains("\t")) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationStoreTests.swift b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift new file mode 100644 index 0000000..8b7ab56 --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CalibrationStore") +struct CalibrationStoreTests { + + private static let sampleCal = """ + CTI3 + DESCRIPTOR "Test printer" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + MAX_TAC "300" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + @Test("Loads metadata and curves from .cal") + func parseCal() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("test_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 30) + try await store.load(url: url) + + let data = await store.data + #expect(data?.colorRep == "RGB") + #expect(data?.descriptor == "Test printer") + #expect(data?.maxTac == 300) + #expect(data?.curves.count == 3) + + let r = data?.curves.first { $0.channel == "R" } + #expect(r?.output == [0, 64, 255]) + } + + @Test("Staleness is true for a very old calibration") + func staleCalibration() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("stale_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 0) + try await store.load(url: url) + let stale = await store.isStale(comparedTo: "Other") + #expect(stale == true) + } + + @Test("Printer mismatch is flagged as stale") + func printerMismatch() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("mismatch_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 9999) + try await store.load(url: url) + await store.setPrinterName("Printer A") + let stale = await store.isStale(comparedTo: "Printer B") + #expect(stale == true) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift new file mode 100644 index 0000000..c43313a --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CalibrationTargenArgs") +struct CalibrationTargenArgsTests { + + @Test("RGB baseline") + func rgbBaseline() throws { + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + whitePatches: 4, + basename: "demo", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + #expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) + } + + @Test("CMYK baseline with ink limit and neutral emphasis") + func cmykWithOptions() throws { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + steps: 25, + whitePatches: 4, + includeNeutralEmphasis: true, + inkLimit: 320, + basename: "printer", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + #expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) + } + + @Test("Rejects out-of-range steps") + func rejectsBadSteps() { + let config = CalibrationTargenConfig(steps: 5, basename: "demo") + #expect(throws: (any Error).self) { + _ = try CalibrationTargenArgs.build(config: config) + } + } + + @Test("Rejects bad CMYK ink limit") + func rejectsBadInkLimit() { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + inkLimit: 500, + basename: "demo" + ) + #expect(throws: (any Error).self) { + _ = try CalibrationTargenArgs.build(config: config) + } + } + + @Test("Does not double-prefix an existing CAL_ basename") + func noDoublePrefix() throws { + let config = CalibrationTargenConfig(basename: "CAL_test") + let args = try CalibrationTargenArgs.build(config: config) + #expect(args.last == "CAL_test") + } +} diff --git a/Tests/ICCeryCoreTests/GamutMeshParserTests.swift b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift new file mode 100644 index 0000000..c3643a2 --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import ICCeryCore + +/// ``GamutMeshParser`` acceptance + edge-case tests. +@Suite("Gamut mesh parser") +struct GamutMeshParserTests { + + /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. + private var bundledSRGBGamURL: URL { + let bundle = Bundle.main + let resource = bundle.resourceURL ?? bundle.bundleURL + return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + } + + @Test("Parses bundled sRGB.gam") + func parsesBundledSRGB() throws { + let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) + + #expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices") + #expect(mesh.faces.count == 892, "sRGB.gam has 892 faces") + } + + @Test("Discards VERTEX_NO and uses push-order indices") + func discardsVertexNo() throws { + let text = """ + GAMUT + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 4 + BEGIN_DATA + 100 10.0 20.0 30.0 + 50 20.0 30.0 40.0 + 2 30.0 40.0 50.0 + 7 40.0 50.0 60.0 + END_DATA + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 1 2 + 1 2 3 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + + #expect(mesh.vertices.count == 4) + #expect(mesh.faces.count == 2) + #expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30)) + #expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60)) + } + + @Test("Ignores comments and blank lines") + func ignoresComments() throws { + let text = """ + # Header comment + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 10.0 20.0 30.0 + # inline comment + 1 20.0 30.0 40.0 + END_DATA + # another comment + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 1 0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + #expect(mesh.vertices.count == 2) + #expect(mesh.faces.count == 1) + } + + @Test("Remaps coordinates to x=a*, y=L*, z=b*") + func remapsCoordinates() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 50.0 -20.0 80.0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + #expect(mesh.vertices.first?.position == SIMD3(-20, 50, 80)) + } + + @Test("Computes per-vertex sRGB colour") + func computesVertexColor() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 100.0 0.0 0.0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + let white = try #require(mesh.vertices.first).rgb + #expect(white.r > 0.95) + #expect(white.g > 0.95) + #expect(white.b > 0.95) + } + + @Test("Drops out-of-bounds face indices") + func dropsOutOfBoundsFaces() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 10.0 0.0 0.0 + 1 20.0 0.0 0.0 + END_DATA + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 1 0 + 0 1 99 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + #expect(mesh.faces.count == 1) + } + + @Test("Throws on empty file") + func throwsOnEmptyFile() { + #expect(throws: GamutMeshParseError.noDataBlock) { + _ = try GamutMeshParser.parse(text: "") + } + } + + @Test("Throws when file is missing") + func throwsWhenMissing() { + let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") + #expect(throws: GamutMeshParseError.missingFile) { + _ = try GamutMeshParser.parse(url: url) + } + } +} diff --git a/Tests/ICCeryCoreTests/PrintcalArgsTests.swift b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift new file mode 100644 index 0000000..e13af6f --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("PrintcalArgs") +struct PrintcalArgsTests { + + private let tmp = URL(fileURLWithPath: "/tmp/out.cal") + + @Test("Default printcal argv") + func defaults() throws { + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + outputURL: tmp + ) + let args = try PrintcalArgs.build(config: config) + #expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + } + + @Test("All options and channel limits") + func allOptions() throws { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + noInkLimit: true, + verify: true, + previousCalPath: "/tmp/old.cal", + totalInkLimit: 280, + channelLimits: [ + PrintcalChannelLimit(channel: "C", percent: 95), + PrintcalChannelLimit(channel: "M", percent: 90) + ] + ) + let args = try PrintcalArgs.build(config: config) + #expect(args == [ + "-v", "-e", + "-I", "-z", + "-a", "/tmp/old.cal", + "-m", "280.0", + "-xC", "95.0", + "-xM", "90.0", + "-o", "/tmp/out.cal", + "CAL_demo" + ]) + } + + @Test("Rejects invalid per-channel limit") + func rejectsBadChannelLimit() { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] + ) + #expect(throws: (any Error).self) { + _ = try PrintcalArgs.build(config: config) + } + } + +} diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift new file mode 100644 index 0000000..c6c2b03 --- /dev/null +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// About and help chrome UI tests (issue #31). +@MainActor +final class AboutHelpUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + private func element(_ id: String) -> XCUIElement { + app.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + func testAboutDialogShowsVersionAndBuildDate() throws { + app.launch() + app.activate() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + _ = waitFor("aboutDialog", timeout: 10) + XCTAssertTrue(element("aboutVersion").exists) + XCTAssertTrue(element("aboutBuildDate").exists) + + let close = app.buttons["closeAboutBtn"] + XCTAssertTrue(close.exists) + close.click() + + XCTAssertFalse(element("aboutDialog").exists) + } + + func testHelpOverlaysDoNotChangeSidebarHeight() throws { + app.launch() + app.activate() + + let toggle = app.buttons["btnToggleAllHelp"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10)) + + let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element + let before = sidebar.frame + + toggle.click() + let after = sidebar.frame + + XCTAssertEqual(before.size.height, after.size.height, + "Toggling global help must not reflow the sidebar height.") + XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists) + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/iccgamut b/Tests/ICCeryUITests/Fixtures/bin/iccgamut index 9c0b74e..dd21229 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/iccgamut +++ b/Tests/ICCeryUITests/Fixtures/bin/iccgamut @@ -1,5 +1,5 @@ #!/bin/sh -# Mock iccgamut for Milestone 5 UI tests. +# Mock iccgamut for Milestone 5/6 UI tests. # Writes {stem}.gam next to the profile path. last="" for arg in "$@"; do last="$arg"; done @@ -9,5 +9,9 @@ if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then fi stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//') dir=$(dirname "$last") -touch "$dir/$stem.gam" +if [ -n "${ICCERY_MOCK_GAMUT_SOURCE}" ] && [ -f "${ICCERY_MOCK_GAMUT_SOURCE}" ]; then + cp "${ICCERY_MOCK_GAMUT_SOURCE}" "$dir/$stem.gam" +else + touch "$dir/$stem.gam" +fi exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/printcal b/Tests/ICCeryUITests/Fixtures/bin/printcal new file mode 100755 index 0000000..a86c770 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/printcal @@ -0,0 +1,25 @@ +#!/bin/sh +# Mock printcal for Stage 0 calibration tests. Creates the .cal named by +# the -o argument in the process working directory. Exit code overridable +# via ICCERY_MOCK_PRINTCAL_EXIT. +output="" +basename="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then + output="$arg" + fi + prev="$arg" +done +# If no -o, derive from the last positional argument. +if [ -z "$output" ]; then + for arg in "$@"; do basename="$arg"; done + output="$basename.cal" +fi +if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then + echo "mock printcal failure" >&2 + exit "$ICCERY_MOCK_PRINTCAL_EXIT" +fi +echo "ideal power 1.0, device power 0.8" +touch "$output" +exit 0 diff --git a/Tests/ICCeryUITests/Milestone6CGATSUITests.swift b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift new file mode 100644 index 0000000..1abe962 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest + +/// Milestone 6 CGATS import UI tests (issue #30). +@MainActor +final class Milestone6CGATSUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var datasetURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + datasetURL = testRoot.appendingPathComponent("imported.csv") + try csv.write(to: datasetURL, atomically: true, encoding: .utf8) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_WORKDIR": testRoot.path, + "ICCERY_TEST_DATASET_IMPORT": datasetURL.path + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + } + + /// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1` + /// Must fail if import presents a save panel or a `.ti1` filter. + func testImportUsesOpenPanelNotSaveTi1() throws { + app.launch() + + // CGATS import needs a working directory; the env provides one. + XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5)) + app.buttons["btnSelectWorkDir"].tap() + + XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10)) + app.buttons["btn-import-dataset"].click() + + // No save panel should appear; the open panel is stubbed under UI testing. + let savePanel = app.sheets.firstMatch + XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.") + + // The dataset should be accepted and the user should advance to Stage 4. + XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10)) + + // The canonical .ti3 should be written next to the source file. + let ti3URL = testRoot.appendingPathComponent("imported.ti3") + XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path)) + } +} diff --git a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift new file mode 100644 index 0000000..befa40a --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest + +/// Milestone 6 — Stage 0 printer calibration UI acceptance. +/// +/// Uses the mock Argyll fixtures and UI-test environment flags so no real +/// instrument, printer, or modal file panel is required. +@MainActor +final class Milestone6CalibrationUITests: XCTestCase { + + private var app: XCUIApplication! + private var testWorkDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testWorkDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cal-ui-test-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: testWorkDir, + withIntermediateDirectories: true + ) + + let binaryDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_ARGYLL_BINARY_DIR": binaryDir.path, + "ICCERY_TEST_WORKDIR": testWorkDir.path + ] + app.launch() + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testWorkDir { + try? FileManager.default.removeItem(at: testWorkDir) + } + } + + func testCalibrationDashboardOpensAndCanGenerate() throws { + // Set up a target and working directory on Stage 1. + let basename = app.textFields["targetBasename"] + XCTAssertTrue(basename.waitForExistence(timeout: 5)) + basename.tap() + basename.typeText("DemoTarget") + + let workDir = app.buttons["btnSelectWorkDir"] + XCTAssertTrue(workDir.waitForExistence(timeout: 5)) + workDir.tap() + + let generate = app.buttons["btnGenerate"] + XCTAssertTrue(generate.waitForExistence(timeout: 5)) + generate.tap() + + // Open the calibration dashboard once Stage 2 is reached. + let advance = app.buttons["btnAdvanceToStage3"] + XCTAssertTrue(advance.waitForExistence(timeout: 10)) + + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 5)) + calButton.tap() + + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + // Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1. + let calGenerate = app.buttons["btnCalGenerate"] + XCTAssertTrue(calGenerate.waitForExistence(timeout: 5)) + calGenerate.tap() + + // After generation the wizard should advance to Stage 2 (layout) because + // a CAL_ .ti1 now exists and the session is in calibration mode. + let layout = app.buttons["btnCreateLayout"] + XCTAssertTrue(layout.waitForExistence(timeout: 10)) + } +} diff --git a/Tests/ICCeryUITests/Milestone6GamutUITests.swift b/Tests/ICCeryUITests/Milestone6GamutUITests.swift new file mode 100644 index 0000000..8c8068d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6GamutUITests.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest + +/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests. +@MainActor +final class Milestone6GamutUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + private var referenceGamutURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m6-gamut-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + + // The bundled sRGB reference used by the app; copied into the test workdir + // by the mock iccgamut so the profile gamut is a real, parseable mesh. + referenceGamutURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + // Pre-stage a measured .ti3 and start the wizard on Stage 4. + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("mytarget.ti3").path, + contents: Data("MOCK_TI3".utf8), + attributes: nil) + + let state: [String: Any] = [ + "currentStage": 4, + "basename": "mytarget", + "cwd": workDir.path, + "printerName": "MockPrinter", + "sessionMode": "profile" + ] + let stateData = try JSONSerialization.data(withJSONObject: state, options: []) + try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json")) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_MOCK_GAMUT_SOURCE": referenceGamutURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + /// Build and verify the mock profile, then open the native gamut viewer. + /// The viewer should load both the reference sRGB mesh and the profile + /// gamut copied from that reference. + func testViewGamutOpensSceneKitSheet() throws { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + + waitFor("btnCreateProfile").click() + + waitFor("btnVerifyProfile").click() + + waitFor("btnViewGamut").click() + + let gamutView = waitFor("gamutView") + XCTAssertTrue(gamutView.exists) + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)") + + // The reset button demonstrates that the viewer is interactive. + let reset = waitFor("btnResetGamutCamera") + XCTAssertTrue(reset.isEnabled) + reset.click() + } +} diff --git a/project.yml b/project.yml index d5014f6..e172855 100644 --- a/project.yml +++ b/project.yml @@ -25,6 +25,7 @@ targets: dependencies: - package: ICCeryCore product: ICCeryCore + - sdk: SceneKit.framework postBuildScripts: - name: Copy Argyll sidecars script: | diff --git a/scripts/dmgbuild-settings.py b/scripts/dmgbuild-settings.py new file mode 100755 index 0000000..9bc33b5 --- /dev/null +++ b/scripts/dmgbuild-settings.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# scripts/dmgbuild-settings.py +# +# dmgbuild settings for ICCery. Set DMG_FILENAME and DMG_VOLUME_NAME in the +# environment, or accept the defaults. Background art can be supplied later by +# placing a PNG at Resources/dmg-background.png and setting DMG_BACKGROUND. + +import os + +filename = os.environ.get('DMG_FILENAME', 'ICCery.dmg') +volume_name = os.environ.get('DMG_VOLUME_NAME', 'ICCery') + +# Background art is optional. If the referenced PNG does not exist, fall back +# to a plain window. See docs/23-assets.md for the DMG background spec. +background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png') +if background and not os.path.exists(background): + background = None + +icon = None + +# Window size is enough for the app icon and the Applications alias. +window_rect = ((100, 100), (640, 480)) + +# Use icon view without extra chrome. +default_view = 'icon-view' +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 + +# Position the .app on the left and the Applications alias on the right. +icon_locations = { + 'ICCery.app': (140, 240), + 'Applications': (500, 240), +} + +# Symlink to /Applications for drag-and-drop install. +symlinks = {'Applications': '/Applications'} diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..66f372f --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,120 @@ +#!/bin/sh +# scripts/package-release.sh +# +# Release packaging pipeline for ICCery v2 macOS. +# +# Steps: +# 1. Fetch and ad-hoc sign Argyll sidecars (scripts/fetch-argyll.sh). +# 2. Generate the Xcode project from project.yml. +# 3. Build a universal Release ICCery.app. +# 4. Sign the .app (Developer ID if CODESIGN_IDENTITY is set, else ad-hoc). +# 5. Hard-fail verify every bundled Mach-O sidecar with codesign -dvv. +# 6. Build a DMG with dmgbuild. +# 7. Optionally notarize and staple the DMG when notarization secrets exist. +# +# Required secrets (optional): +# CODESIGN_IDENTITY Developer ID Application identity name +# DEVELOPMENT_TEAM Apple development team ID (for xcodebuild signing) +# NOTARIZE_APPLE_ID Apple ID for notarytool +# NOTARIZE_PASSWORD App-specific password for notarytool +# APPLE_TEAM_ID Team ID for notarytool + +set -eu + +ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$ROOT" + +echo "==> Fetching Argyll sidecars" +scripts/fetch-argyll.sh + +echo "==> Generating Xcode project" +xcodegen generate --project . + +CONFIG="Release" +DEST="platform=macOS" + +# Default to ad-hoc signing. A real Developer ID can be injected via env. +IDENTITY="${CODESIGN_IDENTITY:--}" +DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}" + +echo "==> Building universal Release app" +BUILD_EXTRA="" +if [ -n "$DEVELOPMENT_TEAM" ]; then + BUILD_EXTRA="DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM" +fi + +# shellcheck disable=SC2086 +xcodebuild \ + -scheme ICCery \ + -destination "$DEST" \ + -configuration "$CONFIG" \ + ARCHS='arm64 x86_64' \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGN_IDENTITY="$IDENTITY" \ + $BUILD_EXTRA \ + build + +echo "==> Locating built app" +BUILT_PRODUCTS_DIR="$(xcodebuild \ + -scheme ICCery \ + -destination "$DEST" \ + -configuration "$CONFIG" \ + -showBuildSettings \ + | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p' \ + | head -n 1)" + +APP="$BUILT_PRODUCTS_DIR/ICCery.app" +if [ ! -d "$APP" ]; then + echo "error: built app not found at $APP" >&2 + exit 1 +fi +echo "App: $APP" + +# If a Developer ID identity was supplied, re-sign the .app bundle. Sidecars +# live in Resources/Argyll and remain ad-hoc signed by fetch-argyll.sh. +if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ]; then + echo "==> Signing $APP with '$CODESIGN_IDENTITY'" + codesign --force --sign "$CODESIGN_IDENTITY" \ + --entitlements Resources/ICCery.entitlements \ + --options runtime \ + "$APP" +else + echo "==> App ad-hoc signed by xcodebuild; not re-signing" +fi + +echo "==> Verifying sidecar signatures" +scripts/verify-sidecar-signatures.sh "$APP" + +echo "==> Building DMG" +VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || echo '2.0.0')" +BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || echo '1')" +DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg" +VOLUME_NAME="ICCery ${VERSION}" + +if ! command -v dmgbuild >/dev/null 2>&1; then + echo "==> Installing dmgbuild" + pip3 install dmgbuild +fi + +DMG_FILENAME="$DMG" \ +DMG_VOLUME_NAME="$VOLUME_NAME" \ +dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG" + +echo "DMG: $PWD/$DMG" + +# Optional notarization/stapling when credentials are present. +if [ -n "${NOTARIZE_APPLE_ID:-}" ] && \ + [ -n "${NOTARIZE_PASSWORD:-}" ] && \ + [ -n "${APPLE_TEAM_ID:-}" ]; then + echo "==> Submitting $DMG for notarization" + xcrun notarytool submit "$DMG" \ + --apple-id "$NOTARIZE_APPLE_ID" \ + --password "$NOTARIZE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + xcrun stapler staple "$DMG" + echo "==> Stapled $DMG" +else + echo "==> Notarization credentials not set; skipping" +fi diff --git a/scripts/verify-sidecar-signatures.sh b/scripts/verify-sidecar-signatures.sh new file mode 100755 index 0000000..e553af8 --- /dev/null +++ b/scripts/verify-sidecar-signatures.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# scripts/verify-sidecar-signatures.sh +# +# Hard-fail check that every Mach-O Argyll sidecar shipped inside the built +# ICCery.app bundle is signed (ad-hoc or Developer ID). Run this in CI after +# xcodebuild and before packaging. +# +# Usage: scripts/verify-sidecar-signatures.sh + +set -eu + +APP="${1:-}" +if [ -z "$APP" ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +if [ ! -d "$APP" ]; then + echo "error: app bundle not found: $APP" >&2 + exit 1 +fi + +SIDECAR_DIR="$APP/Contents/Resources/Argyll" +if [ ! -d "$SIDECAR_DIR" ]; then + echo "error: Argyll sidecar directory not found: $SIDECAR_DIR" >&2 + exit 1 +fi + +UNSIGNED="" +for f in "$SIDECAR_DIR"/*; do + [ -f "$f" ] || continue + if file -b "$f" | grep -q 'Mach-O'; then + if ! codesign -dvv "$f" >/dev/null 2>&1; then + echo "error: unsigned Mach-O sidecar: $f" >&2 + UNSIGNED="$UNSIGNED $f" + fi + fi +done + +if [ -n "$UNSIGNED" ]; then + echo "error: unsigned Argyll sidecars remain:$UNSIGNED" >&2 + exit 1 +fi + +echo "OK: all Mach-O sidecars in $SIDECAR_DIR are signed"