Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc | ||
|
|
716b302374 | ||
|
|
20d6bf7fdc |
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
|
||||
///
|
||||
/// Order:
|
||||
/// 1. Settings `argyll_binary_dir` override — only if `<dir>/<name>`
|
||||
/// exists there.
|
||||
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
|
||||
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
|
||||
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
|
||||
/// 3. If nothing exists the *constructed* bundled path is still returned
|
||||
/// — a missing binary surfaces later as `process:error` on spawn,
|
||||
/// matching v1 semantics.
|
||||
public struct BinaryResolver: Sendable {
|
||||
|
||||
/// Root that contains the platform dirs — `Bundle.resource/Argyll` in
|
||||
/// the app, a fixture dir in tests.
|
||||
public let bundledRoot: URL
|
||||
/// `settings.argyll_binary_dir`, already expanded to a URL.
|
||||
public let overrideDir: URL?
|
||||
/// Host architecture directory names, universal preferred.
|
||||
public let archDirs: [String]
|
||||
|
||||
public init(
|
||||
bundledRoot: URL = AppPaths.bundledArgyllDir,
|
||||
overrideDir: URL? = nil,
|
||||
archDirs: [String]? = nil
|
||||
) {
|
||||
self.bundledRoot = bundledRoot
|
||||
self.overrideDir = overrideDir
|
||||
#if arch(arm64)
|
||||
let fallback = ["macos-arm64", "macos-aarch64"]
|
||||
#else
|
||||
let fallback = ["macos-x86_64"]
|
||||
#endif
|
||||
self.archDirs = archDirs ?? ["macos-universal"] + fallback
|
||||
}
|
||||
|
||||
/// Marker used to decide whether `macos-universal` is usable.
|
||||
public static let markerBinary = "instlist"
|
||||
|
||||
/// Resolves a tool name to an absolute URL (never throws — see type
|
||||
/// docs). `name` is the bare tool name, e.g. `"targen"`.
|
||||
public func resolve(_ name: String) -> URL {
|
||||
let fm = FileManager.default
|
||||
|
||||
if let dir = overrideDir {
|
||||
let candidate = dir.appendingPathComponent(name)
|
||||
if fm.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return bundledRoot
|
||||
.appendingPathComponent(platformDir(), isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// The bundled platform directory that resolution will use.
|
||||
public func platformDir() -> String {
|
||||
let fm = FileManager.default
|
||||
let universal = bundledRoot.appendingPathComponent("macos-universal")
|
||||
if fm.fileExists(
|
||||
atPath: universal.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return "macos-universal"
|
||||
}
|
||||
for dir in archDirs where dir != "macos-universal" {
|
||||
if fm.fileExists(
|
||||
atPath: bundledRoot
|
||||
.appendingPathComponent(dir)
|
||||
.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
// Nothing present — still return the preferred dir so the error
|
||||
// message points at where the user should drop binaries.
|
||||
return archDirs.first ?? "macos-universal"
|
||||
}
|
||||
|
||||
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
|
||||
public func mock(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("mocks", isDirectory: true)
|
||||
.appendingPathComponent("\(name).mock", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||
public func referenceGamut(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// Whether the resolved path exists and is executable.
|
||||
public func exists(_ url: URL) -> Bool {
|
||||
FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// Small host-side file helpers (issue #6).
|
||||
public enum ArtefactFiles {
|
||||
|
||||
/// `get_default_working_dir` — `resolveSafeCwd(nil)`.
|
||||
public static func defaultWorkingDirectory() -> URL {
|
||||
PathSecurity.resolveSafeCwd(nil)
|
||||
}
|
||||
|
||||
/// `read_file_base64` — for **text artefacts** the UI needs verbatim
|
||||
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
|
||||
/// go through `TiffPreview` instead.
|
||||
public static func readBase64(_ url: URL) throws -> String {
|
||||
try Data(contentsOf: url).base64EncodedString()
|
||||
}
|
||||
|
||||
/// `get_app_info` — version + build for the About dialog.
|
||||
public static func appInfo(
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
return (
|
||||
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
||||
info["CFBundleVersion"] as? String ?? "0"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
|
||||
public struct StageArtefacts: Sendable, Equatable {
|
||||
/// `<basename>.ti1` exists (Stage 1 done → unlocks Stage 2).
|
||||
public var stage1Complete = false
|
||||
/// `<basename>.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
|
||||
public var stage2Complete = false
|
||||
/// `<basename>.ti3` exists (Stage 3 done → unlocks Stage 4).
|
||||
public var stage3Complete = false
|
||||
/// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5).
|
||||
public var stage4Complete = false
|
||||
/// Absolute path of the profile file when present.
|
||||
public var profilePath: URL?
|
||||
|
||||
public init(
|
||||
stage1Complete: Bool = false,
|
||||
stage2Complete: Bool = false,
|
||||
stage3Complete: Bool = false,
|
||||
stage4Complete: Bool = false,
|
||||
profilePath: URL? = nil
|
||||
) {
|
||||
self.stage1Complete = stage1Complete
|
||||
self.stage2Complete = stage2Complete
|
||||
self.stage3Complete = stage3Complete
|
||||
self.stage4Complete = stage4Complete
|
||||
self.profilePath = profilePath
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
|
||||
public enum ArtefactProbe {
|
||||
|
||||
/// `verify_stage_artefacts` — the gating truth source.
|
||||
public static func verify(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> StageArtefacts {
|
||||
var out = StageArtefacts()
|
||||
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
|
||||
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
|
||||
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
|
||||
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||
out.stage4Complete = true
|
||||
out.profilePath = profile
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// `<cwd>/<basename>.<ext>` — the canonical artefact URL.
|
||||
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
|
||||
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Profile extension resolution (#69): existing `.icm` wins over
|
||||
/// `.icc`; when neither exists the macOS default is `.icc`.
|
||||
/// (`profcheck`/`iccgamut` swap extension when the requested path is
|
||||
/// missing.)
|
||||
public static func resolveProfile(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL? {
|
||||
let icm = artefact(basename, "icm", cwd)
|
||||
if exists(icm, fm: fileManager) { return icm }
|
||||
let icc = artefact(basename, "icc", cwd)
|
||||
if exists(icc, fm: fileManager) { return icc }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Default extension for a *new* profile on macOS (#69).
|
||||
public static let defaultProfileExtension = "icc"
|
||||
|
||||
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
|
||||
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
|
||||
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` … and
|
||||
/// `<basename>_NN.tif` (manifest naming).
|
||||
public static func existingArtefacts(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> [URL] {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: cwd,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
|
||||
let prefixes = [basename + ".", "CAL_" + basename + "."]
|
||||
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
|
||||
let passPrefix = basename + "_pass"
|
||||
let tifStemPrefix = basename + "_"
|
||||
let calPrefix = "CAL_" + basename
|
||||
|
||||
return entries.filter { url in
|
||||
let name = url.lastPathComponent
|
||||
let ext = url.pathExtension.lowercased()
|
||||
guard suffixes.contains(ext) else { return false }
|
||||
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
|
||||
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
|
||||
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
|
||||
if name.hasPrefix(calPrefix) { return true }
|
||||
return false
|
||||
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
private static func exists(_ url: URL, fm: FileManager) -> Bool {
|
||||
fm.fileExists(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Atomic `.tmp`-then-rename file writes — the convention used by
|
||||
/// settings.json, verification_history.json and wizard_state.json
|
||||
/// (docs/02 §Persistence, #213).
|
||||
public enum AtomicFileWriter {
|
||||
|
||||
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
|
||||
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
|
||||
public static func write(_ data: Data, to url: URL) throws {
|
||||
let fm = FileManager.default
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let tmp = url.appendingPathExtension("tmp")
|
||||
do {
|
||||
try data.write(to: tmp, options: [])
|
||||
// replaceItemAt handles same-volume atomic swap and removes
|
||||
// the destination cleanly; fall back to remove+move.
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
||||
} else {
|
||||
try fm.moveItem(at: tmp, to: url)
|
||||
}
|
||||
} catch {
|
||||
try? fm.removeItem(at: tmp)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public static func write(_ text: String, to url: URL) throws {
|
||||
try write(Data(text.utf8), to: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Basename sanitisation and safe working-directory resolution
|
||||
/// (docs/02 §Working directory, docs/06 §Empty cwd).
|
||||
public enum PathSecurity {
|
||||
|
||||
public enum Error: Swift.Error, Equatable, Sendable {
|
||||
case invalidBasename(String)
|
||||
}
|
||||
|
||||
/// Basenames must not contain `/`, `\`, or `..` and must be
|
||||
/// non-empty. Never invent a default basename (#60).
|
||||
public static func isValidBasename(_ name: String) -> Bool {
|
||||
guard !name.isEmpty else { return false }
|
||||
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func sanitizeBasename(_ name: String) throws -> String {
|
||||
guard isValidBasename(name) else {
|
||||
throw Error.invalidBasename(name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory →
|
||||
/// Documents → Home → app-data. Never returns an empty/nil cwd.
|
||||
public static func resolveSafeCwd(
|
||||
_ explicit: URL?,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL {
|
||||
if let explicit,
|
||||
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
|
||||
return explicit
|
||||
}
|
||||
let candidates: [URL?] = [
|
||||
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
fileManager.homeDirectoryForCurrentUser,
|
||||
AppPaths.appDataDir,
|
||||
]
|
||||
for candidate in candidates {
|
||||
guard let url = candidate else { continue }
|
||||
if !fileManager.fileExists(atPath: url.path) {
|
||||
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
// Last resort: app-data, created unconditionally.
|
||||
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
|
||||
return AppPaths.appDataDir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
|
||||
/// `parse_ti2_header` reads only CGATS keyword lines — the data grid
|
||||
/// itself belongs to issue #30.
|
||||
public struct Ti2Header: Sendable, Equatable {
|
||||
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
|
||||
public var instrument: String?
|
||||
/// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is
|
||||
/// the CGATS column count, *not* the patch count.
|
||||
public var patchCount: Int?
|
||||
/// `NUMBER_OF_PAGES`.
|
||||
public var pageCount: Int?
|
||||
/// A sibling `<stem>.ti1` exists next to the parsed file.
|
||||
public var hasSiblingTi1 = false
|
||||
|
||||
public static func parse(
|
||||
_ url: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> Ti2Header {
|
||||
var header = Ti2Header()
|
||||
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
return header
|
||||
}
|
||||
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
|
||||
break
|
||||
}
|
||||
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
|
||||
guard let space = line.firstIndex(of: " ") else { continue }
|
||||
let key = String(line[..<space])
|
||||
let value = String(line[line.index(after: space)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
switch key {
|
||||
case "TARGET_INSTRUMENT":
|
||||
header.instrument = value
|
||||
case "NUMBER_OF_SETS":
|
||||
header.patchCount = Int(value)
|
||||
case "NUMBER_OF_PAGES":
|
||||
header.pageCount = Int(value)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
let stem = url.deletingPathExtension()
|
||||
header.hasSiblingTi1 = fileManager.fileExists(
|
||||
atPath: stem.appendingPathExtension("ti1").path
|
||||
)
|
||||
return header
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// TIFF → PNG preview for the Stage 2 gallery (#58): decode on the host
|
||||
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
|
||||
/// bytes to the UI.
|
||||
public enum TiffPreview {
|
||||
|
||||
public static let maxEdge: Int = 1200
|
||||
|
||||
/// Returns PNG data for the first page of a TIFF, or `nil` when the
|
||||
/// file cannot be decoded.
|
||||
public static func previewPNG(
|
||||
tiff url: URL,
|
||||
maxEdge: Int = Self.maxEdge
|
||||
) -> Data? {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
||||
return nil
|
||||
}
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source, 0, options as CFDictionary
|
||||
) else { return nil }
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(
|
||||
out, UTType.png.identifier as CFString, 1, nil
|
||||
) else { return nil }
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
guard CGImageDestinationFinalize(dest) else { return nil }
|
||||
return out as Data
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .error: return 0
|
||||
@@ -26,16 +27,19 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Central logger. For M1 PR2 this writes to `os.Logger` only;
|
||||
/// issue #5 adds the rolling file sink and runtime `setLevel`.
|
||||
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
|
||||
/// gated at write time so a settings save takes effect immediately
|
||||
/// (#158).
|
||||
public struct AppLogger: Sendable {
|
||||
public static let shared = AppLogger(category: "app")
|
||||
|
||||
private let osLog: Logger
|
||||
private let sink: LogSink
|
||||
public let category: String
|
||||
|
||||
public init(category: String) {
|
||||
public init(category: String, sink: LogSink = .shared) {
|
||||
self.category = category
|
||||
self.sink = sink
|
||||
self.osLog = Logger(
|
||||
subsystem: AppPaths.bundleIdentifier,
|
||||
category: category
|
||||
@@ -44,7 +48,10 @@ public struct AppLogger: Sendable {
|
||||
|
||||
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
||||
let text = LogSanitizer.sanitize(message())
|
||||
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
||||
if level.rank <= sink.level.rank {
|
||||
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
||||
}
|
||||
sink.write(level: level, category: category, message: text)
|
||||
}
|
||||
|
||||
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Rolling file sink for `AppLogger` — `~/Library/Logs/<bundle>/
|
||||
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
|
||||
/// (`iccery.log.1` … `iccery.log.5`).
|
||||
///
|
||||
/// The minimum level is **runtime state** (#158): `setLevel` takes
|
||||
/// effect immediately — at startup and on every settings save.
|
||||
public final class LogSink: @unchecked Sendable {
|
||||
|
||||
public static let shared = LogSink(fileURL: AppPaths.logFile)
|
||||
|
||||
private let lock = NSLock()
|
||||
private let fileURL: URL
|
||||
private var minimumLevel: LogLevel
|
||||
private var handle: FileHandle?
|
||||
|
||||
/// 5 MiB per segment, 5 historical segments kept.
|
||||
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
|
||||
public static let keptSegments = 5
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.logFile,
|
||||
minimumLevel: LogLevel? = nil
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
#if DEBUG
|
||||
self.minimumLevel = minimumLevel ?? .debug
|
||||
#else
|
||||
self.minimumLevel = minimumLevel ?? .info
|
||||
#endif
|
||||
}
|
||||
|
||||
public var level: LogLevel {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return minimumLevel
|
||||
}
|
||||
|
||||
/// Applied at startup AND on every settings save (issue #5, #158).
|
||||
public func setLevel(_ level: LogLevel) {
|
||||
lock.lock()
|
||||
minimumLevel = level
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `nil` → DEBUG-build default (.debug) / release (.info).
|
||||
public func applySettings(_ settings: AppSettings) {
|
||||
setLevel(settings.effectiveLogLevel)
|
||||
}
|
||||
|
||||
public func shouldLog(_ level: LogLevel) -> Bool {
|
||||
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
|
||||
/// rotating first when the active segment exceeds 5 MiB.
|
||||
public func write(level: LogLevel, category: String, message: String) {
|
||||
guard shouldLog(level) else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
rotateIfNeeded()
|
||||
openIfNeeded()
|
||||
let stamp = Self.timestamp()
|
||||
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
handle?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static func timestamp() -> String {
|
||||
formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func openIfNeeded() {
|
||||
guard handle == nil else { return }
|
||||
try? FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
||||
}
|
||||
handle = try? FileHandle(forWritingTo: fileURL)
|
||||
try? handle?.seekToEnd()
|
||||
}
|
||||
|
||||
/// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the
|
||||
/// writer. Oldest segment is deleted.
|
||||
private func rotateIfNeeded() {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path),
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||
let size = attrs[.size] as? UInt64,
|
||||
size >= Self.maxSegmentBytes
|
||||
else { return }
|
||||
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
let fm = FileManager.default
|
||||
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
|
||||
try? fm.removeItem(at: oldest)
|
||||
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
|
||||
let src = fileURL.appendingPathExtension("\(i)")
|
||||
let dst = fileURL.appendingPathExtension("\(i + 1)")
|
||||
if fm.fileExists(atPath: src.path) {
|
||||
try? fm.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
|
||||
}
|
||||
|
||||
/// Tail of the active log for the settings dialog's "copy excerpt".
|
||||
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return "" }
|
||||
let slice = data.suffix(maxBytes)
|
||||
return String(decoding: slice, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
|
||||
/// A saved wizard preset slot (docs/22 §Presets). The preset *engine*
|
||||
/// lands in issue #11; for M1 the store only needs a Codable container.
|
||||
public struct CustomPreset: Codable, Equatable, Sendable {
|
||||
public var name: String
|
||||
/// Opaque per-stage form values — keyed by field id.
|
||||
public var values: [String: String]
|
||||
|
||||
public init(name: String, values: [String: String] = [:]) {
|
||||
self.name = name
|
||||
self.values = values
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `install_profile` drops finished profiles (docs/22).
|
||||
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
|
||||
case user
|
||||
case system
|
||||
}
|
||||
|
||||
/// `settings.json` model (docs/22). snake_case keys match the v1 file
|
||||
/// so field names stay identical across rewrites.
|
||||
public struct AppSettings: Codable, Equatable, Sendable {
|
||||
|
||||
/// User override for Argyll binaries; `nil` → bundled sidecars.
|
||||
public var argyllBinaryDir: String?
|
||||
|
||||
/// Stored but **never applied to argv** — Stage 2's own instrument
|
||||
/// select is the live source (docs/04 §0.1).
|
||||
public var defaultInstrument: String?
|
||||
|
||||
/// `nil` → `.debug` in debug builds, `.info` in release (#158).
|
||||
public var logLevel: LogLevel?
|
||||
|
||||
public var deltaEGoodMax: Double
|
||||
public var deltaEWarningMax: Double
|
||||
public var customPresets: [CustomPreset]
|
||||
public var enableI1Pro2Leds: Bool
|
||||
public var calibrationStaleDays: Int
|
||||
public var defaultInstallLocation: InstallLocation
|
||||
public var askBeforeOverwriteProfile: Bool
|
||||
public var openColorPanelAfterInstall: Bool
|
||||
|
||||
public init(
|
||||
argyllBinaryDir: String? = nil,
|
||||
defaultInstrument: String? = nil,
|
||||
logLevel: LogLevel? = nil,
|
||||
deltaEGoodMax: Double = 2.0,
|
||||
deltaEWarningMax: Double = 5.0,
|
||||
customPresets: [CustomPreset] = [],
|
||||
enableI1Pro2Leds: Bool = false,
|
||||
calibrationStaleDays: Int = 30,
|
||||
defaultInstallLocation: InstallLocation = .user,
|
||||
askBeforeOverwriteProfile: Bool = true,
|
||||
openColorPanelAfterInstall: Bool = false
|
||||
) {
|
||||
self.argyllBinaryDir = argyllBinaryDir
|
||||
self.defaultInstrument = defaultInstrument
|
||||
self.logLevel = logLevel
|
||||
self.deltaEGoodMax = deltaEGoodMax
|
||||
self.deltaEWarningMax = deltaEWarningMax
|
||||
self.customPresets = customPresets
|
||||
self.enableI1Pro2Leds = enableI1Pro2Leds
|
||||
self.calibrationStaleDays = calibrationStaleDays
|
||||
self.defaultInstallLocation = defaultInstallLocation
|
||||
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
|
||||
self.openColorPanelAfterInstall = openColorPanelAfterInstall
|
||||
}
|
||||
|
||||
public static let `default` = AppSettings()
|
||||
|
||||
/// Effective log level — runtime state, not just persistence (#158).
|
||||
public var effectiveLogLevel: LogLevel {
|
||||
if let logLevel { return logLevel }
|
||||
#if DEBUG
|
||||
return .debug
|
||||
#else
|
||||
return .info
|
||||
#endif
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case argyllBinaryDir = "argyll_binary_dir"
|
||||
case defaultInstrument = "default_instrument"
|
||||
case logLevel = "log_level"
|
||||
case deltaEGoodMax = "delta_e_good_max"
|
||||
case deltaEWarningMax = "delta_e_warning_max"
|
||||
case customPresets = "custom_presets"
|
||||
case enableI1Pro2Leds = "enable_i1pro2_leds"
|
||||
case calibrationStaleDays = "calibration_stale_days"
|
||||
case defaultInstallLocation = "default_install_location"
|
||||
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
|
||||
case openColorPanelAfterInstall = "open_color_panel_after_install"
|
||||
}
|
||||
|
||||
/// UI-facing validation. Strings are part of the contract (issue #5).
|
||||
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
|
||||
public static let errorThresholdOrder =
|
||||
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||
|
||||
/// All validation errors, in declaration order. Empty = valid.
|
||||
public func validate() -> [String] {
|
||||
var errors: [String] = []
|
||||
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
|
||||
errors.append(Self.errorNegativeDeltaE)
|
||||
}
|
||||
if deltaEGoodMax >= deltaEWarningMax {
|
||||
errors.append(Self.errorThresholdOrder)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
public var isValid: Bool { validate().isEmpty }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// Persists `AppSettings` to
|
||||
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||
/// (issue #5 — the v1 path is never read).
|
||||
///
|
||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// reclassify swatches.
|
||||
public final class SettingsStore: Sendable {
|
||||
|
||||
/// Posted on `NotificationCenter.default` after every successful save.
|
||||
public static let settingsDidChange =
|
||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||
|
||||
public let fileURL: URL
|
||||
|
||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
public func load() -> AppSettings {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||
else {
|
||||
return .default
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
/// Validates before persisting — throws `SettingsError` listing
|
||||
/// every violation; nothing is written on failure.
|
||||
public func save(_ settings: AppSettings) throws {
|
||||
let errors = settings.validate()
|
||||
guard errors.isEmpty else {
|
||||
throw SettingsError.validationFailed(errors)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||
}
|
||||
|
||||
public enum SettingsError: Error, Equatable {
|
||||
case validationFailed([String])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
|
||||
///
|
||||
/// Navigation is *disk*, not buttons: a stage unlocks only when its
|
||||
/// predecessor artefacts exist. Forward moves are gated; backward is
|
||||
/// always allowed. Gating is re-evaluated on window focus and on stage
|
||||
/// entry (#151 — files can disappear in Finder).
|
||||
public enum WizardGating {
|
||||
|
||||
/// Whether `stage` is reachable given the probed artefacts.
|
||||
///
|
||||
/// - Stage 0 (calibrate): always — it is out-of-band, not gated.
|
||||
/// - Stage 1: always.
|
||||
/// - Stage 2: `.ti1` exists.
|
||||
/// - Stage 3: `.ti1` **and** `.ti2`.
|
||||
/// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2`
|
||||
/// alone never unlocks it; #109/#110).
|
||||
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
|
||||
public static func isUnlocked(
|
||||
_ stage: WizardStage,
|
||||
artefacts: StageArtefacts
|
||||
) -> Bool {
|
||||
switch stage {
|
||||
case .calibrate: return true
|
||||
case .generate: return true
|
||||
case .layOutPrint: return artefacts.stage1Complete
|
||||
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
|
||||
case .buildProfile: return artefacts.stage3Complete
|
||||
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `go(to:)` may proceed. Backward moves and the current
|
||||
/// stage are always allowed; forward moves must be unlocked.
|
||||
public static func canNavigate(
|
||||
to target: WizardStage,
|
||||
from current: WizardStage,
|
||||
artefacts: StageArtefacts
|
||||
) -> Bool {
|
||||
if target == current { return true }
|
||||
if target == .calibrate || current == .calibrate {
|
||||
// Stage 0 is a side-trip, not stepper navigation.
|
||||
return true
|
||||
}
|
||||
if target.rawValue < current.rawValue { return true }
|
||||
return isUnlocked(target, artefacts: artefacts)
|
||||
}
|
||||
|
||||
/// The deepest unlocked stepper stage — used when revalidation
|
||||
/// locks the current stage (#151).
|
||||
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
|
||||
for stage in WizardStage.stepperStages.reversed()
|
||||
where isUnlocked(stage, artefacts: artefacts) {
|
||||
return stage
|
||||
}
|
||||
return .generate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Session mode (docs/06 §wizardState). `"calibration"` is set while
|
||||
/// Stage 0 is driving a `CAL_` chart through the same pipeline.
|
||||
public enum SessionMode: String, Codable, Sendable {
|
||||
case profile
|
||||
case calibration
|
||||
}
|
||||
|
||||
/// Persisted wizard state (docs/06 §wizardState fields) —
|
||||
/// `wizard_state.json` in app data.
|
||||
public struct WizardState: Codable, Equatable, Sendable {
|
||||
/// 0–5 (`WizardStage.rawValue`).
|
||||
public var currentStage: Int
|
||||
/// Run name without extension — never invented (#60).
|
||||
public var basename: String
|
||||
/// Working directory for artefacts; empty → `resolveSafeCwd` (#59).
|
||||
public var cwd: String
|
||||
/// Last spooled printer, for calibration drift history.
|
||||
public var printerName: String?
|
||||
public var sessionMode: SessionMode
|
||||
/// May differ from `basename` after a `.ti3` import (#94).
|
||||
public var profileBasename: String?
|
||||
|
||||
public init(
|
||||
currentStage: Int = WizardStage.generate.rawValue,
|
||||
basename: String = "",
|
||||
cwd: String = "",
|
||||
printerName: String? = nil,
|
||||
sessionMode: SessionMode = .profile,
|
||||
profileBasename: String? = nil
|
||||
) {
|
||||
self.currentStage = currentStage
|
||||
self.basename = basename
|
||||
self.cwd = cwd
|
||||
self.printerName = printerName
|
||||
self.sessionMode = sessionMode
|
||||
self.profileBasename = profileBasename
|
||||
}
|
||||
|
||||
public static let `default` = WizardState()
|
||||
|
||||
/// The stage a saved `currentStage` resolves to, clamped to a valid
|
||||
/// value (corrupt ints fall back to Stage 1).
|
||||
public var stage: WizardStage {
|
||||
WizardStage(rawValue: currentStage) ?? .generate
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||
public final class WizardStateStore: Sendable {
|
||||
public let fileURL: URL
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
public func load() -> WizardState {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||
else { return .default }
|
||||
return state
|
||||
}
|
||||
|
||||
public func save(_ state: WizardState) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Mock script for chartread -u
|
||||
# This script simulates the behaviour of chartread for testing purposes.
|
||||
|
||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||
IS_XY=0
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--xy" ]; then
|
||||
IS_XY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read -r _calib
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read -r _sheet1
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid1
|
||||
|
||||
echo "locate patch B24 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid2
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
sleep 0.5
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||
EOF
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
echo "Please remove last sheet from table"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handheld / strip reader simulation
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
sleep 1
|
||||
echo "Calibration successful."
|
||||
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||
|
||||
sleep 1
|
||||
echo "Reading strip A..."
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||
EOF
|
||||
|
||||
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||
sleep 1
|
||||
echo "Reading strip B..."
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Ready to read... done."
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Mock script for colprof
|
||||
# Simulates colprof execution and outputs progress log
|
||||
|
||||
basename="$1"
|
||||
# Find last argument if -D or other flags are used
|
||||
for arg in "$@"; do
|
||||
basename="$arg"
|
||||
done
|
||||
|
||||
echo "colprof: Starting profile calculation for $basename"
|
||||
sleep 1
|
||||
echo "Gamut mapping calculation..."
|
||||
sleep 1
|
||||
echo "Fitting cLUT grid points..."
|
||||
sleep 1
|
||||
echo "Writing ICC profile $basename.icc..."
|
||||
touch "$basename.icc"
|
||||
echo "Done."
|
||||
exit 0
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Mock script for profcheck
|
||||
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||
|
||||
echo "profcheck: Checking profile accuracy..."
|
||||
echo "No of test patches = 52"
|
||||
sleep 1
|
||||
cat << 'EOF'
|
||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||
EOF
|
||||
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||
exit 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method
|
||||
/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs).
|
||||
/// No call site shares a generic picker (#103/#210/#211).
|
||||
@MainActor
|
||||
final class FileDialogService {
|
||||
|
||||
static let shared = FileDialogService()
|
||||
private init() {}
|
||||
|
||||
// MARK: - selectDirectory
|
||||
|
||||
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||
func selectDirectory(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = start
|
||||
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
panel.prompt = "Choose"
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
// MARK: - Dedicated open pickers
|
||||
|
||||
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "target.ti1"
|
||||
panel.allowedContentTypes = utTypes(["ti1"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Choose the .ti1 target file to create"
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||
func selectExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["ti1", "ti2"], startingAt: start,
|
||||
message: "Open an existing target (.ti1 or .ti2)")
|
||||
}
|
||||
|
||||
/// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*`
|
||||
/// (#172: the profile filter must not accept datasets).
|
||||
func selectProfileFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["icc", "icm", "mpp"], startingAt: start,
|
||||
message: "Choose an ICC/ICM profile or measurement preconditioning file")
|
||||
}
|
||||
|
||||
/// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i).
|
||||
func selectSpectrumFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["sp"], startingAt: start,
|
||||
message: "Choose a custom illuminant spectrum (.sp)")
|
||||
}
|
||||
|
||||
/// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`,
|
||||
/// `.cgats`, `.csv`). Always an *open* dialog, never save (#211).
|
||||
func selectDatasetFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start,
|
||||
message: "Import a measured dataset")
|
||||
}
|
||||
|
||||
/// `selectCsvSavePath` — verification-history CSV export.
|
||||
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "verification-history.csv"
|
||||
panel.allowedContentTypes = utTypes(["csv"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
/// `selectCalFile` — `.cal` calibration curves.
|
||||
func selectCalFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["cal"], startingAt: start,
|
||||
message: "Choose a calibration file (.cal)")
|
||||
}
|
||||
|
||||
// MARK: - Internals (private — not a shared public picker API)
|
||||
|
||||
private func open(
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
message: String?
|
||||
) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
private func utTypes(_ extensions: [String]) -> [UTType] {
|
||||
extensions.compactMap { UTType(filenameExtension: $0) }
|
||||
}
|
||||
|
||||
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||
panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
private func run(_ panel: NSSavePanel) -> URL? {
|
||||
panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ICCeryCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -6,6 +7,13 @@ struct ICCeryApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var model = WizardViewModel()
|
||||
|
||||
init() {
|
||||
try? AppPaths.ensureDirectories()
|
||||
// Log level is runtime state — apply persisted settings at
|
||||
// startup (#158); the Settings sheet re-applies on save.
|
||||
LogSink.shared.applySettings(SettingsStore().load())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||
Window("ICCery", id: "main") {
|
||||
@@ -19,15 +27,24 @@ struct ICCeryApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// AppDelegate: quit when the single window closes, and give later
|
||||
/// milestones a hook to `killAll` Argyll children before teardown
|
||||
/// (#147/#149 — wired once ProcessManager exists in #2).
|
||||
/// AppDelegate: quit when the single window closes, and `killAll` Argyll
|
||||
/// children before teardown (#147/#149). Termination is deferred until
|
||||
/// `killAll` has signaled every child so `chartread` can park an XY head
|
||||
/// when the UI already sent `q\n`.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var terminationRequested = false
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Issue #2+: ProcessManager.shared.killAll()
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !terminationRequested else { return .terminateNow }
|
||||
terminationRequested = true
|
||||
Task {
|
||||
await ProcessManager.shared.killAll()
|
||||
NSApplication.shared.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||
@@ -28,16 +29,15 @@ struct RootView: View {
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.background(Theme.background)
|
||||
// #151: re-probe artefacts when the window regains focus —
|
||||
// files deleted in Finder must re-lock stages.
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(
|
||||
for: NSWindow.didBecomeKeyNotification
|
||||
)
|
||||
) { _ in model.windowDidBecomeKey() }
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
// Full settings dialog lands in issue #5.
|
||||
VStack(spacing: 12) {
|
||||
Text("Settings").font(.headline)
|
||||
Text("Implemented in issue #5.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Close") { showingSettings = false }
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 420)
|
||||
SettingsView()
|
||||
}
|
||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||
Button("OK") {}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||
struct SettingsView: View {
|
||||
@State var model = SettingsViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private static let instruments: [(code: String, label: String)] = [
|
||||
("i1", "X-Rite i1Pro / i1Pro 2"),
|
||||
("p3", "X-Rite i1Pro 3 / 3 Plus"),
|
||||
("CM", "ColorMunki"),
|
||||
("SS", "Specbos / Spectraval"),
|
||||
("20", "Gretag i1Display 2"),
|
||||
("22", "X-Rite i1Display Pro / ColorMunki Display"),
|
||||
("41", "Datacolor Spyder 4/5"),
|
||||
("51", "Spyder X"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
Section("Argyll") {
|
||||
HStack {
|
||||
TextField(
|
||||
"Bundled sidecars",
|
||||
text: Binding(
|
||||
get: { model.settings.argyllBinaryDir ?? "" },
|
||||
set: {
|
||||
model.settings.argyllBinaryDir =
|
||||
$0.isEmpty ? nil : $0
|
||||
}
|
||||
)
|
||||
)
|
||||
Button("Browse…") {
|
||||
if let dir = FileDialogService.shared.selectDirectory() {
|
||||
model.settings.argyllBinaryDir = dir.path
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Leave empty to use the bundled Argyll tools.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(
|
||||
"Default instrument",
|
||||
selection: Binding(
|
||||
get: { model.settings.defaultInstrument ?? "" },
|
||||
set: {
|
||||
model.settings.defaultInstrument =
|
||||
$0.isEmpty ? nil : $0
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text("None").tag("")
|
||||
ForEach(Self.instruments, id: \.code) {
|
||||
Text($0.label).tag($0.code)
|
||||
}
|
||||
}
|
||||
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle(
|
||||
"Enable i1Pro 2 LEDs",
|
||||
isOn: $model.settings.enableI1Pro2Leds
|
||||
)
|
||||
}
|
||||
|
||||
Section("Verification") {
|
||||
HStack {
|
||||
Text("Good ΔE ≤")
|
||||
TextField(
|
||||
"2.0",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("Warning ΔE ≤")
|
||||
TextField(
|
||||
"5.0",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
}
|
||||
ForEach(model.validationErrors, id: \.self) { error in
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Calibration") {
|
||||
HStack {
|
||||
Text("Stale after")
|
||||
TextField(
|
||||
"30",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("days")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Profile install") {
|
||||
Picker(
|
||||
"Install location",
|
||||
selection: $model.settings.defaultInstallLocation
|
||||
) {
|
||||
Text("User library").tag(InstallLocation.user)
|
||||
Text("System library").tag(InstallLocation.system)
|
||||
}
|
||||
Toggle(
|
||||
"Ask before overwriting a profile",
|
||||
isOn: $model.settings.askBeforeOverwriteProfile
|
||||
)
|
||||
Toggle(
|
||||
"Open ColorSync after install",
|
||||
isOn: $model.settings.openColorPanelAfterInstall
|
||||
)
|
||||
}
|
||||
|
||||
Section("Logging") {
|
||||
Picker(
|
||||
"Log level",
|
||||
selection: Binding(
|
||||
get: { model.settings.logLevel },
|
||||
set: { model.settings.logLevel = $0 }
|
||||
)
|
||||
) {
|
||||
Text("Default").tag(LogLevel?.none)
|
||||
ForEach(LogLevel.allCases, id: \.self) {
|
||||
Text($0.rawValue.capitalized).tag(LogLevel?.some($0))
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button("Open log folder") { model.openLogFolder() }
|
||||
Button("Copy path") { model.copyLogPath() }
|
||||
Button("Copy excerpt") { model.copyLogExcerpt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
if model.savedFlash {
|
||||
Text("Saved")
|
||||
.foregroundStyle(.green)
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Save") {
|
||||
if model.save() { dismiss() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.frame(width: 560, height: 620)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// Backs the Settings sheet (issue #5). Load → edit → save with
|
||||
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||
/// `settingsDidChange` notification fans out to #20.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
|
||||
var settings: AppSettings
|
||||
var validationErrors: [String] = []
|
||||
var savedFlash = false
|
||||
|
||||
private let store: SettingsStore
|
||||
private let sink: LogSink
|
||||
|
||||
init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) {
|
||||
self.store = store
|
||||
self.sink = sink
|
||||
self.settings = store.load()
|
||||
}
|
||||
|
||||
/// Persists after validation. Returns false (and shows inline
|
||||
/// errors) when the form is invalid.
|
||||
@discardableResult
|
||||
func save() -> Bool {
|
||||
validationErrors = settings.validate()
|
||||
guard validationErrors.isEmpty else { return false }
|
||||
do {
|
||||
try store.save(settings)
|
||||
sink.applySettings(settings)
|
||||
savedFlash = true
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
savedFlash = false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
validationErrors = ["Could not save settings: \(error.localizedDescription)"]
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log helpers
|
||||
|
||||
var logFileURL: URL { AppPaths.logFile }
|
||||
|
||||
func openLogFolder() {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: AppPaths.logDir, withIntermediateDirectories: true
|
||||
)
|
||||
NSWorkspace.shared.selectFile(
|
||||
AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path
|
||||
)
|
||||
}
|
||||
|
||||
func copyLogPath() {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string)
|
||||
}
|
||||
|
||||
func copyLogExcerpt() {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string)
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,8 @@ struct SidebarView: View {
|
||||
StepperRow(
|
||||
stage: stage,
|
||||
isActive: model.stage == stage,
|
||||
// Only Stage 1 until artefact gating lands in #4.
|
||||
isEnabled: stage == .generate
|
||||
// Artefact gating (issue #4) — disk is truth.
|
||||
isEnabled: model.isUnlocked(stage)
|
||||
) {
|
||||
model.go(to: stage)
|
||||
}
|
||||
|
||||
@@ -2,44 +2,142 @@ import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// Wizard shell state (issue #1). Artefact gating, persistence and the
|
||||
/// "open existing" flow land in issue #4.
|
||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||
///
|
||||
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
||||
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
|
||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||
/// navigation is disk, not buttons.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WizardViewModel {
|
||||
/// Currently displayed stage.
|
||||
var stage: WizardStage = .generate
|
||||
|
||||
// MARK: - wizardState fields (persisted)
|
||||
|
||||
var stage: WizardStage {
|
||||
didSet { if stage != oldValue { persist() } }
|
||||
}
|
||||
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||
var basename: String {
|
||||
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||
}
|
||||
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||
var workingDirectory: URL? {
|
||||
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||
}
|
||||
var printerName: String? {
|
||||
didSet { if printerName != oldValue { persist() } }
|
||||
}
|
||||
var sessionMode: SessionMode {
|
||||
didSet { if sessionMode != oldValue { persist() } }
|
||||
}
|
||||
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||
var profileBasename: String? {
|
||||
didSet { if profileBasename != oldValue { persist() } }
|
||||
}
|
||||
|
||||
// MARK: - Ephemeral
|
||||
|
||||
/// Banner notice currently displayed (`#wizardNotification`).
|
||||
var notice: Notice?
|
||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||
private(set) var artefacts = StageArtefacts()
|
||||
|
||||
/// Target basename shared across stages (`targetBasename`).
|
||||
var basename: String = ""
|
||||
|
||||
/// Working directory for all Argyll artefacts.
|
||||
var workingDirectory: URL?
|
||||
|
||||
/// Printer queue selected in Stage 2; retained across stages.
|
||||
var printerName: String?
|
||||
|
||||
private let stateStore: WizardStateStore
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
|
||||
/// `true` while Stage 0 (printer calibration) is shown instead of a
|
||||
/// stepper stage.
|
||||
init(stateStore: WizardStateStore = WizardStateStore()) {
|
||||
self.stateStore = stateStore
|
||||
let s = stateStore.load()
|
||||
self.stage = s.stage
|
||||
self.basename = s.basename
|
||||
self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd)
|
||||
self.printerName = s.printerName
|
||||
self.sessionMode = s.sessionMode
|
||||
self.profileBasename = s.profileBasename
|
||||
refreshGating()
|
||||
// A restored stage may have been locked since (#151).
|
||||
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
||||
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gating
|
||||
|
||||
/// `isUnlocked` for the sidebar stepper.
|
||||
func isUnlocked(_ stage: WizardStage) -> Bool {
|
||||
WizardGating.isUnlocked(stage, artefacts: artefacts)
|
||||
}
|
||||
|
||||
/// `true` while Stage 0 (printer calibration) is shown.
|
||||
var isCalibrating: Bool { stage == .calibrate }
|
||||
|
||||
func go(to stage: WizardStage) {
|
||||
self.stage = stage
|
||||
/// Re-probes the artefact directory and re-locks (#151). Called on
|
||||
/// window focus, stage entry, and basename/cwd changes.
|
||||
func refreshGating() {
|
||||
guard !basename.isEmpty, let dir = effectiveWorkingDirectory else {
|
||||
artefacts = StageArtefacts()
|
||||
return
|
||||
}
|
||||
artefacts = ArtefactProbe.verify(basename: basename, cwd: dir)
|
||||
}
|
||||
|
||||
/// `setTarget(basename, cwd)` — validates the basename (no `/`, `\`,
|
||||
/// `..`; no placeholders — #60) and resolves the cwd (#59).
|
||||
func setTarget(basename: String, workingDirectory: URL?) {
|
||||
do {
|
||||
self.basename = try PathSecurity.sanitizeBasename(basename)
|
||||
} catch {
|
||||
showNotice("Invalid target name.", kind: .error)
|
||||
return
|
||||
}
|
||||
self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory)
|
||||
}
|
||||
|
||||
/// cwd never stays empty once a basename exists (#59).
|
||||
var effectiveWorkingDirectory: URL? {
|
||||
if let workingDirectory { return workingDirectory }
|
||||
return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
/// `navigateToStage(n)` — refuses locked forward moves with a
|
||||
/// warning banner; backward is always allowed (docs/06).
|
||||
func go(to target: WizardStage) {
|
||||
guard target != .calibrate else { enterCalibration(); return }
|
||||
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
||||
stage = target
|
||||
} else {
|
||||
showNotice(
|
||||
"Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.",
|
||||
kind: .warning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func enterCalibration() {
|
||||
sessionMode = .calibration
|
||||
stage = .calibrate
|
||||
}
|
||||
|
||||
func exitCalibration() {
|
||||
sessionMode = .profile
|
||||
stage = .generate
|
||||
}
|
||||
|
||||
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
|
||||
/// If the current stage re-locked, fall back to the deepest unlocked.
|
||||
func windowDidBecomeKey() {
|
||||
refreshGating()
|
||||
if stage != .calibrate,
|
||||
!WizardGating.isUnlocked(stage, artefacts: artefacts) {
|
||||
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notice
|
||||
|
||||
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
||||
noticeDismissTask?.cancel()
|
||||
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||
@@ -59,4 +157,18 @@ final class WizardViewModel {
|
||||
noticeDismissTask?.cancel()
|
||||
notice = nil
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func persist() {
|
||||
let state = WizardState(
|
||||
currentStage: stage.rawValue,
|
||||
basename: basename,
|
||||
cwd: workingDirectory?.path ?? "",
|
||||
printerName: printerName,
|
||||
sessionMode: sessionMode,
|
||||
profileBasename: profileBasename
|
||||
)
|
||||
try? stateStore.save(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempURL(_ name: String) -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
|
||||
.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
@Suite("Ti2Header")
|
||||
struct Ti2HeaderTests {
|
||||
@Test func parsesKeywordsAndSibling() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try """
|
||||
CTI2
|
||||
TARGET_INSTRUMENT "i1iO"
|
||||
NUMBER_OF_FIELDS 9
|
||||
NUMBER_OF_SETS 800
|
||||
NUMBER_OF_PAGES 3
|
||||
BEGIN_DATA_FORMAT
|
||||
SAMPLE_ID RGB_R
|
||||
END_DATA_FORMAT
|
||||
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
|
||||
try "CGATS".write(
|
||||
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
|
||||
)
|
||||
|
||||
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||
#expect(h.instrument == "i1iO")
|
||||
#expect(h.patchCount == 800)
|
||||
#expect(h.pageCount == 3)
|
||||
#expect(h.hasSiblingTi1)
|
||||
}
|
||||
|
||||
@Test func missingFileYieldsEmptyHeader() {
|
||||
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||
}
|
||||
|
||||
@Test func numberOfFieldsIsNotPatchCount() throws {
|
||||
let url = tempURL("t.ti2")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||
to: url, atomically: true, encoding: .utf8
|
||||
)
|
||||
#expect(Ti2Header.parse(url).patchCount == 52)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("TiffPreview")
|
||||
struct TiffPreviewTests {
|
||||
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
||||
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
||||
let url = tempURL("big.tif")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let ctx = CGContext(
|
||||
data: nil, width: width, height: height,
|
||||
bitsPerComponent: 8, bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)!
|
||||
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let image = ctx.makeImage()!
|
||||
|
||||
guard let dest = CGImageDestinationCreateWithURL(
|
||||
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
|
||||
) else { throw CocoaError(.fileWriteUnknown) }
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func producesCappedPNG() throws {
|
||||
let tiff = try makeTiff()
|
||||
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||
#expect(png != nil)
|
||||
// PNG magic
|
||||
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||
// Verify the cap by decoding the thumbnail header.
|
||||
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
||||
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||
#expect(img.width == 1200)
|
||||
}
|
||||
|
||||
@Test func nonTiffReturnsNil() throws {
|
||||
let url = tempURL("not-tiff.txt")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactFiles")
|
||||
struct ArtefactFilesTests {
|
||||
@Test func base64RoundTrip() throws {
|
||||
let url = tempURL("a.txt")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||
let b64 = try ArtefactFiles.readBase64(url)
|
||||
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
||||
}
|
||||
|
||||
@Test func defaultWorkingDirExists() {
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("BinaryResolver")
|
||||
struct BinaryResolverTests {
|
||||
|
||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try body(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private func touch(_ url: URL, executable: Bool = true) throws {
|
||||
FileManager.default.createFile(atPath: url.path, contents: Data())
|
||||
if executable {
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func overrideDirWinsWhenFileExists() throws {
|
||||
let override = try makeTree { root in
|
||||
try touch(root.appendingPathComponent("targen"))
|
||||
}
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||
}
|
||||
|
||||
@Test func overrideFallsThroughWhenMissing() throws {
|
||||
let override = try makeTree { _ in }
|
||||
let bundled = try makeTree { root in
|
||||
let dir = root.appendingPathComponent("macos-universal")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try touch(dir.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||
}
|
||||
|
||||
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||
let bundled = try makeTree { root in
|
||||
for dir in ["macos-universal", "macos-x86_64"] {
|
||||
let d = root.appendingPathComponent(dir)
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
#expect(r.platformDir() == "macos-universal")
|
||||
}
|
||||
|
||||
@Test func fallsBackToArchDir() throws {
|
||||
let bundled = try makeTree { root in
|
||||
let d = root.appendingPathComponent("macos-x86_64")
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(
|
||||
bundledRoot: bundled,
|
||||
archDirs: ["macos-universal", "macos-x86_64"]
|
||||
)
|
||||
#expect(r.platformDir() == "macos-x86_64")
|
||||
}
|
||||
|
||||
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
// v1 semantic: path is returned; spawn surfaces the error.
|
||||
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||
#expect(!r.exists(r.resolve("targen")))
|
||||
}
|
||||
|
||||
@Test func mockAndGamutPaths() throws {
|
||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-files-\(name)")
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
private func touch(_ url: URL, _ contents: String = "x") throws {
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@Suite("PathSecurity")
|
||||
struct PathSecurityTests {
|
||||
@Test func rejectsTraversalAndSeparators() {
|
||||
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||
#expect(!PathSecurity.isValidBasename(bad))
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try PathSecurity.sanitizeBasename(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acceptsNormalNames() {
|
||||
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||
#expect(PathSecurity.isValidBasename(good))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AtomicFileWriter")
|
||||
struct AtomicFileWriterTests {
|
||||
@Test func writesAndLeavesNoTmp() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("state.json")
|
||||
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||
}
|
||||
|
||||
@Test func overwritesExistingAtomically() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("f.txt")
|
||||
try AtomicFileWriter.write("one", to: url)
|
||||
try AtomicFileWriter.write("two-longer", to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||
}
|
||||
|
||||
@Test func createsParentDirs() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||
try AtomicFileWriter.write("{}", to: url)
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactProbe")
|
||||
struct ArtefactProbeTests {
|
||||
@Test func verifyProgression() throws {
|
||||
let dir = try tempDir()
|
||||
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v == StageArtefacts())
|
||||
|
||||
try touch(dir.appendingPathComponent("t.ti1"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||
|
||||
try touch(dir.appendingPathComponent("t.ti2"))
|
||||
try touch(dir.appendingPathComponent("t.ti3"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||
|
||||
try touch(dir.appendingPathComponent("t.icc"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||
}
|
||||
|
||||
@Test func icmWinsOverIcc() throws {
|
||||
let dir = try tempDir()
|
||||
try touch(dir.appendingPathComponent("p.icc"))
|
||||
try touch(dir.appendingPathComponent("p.icm"))
|
||||
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||
#expect(profile?.pathExtension == "icm")
|
||||
}
|
||||
|
||||
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||
let dir = try tempDir()
|
||||
for name in [
|
||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||
"t.icc", "t.gam",
|
||||
"CAL_t.ti1", "CAL_t.cal",
|
||||
// must NOT match:
|
||||
"other.ti1", "t.txt", "CAL_other.ti1",
|
||||
] { try touch(dir.appendingPathComponent(name)) }
|
||||
|
||||
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
|
||||
.map(\.lastPathComponent)
|
||||
for expected in [
|
||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||
] {
|
||||
#expect(names.contains(expected), "missing \(expected)")
|
||||
}
|
||||
#expect(!names.contains("other.ti1"))
|
||||
#expect(!names.contains("t.txt"))
|
||||
#expect(!names.contains("CAL_other.ti1"))
|
||||
}
|
||||
|
||||
@Test func emptyDirReturnsEmpty() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempStoreURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-settings-\(UUID().uuidString)")
|
||||
.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Suite("AppSettings")
|
||||
struct AppSettingsTests {
|
||||
@Test func defaults() {
|
||||
let s = AppSettings.default
|
||||
#expect(s.argyllBinaryDir == nil)
|
||||
#expect(s.defaultInstrument == nil)
|
||||
#expect(s.logLevel == nil)
|
||||
#expect(s.deltaEGoodMax == 2.0)
|
||||
#expect(s.deltaEWarningMax == 5.0)
|
||||
#expect(s.customPresets.isEmpty)
|
||||
#expect(!s.enableI1Pro2Leds)
|
||||
#expect(s.calibrationStaleDays == 30)
|
||||
#expect(s.defaultInstallLocation == .user)
|
||||
#expect(s.askBeforeOverwriteProfile)
|
||||
#expect(!s.openColorPanelAfterInstall)
|
||||
#expect(s.isValid)
|
||||
}
|
||||
|
||||
@Test func negativeThresholds() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = -1
|
||||
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
||||
s.deltaEGoodMax = 2.0
|
||||
s.deltaEWarningMax = -0.5
|
||||
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
||||
#expect(s.validate() == [
|
||||
AppSettings.errorNegativeDeltaE,
|
||||
AppSettings.errorThresholdOrder,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func goodMustBeStrictlyLessThanWarning() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 5.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 6.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 4.9
|
||||
#expect(s.isValid)
|
||||
}
|
||||
|
||||
@Test func snakeCaseKeys() throws {
|
||||
let s = AppSettings.default
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let json = String(data: data, encoding: .utf8)!
|
||||
#expect(json.contains("\"delta_e_good_max\""))
|
||||
#expect(json.contains("\"default_install_location\""))
|
||||
#expect(json.contains("\"enable_i1pro2_leds\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SettingsStore")
|
||||
struct SettingsStoreTests {
|
||||
@Test func roundTrip() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 1.5
|
||||
s.defaultInstrument = "p3"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
}
|
||||
|
||||
@Test func corruptJsonFallsBackToDefaults() throws {
|
||||
let url = tempStoreURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(SettingsStore(fileURL: url).load() == .default)
|
||||
}
|
||||
|
||||
@Test func missingFileReturnsDefaults() {
|
||||
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
||||
}
|
||||
|
||||
@Test func invalidSettingsNotPersisted() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test func savePostsNotification() async throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var fired = false
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||
) { _ in fired = true }
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
try store.save(.default)
|
||||
#expect(fired)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("LogSink")
|
||||
struct LogSinkTests {
|
||||
private func tempLog() -> (URL, LogSink) {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||
.appendingPathComponent("iccery.log")
|
||||
return (url, LogSink(fileURL: url))
|
||||
}
|
||||
|
||||
@Test func writesFormattedLines() {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "test", message: "hello")
|
||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
#expect(content.contains("[INFO] test: hello"))
|
||||
}
|
||||
|
||||
@Test func levelFilteringIsLive() {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.error)
|
||||
sink.write(level: .info, category: "t", message: "hidden")
|
||||
sink.setLevel(.info) // runtime change, no restart (#158)
|
||||
sink.write(level: .info, category: "t", message: "shown")
|
||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
#expect(!content.contains("hidden"))
|
||||
#expect(content.contains("shown"))
|
||||
}
|
||||
|
||||
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.trace)
|
||||
// Pre-fill the active log just under the cap, then cross it.
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes))
|
||||
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: url.appendingPathExtension("1").path
|
||||
))
|
||||
// Active log is small again.
|
||||
let size = try FileManager.default.attributesOfItem(
|
||||
atPath: url.path
|
||||
)[.size] as? UInt64
|
||||
#expect((size ?? 0) < 1024)
|
||||
}
|
||||
|
||||
@Test func tailExcerptCaps() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "t", message: "line")
|
||||
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func artefacts(
|
||||
ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false
|
||||
) -> StageArtefacts {
|
||||
var a = StageArtefacts()
|
||||
a.stage1Complete = ti1
|
||||
a.stage2Complete = ti2
|
||||
a.stage3Complete = ti3
|
||||
a.stage4Complete = profile
|
||||
if profile {
|
||||
a.profilePath = URL(fileURLWithPath: "/x/t.icc")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@Suite("WizardGating matrix")
|
||||
struct WizardGatingTests {
|
||||
|
||||
@Test func emptyProjectOnlyStage1() {
|
||||
let a = artefacts()
|
||||
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
||||
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ti1UnlocksStage2Only() {
|
||||
let a = artefacts(ti1: true)
|
||||
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func stage3NeedsTi1AndTi2() {
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||
}
|
||||
|
||||
@Test func stage4NeedsTi3NotTi2() {
|
||||
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||
let a = artefacts(ti1: true, ti2: true)
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||
}
|
||||
|
||||
@Test func stage5NeedsTi3AndProfile() {
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||
#expect(WizardGating.isUnlocked(
|
||||
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func forwardGatedBackwardFree() {
|
||||
let a = artefacts()
|
||||
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||
// Backward always allowed even when artefacts vanished.
|
||||
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||
// Same stage is a no-op.
|
||||
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||
// Stage 0 is a side-trip, never gated.
|
||||
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func deepestUnlocked() {
|
||||
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti1: true, ti2: true)
|
||||
) == .measure)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti3: true, profile: true)
|
||||
) == .verifyInstall)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStateStore")
|
||||
struct WizardStateStoreTests {
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
}
|
||||
|
||||
@Test func roundTrip() throws {
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
var s = WizardState()
|
||||
s.currentStage = 3
|
||||
s.basename = "run-42"
|
||||
s.cwd = "/tmp/charts"
|
||||
s.sessionMode = .calibration
|
||||
s.profileBasename = "imported"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
}
|
||||
|
||||
@Test func missingFileDefaults() {
|
||||
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||
#expect(s == .default)
|
||||
#expect(s.stage == .generate)
|
||||
#expect(s.sessionMode == .profile)
|
||||
}
|
||||
|
||||
@Test func corruptStageFallsBackToGenerate() throws {
|
||||
let url = tempURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||
.write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||
}
|
||||
|
||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||
var s = WizardState(sessionMode: .calibration)
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||
#expect(decoded.sessionMode == .calibration)
|
||||
s.sessionMode = .profile
|
||||
#expect(s.sessionMode == .profile)
|
||||
}
|
||||
}
|
||||
+16
@@ -19,9 +19,25 @@ targets:
|
||||
- path: Resources
|
||||
excludes:
|
||||
- ICCery.entitlements
|
||||
- Argyll
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
postBuildScripts:
|
||||
- name: Copy Argyll sidecars
|
||||
script: |
|
||||
set -e
|
||||
SRC="${SRCROOT}/Vendor/Argyll"
|
||||
DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll"
|
||||
if [ -d "$SRC" ]; then
|
||||
mkdir -p "$DEST"
|
||||
rsync -a "$SRC/" "$DEST/"
|
||||
else
|
||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||
fi
|
||||
basedOnDependencyAnalysis: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# scripts/fetch-argyll.sh
|
||||
#
|
||||
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
|
||||
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
|
||||
#
|
||||
# Env overrides (parity with v1 fetch-argyll.mjs):
|
||||
# ARGYLL_SERVER_URL default https://git.i3omb.com
|
||||
# ARGYLL_REPO default gronod/argyllcms
|
||||
# ARGYLL_RELEASE_TAG default: latest release
|
||||
# GITEA_TOKEN optional, for private repos
|
||||
#
|
||||
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
|
||||
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
|
||||
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
|
||||
# they ship in git, not in the release tarball.
|
||||
|
||||
set -eu
|
||||
|
||||
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
|
||||
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
|
||||
TAG="${ARGYLL_RELEASE_TAG:-}"
|
||||
SUFFIX="_macOS_universal_bin.tgz"
|
||||
PLATFORM_DIR="macos-universal"
|
||||
MARKER="instlist"
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
|
||||
|
||||
FORCE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
|
||||
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
AUTH_HEADER=""
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
|
||||
fi
|
||||
|
||||
api_get() {
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
|
||||
else
|
||||
curl -fsSL -H 'Accept: application/json' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$TAG" ]; then
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
|
||||
else
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
|
||||
fi
|
||||
|
||||
echo "Fetching release info from $API_URL"
|
||||
RELEASE_JSON="$(api_get "$API_URL")" || {
|
||||
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find the macOS universal asset's browser_download_url without jq.
|
||||
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
|
||||
| tr ',' '\n' \
|
||||
| grep '"browser_download_url"' \
|
||||
| grep "$SUFFIX" \
|
||||
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
|
||||
| head -n 1)"
|
||||
|
||||
if [ -z "$ASSET_URL" ]; then
|
||||
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
|
||||
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading $ASSET_URL"
|
||||
TMPDIR_FETCH="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
|
||||
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
|
||||
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
|
||||
else
|
||||
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
|
||||
fi
|
||||
|
||||
EXTRACT="$TMPDIR_FETCH/extract"
|
||||
mkdir -p "$EXTRACT"
|
||||
tar -xzf "$ARCHIVE" -C "$EXTRACT"
|
||||
|
||||
# Archive contains Argyll_V*/bin/ (or a bare bin/).
|
||||
BIN_DIR=""
|
||||
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
|
||||
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
|
||||
done
|
||||
if [ -z "$BIN_DIR" ]; then
|
||||
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script.
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
codesign -f -s - "$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
UNSIGNED=""
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
if ! codesign -dvv "$f" >/dev/null 2>&1; then
|
||||
UNSIGNED="$UNSIGNED $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -n "$UNSIGNED" ]; then
|
||||
echo "error: unsigned binaries remain:$UNSIGNED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$DEST/$MARKER" ]; then
|
||||
echo "error: marker binary $MARKER missing after extraction" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"
|
||||
Reference in New Issue
Block a user