From 4440a264760d37b3b516b791fc68957e1d26c5ef Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 18:29:50 +0100 Subject: [PATCH 1/8] App scaffold & wizard shell (#1) XcodeGen-managed project (project.yml), SwiftUI single-window app 1280x800 / min 1100x700, dark theme tokens from docs/21, 270pt sidebar with logo/preset select/Calibrate/stepper 1-5, notice banner, five stage placeholders, ICCeryCore SPM package with AppPaths + WizardStage, Swift Testing plumbing. Sandbox off, hardened runtime on, bundle id com.gronod.iccery2. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 5 +- Makefile | 23 ++++ Packages/ICCeryCore/Package.swift | 13 +++ .../Sources/ICCeryCore/Paths/AppPaths.swift | 51 +++++++++ .../ICCeryCore/Wizard/WizardStage.swift | 45 ++++++++ .../AccentColor.colorset/Contents.json | 20 ++++ .../AppIcon.appiconset/Contents.json | 58 ++++++++++ Resources/Assets.xcassets/Contents.json | 6 + .../ICCery-logo.imageset/Contents.json | 16 +++ .../ICCery-logo.imageset/ICCery-logo.svg | 47 ++++++++ Resources/ICCery.entitlements | 8 ++ Sources/ICCery/ICCeryApp.swift | 33 ++++++ Sources/ICCery/NoticeBanner.swift | 62 +++++++++++ Sources/ICCery/RootView.swift | 48 ++++++++ Sources/ICCery/SidebarView.swift | 105 ++++++++++++++++++ Sources/ICCery/StagePlaceholderView.swift | 24 ++++ Sources/ICCery/Theme.swift | 21 ++++ Sources/ICCery/WizardViewModel.swift | 62 +++++++++++ Tests/ICCeryCoreTests/AppPathsTests.swift | 27 +++++ project.yml | 80 +++++++++++++ 20 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 Packages/ICCeryCore/Package.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift create mode 100644 Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Resources/Assets.xcassets/Contents.json create mode 100644 Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json create mode 100644 Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg create mode 100644 Resources/ICCery.entitlements create mode 100644 Sources/ICCery/ICCeryApp.swift create mode 100644 Sources/ICCery/NoticeBanner.swift create mode 100644 Sources/ICCery/RootView.swift create mode 100644 Sources/ICCery/SidebarView.swift create mode 100644 Sources/ICCery/StagePlaceholderView.swift create mode 100644 Sources/ICCery/Theme.swift create mode 100644 Sources/ICCery/WizardViewModel.swift create mode 100644 Tests/ICCeryCoreTests/AppPathsTests.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore index 6e57a6a..d131153 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,10 @@ DerivedData/ Package.resolved # Fetched Argyll sidecars (release artefacts, not git blobs — #127) -Resources/argyll/ +Vendor/Argyll/ + +# XcodeGen output (regenerate with `make gen`) +ICCery.xcodeproj/ # macOS .DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..70e8da4 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +SCHEME := ICCery +DEST := 'platform=macOS' + +.PHONY: gen build test universal fetch-argyll clean + +gen: + xcodegen generate + +build: gen + xcodebuild build -scheme $(SCHEME) -destination $(DEST) + +test: gen + xcodebuild build test -scheme $(SCHEME) -destination $(DEST) + +universal: gen + xcodebuild build -scheme $(SCHEME) -destination $(DEST) \ + ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO + +fetch-argyll: + scripts/fetch-argyll.sh + +clean: + rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift new file mode 100644 index 0000000..7de992e --- /dev/null +++ b/Packages/ICCeryCore/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ICCeryCore", + platforms: [.macOS(.v14)], + products: [ + .library(name: "ICCeryCore", targets: ["ICCeryCore"]), + ], + targets: [ + .target(name: "ICCeryCore"), + ] +) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift new file mode 100644 index 0000000..24a280f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Well-known filesystem locations for the ICCery host process. +/// +/// macOS paths (docs/02 §Persistence): +/// - App data: `~/Library/Application Support//` +/// - Log file: `~/Library/Logs//iccery.log` +/// - Bundled Argyll tools: `/Contents/Resources/Argyll/` +public enum AppPaths { + + /// `com.gronod.iccery2` — read from the main bundle so tests can override. + public static var bundleIdentifier: String { + Bundle.main.bundleIdentifier ?? "com.gronod.iccery2" + } + + /// `~/Library/Application Support/com.gronod.iccery2` + public static var appDataDir: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent(bundleIdentifier, isDirectory: true) + } + + /// `~/Library/Logs/com.gronod.iccery2` + public static var logDir: URL { + FileManager.default + .urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent(bundleIdentifier, isDirectory: true) + } + + /// `~/Library/Logs/com.gronod.iccery2/iccery.log` + public static var logFile: URL { + logDir.appendingPathComponent("iccery.log", isDirectory: false) + } + + /// `/Contents/Resources/Argyll` — bundled sidecar root. + public static var bundledArgyllDir: URL { + Bundle.main.resourceURL? + .appendingPathComponent("Argyll", isDirectory: true) + ?? URL(fileURLWithPath: "/nonexistent") + } + + /// Creates the app data and log directories if missing. + @discardableResult + public static func ensureDirectories() throws -> (appData: URL, logs: URL) { + let fm = FileManager.default + try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true) + try fm.createDirectory(at: logDir, withIntermediateDirectories: true) + return (appDataDir, logDir) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift new file mode 100644 index 0000000..c34c8a0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift @@ -0,0 +1,45 @@ +import Foundation + +/// The five wizard stages plus Stage 0 (calibration), matching the v1 +/// `data-stage` contract in docs/21. Stepper buttons 1–5 map to +/// `.generate` … `.verifyInstall`; `.calibrate` lives outside the stepper. +public enum WizardStage: Int, CaseIterable, Sendable, Codable { + case calibrate = 0 + case generate = 1 + case layOutPrint = 2 + case measure = 3 + case buildProfile = 4 + case verifyInstall = 5 + + /// Sidebar stepper position (1–5); `nil` for the out-of-band calibrate stage. + public var stepperIndex: Int? { + self == .calibrate ? nil : rawValue + } + + public var title: String { + switch self { + case .calibrate: return "Printer Calibration" + case .generate: return "Generate Target" + case .layOutPrint: return "Lay Out & Print" + case .measure: return "Measure Chart" + case .buildProfile: return "Build Profile" + case .verifyInstall: return "Verify & Install" + } + } + + public var symbolName: String { + switch self { + case .calibrate: return "slider.horizontal.3" + case .generate: return "square.grid.3x3" + case .layOutPrint: return "printer" + case .measure: return "eyedropper.halffull" + case .buildProfile: return "paintpalette" + case .verifyInstall: return "checkmark.seal" + } + } + + /// Stages shown in the sidebar stepper, in order. + public static var stepperStages: [WizardStage] { + [.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall] + } +} diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..64563b3 --- /dev/null +++ b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.800", + "green" : "0.478", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json new file mode 100644 index 0000000..a1ec0b2 --- /dev/null +++ b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ICCery-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg new file mode 100644 index 0000000..2b7b7e2 --- /dev/null +++ b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCery + diff --git a/Resources/ICCery.entitlements b/Resources/ICCery.entitlements new file mode 100644 index 0000000..df6ecba --- /dev/null +++ b/Resources/ICCery.entitlements @@ -0,0 +1,8 @@ + + + + + + + diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift new file mode 100644 index 0000000..9dba46a --- /dev/null +++ b/Sources/ICCery/ICCeryApp.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI + +@main +struct ICCeryApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var model = WizardViewModel() + + var body: some Scene { + // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). + Window("ICCery", id: "main") { + RootView(model: model) + .frame(minWidth: 1100, minHeight: 700) + .preferredColorScheme(.dark) + } + .defaultSize(width: 1280, height: 800) + .windowResizability(.contentMinSize) + .defaultPosition(.center) + } +} + +/// AppDelegate: quit when the single window closes, and give later +/// milestones a hook to `killAll` Argyll children before teardown +/// (#147/#149 — wired once ProcessManager exists in #2). +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ notification: Notification) { + // Issue #2+: ProcessManager.shared.killAll() + } +} diff --git a/Sources/ICCery/NoticeBanner.swift b/Sources/ICCery/NoticeBanner.swift new file mode 100644 index 0000000..c0bfb67 --- /dev/null +++ b/Sources/ICCery/NoticeBanner.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Banner notice model — the v2 equivalent of `#wizardNotification` +/// (docs/21 §Banner). +struct Notice: Identifiable, Equatable { + enum Kind: Equatable { + case info, warning, error + + var symbolName: String { + switch self { + case .info: return "info.circle" + case .warning: return "exclamationmark.triangle" + case .error: return "xmark.octagon" + } + } + + var tint: Color { + switch self { + case .info: return Theme.accent + case .warning: return .orange + case .error: return .red + } + } + } + + let id = UUID() + let kind: Kind + let text: String + /// Auto-dismiss interval; `nil` keeps the banner until closed. + var autoHideAfter: TimeInterval? = 6 +} + +struct NoticeBanner: View { + let notice: Notice + let onClose: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: notice.kind.symbolName) + .foregroundStyle(notice.kind.tint) + Text(notice.text) + .font(.callout) + .foregroundStyle(Theme.text) + .lineLimit(3) + Spacer() + Button(action: onClose) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Theme.panel) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundStyle(Theme.border), + alignment: .bottom + ) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift new file mode 100644 index 0000000..6d5f619 --- /dev/null +++ b/Sources/ICCery/RootView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// Root layout: 270 pt sidebar + main stage area with the notification +/// banner pinned to the top (docs/21 §Shell). +struct RootView: View { + @Bindable var model: WizardViewModel + @State private var showingSettings = false + @State private var showingAbout = false + + var body: some View { + HStack(spacing: 0) { + SidebarView( + model: model, + onOpenSettings: { showingSettings = true }, + onOpenAbout: { showingAbout = true } + ) + + Rectangle() + .fill(Theme.border) + .frame(width: 1) + + VStack(spacing: 0) { + if let notice = model.notice { + NoticeBanner(notice: notice, onClose: model.dismissNotice) + } + StagePlaceholderView(stage: model.stage) + } + } + .frame(minWidth: 1100, minHeight: 700) + .background(Theme.background) + .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) + } + .alert("ICCery 2.0.0", isPresented: $showingAbout) { + Button("OK") {} + } message: { + Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") + } + } +} diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift new file mode 100644 index 0000000..a165035 --- /dev/null +++ b/Sources/ICCery/SidebarView.swift @@ -0,0 +1,105 @@ +import SwiftUI +import ICCeryCore + +/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset +/// select, Calibrate Printer + status chip, and the 1–5 stepper. +struct SidebarView: View { + @Bindable var model: WizardViewModel + var onOpenSettings: () -> Void + var onOpenAbout: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Image("ICCery-logo") + .resizable() + .scaledToFit() + .frame(height: 40) + Spacer() + Button(action: onOpenSettings) { + Image(systemName: "gearshape") + } + .buttonStyle(.plain) + .help("Settings") + Button(action: onOpenAbout) { + Image(systemName: "info.circle") + } + .buttonStyle(.plain) + .help("About ICCery") + } + .padding(12) + + Divider().overlay(Theme.border) + + // Preset select (`#presetSelect`). Preset engine lands in #11. + Picker("Preset", selection: .constant("none")) { + Text("No preset").tag("none") + } + .pickerStyle(.menu) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + // Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip` + // is hidden until the calibration library lands in #29. + Button(action: { model.enterCalibration() }) { + Label("Calibrate Printer", systemImage: "slider.horizontal.3") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .padding(.horizontal, 12) + + Divider().overlay(Theme.border) + .padding(.vertical, 8) + + // Stepper 1–5. + VStack(alignment: .leading, spacing: 2) { + ForEach(WizardStage.stepperStages, id: \.self) { stage in + StepperRow( + stage: stage, + isActive: model.stage == stage + ) { + model.go(to: stage) + } + } + } + .padding(.horizontal, 6) + + Spacer() + } + .frame(width: Theme.Metrics.sidebarWidth) + .background(Theme.panel) + } +} + +private struct StepperRow: View { + let stage: WizardStage + let isActive: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(isActive ? Theme.accent : Theme.border) + .frame(width: 26, height: 26) + Text("\(stage.stepperIndex ?? 0)") + .font(.callout.bold()) + .foregroundStyle(isActive ? .white : Theme.text) + } + Label(stage.title, systemImage: stage.symbolName) + .font(.callout) + .foregroundStyle(isActive ? Theme.text : .secondary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .fill(isActive ? Theme.accent.opacity(0.15) : .clear) + ) + } +} diff --git a/Sources/ICCery/StagePlaceholderView.swift b/Sources/ICCery/StagePlaceholderView.swift new file mode 100644 index 0000000..ae92b96 --- /dev/null +++ b/Sources/ICCery/StagePlaceholderView.swift @@ -0,0 +1,24 @@ +import SwiftUI +import ICCeryCore + +/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5 +/// (issues #7–#31); Stage 0 lands in M6 (issue #29). +struct StagePlaceholderView: View { + let stage: WizardStage + + var body: some View { + VStack(spacing: 16) { + Image(systemName: stage.symbolName) + .font(.system(size: 44)) + .foregroundStyle(Theme.accent) + Text(stage.title) + .font(.title2) + .foregroundStyle(Theme.text) + Text("This stage is not implemented yet — see the milestone plan.") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.background) + } +} diff --git a/Sources/ICCery/Theme.swift b/Sources/ICCery/Theme.swift new file mode 100644 index 0000000..c65ca0c --- /dev/null +++ b/Sources/ICCery/Theme.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens). +enum Theme { + static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255) + static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255) + static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255) + static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255) + static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255) + /// v1 window/titlebar backing colour (docs/02 §Window contract). + static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255) + + enum Metrics { + static let sidebarWidth: CGFloat = 270 + static let buttonSmall: CGFloat = 28 + static let buttonMedium: CGFloat = 36 + static let buttonLarge: CGFloat = 40 + static let cornerSmall: CGFloat = 4 + static let cornerMedium: CGFloat = 6 + } +} diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift new file mode 100644 index 0000000..4c405bf --- /dev/null +++ b/Sources/ICCery/WizardViewModel.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation +import ICCeryCore + +/// Wizard shell state (issue #1). Artefact gating, persistence and the +/// "open existing" flow land in issue #4. +@MainActor +@Observable +final class WizardViewModel { + /// Currently displayed stage. + var stage: WizardStage = .generate + + /// Banner notice currently displayed (`#wizardNotification`). + var notice: Notice? + + /// 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 var noticeDismissTask: Task? + + /// `true` while Stage 0 (printer calibration) is shown instead of a + /// stepper stage. + var isCalibrating: Bool { stage == .calibrate } + + func go(to stage: WizardStage) { + self.stage = stage + } + + func enterCalibration() { + stage = .calibrate + } + + func exitCalibration() { + stage = .generate + } + + func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) { + noticeDismissTask?.cancel() + let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter) + self.notice = notice + if let delay = notice.autoHideAfter { + noticeDismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + if self?.notice?.id == notice.id { + self?.notice = nil + } + } + } + } + + func dismissNotice() { + noticeDismissTask?.cancel() + notice = nil + } +} diff --git a/Tests/ICCeryCoreTests/AppPathsTests.swift b/Tests/ICCeryCoreTests/AppPathsTests.swift new file mode 100644 index 0000000..2316056 --- /dev/null +++ b/Tests/ICCeryCoreTests/AppPathsTests.swift @@ -0,0 +1,27 @@ +import Testing +import Foundation +@testable import ICCeryCore + +@Suite("AppPaths") +struct AppPathsTests { + @Test func appDataDirUsesBundleID() { + #expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2")) + } + + @Test func logFileIsUnderLibraryLogs() { + #expect(AppPaths.logFile.lastPathComponent == "iccery.log") + #expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) + } + + @Test func bundledArgyllDirIsInsideResources() { + #expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll") + } +} + +@Suite("WizardStage") +struct WizardStageTests { + @Test func stepperOrderIsOneThroughFive() { + #expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5]) + #expect(WizardStage.calibrate.stepperIndex == nil) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..4618da8 --- /dev/null +++ b/project.yml @@ -0,0 +1,80 @@ +name: ICCery +options: + bundleIdPrefix: com.gronod + deploymentTarget: + macOS: "14.0" + groupSortPosition: top + +packages: + ICCeryCore: + path: Packages/ICCeryCore + +targets: + ICCery: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ICCery + - path: Resources + excludes: + - ICCery.entitlements + dependencies: + - package: ICCeryCore + product: ICCeryCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2 + PRODUCT_NAME: ICCery + PRODUCT_BUNDLE_PACKAGE_TYPE: APPL + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: ICCery + INFOPLIST_KEY_LSMinimumSystemVersion: "14.0" + INFOPLIST_KEY_NSPrincipalClass: NSApplication + INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." + MARKETING_VERSION: "2.0.0" + CURRENT_PROJECT_VERSION: "1" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_STYLE: Automatic + ENABLE_APP_SANDBOX: NO + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + MACOSX_DEPLOYMENT_TARGET: "14.0" + ARCHS: "$(ARCHS_STANDARD)" + + ICCeryCoreTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Tests/ICCeryCoreTests + dependencies: + - package: ICCeryCore + product: ICCeryCore + - target: ICCery + settings: + base: + BUNDLE_LOADER: "$(TEST_HOST)" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_IDENTITY: "-" + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + +schemes: + ICCery: + build: + targets: + ICCery: all + ICCeryCoreTests: [test] + run: + config: Debug + test: + config: Debug + gatherCoverageData: false + targets: + - ICCeryCoreTests -- 2.39.5 From 5bb057a1e3d90fd9aebc59bed5f704fd313e0878 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 18:51:55 +0100 Subject: [PATCH 2/8] ProcessManager: spawn / stdin / kill / captured / event bus (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actor ProcessManager: runStreaming + runCaptured (concurrent pipe drain — no 64 KiB deadlock), sendStdin with independent stdin map (#84), kill/killAll, duplicate-id rejection (#116) - Multicast AsyncStream bus: stdout/stderr/exit/error/ jsonRow (ROW_COLORS_JSON: prefix stripped) - exit emitted exactly once, gated on both pipes reaching EOF so buffered output is never lost on fast exits or kills - ARGYLL_NOT_INTERACTIVE=1 on every child; argv logged with ~ home sanitization; subprocess stdout→info, stderr→warn - ProcessLineDecoder (byte-split at \n, UTF-8 safe, CRLF, unterminated tail flush), JSONAccumulator (multiline JSON for instlist/profcheck/ manifest), ProcessID conventions - 19 new tests incl. large-output captured run and stdin round-trip - Fixup: entitlements gain com.apple.security.device.usb; sidebar preset/Calibrate disabled and only Stage 1 enabled per #1 AC Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Logging/AppLogger.swift | 55 +++ .../ICCeryCore/Logging/LogSanitizer.swift | 17 + .../ICCeryCore/Process/JSONAccumulator.swift | 59 +++ .../ICCeryCore/Process/ProcessEvent.swift | 36 ++ .../ICCeryCore/Process/ProcessID.swift | 17 + .../Process/ProcessLineDecoder.swift | 41 ++ .../ICCeryCore/Process/ProcessManager.swift | 354 ++++++++++++++++++ Resources/ICCery.entitlements | 2 + Sources/ICCery/SidebarView.swift | 16 +- .../ICCeryCoreTests/ProcessManagerTests.swift | 262 +++++++++++++ 10 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift create mode 100644 Tests/ICCeryCoreTests/ProcessManagerTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift new file mode 100644 index 0000000..0012e14 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift @@ -0,0 +1,55 @@ +import Foundation +import OSLog + +/// Severity levels, matching the v1 `log_level` setting values. +public enum LogLevel: String, Codable, Sendable, CaseIterable { + case error, warn, info, debug, trace + + var osType: OSLogType { + switch self { + case .error: return .error + case .warn: return .default + case .info: return .info + case .debug: return .debug + case .trace: return .debug + } + } + + var rank: Int { + switch self { + case .error: return 0 + case .warn: return 1 + case .info: return 2 + case .debug: return 3 + case .trace: return 4 + } + } +} + +/// Central logger. For M1 PR2 this writes to `os.Logger` only; +/// issue #5 adds the rolling file sink and runtime `setLevel`. +public struct AppLogger: Sendable { + public static let shared = AppLogger(category: "app") + + private let osLog: Logger + public let category: String + + public init(category: String) { + self.category = category + self.osLog = Logger( + subsystem: AppPaths.bundleIdentifier, + category: category + ) + } + + public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { + let text = LogSanitizer.sanitize(message()) + osLog.log(level: level.osType, "\(text, privacy: .public)") + } + + public func error(_ message: @autoclosure () -> String) { log(.error, message()) } + public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) } + public func info(_ message: @autoclosure () -> String) { log(.info, message()) } + public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) } + public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift new file mode 100644 index 0000000..a5d54e0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Rewrites the user's home directory to `~` in log output +/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`). +public enum LogSanitizer { + /// Replaces every occurrence of the current user's home path with `~`. + public static func sanitize(_ text: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty else { return text } + return text.replacingOccurrences(of: home, with: "~") + } + + /// Sanitizes an argv list for display. + public static func sanitizeArgs(_ args: [String]) -> String { + args.map(sanitize).joined(separator: " ") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift new file mode 100644 index 0000000..40e2c23 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Accumulates stdout lines into a complete JSON document. +/// +/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest) +/// emit pretty-printed multi-line JSON on stdout. Individual lines are +/// *not* valid JSON — only the whole block is — so callers route stdout +/// lines here and get `Data` back once the buffer parses. +/// +/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager +/// diverts them to `jsonRow` events first. +public struct JSONAccumulator: Sendable { + private var buffer = Data() + + public init() {} + + /// Appends one stdout line. Returns the complete document bytes when + /// the accumulated buffer forms valid JSON, otherwise `nil`. + public mutating func feed(line: String) -> Data? { + buffer.append(Data(line.utf8)) + buffer.append(0x0A) + return tryParse() + } + + /// Attempts to decode the accumulated buffer; clears it on success. + public mutating func decode(_ type: T.Type) -> T? { + guard let data = tryParse() else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + /// Raw buffer when it parses, `nil` while still incomplete. + public var completeData: Data? { + var copy = self + return copy.tryParse() + } + + public mutating func reset() { + buffer.removeAll(keepingCapacity: false) + } + + public var isEmpty: Bool { buffer.isEmpty } + + private mutating func tryParse() -> Data? { + // Cheap gate: JSON documents start with { or [. + guard let first = buffer.first(where: { !$0.isJSONWhitespace }), + first == UInt8(ascii: "{") || first == UInt8(ascii: "[") + else { return nil } + guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil } + let out = buffer + buffer.removeAll(keepingCapacity: false) + return out + } +} + +private extension UInt8 { + var isJSONWhitespace: Bool { + self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift new file mode 100644 index 0000000..d04acf3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Events on the process bus — the v2 equivalent of the v1 Tauri events +/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus). +public enum ProcessEvent: Sendable, Equatable { + /// Non-JSON stdout line. (`process:stdout`) + case stdout(id: String, line: String) + /// stderr line. (`process:stderr`) + case stderr(id: String, line: String) + /// Child exited; 0 = success. (`process:exit`) + case exit(id: String, code: Int32) + /// Spawn failure. (`process:error`) + case error(id: String, message: String) + /// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped, + /// payload is the remaining raw bytes. (`process:json_row`) + case jsonRow(id: String, payload: Data) + + public var id: String { + switch self { + case .stdout(let id, _), .stderr(let id, _), .exit(let id, _), + .error(let id, _), .jsonRow(let id, _): + return id + } + } +} + +public enum ProcessError: Error, Equatable, Sendable { + /// A child with this id is still running (#116). + case duplicateID(String) + /// No child registered under this id. + case unknownID(String) + /// Process refused to launch. + case spawnFailed(String) + /// stdin write failed (pipe closed / process gone). + case stdinFailed(String) +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift new file mode 100644 index 0000000..8377a51 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Deterministic process ids (docs/02 §Event bus). Listeners must always +/// filter events on `id` — historical bug #56 was an id mismatch. +public enum ProcessID { + public static let instlist = "instlist" + + public static func targen(_ basename: String) -> String { "targen_\(basename)" } + public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } + public static func chartread(_ basename: String) -> String { "chartread_\(basename)" } + public static func average(_ basename: String) -> String { "average_\(basename)" } + public static func colprof(_ basename: String) -> String { "colprof_\(basename)" } + public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" } + public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" } + public static func printcal(_ stem: String) -> String { "printcal_\(stem)" } + public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift new file mode 100644 index 0000000..ab79ff6 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Incremental byte→line decoder for process pipes. +/// +/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never +/// appear inside a multi-byte UTF-8 sequence (continuation bytes are +/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line +/// is then decoded with a lossy fallback for non-UTF-8 output. +public struct ProcessLineDecoder: Sendable { + public private(set) var pending = Data() + + public init() {} + + /// Feeds a chunk; returns every complete line found (without `\n`). + public mutating func feed(_ chunk: Data) -> [String] { + guard !chunk.isEmpty else { return [] } + pending.append(chunk) + var lines: [String] = [] + while let nl = pending.firstIndex(of: 0x0A) { + var slice = pending.prefix(upTo: nl) + pending = pending.suffix(from: pending.index(after: nl)) + // Tolerate CRLF output. + if slice.last == 0x0D { slice = slice.dropLast() } + lines.append(Self.decode(slice)) + } + return lines + } + + /// Flushes any unterminated remainder at EOF. Returns `nil` when empty. + public mutating func finish() -> String? { + guard !pending.isEmpty else { return nil } + var rest = pending + pending.removeAll(keepingCapacity: false) + if rest.last == 0x0D { rest = rest.dropLast() } + return rest.isEmpty ? nil : Self.decode(rest) + } + + private static func decode(_ bytes: Data.SubSequence) -> String { + String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift new file mode 100644 index 0000000..1e130af --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -0,0 +1,354 @@ +import Foundation + +/// Captured output from `runCaptured` (used by printcal/applycal — +/// the only tools whose results arrive as one-shot output). +public struct CapturedResult: Sendable, Equatable { + public let stdout: String + public let stderr: String + public let exitCode: Int32 +} + +/// Spawn / stdin / kill / event bus for Argyll sidecar children +/// (docs/02 §Event bus, docs/03 §Process manager). +/// +/// Invariants: +/// - Duplicate `id` while a child runs is rejected (#116). +/// - The stdin handle lives in its own map, independent of wait, so +/// `sendStdin` never blocks on process exit (#84). +/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child. +/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events +/// with the prefix stripped; all other stdout is `stdout` events. +/// - `exit` is emitted exactly once per child, and only after both +/// output pipes reach EOF — so no buffered output is lost on fast +/// exits or kills. +/// - `kill` drops the stdin handle so writers fail fast. +public actor ProcessManager { + + public static let rowColorsPrefix = "ROW_COLORS_JSON: " + + public static let shared = ProcessManager() + + // MARK: - Event bus (multicast) + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + + /// Subscribe to the event bus. Each call returns an independent + /// stream; every event is delivered to every live subscriber. + public nonisolated func events() -> AsyncStream { + AsyncStream { continuation in + let token = UUID() + Task { await self.addSubscriber(continuation, token: token) } + continuation.onTermination = { _ in + Task { await self.removeSubscriber(token) } + } + } + } + + private func addSubscriber( + _ continuation: AsyncStream.Continuation, + token: UUID + ) { + subscribers[token] = continuation + } + + private func removeSubscriber(_ token: UUID) { + subscribers.removeValue(forKey: token) + } + + private func emit(_ event: ProcessEvent) { + for continuation in subscribers.values { + continuation.yield(event) + } + } + + // MARK: - Child registry + + private struct RunningChild { + let process: Process + /// stdin lives in its own slot, independent of process wait (#84). + var stdin: FileHandle? + var stdoutDecoder: ProcessLineDecoder + var stderrDecoder: ProcessLineDecoder + var stdoutEOF = false + var stderrEOF = false + /// Set by the termination handler; `exit` is emitted once both + /// pipes have also reached EOF. + var pendingExitCode: Int32? + var finalized = false + } + + private var children: [String: RunningChild] = [:] + /// Processes owned by `runCaptured` (dup detection + kill support). + private var captured: [String: Process] = [:] + + /// Ids of currently-running children. + public var runningIDs: [String] { Array(children.keys) + captured.keys } + + public func isRunning(_ id: String) -> Bool { + children[id] != nil || captured[id] != nil + } + + // MARK: - Spawn (streaming) + + /// Spawns a streaming child. Returns after spawn; callers wait for + /// `exit(id:)` events — never assume the return means the tool + /// finished (docs/03). + public func runStreaming( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) throws { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = childEnvironment(extra: environment) + + AppLogger(category: "process").debug( + "spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" + ) + + children[id] = RunningChild( + process: process, + stdin: stdinPipe.fileHandleForWriting, + stdoutDecoder: ProcessLineDecoder(), + stderrDecoder: ProcessLineDecoder() + ) + + do { + try process.run() + } catch { + children.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + stdoutHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) } + } + stderrHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) } + } + + process.terminationHandler = { [weak self] proc in + guard let self else { return } + Task { await self.didTerminate(id: id, code: proc.terminationStatus) } + } + } + + // MARK: - Spawn (captured) + + /// Runs a child to completion and returns all output. Reads stdout + /// and stderr concurrently so a full pipe buffer can never deadlock + /// the child. Used by `printcal` / `applycal` (docs/03). + public func runCaptured( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) async throws -> CapturedResult { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = childEnvironment(extra: environment) + + AppLogger(category: "process").debug( + "spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" + ) + + // Register before run() so a concurrent duplicate spawn fails. + captured[id] = process + + do { + try process.run() + } catch { + captured.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + + async let outData = Task.detached { + stdoutPipe.fileHandleForReading.readDataToEndOfFile() + }.value + async let errData = Task.detached { + stderrPipe.fileHandleForReading.readDataToEndOfFile() + }.value + + let code = await withCheckedContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: proc.terminationStatus) + } + } + + let (out, err) = await (outData, errData) + // If kill() already reaped this child, its exit event went out. + if captured.removeValue(forKey: id) != nil { + emit(.exit(id: id, code: code)) + } + + return CapturedResult( + stdout: String(decoding: out, as: UTF8.self), + stderr: String(decoding: err, as: UTF8.self), + exitCode: code + ) + } + + // MARK: - stdin + + /// Writes the exact bytes (caller includes `\n`) to a child's stdin + /// and flushes (docs/03 §stdin protocol). + public func sendStdin(id: String, bytes: Data) throws { + guard let child = children[id] else { throw ProcessError.unknownID(id) } + guard let handle = child.stdin else { + throw ProcessError.stdinFailed("stdin closed for \(id)") + } + do { + try handle.write(contentsOf: bytes) + } catch { + throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)") + } + } + + public func sendStdin(id: String, text: String) throws { + try sendStdin(id: id, bytes: Data(text.utf8)) + } + + // MARK: - Kill + + /// Terminates a child. The `exit` event still fires exactly once. + /// stdin is dropped immediately so writers fail fast (docs/03 rule 7). + public func kill(id: String) { + if var child = children[id] { + try? child.stdin?.close() + child.stdin = nil + children[id] = child + if child.process.isRunning { + child.process.terminate() + } else { + Task { await self.didTerminate(id: id, code: child.process.terminationStatus) } + } + return + } + if let process = captured[id] { + if process.isRunning { process.terminate() } + if captured.removeValue(forKey: id) != nil { + emit(.exit(id: id, code: process.terminationStatus)) + } + } + } + + /// Terminates every running child; returns how many were signaled + /// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149). + @discardableResult + public func killAll() -> Int { + let ids = Array(children.keys) + Array(captured.keys) + for id in ids { kill(id: id) } + return ids.count + } + + // MARK: - Internals + + private func childEnvironment(extra: [String: String]) -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["ARGYLL_NOT_INTERACTIVE"] = "1" + for (key, value) in extra { env[key] = value } + return env + } + + private func ingestOutput( + _ data: Data, + id: String, + isStderr: Bool, + handle: FileHandle + ) { + guard var child = children[id] else { return } + + if data.isEmpty { + // EOF on this pipe. + handle.readabilityHandler = nil + if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true } + children[id] = child + maybeFinalize(id: id) + return + } + + let lines: [String] = isStderr + ? child.stderrDecoder.feed(data) + : child.stdoutDecoder.feed(data) + children[id] = child + + let log = AppLogger(category: "subprocess") + for line in lines { + if !isStderr, line.hasPrefix(Self.rowColorsPrefix) { + let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8) + emit(.jsonRow(id: id, payload: payload)) + } else if isStderr { + log.warn("[\(id)] \(line)") + emit(.stderr(id: id, line: line)) + } else { + log.info("[\(id)] \(line)") + emit(.stdout(id: id, line: line)) + } + } + } + + private func didTerminate(id: String, code: Int32) { + guard var child = children[id], !child.finalized else { return } + child.pendingExitCode = code + try? child.stdin?.close() + child.stdin = nil + children[id] = child + maybeFinalize(id: id) + } + + /// Emits `exit` once the child has terminated *and* both pipes have + /// drained to EOF, so no buffered output is lost. + private func maybeFinalize(id: String) { + guard var child = children[id], + let code = child.pendingExitCode, + child.stdoutEOF, child.stderrEOF, + !child.finalized + else { return } + child.finalized = true + children.removeValue(forKey: id) + + // Flush unterminated tail lines. + if var decoder = Optional(child.stdoutDecoder), + let tail = decoder.finish() { + if tail.hasPrefix(Self.rowColorsPrefix) { + emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8))) + } else { + emit(.stdout(id: id, line: tail)) + } + } + if var decoder = Optional(child.stderrDecoder), + let tail = decoder.finish() { + emit(.stderr(id: id, line: tail)) + } + emit(.exit(id: id, code: code)) + } +} diff --git a/Resources/ICCery.entitlements b/Resources/ICCery.entitlements index df6ecba..d8671ee 100644 --- a/Resources/ICCery.entitlements +++ b/Resources/ICCery.entitlements @@ -4,5 +4,7 @@ + com.apple.security.device.usb + diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index a165035..0cefc64 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -31,21 +31,24 @@ struct SidebarView: View { Divider().overlay(Theme.border) - // Preset select (`#presetSelect`). Preset engine lands in #11. + // Preset select (`#presetSelect`). Disabled until the preset + // engine lands in issue #11. Picker("Preset", selection: .constant("none")) { Text("No preset").tag("none") } .pickerStyle(.menu) + .disabled(true) .padding(.horizontal, 12) .padding(.vertical, 8) - // Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip` - // is hidden until the calibration library lands in #29. + // Calibrate Printer (`#btnCalibratePrinter`). Disabled until + // Stage 0 lands in issue #29; `#calStatusChip` likewise. Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") .frame(maxWidth: .infinity) } .controlSize(.large) + .disabled(true) .padding(.horizontal, 12) Divider().overlay(Theme.border) @@ -56,7 +59,9 @@ struct SidebarView: View { ForEach(WizardStage.stepperStages, id: \.self) { stage in StepperRow( stage: stage, - isActive: model.stage == stage + isActive: model.stage == stage, + // Only Stage 1 until artefact gating lands in #4. + isEnabled: stage == .generate ) { model.go(to: stage) } @@ -74,6 +79,7 @@ struct SidebarView: View { private struct StepperRow: View { let stage: WizardStage let isActive: Bool + let isEnabled: Bool let action: () -> Void var body: some View { @@ -97,6 +103,8 @@ private struct StepperRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) .background( RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) .fill(isActive ? Theme.accent.opacity(0.15) : .clear) diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift new file mode 100644 index 0000000..3473d74 --- /dev/null +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -0,0 +1,262 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Helpers shared across ProcessManager tests. Fixture binaries are shell +/// scripts written to a temp dir — no resource bundling required. +@Suite("ProcessManager", .serialized) +struct ProcessManagerTests { + + // MARK: - Fixture plumbing + + private static let fixtureDir: URL = { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)") + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + /// Writes a shell script fixture and returns its executable URL. + private func script(_ name: String, _ body: String) throws -> URL { + let url = Self.fixtureDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path + ) + return url + } + + /// Collects events for `id` until `.exit`, `timeout` seconds max. + private func collect( + _ manager: ProcessManager, + id: String, + timeout: TimeInterval = 10 + ) async -> [ProcessEvent] { + await withCheckedContinuation { cont in + let box = Box() + Task { + for await event in manager.events() { + guard event.id == id else { continue } + box.append(event) + if case .exit = event { break } + } + if box.finish() { cont.resume(returning: box.events) } + } + Task { + try? await Task.sleep(for: .seconds(timeout)) + if box.finish() { cont.resume(returning: box.events) } + } + } + } + + private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var _events: [ProcessEvent] = [] + private var finished = false + var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events } + func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() } + func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true } + } + + // MARK: - Tests + + @Test func streamsStdoutAndEmitsExit() async throws { + let pm = ProcessManager() + let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") + async let events = collect(pm, id: "t1") + try await pm.runStreaming(id: "t1", binary: bin, arguments: []) + let evs = await events + let lines = evs.compactMap { e -> String? in + if case .stdout(_, let l) = e { return l }; return nil + } + #expect(lines == ["hello", "world"]) + #expect(evs.contains(.exit(id: "t1", code: 0))) + } + + @Test func routesStderrSeparately() async throws { + let pm = ProcessManager() + let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") + async let evs = collect(pm, id: "t2") + try await pm.runStreaming(id: "t2", binary: bin, arguments: []) + let events = await evs + #expect(events.contains(.stdout(id: "t2", line: "out"))) + #expect(events.contains(.stderr(id: "t2", line: "oops"))) + } + + @Test func stripsRowColorsJSONPrefix() async throws { + let pm = ProcessManager() + let bin = try script( + "rows.sh", + "#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n" + ) + async let evs = collect(pm, id: "t3") + try await pm.runStreaming(id: "t3", binary: bin, arguments: []) + let events = await evs + let rows = events.compactMap { e -> String? in + if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } + return nil + } + #expect(rows == ["{\"row\":1}"]) + #expect(events.contains(.stdout(id: "t3", line: "plain"))) + // Prefixed lines must not leak into stdout. + #expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) + } + + @Test func unterminatedTailFlushesOnExit() async throws { + let pm = ProcessManager() + let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") + async let evs = collect(pm, id: "t4") + try await pm.runStreaming(id: "t4", binary: bin, arguments: []) + #expect(await evs.contains(.stdout(id: "t4", line: "no-newline"))) + } + + @Test func stdinRoundTrip() async throws { + let pm = ProcessManager() + // Read two lines then exit naturally — a killed sh would lose its + // buffered stdio output, which is exactly the chartread pattern. + let bin = try script( + "echo.sh", + "#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n" + ) + async let evs = collect(pm, id: "t5") + try await pm.runStreaming(id: "t5", binary: bin, arguments: []) + try await pm.sendStdin(id: "t5", text: " \n") + try await pm.sendStdin(id: "t5", text: "d\n") + let events = await evs + #expect(events.contains(.stdout(id: "t5", line: "got: "))) + #expect(events.contains(.stdout(id: "t5", line: "got:d"))) + } + + @Test func duplicateIDRejected() async throws { + let pm = ProcessManager() + let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + await #expect(throws: ProcessError.duplicateID("t6")) { + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + } + await pm.kill(id: "t6") + } + + @Test func killEmitsExitAndClosesStdin() async throws { + let pm = ProcessManager() + let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") + async let evs = collect(pm, id: "t7") + try await pm.runStreaming(id: "t7", binary: bin, arguments: []) + await pm.kill(id: "t7") + let events = await evs + // exit emitted exactly once + let exits = events.filter { if case .exit = $0 { return true }; return false } + #expect(exits.count == 1) + await #expect(throws: ProcessError.unknownID("t7")) { + try await pm.sendStdin(id: "t7", text: "d\n") + } + } + + @Test func killAllCountsSignaled() async throws { + let pm = ProcessManager() + let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "a", binary: bin, arguments: []) + try await pm.runStreaming(id: "b", binary: bin, arguments: []) + let count = await pm.killAll() + #expect(count == 2) + } + + @Test func capturedRunReturnsBothStreams() async throws { + let pm = ProcessManager() + let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") + let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) + #expect(result.stdout.contains("out-data")) + #expect(result.stderr.contains("err-data")) + #expect(result.exitCode == 3) + } + + @Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { + let pm = ProcessManager() + // 5000 lines each stream exceeds the 64 KiB pipe buffer. + let bin = try script( + "big.sh", + "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" + ) + let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) + #expect(result.stdout.contains("out-4999")) + #expect(result.stderr.contains("err-4999")) + } + + @Test func argyllEnvVarIsSet() async throws { + let pm = ProcessManager() + let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") + async let evs = collect(pm, id: "t10") + try await pm.runStreaming(id: "t10", binary: bin, arguments: []) + #expect(await evs.contains(.stdout(id: "t10", line: "ANI=1"))) + } + + @Test func unknownIDStdinThrows() async throws { + let pm = ProcessManager() + await #expect(throws: ProcessError.unknownID("nope")) { + try await pm.sendStdin(id: "nope", text: "d\n") + } + } +} + +@Suite("ProcessLineDecoder") +struct ProcessLineDecoderTests { + @Test func splitsAcrossChunkBoundaries() { + var d = ProcessLineDecoder() + #expect(d.feed(Data("he".utf8)) == []) + #expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) + #expect(d.feed(Data("ld\n".utf8)) == ["world"]) + #expect(d.finish() == nil) + } + + @Test func crlfIsStripped() { + var d = ProcessLineDecoder() + #expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"]) + } + + @Test func finishReturnsRemainder() { + var d = ProcessLineDecoder() + _ = d.feed(Data("x".utf8)) + #expect(d.finish() == "x") + #expect(d.finish() == nil) + } +} + +@Suite("JSONAccumulator") +struct JSONAccumulatorTests { + @Test func multilinePrettyJSON() { + var acc = JSONAccumulator() + #expect(acc.feed(line: "{") == nil) + #expect(acc.feed(line: " \"k\": 1") == nil) + let done = acc.feed(line: "}") + #expect(done != nil) + let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] + #expect(obj?["k"] == 1) + } + + @Test func nonJSONLinesIgnored() { + var acc = JSONAccumulator() + #expect(acc.feed(line: "Reading instrument...") == nil) + #expect(acc.feed(line: "still text") == nil) + #expect(acc.completeData == nil) + } + + @Test func decodeTyped() { + struct Doc: Decodable { let n: Int } + var acc = JSONAccumulator() + // Split so the doc completes on the second feed. + #expect(acc.feed(line: "{\"n\":") == nil) + let data = acc.feed(line: "7}") + #expect(data != nil) + let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } + #expect(doc?.n == 7) + #expect(acc.isEmpty) + } +} + +@Suite("LogSanitizer") +struct LogSanitizerTests { + @Test func homeIsRewritten() { + let path = "\(NSHomeDirectory())/Documents/foo.ti1" + #expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") + } +} -- 2.39.5 From 716b302374b49e7ff2b5715c0d102f32ae145531 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:00:39 +0100 Subject: [PATCH 3/8] Argyll sidecar fetch & binary resolution (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/fetch-argyll.sh: POSIX sh port of fetch-argyll.mjs; queries the Gitea release API for the *_macOS_universal_bin.tgz asset, extracts Argyll_V*/bin, chmod+x, strips quarantine xattr; honors ARGYLL_SERVER_URL / ARGYLL_REPO / ARGYLL_RELEASE_TAG / GITEA_TOKEN. Verified end-to-end: 51 universal tools from v3.5.0-ICCery1.8. - BinaryResolver: settings argyll_binary_dir override (existence-gated) → bundled Argyll//, macos-universal preferred when instlist marker present, else macos-arm64/macos-x86_64; constructed path returned even when absent (spawn surfaces process:error). Mock and reference-gamut helpers. - Vendored tracked resources: mocks/{chartread,colprof,profcheck}.mock + reference_gamuts/sRGB.gam from ICCery v1, copied as a folder reference so the Argyll/ subtree structure survives into the bundle. - Build phase rsyncs Vendor/Argyll/ → Contents/Resources/Argyll/. - AppDelegate: killAll via terminateLater so children are signaled before teardown (#147/#149). - 6 resolver tests + fetch script smoke-verified against real release. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Argyll/BinaryResolver.swift | 100 +++++++++++++++ Resources/Argyll/mocks/chartread.mock | 75 +++++++++++ Resources/Argyll/mocks/colprof.mock | 20 +++ Resources/Argyll/mocks/profcheck.mock | 12 ++ Resources/Argyll/reference_gamuts/sRGB.gam | 16 +++ Sources/ICCery/ICCeryApp.swift | 20 ++- .../ICCeryCoreTests/BinaryResolverTests.swift | 83 ++++++++++++ project.yml | 16 +++ scripts/fetch-argyll.sh | 118 ++++++++++++++++++ 9 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift create mode 100755 Resources/Argyll/mocks/chartread.mock create mode 100755 Resources/Argyll/mocks/colprof.mock create mode 100755 Resources/Argyll/mocks/profcheck.mock create mode 100644 Resources/Argyll/reference_gamuts/sRGB.gam create mode 100644 Tests/ICCeryCoreTests/BinaryResolverTests.swift create mode 100755 scripts/fetch-argyll.sh diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift new file mode 100644 index 0000000..d7c07f6 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`). +/// +/// Order: +/// 1. Settings `argyll_binary_dir` override — only if `/` +/// exists there. +/// 2. Bundled `/Resources/Argyll//`. +/// On macOS, `macos-universal` wins whenever it contains the `instlist` +/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch. +/// 3. If nothing exists the *constructed* bundled path is still returned +/// — a missing binary surfaces later as `process:error` on spawn, +/// matching v1 semantics. +public struct BinaryResolver: Sendable { + + /// Root that contains the platform dirs — `Bundle.resource/Argyll` in + /// the app, a fixture dir in tests. + public let bundledRoot: URL + /// `settings.argyll_binary_dir`, already expanded to a URL. + public let overrideDir: URL? + /// Host architecture directory names, universal preferred. + public let archDirs: [String] + + public init( + bundledRoot: URL = AppPaths.bundledArgyllDir, + overrideDir: URL? = nil, + archDirs: [String]? = nil + ) { + self.bundledRoot = bundledRoot + self.overrideDir = overrideDir + #if arch(arm64) + let fallback = ["macos-arm64", "macos-aarch64"] + #else + let fallback = ["macos-x86_64"] + #endif + self.archDirs = archDirs ?? ["macos-universal"] + fallback + } + + /// Marker used to decide whether `macos-universal` is usable. + public static let markerBinary = "instlist" + + /// Resolves a tool name to an absolute URL (never throws — see type + /// docs). `name` is the bare tool name, e.g. `"targen"`. + public func resolve(_ name: String) -> URL { + let fm = FileManager.default + + if let dir = overrideDir { + let candidate = dir.appendingPathComponent(name) + if fm.fileExists(atPath: candidate.path) { + return candidate + } + } + + return bundledRoot + .appendingPathComponent(platformDir(), isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + } + + /// The bundled platform directory that resolution will use. + public func platformDir() -> String { + let fm = FileManager.default + let universal = bundledRoot.appendingPathComponent("macos-universal") + if fm.fileExists( + atPath: universal.appendingPathComponent(Self.markerBinary).path + ) { + return "macos-universal" + } + for dir in archDirs where dir != "macos-universal" { + if fm.fileExists( + atPath: bundledRoot + .appendingPathComponent(dir) + .appendingPathComponent(Self.markerBinary).path + ) { + return dir + } + } + // Nothing present — still return the preferred dir so the error + // message points at where the user should drop binaries. + return archDirs.first ?? "macos-universal" + } + + /// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`). + public func mock(_ name: String) -> URL { + bundledRoot + .appendingPathComponent("mocks", isDirectory: true) + .appendingPathComponent("\(name).mock", isDirectory: false) + } + + /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). + public func referenceGamut(_ name: String) -> URL { + bundledRoot + .appendingPathComponent("reference_gamuts", isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + } + + /// Whether the resolved path exists and is executable. + public func exists(_ url: URL) -> Bool { + FileManager.default.isExecutableFile(atPath: url.path) + } +} diff --git a/Resources/Argyll/mocks/chartread.mock b/Resources/Argyll/mocks/chartread.mock new file mode 100755 index 0000000..35d2b08 --- /dev/null +++ b/Resources/Argyll/mocks/chartread.mock @@ -0,0 +1,75 @@ +#!/bin/bash +# Mock script for chartread -u +# This script simulates the behaviour of chartread for testing purposes. + +# Check for --xy argument or MOCK_XY_TABLE environment variable +IS_XY=0 +for arg in "$@"; do + if [ "$arg" = "--xy" ]; then + IS_XY=1 + break + fi +done + +if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then + echo "Place instrument on calibration tile and hit [Space] to calibrate." + read -r _calib + echo "Calibration successful." + + echo "Please place sheet 1 of 1 on the table" + echo "hit return to continue, Esc or 'q' to give up" + read -r _sheet1 + + echo "locate patch A1 with the sight," + echo "then hit return to continue" + read -r _fid1 + + echo "locate patch B24 with the sight," + echo "then hit return to continue" + read -r _fid2 + + echo "Reading sheet 1..." + sleep 0.5 + + # Emit mock JSON for strip A + cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]} +EOF + + # Emit mock JSON for strip B + cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]} +EOF + + echo "Sheet 1 of 1 read OK" + echo "Please remove last sheet from table" + exit 0 +fi + +# Handheld / strip reader simulation +echo "Place instrument on calibration tile and hit [Space] to calibrate." + +# We don't really wait for input, just wait 1 second +sleep 1 +echo "Calibration successful." +echo "Hit [Space] to read strip A (or 's' to skip)." + +sleep 1 +echo "Reading strip A..." + +# Emit mock JSON for strip A +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]} +EOF + +echo "Hit [Space] to read strip B (or 's' to skip)." +sleep 1 +echo "Reading strip B..." + +# Emit mock JSON for strip B +cat << 'EOF' +ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]} +EOF + +echo "Ready to read... done." +exit 0 diff --git a/Resources/Argyll/mocks/colprof.mock b/Resources/Argyll/mocks/colprof.mock new file mode 100755 index 0000000..8bbd22c --- /dev/null +++ b/Resources/Argyll/mocks/colprof.mock @@ -0,0 +1,20 @@ +#!/bin/bash +# Mock script for colprof +# Simulates colprof execution and outputs progress log + +basename="$1" +# Find last argument if -D or other flags are used +for arg in "$@"; do + basename="$arg" +done + +echo "colprof: Starting profile calculation for $basename" +sleep 1 +echo "Gamut mapping calculation..." +sleep 1 +echo "Fitting cLUT grid points..." +sleep 1 +echo "Writing ICC profile $basename.icc..." +touch "$basename.icc" +echo "Done." +exit 0 diff --git a/Resources/Argyll/mocks/profcheck.mock b/Resources/Argyll/mocks/profcheck.mock new file mode 100755 index 0000000..c424509 --- /dev/null +++ b/Resources/Argyll/mocks/profcheck.mock @@ -0,0 +1,12 @@ +#!/bin/bash +# Mock script for profcheck +# Simulates real ArgyllCMS profcheck -v -k -s -u output + +echo "profcheck: Checking profile accuracy..." +echo "No of test patches = 52" +sleep 1 +cat << 'EOF' +{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} +EOF +echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02" +exit 0 diff --git a/Resources/Argyll/reference_gamuts/sRGB.gam b/Resources/Argyll/reference_gamuts/sRGB.gam new file mode 100644 index 0000000..9826a02 --- /dev/null +++ b/Resources/Argyll/reference_gamuts/sRGB.gam @@ -0,0 +1,16 @@ +CGATS.17 +NUMBER_OF_FIELDS 4 +BEGIN_DATA_FORMAT +INDEX LAB_L LAB_A LAB_B +END_DATA_FORMAT +NUMBER_OF_SETS 8 +BEGIN_DATA +0 0.0 0.0 0.0 +1 100.0 0.0 0.0 +2 53.2 80.1 67.2 +3 87.7 -86.2 83.2 +4 97.1 -21.6 94.5 +5 32.3 79.2 -107.9 +6 60.3 98.2 -60.8 +7 91.1 -48.1 -14.1 +END_DATA diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index 9dba46a..e43c41b 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -1,4 +1,5 @@ import AppKit +import ICCeryCore import SwiftUI @main @@ -19,15 +20,24 @@ struct ICCeryApp: App { } } -/// AppDelegate: quit when the single window closes, and give later -/// milestones a hook to `killAll` Argyll children before teardown -/// (#147/#149 — wired once ProcessManager exists in #2). +/// AppDelegate: quit when the single window closes, and `killAll` Argyll +/// children before teardown (#147/#149). Termination is deferred until +/// `killAll` has signaled every child so `chartread` can park an XY head +/// when the UI already sent `q\n`. final class AppDelegate: NSObject, NSApplicationDelegate { + private var terminationRequested = false + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } - func applicationWillTerminate(_ notification: Notification) { - // Issue #2+: ProcessManager.shared.killAll() + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !terminationRequested else { return .terminateNow } + terminationRequested = true + Task { + await ProcessManager.shared.killAll() + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + return .terminateLater } } diff --git a/Tests/ICCeryCoreTests/BinaryResolverTests.swift b/Tests/ICCeryCoreTests/BinaryResolverTests.swift new file mode 100644 index 0000000..c1c7c76 --- /dev/null +++ b/Tests/ICCeryCoreTests/BinaryResolverTests.swift @@ -0,0 +1,83 @@ +import Testing +import Foundation +@testable import ICCeryCore + +@Suite("BinaryResolver") +struct BinaryResolverTests { + + private func makeTree(_ body: (URL) throws -> Void) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-resolver-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try body(root) + return root + } + + private func touch(_ url: URL, executable: Bool = true) throws { + FileManager.default.createFile(atPath: url.path, contents: Data()) + if executable { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path + ) + } + } + + @Test func overrideDirWinsWhenFileExists() throws { + let override = try makeTree { root in + try touch(root.appendingPathComponent("targen")) + } + let bundled = try makeTree { _ in } + let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) + #expect(r.resolve("targen") == override.appendingPathComponent("targen")) + } + + @Test func overrideFallsThroughWhenMissing() throws { + let override = try makeTree { _ in } + let bundled = try makeTree { root in + let dir = root.appendingPathComponent("macos-universal") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try touch(dir.appendingPathComponent("instlist")) + } + let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) + #expect(r.resolve("targen").path.contains("macos-universal/targen")) + } + + @Test func universalPreferredWhenMarkerPresent() throws { + let bundled = try makeTree { root in + for dir in ["macos-universal", "macos-x86_64"] { + let d = root.appendingPathComponent(dir) + try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + try touch(d.appendingPathComponent("instlist")) + } + } + let r = BinaryResolver(bundledRoot: bundled) + #expect(r.platformDir() == "macos-universal") + } + + @Test func fallsBackToArchDir() throws { + let bundled = try makeTree { root in + let d = root.appendingPathComponent("macos-x86_64") + try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + try touch(d.appendingPathComponent("instlist")) + } + let r = BinaryResolver( + bundledRoot: bundled, + archDirs: ["macos-universal", "macos-x86_64"] + ) + #expect(r.platformDir() == "macos-x86_64") + } + + @Test func missingEverythingReturnsConstructedPath() throws { + let bundled = try makeTree { _ in } + let r = BinaryResolver(bundledRoot: bundled) + // v1 semantic: path is returned; spawn surfaces the error. + #expect(r.resolve("targen").path.hasSuffix("macos-universal/targen")) + #expect(!r.exists(r.resolve("targen"))) + } + + @Test func mockAndGamutPaths() throws { + let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) + #expect(r.mock("chartread").path == "/x/mocks/chartread.mock") + #expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam") + } +} diff --git a/project.yml b/project.yml index 4618da8..1afb884 100644 --- a/project.yml +++ b/project.yml @@ -19,9 +19,25 @@ targets: - path: Resources excludes: - ICCery.entitlements + - Argyll + - path: Resources/Argyll + type: folder dependencies: - package: ICCeryCore product: ICCeryCore + postBuildScripts: + - name: Copy Argyll sidecars + script: | + set -e + SRC="${SRCROOT}/Vendor/Argyll" + DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll" + if [ -d "$SRC" ]; then + mkdir -p "$DEST" + rsync -a "$SRC/" "$DEST/" + else + echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh" + fi + basedOnDependencyAnalysis: false settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2 diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh new file mode 100755 index 0000000..1a44c89 --- /dev/null +++ b/scripts/fetch-argyll.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# scripts/fetch-argyll.sh +# +# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries) +# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency. +# +# Env overrides (parity with v1 fetch-argyll.mjs): +# ARGYLL_SERVER_URL default https://git.i3omb.com +# ARGYLL_REPO default gronod/argyllcms +# ARGYLL_RELEASE_TAG default: latest release +# GITEA_TOKEN optional, for private repos +# +# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout): +# Vendor/Argyll/macos-universal/ # marker binary: instlist +# Mocks and reference_gamuts are tracked under Resources/Argyll/ — +# they ship in git, not in the release tarball. + +set -eu + +SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}" +REPO="${ARGYLL_REPO:-gronod/argyllcms}" +TAG="${ARGYLL_RELEASE_TAG:-}" +SUFFIX="_macOS_universal_bin.tgz" +PLATFORM_DIR="macos-universal" +MARKER="instlist" + +ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR" + +FORCE=0 +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + *) echo "usage: $0 [--force]" >&2; exit 2 ;; + esac +done + +if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then + echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)" + exit 0 +fi + +AUTH_HEADER="" +if [ -n "${GITEA_TOKEN:-}" ]; then + AUTH_HEADER="Authorization: token $GITEA_TOKEN" +fi + +api_get() { + if [ -n "$AUTH_HEADER" ]; then + curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1" + else + curl -fsSL -H 'Accept: application/json' "$1" + fi +} + +if [ -n "$TAG" ]; then + API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG" +else + API_URL="$SERVER/api/v1/repos/$REPO/releases/latest" +fi + +echo "Fetching release info from $API_URL" +RELEASE_JSON="$(api_get "$API_URL")" || { + echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2 + exit 1 +} + +# Find the macOS universal asset's browser_download_url without jq. +ASSET_URL="$(printf '%s' "$RELEASE_JSON" \ + | tr ',' '\n' \ + | grep '"browser_download_url"' \ + | grep "$SUFFIX" \ + | sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \ + | head -n 1)" + +if [ -z "$ASSET_URL" ]; then + echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2 + echo "looked-for pattern: Argyll__$SUFFIX" >&2 + exit 1 +fi + +echo "Downloading $ASSET_URL" +TMPDIR_FETCH="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_FETCH"' EXIT +ARCHIVE="$TMPDIR_FETCH/argyll.tgz" + +if [ -n "$AUTH_HEADER" ]; then + curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL" +else + curl -fSL -o "$ARCHIVE" "$ASSET_URL" +fi + +EXTRACT="$TMPDIR_FETCH/extract" +mkdir -p "$EXTRACT" +tar -xzf "$ARCHIVE" -C "$EXTRACT" + +# Archive contains Argyll_V*/bin/ (or a bare bin/). +BIN_DIR="" +for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do + if [ -d "$d" ]; then BIN_DIR="$d"; break; fi +done +if [ -z "$BIN_DIR" ]; then + echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2 + exit 1 +fi + +mkdir -p "$DEST" +cp -R "$BIN_DIR"/. "$DEST"/ +find "$DEST" -type f -exec chmod 0755 {} + +# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools. +xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true + +if [ ! -x "$DEST/$MARKER" ]; then + echo "error: marker binary $MARKER missing after extraction" >&2 + exit 1 +fi + +echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST" -- 2.39.5 From 5ed3ff5428601b1a9b8942eafddf8bf493634710 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:02:49 +0100 Subject: [PATCH 4/8] fetch-argyll: ad-hoc sign + verify; ship real sRGB.gam (#3 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codesign -f -s - every fetched Mach-O, then codesign -dvv verify — an unsigned sidecar now fails the script (#165) - Replace the 8-cusp reference_gamuts stub with the real v0.8.5 src/assets/sRGB.gam (448 verts / 892 faces) used by the Stage 5 gamut overlay (#185) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Resources/Argyll/reference_gamuts/sRGB.gam | 1385 +++++++++++++++++++- scripts/fetch-argyll.sh | 22 + 2 files changed, 1396 insertions(+), 11 deletions(-) diff --git a/Resources/Argyll/reference_gamuts/sRGB.gam b/Resources/Argyll/reference_gamuts/sRGB.gam index 9826a02..993c5b5 100644 --- a/Resources/Argyll/reference_gamuts/sRGB.gam +++ b/Resources/Argyll/reference_gamuts/sRGB.gam @@ -1,16 +1,1379 @@ -CGATS.17 +GAMUT + +DESCRIPTOR "Argyll Gamut surface poligon data" +ORIGINATOR "Argyll CMS gamut library" +CREATED "Tue Aug 25 10:22:46 2026" +COLOR_REP "LAB" +GAMUT_CENTER "50.000000 0.000000 0.000000" +CSPACE_WHITE "100.000000 -2.387816 -19.404026" +GAMUT_WHITE "99.998874 -2.387789 -19.403808" +CSPACE_BLACK "0.000000 0.000000 0.000000" +GAMUT_BLACK "0.000000 0.000000 0.000000" +CUSP_RED "53.237382 78.287871 62.148058" +CUSP_YELLOW "97.137321 -23.769791 84.721311" +CUSP_GREEN "87.733928 -87.887785 73.898355" +CUSP_CYAN "91.113320 -50.031832 -33.437063" +CUSP_BLUE "32.301170 77.838134 -126.416203" +CUSP_MAGENTA "60.323069 96.208467 -79.513768" +# First come the triangle verticy location + NUMBER_OF_FIELDS 4 BEGIN_DATA_FORMAT -INDEX LAB_L LAB_A LAB_B +VERTEX_NO LAB_L LAB_A LAB_B END_DATA_FORMAT -NUMBER_OF_SETS 8 + +NUMBER_OF_SETS 448 BEGIN_DATA -0 0.0 0.0 0.0 -1 100.0 0.0 0.0 -2 53.2 80.1 67.2 -3 87.7 -86.2 83.2 -4 97.1 -21.6 94.5 -5 32.3 79.2 -107.9 -6 60.3 98.2 -60.8 -7 91.1 -48.1 -14.1 +0 53.23738 78.28787 62.14806 +1 87.73393 -87.88779 73.89835 +2 32.30117 77.83813 -126.4162 +3 97.13732 -23.76979 84.72131 +4 60.32307 96.20847 -79.51377 +5 92.08525 -14.24625 81.54220 +6 53.35447 78.60413 50.79253 +7 83.87564 2.280649 76.53540 +8 80.69498 9.066293 74.66362 +9 77.59264 15.90655 72.88418 +10 57.93092 64.97051 63.46530 +11 58.76878 62.66166 63.75963 +12 53.57668 79.20211 36.15187 +13 89.34786 -74.05441 75.78451 +14 71.68937 29.56313 69.65578 +15 65.07949 45.92003 66.37764 +16 73.12190 26.16928 70.41736 +17 66.31765 42.76656 66.95748 +18 53.65394 79.40935 32.13248 +19 90.79613 -62.92803 77.46661 +20 91.08723 -60.81567 77.80354 +21 53.94195 80.17896 19.76368 +22 92.04631 -54.11558 78.91087 +23 54.05752 80.48649 15.59671 +24 92.39458 -51.77448 79.31196 +25 95.22381 -34.29738 82.55042 +26 54.46471 81.56421 3.147035 +27 55.15354 83.36736 -13.13741 +28 58.23555 91.15715 -58.67024 +29 56.25383 86.19822 -32.74892 +30 43.82204 44.39999 -107.3457 +31 88.00240 -84.44335 56.17783 +32 53.79934 90.68751 -90.31915 +33 52.74215 89.83989 -92.07732 +34 88.28748 -80.88480 41.84045 +35 91.11332 -50.03183 -33.43706 +36 50.66167 88.21685 -95.54287 +37 55.47044 15.53048 -88.43226 +38 70.25635 -15.11766 -65.05040 +39 88.51724 -78.08693 32.21627 +40 49.64029 87.44353 -97.24689 +41 50.24043 27.91848 -96.87445 +42 90.56657 -55.46975 -23.06920 +43 12.35755 45.69866 -74.21878 +44 13.42559 47.41983 -77.01411 +45 90.39594 -57.21271 -19.59352 +46 67.01897 -70.33719 59.14136 +47 89.11856 -71.03844 11.82216 +48 74.03954 -22.07350 -59.19191 +49 90.07208 -60.58426 -12.62126 +50 49.46805 -55.46731 46.63837 +51 64.65829 -3.983664 -73.81222 +52 75.93542 -25.44286 -56.27611 +53 62.76463 -0.333769 -76.80409 +54 5.923683 34.08038 -56.46335 +55 89.62939 -65.33530 -2.130596 +56 2.837725 19.48753 -41.27759 +57 15.14731 -26.38932 20.02350 +58 28.15269 -37.40803 31.45365 +59 17.84328 -28.67346 22.94881 +60 30.63103 -39.50779 33.21918 +61 85.42860 -41.27479 -41.88407 +62 2.264561 15.55144 -36.92225 +63 87.32555 -44.25171 -39.05084 +64 3.487739 23.76854 -45.40603 +65 35.09873 57.77819 45.86665 +66 26.18016 47.69382 36.88900 +67 9.565408 -19.61366 13.12312 +68 1.765340 12.12314 -32.33317 +69 24.64917 45.96270 35.11541 +70 99.99887 -2.368094 -19.42243 +71 0.00000 0.00000 0.00000 +72 73.84328 58.41718 -58.35473 +73 89.01410 21.35955 -35.43485 +74 90.53891 17.92010 -33.18221 +75 92.08034 14.49254 -30.91473 +76 77.62019 48.68730 -52.56424 +77 13.46979 33.32198 20.41046 +78 86.02128 28.25508 -39.88383 +79 11.79010 31.42273 17.96859 +80 0.976169 6.703653 -22.41022 +81 78.94902 45.34606 -50.54002 +82 97.40326 -21.64644 65.83800 +83 84.55614 31.70238 -42.07507 +84 97.87024 -17.99013 42.20060 +85 99.53319 -5.650714 -9.059997 +86 99.24797 -7.696724 -2.125014 +87 99.11290 -8.675347 1.345406 +88 98.62197 -12.28656 15.20945 +89 99.38806 -6.688337 -5.594113 +90 98.98277 -9.624138 4.816620 +91 98.73735 -11.43000 11.75069 +92 98.85759 -10.54257 8.286179 +93 98.39120 0.957317 -21.73439 +94 0.679866 4.668850 -17.05062 +95 1.282928 -2.661895 1.764970 +96 0.129513 0.889405 -3.341660 +97 6.734616 -13.97338 9.265051 +98 5.199688 21.73802 7.977318 +99 99.52551 -4.488846 -20.16416 +100 98.18088 -10.71721 -22.27412 +101 10.15105 -14.53865 -3.319695 +102 3.938488 17.17106 6.042395 +103 1.664400 -0.998752 2.350220 +104 9.951148 -16.30966 1.630954 +105 93.99286 -32.42990 -28.87434 +106 10.39826 -12.29228 -8.240623 +107 93.73185 -33.92167 -29.28710 +108 0.825811 4.714559 -10.87949 +109 9.796380 -17.65318 6.241842 +110 8.071554 -4.789824 -17.08635 +111 1.547389 -0.845764 -5.058574 +112 7.646280 28.27220 -11.70984 +113 7.710785 -7.269724 -12.26130 +114 4.531049 -8.243161 2.713690 +115 10.19545 29.79051 12.06211 +116 8.489888 -1.807740 -21.73665 +117 10.69333 -9.569632 -13.07176 +118 15.40892 -23.12967 8.665202 +119 2.591700 3.044100 3.772876 +120 5.703828 26.49037 -25.86414 +121 3.285427 6.068621 4.837187 +122 3.595860 1.268164 -18.47215 +123 6.203050 28.34728 -30.40435 +124 4.382828 20.02936 -5.422344 +125 1.438284 6.595400 -1.333755 +126 20.91115 -7.754914 26.97127 +127 20.27872 -11.23640 26.16018 +128 13.07359 -16.15192 -4.356306 +129 19.75035 24.39195 -61.99173 +130 20.43478 27.07432 -65.41167 +131 7.513566 -10.57730 10.46011 +132 5.081402 -4.463716 -10.68033 +133 5.377705 -2.428913 -15.78637 +134 21.61150 -4.222775 27.85812 +135 7.464250 30.09177 -28.31670 +136 23.48754 27.82838 -69.36887 +137 19.23150 -17.78240 24.79616 +138 30.72092 34.40688 -83.56651 +139 21.24832 7.098725 28.18333 +140 22.37545 -0.689668 28.81160 +141 28.48420 11.23173 -56.81067 +142 5.221417 14.44958 7.807366 +143 27.77366 37.12635 37.13350 +144 3.555104 17.20435 -12.53467 +145 19.39693 11.38805 26.46904 +146 47.69490 -47.30841 18.78004 +147 6.482617 18.55770 9.742288 +148 36.73982 11.47773 -65.27615 +149 4.202949 18.92348 -0.781149 +150 62.93561 -64.00783 42.28715 +151 31.67616 21.19027 38.39296 +152 65.12867 -65.33676 41.60032 +153 4.665997 -7.316435 -0.768194 +154 17.39142 22.45460 24.82553 +155 20.90425 -25.78611 6.307888 +156 30.74844 -38.00092 25.43964 +157 26.66549 -0.0907351 -41.71491 +158 13.91397 34.50998 3.577564 +159 20.33466 3.625928 27.01590 +160 4.068001 18.04872 2.700735 +161 15.90574 -20.09466 21.08254 +162 30.98258 -35.10897 14.50448 +163 35.98428 -37.59082 12.34234 +164 11.88289 -5.858106 16.57264 +165 10.67036 -13.02915 14.77963 +166 33.49846 -36.37075 13.41920 +167 32.29113 14.26994 38.16461 +168 47.73146 -1.699098 -60.36746 +169 30.09398 36.68870 39.02219 +170 26.37535 -26.46001 -0.308204 +171 28.98639 -30.35030 32.42729 +172 15.46523 8.927534 21.70760 +173 20.77851 -27.26990 10.70253 +174 45.39094 -0.217804 -59.91525 +175 17.88513 -0.883742 -32.12110 +176 14.74711 23.14890 21.53339 +177 72.35878 -60.47868 11.85376 +178 91.67496 -32.25284 -32.33429 +179 92.20045 -29.29361 -31.50100 +180 92.48407 -27.72691 -31.05153 +181 77.63971 34.10040 -52.78188 +182 83.30741 27.60819 -44.07625 +183 13.76159 34.10536 8.718749 +184 75.54892 35.67697 -56.03791 +185 43.72381 -36.17902 -2.844653 +186 76.39551 30.21868 -54.79453 +187 76.21464 20.90006 -55.22162 +188 13.64415 33.79146 13.43158 +189 78.87199 21.14692 -51.06609 +190 87.32002 15.09810 -38.13399 +191 67.26989 26.66099 -69.28892 +192 38.66394 62.86364 10.75828 +193 35.19656 58.04239 36.57309 +194 83.65310 20.03624 -43.67447 +195 90.66601 -12.51473 -33.53344 +196 66.21972 13.60448 -71.12634 +197 89.57944 -17.91095 -35.26060 +198 37.26335 61.33582 8.675190 +199 23.91933 -23.30291 -3.733546 +200 2.682365 13.39937 -13.90632 +201 98.13709 -14.63988 14.48116 +202 74.35996 -7.603405 -58.51742 +203 74.68159 15.36010 -57.70693 +204 37.13105 60.98447 13.13676 +205 35.71721 59.43591 11.05708 +206 75.17492 17.17678 -56.90661 +207 27.77639 49.62372 31.59393 +208 67.91145 44.21902 -68.02449 +209 65.35662 20.31670 -72.42599 +210 11.42291 6.317206 16.36066 +211 88.37362 9.567093 -36.62598 +212 3.319578 15.58691 -7.053573 +213 88.56494 -11.00468 -36.68412 +214 74.63991 -6.294046 -58.06294 +215 27.98927 50.19579 18.84953 +216 31.00037 -16.59208 34.74828 +217 65.71297 30.38908 -71.73081 +218 36.82309 60.16166 26.36287 +219 67.26234 49.39310 -68.98901 +220 88.19055 -12.81586 -37.28068 +221 74.59533 4.811492 -57.98787 +222 74.20986 3.198571 -58.61370 +223 16.10752 19.55477 23.06607 +224 37.41096 61.72633 4.231548 +225 13.31752 -13.69400 -9.151914 +226 26.39913 48.28369 21.36279 +227 87.86746 7.522807 -37.43259 +228 98.90170 -7.761468 -17.43958 +229 35.24765 62.42859 -36.12594 +230 31.75068 -3.266419 -42.71249 +231 28.01680 52.51032 -19.93533 +232 59.44113 -50.08606 6.299467 +233 33.94732 61.18875 -38.24646 +234 96.00062 -29.01119 61.31600 +235 88.10144 -1.914353 -37.23781 +236 94.56869 -35.07831 27.10537 +237 95.28485 -31.26178 31.65535 +238 23.35841 44.90028 17.21548 +239 2.266959 10.54665 -3.751327 +240 28.09646 50.48229 14.21560 +241 34.34219 60.15362 -23.87512 +242 37.29636 63.54341 -23.72363 +243 26.33149 50.38084 -17.82716 +244 95.98191 -27.88023 39.51939 +245 2.446838 11.78193 -8.392522 +246 13.92629 -8.181595 -18.46406 +247 94.87371 -33.59099 31.05815 +248 98.29312 -11.03405 -14.68680 +249 95.37605 -30.49052 28.28770 +250 94.77740 -35.63092 53.89733 +251 27.50338 51.19254 -11.10843 +252 98.45214 -9.864210 -18.14280 +253 29.98711 53.20877 2.465737 +254 28.53656 51.64811 0.250181 +255 32.13856 -0.522276 -46.54584 +256 48.32618 -40.06980 -0.680740 +257 11.85507 -0.428976 -26.77594 +258 9.504890 10.87540 13.86657 +259 93.81622 -39.51807 26.00116 +260 74.52452 -61.48449 10.94735 +261 97.70015 -14.30301 -11.90641 +262 31.55372 -13.38783 35.37864 +263 10.42073 2.579694 14.87232 +264 97.44691 -17.41806 6.142709 +265 97.55030 -15.43866 -8.437139 +266 94.36246 -38.06815 53.33448 +267 13.04887 34.71996 -13.88170 +268 12.75360 33.96459 -9.020249 +269 93.39587 -43.35149 42.52101 +270 33.25497 17.34379 39.19856 +271 8.715568 -5.149986 12.30012 +272 97.78216 -16.07696 10.30740 +273 20.20364 -5.467953 -28.48208 +274 97.40563 -16.54334 -4.963766 +275 26.61730 51.10743 -22.18650 +276 34.30220 62.06052 -42.18343 +277 91.00758 -56.66178 11.02375 +278 70.70544 -53.57729 -1.864092 +279 14.10349 35.00912 -1.521011 +280 30.98682 57.86378 -38.52871 +281 9.512529 -1.287457 13.50842 +282 93.32611 -43.99792 45.67200 +283 94.01887 -37.70669 19.16257 +284 13.60171 -11.02093 -13.85885 +285 97.27410 -16.40380 -12.56932 +286 8.043309 -8.267045 11.27282 +287 93.43482 -40.88541 14.67685 +288 93.77435 -38.83760 15.18622 +289 90.89224 -57.84543 14.48903 +290 97.26612 -17.61638 -1.488146 +291 92.20520 -50.48310 34.11207 +292 91.36553 -55.25143 22.38978 +293 93.03680 -42.60741 6.773126 +294 92.86103 -42.47785 -4.573553 +295 96.97734 -18.69401 -5.625079 +296 24.91315 48.95145 -20.10400 +297 93.22558 -41.81436 10.72090 +298 92.74040 -42.78198 -8.474425 +299 97.13171 -18.65765 1.989240 +300 91.12239 -56.93396 22.03022 +301 92.69355 -47.79108 41.53138 +302 92.89173 -41.39279 -11.95982 +303 93.21108 -38.50587 -18.91533 +304 97.00239 -19.66648 5.465989 +305 93.04862 -39.96704 -15.44071 +306 91.52506 -54.47515 26.16916 +307 33.23674 -12.70974 -31.45839 +308 34.01268 10.28814 39.10684 +309 33.49186 -3.503987 37.56308 +310 11.50229 33.18357 -16.37708 +311 29.30833 55.73847 -36.66815 +312 8.335318 7.298841 12.08963 +313 91.90437 -52.48325 33.67877 +314 24.12544 -21.15564 -8.183776 +315 96.86083 -9.866071 16.50597 +316 77.95635 -13.90732 70.77213 +317 9.421928 32.61559 -30.32216 +318 62.64612 -31.14253 57.93254 +319 60.40093 -39.80946 -16.46213 +320 48.42654 -15.59406 -42.63749 +321 6.404035 -0.402050 9.127567 +322 7.276775 3.402146 10.46652 +323 48.84672 -34.55524 -12.53862 +324 61.81548 -36.45738 56.97924 +325 59.94608 24.49026 60.21842 +326 6.558180 11.46464 9.646398 +327 61.42550 11.70671 60.16647 +328 48.14355 -45.40177 46.12738 +329 49.32816 -19.97056 48.49804 +330 69.33594 -43.88677 -19.45580 +331 48.82496 -22.91644 47.92431 +332 47.73441 19.14651 50.39953 +333 49.07231 31.10492 52.63467 +334 49.32683 15.43808 51.26434 +335 45.27055 19.94972 48.60077 +336 39.82562 28.05534 45.30432 +337 48.13794 -18.00573 -38.88927 +338 70.36580 -33.95143 -37.58748 +339 38.79589 25.27193 44.22368 +340 25.20039 -11.21379 -25.49234 +341 49.77231 -4.182637 49.95564 +342 31.38055 -33.09592 34.09503 +343 22.54416 -9.789864 -24.85476 +344 35.74211 -14.05042 -32.06197 +345 37.37400 28.84882 43.53951 +346 47.71964 34.51697 51.98748 +347 34.91799 -21.38088 -19.94736 +348 49.04452 -32.55371 -16.46482 +349 34.26401 -13.90936 37.41112 +350 38.45770 31.53684 44.66277 +351 70.14358 -36.02027 -33.99700 +352 37.41040 -22.57751 -20.67675 +353 49.71131 -26.14210 -28.10082 +354 24.35665 -18.85029 -12.59794 +355 24.89436 -13.85676 -21.26108 +356 49.47680 -28.34128 -24.24881 +357 22.22683 -12.49970 -20.56104 +358 24.61284 -16.41120 -16.95794 +359 35.44970 -16.55937 -28.07054 +360 21.07184 47.77452 -45.56465 +361 23.34524 51.15712 -51.13708 +362 35.17491 -19.00693 -24.03086 +363 87.06072 9.370494 7.187899 +364 88.52195 5.174662 12.87112 +365 90.12211 1.800273 14.97607 +366 87.61671 12.88924 -7.151265 +367 82.81879 9.180387 41.93159 +368 85.62945 13.62353 1.468319 +369 86.93636 8.570999 10.77074 +370 31.85722 61.79949 -59.60587 +371 89.33123 10.42784 -8.524011 +372 87.46891 11.96257 -3.571796 +373 85.49520 12.78818 5.074162 +374 88.76889 6.798596 5.741566 +375 85.76969 14.49072 -2.135386 +376 85.36696 11.98546 8.677448 +377 92.22258 1.787498 2.973154 +378 90.62339 5.158576 0.784580 +379 91.96858 0.0543591 10.04090 +380 80.27421 19.38626 17.09611 +381 79.28559 25.73400 0.430351 +382 86.81771 7.803842 14.34869 +383 91.73611 -1.550446 17.08429 +384 81.21472 24.81315 -8.495535 +385 91.21404 9.016163 -13.38990 +386 82.75019 8.740551 45.22326 +387 81.18401 12.15713 43.52679 +388 92.09286 0.905013 6.508738 +389 78.77087 22.82519 15.14776 +390 92.35770 2.700996 -0.561588 +391 79.58249 27.38259 -6.915997 +392 81.50122 14.12340 29.79385 +393 83.37224 25.16023 -20.83636 +394 85.91600 15.38956 -5.735542 +395 83.94803 16.21828 2.971552 +396 83.14824 11.27243 28.15276 +397 79.74100 28.25421 -10.57715 +398 71.32551 47.57174 -18.68964 +399 90.90744 7.026770 -6.313611 +400 81.54185 26.64941 -15.76232 +401 78.88980 23.50318 11.47322 +402 85.04753 22.73359 -22.24328 +403 93.96598 -0.651517 1.647721 +404 93.47278 -4.094151 15.70088 +405 70.07438 50.78030 -20.50606 +406 85.22966 23.78332 -25.80187 +407 83.55337 26.18182 -24.41524 +408 12.49315 37.63989 -40.46678 +409 93.11594 7.722502 -18.17200 +410 79.01517 24.21397 7.793819 +411 89.90427 0.315521 22.02200 +412 92.95320 6.659334 -14.66383 +413 81.59492 14.69861 26.24226 +414 69.07035 46.34499 2.218811 +415 78.65839 22.18071 18.80973 +416 94.70243 4.342196 -15.90200 +417 77.68103 28.39026 2.114921 +418 81.41337 13.58186 33.30720 +419 80.16523 18.74256 20.72951 +420 69.53654 48.42463 -9.213966 +421 87.93016 14.83435 -14.28997 +422 87.83105 0.527343 37.31729 +423 70.49374 43.77068 0.154891 +424 94.46153 -12.13477 41.60092 +425 69.85356 40.76189 19.17483 +426 65.82409 56.94787 -10.42383 +427 85.87591 1.556436 51.72802 +428 74.82919 24.13317 53.16703 +429 8.603005 16.24850 -42.11586 +430 76.36188 20.99314 51.57010 +431 68.79887 45.11718 9.878961 +432 69.96660 41.29868 15.38241 +433 63.04901 60.16254 2.151702 +434 72.28298 32.67149 33.56793 +435 63.53064 61.96164 -9.575327 +436 73.19295 46.96005 -27.89996 +437 62.77183 59.11453 10.03247 +438 20.37149 48.00729 -51.43156 +439 73.40029 47.90326 -31.55172 +440 72.06443 31.53639 44.02758 +441 68.34758 43.04775 25.12152 +442 70.90211 36.02996 31.97182 +443 62.64618 58.63630 13.97813 +444 70.82030 35.62091 35.58479 +445 63.48416 54.93669 23.14435 +446 62.42032 57.77172 21.84206 +447 64.73492 66.34472 -32.42670 +END_DATA + + +# And then come the triangles + +NUMBER_OF_FIELDS 3 +BEGIN_DATA_FORMAT +VERTEX_0 VERTEX_1 VERTEX_2 +END_DATA_FORMAT + +NUMBER_OF_SETS 892 +BEGIN_DATA +13 1 46 +6 0 65 +4 32 72 +32 33 72 +25 3 82 +70 93 99 +99 93 100 +95 71 103 +63 35 107 +97 67 109 +104 97 109 +96 71 111 +108 96 111 +97 104 114 +104 101 114 +79 98 115 +110 113 117 +113 106 117 +67 57 118 +109 67 118 +104 109 118 +103 71 119 +62 56 120 +68 62 120 +119 71 121 +80 94 122 +116 80 122 +110 116 122 +64 54 123 +56 64 123 +120 56 123 +71 96 125 +96 108 125 +121 71 125 +102 121 125 +101 104 128 +106 101 128 +43 54 129 +54 64 129 +44 43 130 +43 129 130 +97 114 131 +106 113 132 +94 108 132 +108 111 132 +110 122 133 +122 94 133 +94 132 133 +113 110 133 +132 113 133 +120 123 135 +2 44 136 +44 130 136 +130 129 136 +59 57 137 +30 2 138 +2 136 138 +136 129 141 +138 136 141 +121 102 142 +69 66 143 +68 120 144 +120 112 144 +112 124 144 +60 50 146 +102 98 147 +142 102 147 +37 41 148 +41 30 148 +30 138 148 +138 141 148 +124 98 149 +50 46 150 +146 50 150 +1 31 152 +46 1 152 +150 46 152 +31 34 152 +34 146 152 +146 150 152 +71 95 153 +111 71 153 +101 106 153 +114 101 153 +95 114 153 +132 111 153 +106 132 153 +77 69 154 +69 143 154 +143 151 154 +151 145 154 +58 60 156 +141 129 157 +115 98 158 +139 140 159 +140 134 159 +98 102 160 +149 98 160 +127 137 161 +57 67 161 +137 57 161 +59 58 162 +58 156 162 +60 146 163 +156 60 163 +126 127 164 +134 126 164 +159 134 164 +67 97 165 +97 131 165 +127 161 165 +164 127 165 +161 67 165 +162 156 166 +156 163 166 +139 145 167 +145 151 167 +51 53 168 +53 37 168 +66 65 169 +143 66 169 +151 143 169 +162 166 170 +166 163 170 +60 58 171 +58 59 171 +59 137 171 +145 139 172 +139 159 172 +57 59 173 +118 57 173 +155 118 173 +59 162 173 +162 170 173 +170 155 173 +37 148 174 +168 37 174 +62 68 175 +56 62 175 +98 79 176 +147 98 176 +79 77 176 +77 154 176 +39 47 177 +61 63 178 +63 107 178 +107 105 178 +61 178 179 +178 105 179 +105 100 180 +179 105 180 +83 81 181 +78 83 182 +79 115 183 +115 158 183 +81 76 184 +181 81 184 +170 163 185 +83 181 186 +181 184 186 +77 79 188 +79 183 188 +182 83 189 +83 186 189 +186 187 189 +74 73 190 +187 186 191 +23 21 192 +12 6 193 +6 65 193 +65 66 193 +73 78 194 +78 182 194 +182 189 194 +190 73 194 +100 93 195 +37 53 196 +61 179 197 +179 180 197 +180 100 197 +100 195 197 +52 61 197 +26 23 198 +23 192 198 +104 118 199 +118 155 199 +128 104 199 +155 170 199 +94 80 200 +108 94 200 +80 68 200 +68 144 200 +38 48 202 +194 189 203 +21 18 204 +192 21 204 +198 192 205 +192 204 205 +187 191 206 +191 203 206 +189 187 206 +203 189 206 +193 66 207 +184 76 208 +2 30 208 +186 184 208 +41 37 209 +191 41 209 +37 196 209 +196 203 209 +203 191 209 +75 74 211 +74 190 211 +124 149 212 +144 124 212 +51 38 214 +38 202 214 +202 48 214 +137 127 216 +171 137 216 +30 41 217 +41 191 217 +208 30 217 +191 186 217 +186 208 217 +18 12 218 +12 193 218 +204 18 218 +215 204 218 +193 207 218 +36 40 219 +33 36 219 +72 33 219 +76 72 219 +208 76 219 +40 2 219 +2 208 219 +48 52 220 +52 197 220 +197 195 220 +195 213 220 +213 214 220 +214 48 220 +196 53 221 +203 196 221 +53 51 222 +221 53 222 +51 214 222 +154 145 223 +145 172 223 +176 154 223 +27 26 224 +26 198 224 +106 128 225 +66 69 226 +207 66 226 +215 218 226 +218 207 226 +75 211 227 +190 194 227 +211 190 227 +194 203 227 +203 221 227 +28 29 229 +34 39 232 +146 34 232 +39 177 232 +25 82 234 +82 84 234 +195 93 235 +213 195 235 +221 222 235 +222 214 235 +93 75 235 +75 227 235 +214 213 235 +227 221 235 +183 158 238 +188 183 238 +215 226 238 +69 77 238 +226 69 238 +77 188 238 +102 125 239 +160 102 239 +149 160 239 +212 149 239 +125 108 239 +205 204 240 +204 215 240 +215 238 240 +29 27 242 +229 29 242 +241 229 242 +84 237 244 +200 144 245 +144 212 245 +108 200 245 +212 239 245 +239 108 245 +116 110 246 +110 117 246 +85 70 248 +70 228 248 +88 201 249 +84 88 249 +237 84 249 +236 247 249 +247 237 249 +24 25 250 +25 234 250 +234 84 250 +84 244 250 +27 224 251 +242 27 251 +231 241 251 +241 242 251 +243 231 251 +70 99 252 +228 70 252 +99 100 252 +100 248 252 +248 228 252 +198 205 253 +224 198 253 +205 240 253 +238 158 254 +240 238 254 +253 240 254 +251 224 254 +224 253 254 +148 141 255 +141 157 255 +157 230 255 +174 148 255 +230 174 255 +163 146 256 +185 163 256 +146 232 256 +68 80 257 +80 116 257 +175 68 257 +116 246 257 +172 210 258 +223 172 258 +147 176 258 +176 223 258 +247 236 259 +177 47 260 +85 248 261 +127 126 262 +216 127 262 +126 134 262 +159 164 263 +172 159 263 +210 172 263 +92 90 264 +91 92 264 +89 85 265 +85 261 265 +22 24 266 +24 250 266 +250 244 266 +112 267 268 +267 251 268 +244 237 269 +237 247 269 +247 259 269 +167 151 270 +88 91 272 +91 264 272 +201 88 272 +249 201 272 +157 175 273 +175 257 273 +257 246 273 +86 89 274 +89 265 274 +241 231 275 +231 243 275 +4 28 276 +28 229 276 +229 233 276 +32 4 276 +55 47 277 +55 49 278 +47 55 278 +256 232 278 +260 47 278 +232 177 278 +177 260 278 +98 124 279 +158 98 279 +124 112 279 +251 254 279 +254 158 279 +112 268 279 +268 251 279 +276 233 280 +263 164 281 +164 271 281 +13 19 282 +19 20 282 +20 22 282 +22 266 282 +266 244 282 +244 269 282 +259 236 283 +236 249 283 +249 272 283 +117 106 284 +106 225 284 +246 117 284 +100 105 285 +248 100 285 +261 248 285 +265 261 285 +164 165 286 +165 131 286 +114 95 286 +95 271 286 +131 114 286 +271 164 286 +287 283 288 +277 47 289 +87 86 290 +86 274 290 +31 1 291 +287 289 292 +55 277 293 +49 55 294 +55 293 294 +274 265 295 +265 285 295 +290 274 295 +243 251 296 +251 267 296 +275 243 296 +277 289 297 +289 287 297 +293 277 297 +287 288 297 +45 49 298 +49 294 298 +90 87 299 +87 290 299 +264 90 299 +290 295 299 +295 294 299 +47 39 300 +39 292 300 +289 47 300 +292 289 300 +1 13 301 +13 282 301 +282 269 301 +269 259 301 +259 291 301 +291 1 301 +45 298 302 +294 295 302 +298 294 302 +35 42 303 +107 35 303 +105 107 303 +285 105 303 +295 285 303 +272 264 304 +283 272 304 +288 283 304 +294 293 304 +299 294 304 +264 299 304 +297 288 304 +293 297 304 +42 45 305 +45 302 305 +303 42 305 +302 295 305 +295 303 305 +39 34 306 +259 283 306 +283 287 306 +287 292 306 +291 259 306 +292 39 306 +230 157 307 +157 273 307 +140 139 308 +139 167 308 +167 270 308 +134 140 309 +262 134 309 +140 308 309 +112 120 310 +120 135 310 +267 112 310 +296 267 310 +229 241 311 +241 275 311 +233 229 311 +280 233 311 +258 210 312 +210 263 312 +34 31 313 +31 291 313 +291 306 313 +306 34 313 +199 170 314 +128 199 314 +225 128 314 +88 84 315 +5 3 316 +7 5 316 +123 54 317 +135 123 317 +296 310 317 +310 135 317 +3 25 318 +316 3 318 +25 24 318 +24 22 318 +278 49 319 +256 278 319 +38 51 320 +51 168 320 +168 174 320 +174 230 320 +95 103 321 +271 95 321 +281 271 321 +103 119 321 +119 121 322 +321 119 322 +263 281 322 +281 321 322 +312 263 322 +121 312 322 +185 256 323 +256 319 323 +19 13 324 +13 46 324 +46 50 324 +20 19 324 +22 20 324 +318 22 324 +17 14 325 +142 147 326 +147 258 326 +121 142 326 +312 121 326 +258 312 326 +9 8 327 +16 9 327 +50 60 328 +324 50 328 +316 318 329 +42 35 330 +45 42 330 +49 45 330 +319 49 330 +318 324 331 +329 318 331 +324 328 331 +17 325 332 +15 17 333 +17 332 333 +14 16 334 +325 14 334 +16 327 334 +332 325 334 +308 270 335 +333 332 335 +332 334 335 +334 308 335 +333 335 336 +52 48 337 +48 38 337 +38 320 337 +61 52 338 +270 151 339 +335 270 339 +336 335 339 +307 273 340 +8 7 341 +327 8 341 +7 316 341 +316 329 341 +334 327 341 +309 308 341 +308 334 341 +60 171 342 +171 216 342 +328 60 342 +331 328 342 +273 246 343 +340 273 343 +230 307 344 +320 230 344 +337 320 344 +151 169 345 +339 151 345 +336 339 345 +11 15 346 +15 333 346 +333 336 346 +170 185 347 +314 170 347 +323 319 348 +262 309 349 +216 262 349 +329 331 349 +309 341 349 +341 329 349 +331 342 349 +342 216 349 +65 0 350 +336 345 350 +169 65 350 +345 169 350 +0 10 350 +346 336 350 +10 11 350 +11 346 350 +35 63 351 +330 35 351 +63 61 351 +61 338 351 +319 330 351 +185 323 352 +347 185 352 +323 348 352 +52 337 353 +338 52 353 +351 338 353 +284 225 354 +225 314 354 +314 347 354 +340 343 355 +348 319 356 +319 351 356 +351 353 356 +352 348 356 +353 352 356 +246 284 357 +343 246 357 +355 343 357 +354 347 358 +347 355 358 +284 354 358 +357 284 358 +355 357 358 +344 307 359 +337 344 359 +353 337 359 +307 340 359 +275 296 360 +311 275 360 +44 2 361 +280 311 361 +311 360 361 +347 352 362 +352 353 362 +353 359 362 +340 355 362 +359 340 362 +355 347 362 +33 32 370 +32 276 370 +36 33 370 +40 36 370 +2 40 370 +361 2 370 +276 280 370 +280 361 370 +366 371 372 +364 369 374 +369 363 374 +363 373 374 +373 368 374 +372 368 375 +363 369 376 +373 363 376 +368 372 378 +374 368 378 +377 374 378 +92 91 379 +365 364 379 +364 374 379 +364 365 382 +369 364 382 +376 369 382 +380 376 382 +7 8 386 +8 9 387 +386 8 387 +367 386 387 +90 92 388 +92 379 388 +374 377 388 +379 374 388 +89 86 390 +85 89 390 +377 378 390 +384 381 391 +366 372 394 +372 375 394 +375 381 394 +381 384 394 +368 373 395 +375 368 395 +381 375 395 +373 376 395 +384 391 397 +70 85 399 +372 371 399 +378 372 399 +371 385 399 +85 390 399 +390 378 399 +384 397 400 +376 380 401 +395 376 401 +380 389 401 +73 74 402 +74 75 402 +400 393 402 +86 87 403 +87 90 403 +90 388 403 +388 377 403 +377 390 403 +390 86 403 +88 315 404 +315 383 404 +91 88 404 +379 91 404 +365 379 404 +383 365 404 +78 73 406 +73 402 406 +83 78 407 +393 400 407 +78 406 407 +402 393 407 +406 402 407 +54 43 408 +43 360 408 +317 54 408 +296 317 408 +360 296 408 +75 93 409 +381 395 410 +395 401 410 +315 84 411 +383 315 411 +382 365 411 +396 382 411 +365 383 411 +399 385 412 +385 409 412 +409 93 412 +382 396 413 +396 392 413 +389 380 415 +93 70 416 +412 93 416 +70 399 416 +399 412 416 +391 381 417 +381 410 417 +367 387 418 +392 396 418 +396 367 418 +380 382 419 +382 413 419 +413 392 419 +415 380 419 +397 391 420 +398 397 420 +405 398 420 +371 366 421 +385 371 421 +402 75 421 +366 394 421 +394 384 421 +384 400 421 +400 402 421 +75 409 421 +409 385 421 +3 5 422 +367 396 422 +396 411 422 +391 417 423 +420 391 423 +414 420 423 +82 3 424 +3 422 424 +84 82 424 +411 84 424 +422 411 424 +401 389 425 +389 415 425 +27 29 426 +29 405 426 +405 420 426 +420 414 426 +5 7 427 +422 5 427 +7 386 427 +386 367 427 +367 422 427 +64 56 429 +129 64 429 +157 129 429 +56 175 429 +175 157 429 +9 16 430 +387 9 430 +16 14 430 +14 428 430 +418 387 430 +414 423 431 +423 417 431 +410 401 432 +401 425 432 +417 410 432 +431 417 432 +26 27 433 +426 414 433 +415 419 434 +419 392 434 +27 426 435 +426 433 435 +433 27 435 +397 398 436 +400 397 436 +398 405 436 +407 400 436 +23 26 437 +26 433 437 +414 431 437 +433 414 437 +43 44 438 +44 361 438 +360 43 438 +361 360 438 +28 4 439 +436 28 439 +4 72 439 +81 83 439 +83 407 439 +407 436 439 +72 76 439 +76 81 439 +17 15 440 +14 17 440 +428 14 440 +392 418 440 +434 392 440 +418 430 440 +430 428 440 +0 6 441 +6 12 441 +425 415 442 +415 434 442 +10 0 442 +0 441 442 +441 425 442 +11 10 442 +21 23 443 +23 437 443 +437 431 443 +434 440 444 +440 15 444 +15 11 444 +11 442 444 +442 434 444 +12 18 445 +432 425 445 +425 441 445 +441 12 445 +431 432 445 +18 21 446 +445 18 446 +21 443 446 +443 431 446 +431 445 446 +29 28 447 +405 29 447 +28 436 447 +436 405 447 END_DATA diff --git a/scripts/fetch-argyll.sh b/scripts/fetch-argyll.sh index 1a44c89..cc2b0c8 100755 --- a/scripts/fetch-argyll.sh +++ b/scripts/fetch-argyll.sh @@ -110,6 +110,28 @@ 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 -- 2.39.5 From 552227c3af1c13128fb5407a49b88e6e2d57ffa7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:06:10 +0100 Subject: [PATCH 5/8] File dialogs & artefact helpers (#6) - PathSecurity: basename sanitisation (reject / \ .., empty); resolveSafeCwd explicit - Documents - Home - app-data (#59, #60) - AtomicFileWriter: .tmp + rename/replaceItemAt, parent dirs (#213) - ArtefactProbe: verify_stage_artefacts, .icm-over-.icc resolution (#69), enumeration incl. .N.tif/_NN.tif/_passN.ti3/CAL_ - FileDialogService: NSOpenPanel/NSSavePanel wrappers (v2 select_* equivalents) - 11 new tests; 40/40 total green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Files/ArtefactProbe.swift | 98 +++++++++++++ .../ICCeryCore/Files/AtomicFileWriter.swift | 34 +++++ .../ICCeryCore/Files/PathSecurity.swift | 54 ++++++++ Sources/ICCery/FileDialogService.swift | 98 +++++++++++++ Tests/ICCeryCoreTests/FileHelpersTests.swift | 129 ++++++++++++++++++ 5 files changed, 413 insertions(+) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift create mode 100644 Sources/ICCery/FileDialogService.swift create mode 100644 Tests/ICCeryCoreTests/FileHelpersTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift new file mode 100644 index 0000000..a090b31 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06). +public struct StageArtefacts: Sendable, Equatable { + /// `.ti1` exists (Stage 1 done → unlocks Stage 2). + public var stage1Complete = false + /// `.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3). + public var stage2Complete = false + /// `.ti3` exists (Stage 3 done → unlocks Stage 4). + public var stage3Complete = false + /// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5). + public var stage4Complete = false + /// Absolute path of the profile file when present. + public var profilePath: URL? +} + +/// Filesystem probing for wizard artefacts (docs/02 §Working directory, +/// docs/06 §Stages). All artefacts live next to each other in `cwd`. +public enum ArtefactProbe { + + /// `verify_stage_artefacts` — the gating truth source. + public static func verify( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> StageArtefacts { + var out = StageArtefacts() + out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager) + out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager) + out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager) + if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { + out.stage4Complete = true + out.profilePath = profile + } + return out + } + + /// `/.` — the canonical artefact URL. + public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL { + cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false) + } + + /// Profile extension resolution (#69): existing `.icm` wins over + /// `.icc`; when neither exists the macOS default is `.icc`. + /// (`profcheck`/`iccgamut` swap extension when the requested path is + /// missing.) + public static func resolveProfile( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> URL? { + let icm = artefact(basename, "icm", cwd) + if exists(icm, fm: fileManager) { return icm } + let icc = artefact(basename, "icc", cwd) + if exists(icc, fm: fileManager) { return icc } + return nil + } + + /// Default extension for a *new* profile on macOS (#69). + public static let defaultProfileExtension = "icc" + + /// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif + /// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_` namespace. + /// Multi-page TIFFs match `.tif`, `.1.tif` … and + /// `_NN.tif` (manifest naming). + public static func existingArtefacts( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> [URL] { + guard let entries = try? fileManager.contentsOfDirectory( + at: cwd, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + let prefixes = [basename + ".", "CAL_" + basename + "."] + let suffixes: Set = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"] + let passPrefix = basename + "_pass" + let tifStemPrefix = basename + "_" + let calPrefix = "CAL_" + basename + + return entries.filter { url in + let name = url.lastPathComponent + let ext = url.pathExtension.lowercased() + guard suffixes.contains(ext) else { return false } + if prefixes.contains(where: { name.hasPrefix($0) }) { return true } + if name.hasPrefix(passPrefix), ext == "ti3" { return true } + if name.hasPrefix(tifStemPrefix), ext == "tif" { return true } + if name.hasPrefix(calPrefix) { return true } + return false + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private static func exists(_ url: URL, fm: FileManager) -> Bool { + fm.fileExists(atPath: url.path) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift new file mode 100644 index 0000000..40a29b0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Atomic `.tmp`-then-rename file writes — the convention used by +/// settings.json, verification_history.json and wizard_state.json +/// (docs/02 §Persistence, #213). +public enum AtomicFileWriter { + + /// Writes `data` to `url` atomically: sibling `.tmp`, then a + /// rename (which is atomic on APFS/HFS+). Parent dirs are created. + public static func write(_ data: Data, to url: URL) throws { + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let tmp = url.appendingPathExtension("tmp") + do { + try data.write(to: tmp, options: []) + // replaceItemAt handles same-volume atomic swap and removes + // the destination cleanly; fall back to remove+move. + if fm.fileExists(atPath: url.path) { + _ = try fm.replaceItemAt(url, withItemAt: tmp) + } else { + try fm.moveItem(at: tmp, to: url) + } + } catch { + try? fm.removeItem(at: tmp) + throw error + } + } + + public static func write(_ text: String, to url: URL) throws { + try write(Data(text.utf8), to: url) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift new file mode 100644 index 0000000..3b158a5 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Basename sanitisation and safe working-directory resolution +/// (docs/02 §Working directory, docs/06 §Empty cwd). +public enum PathSecurity { + + public enum Error: Swift.Error, Equatable, Sendable { + case invalidBasename(String) + } + + /// Basenames must not contain `/`, `\`, or `..` and must be + /// non-empty. Never invent a default basename (#60). + public static func isValidBasename(_ name: String) -> Bool { + guard !name.isEmpty else { return false } + return !name.contains("/") && !name.contains("\\") && !name.contains("..") + } + + @discardableResult + public static func sanitizeBasename(_ name: String) throws -> String { + guard isValidBasename(name) else { + throw Error.invalidBasename(name) + } + return name + } + + /// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory → + /// Documents → Home → app-data. Never returns an empty/nil cwd. + public static func resolveSafeCwd( + _ explicit: URL?, + fileManager: FileManager = .default + ) -> URL { + if let explicit, + fileManager.fileExists(atPath: explicit.path, isDirectory: nil) { + return explicit + } + let candidates: [URL?] = [ + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first, + fileManager.homeDirectoryForCurrentUser, + AppPaths.appDataDir, + ] + for candidate in candidates { + guard let url = candidate else { continue } + if !fileManager.fileExists(atPath: url.path) { + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + if fileManager.fileExists(atPath: url.path, isDirectory: nil) { + return url + } + } + // Last resort: app-data, created unconditionally. + try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true) + return AppPaths.appDataDir + } +} diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift new file mode 100644 index 0000000..84b7c0d --- /dev/null +++ b/Sources/ICCery/FileDialogService.swift @@ -0,0 +1,98 @@ +import AppKit +import UniformTypeIdentifiers + +/// NSOpenPanel / NSSavePanel wrappers (issue #6). All CGATS/ICC file +/// picking in the app goes through this service — the v1 equivalent of +/// the `select_*` Tauri commands (docs/21 §Dialogs). +@MainActor +final class FileDialogService { + + static let shared = FileDialogService() + private init() {} + + // MARK: - Directory + + /// `#btnBrowse` — working directory for Argyll artefacts. + /// Defaults to Documents (docs/06 §Empty cwd). + func chooseDirectory(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: - Open files + + func chooseTI1(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["ti1"], startingAt: start) + } + + func chooseTI2(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["ti2"], startingAt: start) + } + + /// Stage 1 "Open Existing" — `.ti1` or `.ti2` (docs/06 §Resume). + func chooseExistingTarget(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["ti1", "ti2"], startingAt: start) + } + + func chooseTI3(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["ti3"], startingAt: start) + } + + /// ICC/ICM picker (profiles, preconditioning, calibration `.cal`). + func chooseProfile(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["icc", "icm"], startingAt: start) + } + + func chooseCalibration(startingAt start: URL? = nil) -> URL? { + chooseFile(extensions: ["cal"], startingAt: start) + } + + func chooseFile( + extensions: [String], + startingAt start: URL? = nil, + message: String? = nil + ) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } + panel.allowsOtherFileTypes = true + panel.directoryURL = start + if let message { panel.message = message } + return run(panel) + } + + // MARK: - Save + + func saveFile( + defaultName: String, + extensions: [String], + startingAt start: URL? = nil, + message: String? = nil + ) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = defaultName + panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } + panel.allowsOtherFileTypes = true + panel.directoryURL = start + if let message { panel.message = message } + return run(panel) + } + + // MARK: - Internals + + private func run(_ panel: NSOpenPanel) -> URL? { + panel.runModal() == .OK ? panel.url : nil + } + + private func run(_ panel: NSSavePanel) -> URL? { + panel.runModal() == .OK ? panel.url : nil + } +} diff --git a/Tests/ICCeryCoreTests/FileHelpersTests.swift b/Tests/ICCeryCoreTests/FileHelpersTests.swift new file mode 100644 index 0000000..0854f8c --- /dev/null +++ b/Tests/ICCeryCoreTests/FileHelpersTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import ICCeryCore + +private func tempDir(_ name: String = UUID().uuidString) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-files-\(name)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func touch(_ url: URL, _ contents: String = "x") throws { + try contents.write(to: url, atomically: true, encoding: .utf8) +} + +@Suite("PathSecurity") +struct PathSecurityTests { + @Test func rejectsTraversalAndSeparators() { + for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { + #expect(!PathSecurity.isValidBasename(bad)) + #expect(throws: PathSecurity.Error.self) { + try PathSecurity.sanitizeBasename(bad) + } + } + } + + @Test func acceptsNormalNames() { + for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { + #expect(PathSecurity.isValidBasename(good)) + } + } + + @Test func resolveSafeCwdPrefersExplicit() throws { + let dir = try tempDir() + #expect(PathSecurity.resolveSafeCwd(dir) == dir) + } + + @Test func resolveSafeCwdNeverReturnsNil() { + let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") + let resolved = PathSecurity.resolveSafeCwd(missing) + #expect(FileManager.default.fileExists(atPath: resolved.path)) + } +} + +@Suite("AtomicFileWriter") +struct AtomicFileWriterTests { + @Test func writesAndLeavesNoTmp() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("state.json") + try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) + #expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}") + #expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) + } + + @Test func overwritesExistingAtomically() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("f.txt") + try AtomicFileWriter.write("one", to: url) + try AtomicFileWriter.write("two-longer", to: url) + #expect(try String(contentsOf: url, encoding: .utf8) == "two-longer") + } + + @Test func createsParentDirs() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("a/b/c/deep.json") + try AtomicFileWriter.write("{}", to: url) + #expect(FileManager.default.fileExists(atPath: url.path)) + } +} + +@Suite("ArtefactProbe") +struct ArtefactProbeTests { + @Test func verifyProgression() throws { + let dir = try tempDir() + var v = ArtefactProbe.verify(basename: "t", cwd: dir) + #expect(v == StageArtefacts()) + + try touch(dir.appendingPathComponent("t.ti1")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + #expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) + + try touch(dir.appendingPathComponent("t.ti2")) + try touch(dir.appendingPathComponent("t.ti3")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + #expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete) + + try touch(dir.appendingPathComponent("t.icc")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + #expect(v.stage4Complete && v.profilePath?.pathExtension == "icc") + } + + @Test func icmWinsOverIcc() throws { + let dir = try tempDir() + try touch(dir.appendingPathComponent("p.icc")) + try touch(dir.appendingPathComponent("p.icm")) + let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) + #expect(profile?.pathExtension == "icm") + } + + @Test func enumeratesPassesPagesAndCAL() throws { + let dir = try tempDir() + for name in [ + "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", + "t.ti3", "t_pass1.ti3", "t_pass2.ti3", + "t.icc", "t.gam", + "CAL_t.ti1", "CAL_t.cal", + // must NOT match: + "other.ti1", "t.txt", "CAL_other.ti1", + ] { try touch(dir.appendingPathComponent(name)) } + + let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir) + .map(\.lastPathComponent) + for expected in [ + "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", + "t.ti3", "t_pass1.ti3", "t_pass2.ti3", + "t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", + ] { + #expect(names.contains(expected), "missing \(expected)") + } + #expect(!names.contains("other.ti1")) + #expect(!names.contains("t.txt")) + #expect(!names.contains("CAL_other.ti1")) + } + + @Test func emptyDirReturnsEmpty() throws { + let dir = try tempDir() + #expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) + } +} -- 2.39.5 From 2a608c89620477175d5a22282aabe32d9b66d9b4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:09:19 +0100 Subject: [PATCH 6/8] =?UTF-8?q?File=20dialogs=20&=20artefact=20helpers=20?= =?UTF-8?q?=E2=80=94=20dedicated=20pickers=20+=20host=20helpers=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One dedicated method per purpose: selectTargetFile (save .ti1), selectExistingTarget (ti1/ti2), selectProfileFile (icc/icm/mpp — never ti*, #172), selectSpectrumFile (.sp), selectDatasetFile (open-only ti3/txt/cgats/csv, #211), selectCsvSavePath, selectCalFile, selectDirectory. No shared generic picker API (#103/#210/#211). - Ti2Header: TARGET_INSTRUMENT / NUMBER_OF_SETS / NUMBER_OF_PAGES + sibling .ti1 detection; NUMBER_OF_FIELDS explicitly not patch count - TiffPreview: host-side TIFF→PNG thumbnail, 1200px max edge (#58) - ArtefactFiles: defaultWorkingDirectory, readBase64, appInfo - 7 new tests; 47/47 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Files/ArtefactFiles.swift | 28 ++++ .../Sources/ICCeryCore/Files/Ti2Header.swift | 53 ++++++++ .../ICCeryCore/Files/TiffPreview.swift | 38 ++++++ Sources/ICCery/FileDialogService.swift | 96 ++++++++------ .../ICCeryCoreTests/ArtefactFilesTests.swift | 124 ++++++++++++++++++ 5 files changed, 298 insertions(+), 41 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Files/TiffPreview.swift create mode 100644 Tests/ICCeryCoreTests/ArtefactFilesTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift new file mode 100644 index 0000000..48303ab --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Small host-side file helpers (issue #6). +public enum ArtefactFiles { + + /// `get_default_working_dir` — `resolveSafeCwd(nil)`. + public static func defaultWorkingDirectory() -> URL { + PathSecurity.resolveSafeCwd(nil) + } + + /// `read_file_base64` — for **text artefacts** the UI needs verbatim + /// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF) + /// go through `TiffPreview` instead. + public static func readBase64(_ url: URL) throws -> String { + try Data(contentsOf: url).base64EncodedString() + } + + /// `get_app_info` — version + build for the About dialog. + public static func appInfo( + bundle: Bundle = .main + ) -> (version: String, build: String) { + let info = bundle.infoDictionary ?? [:] + return ( + info["CFBundleShortVersionString"] as? String ?? "0.0.0", + info["CFBundleVersion"] as? String ?? "0" + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift new file mode 100644 index 0000000..79560e9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume). +/// `parse_ti2_header` reads only CGATS keyword lines — the data grid +/// itself belongs to issue #30. +public struct Ti2Header: Sendable, Equatable { + /// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`). + public var instrument: String? + /// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is + /// the CGATS column count, *not* the patch count. + public var patchCount: Int? + /// `NUMBER_OF_PAGES`. + public var pageCount: Int? + /// A sibling `.ti1` exists next to the parsed file. + public var hasSiblingTi1 = false + + public static func parse( + _ url: URL, + fileManager: FileManager = .default + ) -> Ti2Header { + var header = Ti2Header() + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return header + } + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") { + break + } + // CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`. + guard let space = line.firstIndex(of: " ") else { continue } + let key = String(line[.. Data? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxEdge, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, 0, options as CFDictionary + ) else { return nil } + + let out = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + out, UTType.png.identifier as CFString, 1, nil + ) else { return nil } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { return nil } + return out as Data + } +} diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index 84b7c0d..d7b7a33 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -1,20 +1,20 @@ import AppKit import UniformTypeIdentifiers -/// NSOpenPanel / NSSavePanel wrappers (issue #6). All CGATS/ICC file -/// picking in the app goes through this service — the v1 equivalent of -/// the `select_*` Tauri commands (docs/21 §Dialogs). +/// 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: - Directory + // MARK: - selectDirectory /// `#btnBrowse` — working directory for Argyll artefacts. /// Defaults to Documents (docs/06 §Empty cwd). - func chooseDirectory(startingAt start: URL? = nil) -> URL? { + func selectDirectory(startingAt start: URL? = nil) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false @@ -25,69 +25,83 @@ final class FileDialogService { return run(panel) } - // MARK: - Open files + // MARK: - Dedicated open pickers - func chooseTI1(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti1"], startingAt: start) + /// `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) } - func chooseTI2(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti2"], startingAt: start) + /// `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)") } - /// Stage 1 "Open Existing" — `.ti1` or `.ti2` (docs/06 §Resume). - func chooseExistingTarget(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti1", "ti2"], startingAt: start) + /// `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") } - func chooseTI3(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["ti3"], startingAt: start) + /// `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)") } - /// ICC/ICM picker (profiles, preconditioning, calibration `.cal`). - func chooseProfile(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["icc", "icm"], startingAt: start) + /// `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") } - func chooseCalibration(startingAt start: URL? = nil) -> URL? { - chooseFile(extensions: ["cal"], startingAt: start) + /// `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) } - func chooseFile( + /// `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? = nil, - message: String? = nil + startingAt start: URL?, + message: String? ) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false - panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } + panel.allowedContentTypes = utTypes(extensions) panel.allowsOtherFileTypes = true panel.directoryURL = start if let message { panel.message = message } return run(panel) } - // MARK: - Save - - func saveFile( - defaultName: String, - extensions: [String], - startingAt start: URL? = nil, - message: String? = nil - ) -> URL? { - let panel = NSSavePanel() - panel.nameFieldStringValue = defaultName - panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } - 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) } } - // MARK: - Internals - private func run(_ panel: NSOpenPanel) -> URL? { panel.runModal() == .OK ? panel.url : nil } diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift new file mode 100644 index 0000000..9a8b591 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift @@ -0,0 +1,124 @@ +import Testing +import Foundation +import ImageIO +import UniformTypeIdentifiers +@testable import ICCeryCore + +private func tempURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-af-\(UUID().uuidString)") + .appendingPathComponent(name) +} + +@Suite("Ti2Header") +struct Ti2HeaderTests { + @Test func parsesKeywordsAndSibling() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ti2-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try """ + CTI2 + TARGET_INSTRUMENT "i1iO" + NUMBER_OF_FIELDS 9 + NUMBER_OF_SETS 800 + NUMBER_OF_PAGES 3 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + """.write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8) + try "CGATS".write( + to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8 + ) + + let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) + #expect(h.instrument == "i1iO") + #expect(h.patchCount == 800) + #expect(h.pageCount == 3) + #expect(h.hasSiblingTi1) + } + + @Test func missingFileYieldsEmptyHeader() { + let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) + #expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) + } + + @Test func numberOfFieldsIsNotPatchCount() throws { + let url = tempURL("t.ti2") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( + to: url, atomically: true, encoding: .utf8 + ) + #expect(Ti2Header.parse(url).patchCount == 52) + } +} + +@Suite("TiffPreview") +struct TiffPreviewTests { + /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. + private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { + let url = tempURL("big.tif") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let ctx = CGContext( + data: nil, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let image = ctx.makeImage()! + + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, UTType.tiff.identifier as CFString, 1, nil + ) else { throw CocoaError(.fileWriteUnknown) } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) } + return url + } + + @Test func producesCappedPNG() throws { + let tiff = try makeTiff() + let png = TiffPreview.previewPNG(tiff: tiff) + #expect(png != nil) + // PNG magic + #expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + // Verify the cap by decoding the thumbnail header. + let src = CGImageSourceCreateWithData(png! as CFData, nil)! + let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! + #expect(max(img.width, img.height) <= TiffPreview.maxEdge) + #expect(img.width == 1200) + } + + @Test func nonTiffReturnsNil() throws { + let url = tempURL("not-tiff.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + #expect(TiffPreview.previewPNG(tiff: url) == nil) + } +} + +@Suite("ArtefactFiles") +struct ArtefactFilesTests { + @Test func base64RoundTrip() throws { + let url = tempURL("a.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + let b64 = try ArtefactFiles.readBase64(url) + #expect(Data(base64Encoded: b64) == Data("hello".utf8)) + } + + @Test func defaultWorkingDirExists() { + #expect(FileManager.default.fileExists( + atPath: ArtefactFiles.defaultWorkingDirectory().path + )) + } +} -- 2.39.5 From ee16fb3fae09d4947149e379a3224e6349dffe3b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:14:14 +0100 Subject: [PATCH 7/8] Settings store, logging & settings dialog (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSettings: snake_case Codable model — argyll_binary_dir, default_instrument (stored, never applied to argv), log_level (nil -> Debug debug / Info release), delta_e thresholds (2.0/5.0), custom_presets, enable_i1pro2_leds, calibration_stale_days 30, default_install_location user, ask_before_overwrite_profile, open_color_panel_after_install - Validation with the exact contract strings; save() refuses invalid settings; corrupt/missing JSON -> defaults; settingsDidChange notification posted on save (for #20) - LogSink: rolling file at ~/Library/Logs/com.gronod.iccery2/ iccery.log, 5 MiB x 5 segments, runtime setLevel applied at startup and on save (#158); AppLogger gates os_log+file through it - SettingsView sheet: Argyll dir picker, instrument (display-only caveat), i1Pro2 LEDs, ΔE fields + inline errors, stale days, install location, overwrite + ColorSync toggles, log level, open-log-folder / copy-path / copy-excerpt - v1 settings path never read; writes atomic via AtomicFileWriter - 13 new tests; 60/60 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Logging/AppLogger.swift | 15 +- .../Logging/RollingFileLogger.swift | 127 +++++++++++++ .../ICCeryCore/Settings/AppSettings.swift | 115 ++++++++++++ .../ICCeryCore/Settings/SettingsStore.swift | 47 +++++ Sources/ICCery/ICCeryApp.swift | 7 + Sources/ICCery/RootView.swift | 10 +- Sources/ICCery/SettingsView.swift | 169 ++++++++++++++++++ Sources/ICCery/SettingsViewModel.swift | 68 +++++++ Tests/ICCeryCoreTests/SettingsTests.swift | 164 +++++++++++++++++ 9 files changed, 709 insertions(+), 13 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift create mode 100644 Sources/ICCery/SettingsView.swift create mode 100644 Sources/ICCery/SettingsViewModel.swift create mode 100644 Tests/ICCeryCoreTests/SettingsTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift index 0012e14..c3cfdc7 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift @@ -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()) } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift new file mode 100644 index 0000000..231c30a --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift @@ -0,0 +1,127 @@ +import Foundation +import OSLog + +/// Rolling file sink for `AppLogger` — `~/Library/Logs// +/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments +/// (`iccery.log.1` … `iccery.log.5`). +/// +/// The minimum level is **runtime state** (#158): `setLevel` takes +/// effect immediately — at startup and on every settings save. +public final class LogSink: @unchecked Sendable { + + public static let shared = LogSink(fileURL: AppPaths.logFile) + + private let lock = NSLock() + private let fileURL: URL + private var minimumLevel: LogLevel + private var handle: FileHandle? + + /// 5 MiB per segment, 5 historical segments kept. + public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024 + public static let keptSegments = 5 + + public init( + fileURL: URL = AppPaths.logFile, + minimumLevel: LogLevel? = nil + ) { + self.fileURL = fileURL + #if DEBUG + self.minimumLevel = minimumLevel ?? .debug + #else + self.minimumLevel = minimumLevel ?? .info + #endif + } + + public var level: LogLevel { + lock.lock() + defer { lock.unlock() } + return minimumLevel + } + + /// Applied at startup AND on every settings save (issue #5, #158). + public func setLevel(_ level: LogLevel) { + lock.lock() + minimumLevel = level + lock.unlock() + } + + /// `nil` → DEBUG-build default (.debug) / release (.info). + public func applySettings(_ settings: AppSettings) { + setLevel(settings.effectiveLogLevel) + } + + public func shouldLog(_ level: LogLevel) -> Bool { + level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank + } + + // MARK: - Writing + + /// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line, + /// rotating first when the active segment exceeds 5 MiB. + public func write(level: LogLevel, category: String, message: String) { + guard shouldLog(level) else { return } + lock.lock() + defer { lock.unlock() } + rotateIfNeeded() + openIfNeeded() + let stamp = Self.timestamp() + let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n" + if let data = line.data(using: .utf8) { + handle?.write(data) + } + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static func timestamp() -> String { + formatter.string(from: Date()) + } + + private func openIfNeeded() { + guard handle == nil else { return } + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + handle = try? FileHandle(forWritingTo: fileURL) + try? handle?.seekToEnd() + } + + /// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the + /// writer. Oldest segment is deleted. + private func rotateIfNeeded() { + guard FileManager.default.fileExists(atPath: fileURL.path), + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let size = attrs[.size] as? UInt64, + size >= Self.maxSegmentBytes + else { return } + + try? handle?.close() + handle = nil + let fm = FileManager.default + let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)") + try? fm.removeItem(at: oldest) + for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) { + let src = fileURL.appendingPathExtension("\(i)") + let dst = fileURL.appendingPathExtension("\(i + 1)") + if fm.fileExists(atPath: src.path) { + try? fm.moveItem(at: src, to: dst) + } + } + try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1")) + } + + /// Tail of the active log for the settings dialog's "copy excerpt". + public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String { + guard let data = try? Data(contentsOf: fileURL) else { return "" } + let slice = data.suffix(maxBytes) + return String(decoding: slice, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift new file mode 100644 index 0000000..2464ac9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift @@ -0,0 +1,115 @@ +import Foundation + +/// A saved wizard preset slot (docs/22 §Presets). The preset *engine* +/// lands in issue #11; for M1 the store only needs a Codable container. +public struct CustomPreset: Codable, Equatable, Sendable { + public var name: String + /// Opaque per-stage form values — keyed by field id. + public var values: [String: String] + + public init(name: String, values: [String: String] = [:]) { + self.name = name + self.values = values + } +} + +/// Where `install_profile` drops finished profiles (docs/22). +public enum InstallLocation: String, Codable, Sendable, CaseIterable { + case user + case system +} + +/// `settings.json` model (docs/22). snake_case keys match the v1 file +/// so field names stay identical across rewrites. +public struct AppSettings: Codable, Equatable, Sendable { + + /// User override for Argyll binaries; `nil` → bundled sidecars. + public var argyllBinaryDir: String? + + /// Stored but **never applied to argv** — Stage 2's own instrument + /// select is the live source (docs/04 §0.1). + public var defaultInstrument: String? + + /// `nil` → `.debug` in debug builds, `.info` in release (#158). + public var logLevel: LogLevel? + + public var deltaEGoodMax: Double + public var deltaEWarningMax: Double + public var customPresets: [CustomPreset] + public var enableI1Pro2Leds: Bool + public var calibrationStaleDays: Int + public var defaultInstallLocation: InstallLocation + public var askBeforeOverwriteProfile: Bool + public var openColorPanelAfterInstall: Bool + + public init( + argyllBinaryDir: String? = nil, + defaultInstrument: String? = nil, + logLevel: LogLevel? = nil, + deltaEGoodMax: Double = 2.0, + deltaEWarningMax: Double = 5.0, + customPresets: [CustomPreset] = [], + enableI1Pro2Leds: Bool = false, + calibrationStaleDays: Int = 30, + defaultInstallLocation: InstallLocation = .user, + askBeforeOverwriteProfile: Bool = true, + openColorPanelAfterInstall: Bool = false + ) { + self.argyllBinaryDir = argyllBinaryDir + self.defaultInstrument = defaultInstrument + self.logLevel = logLevel + self.deltaEGoodMax = deltaEGoodMax + self.deltaEWarningMax = deltaEWarningMax + self.customPresets = customPresets + self.enableI1Pro2Leds = enableI1Pro2Leds + self.calibrationStaleDays = calibrationStaleDays + self.defaultInstallLocation = defaultInstallLocation + self.askBeforeOverwriteProfile = askBeforeOverwriteProfile + self.openColorPanelAfterInstall = openColorPanelAfterInstall + } + + public static let `default` = AppSettings() + + /// Effective log level — runtime state, not just persistence (#158). + public var effectiveLogLevel: LogLevel { + if let logLevel { return logLevel } + #if DEBUG + return .debug + #else + return .info + #endif + } + + enum CodingKeys: String, CodingKey { + case argyllBinaryDir = "argyll_binary_dir" + case defaultInstrument = "default_instrument" + case logLevel = "log_level" + case deltaEGoodMax = "delta_e_good_max" + case deltaEWarningMax = "delta_e_warning_max" + case customPresets = "custom_presets" + case enableI1Pro2Leds = "enable_i1pro2_leds" + case calibrationStaleDays = "calibration_stale_days" + case defaultInstallLocation = "default_install_location" + case askBeforeOverwriteProfile = "ask_before_overwrite_profile" + case openColorPanelAfterInstall = "open_color_panel_after_install" + } + + /// UI-facing validation. Strings are part of the contract (issue #5). + public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative." + public static let errorThresholdOrder = + "Good ΔE threshold must be strictly less than the warning threshold." + + /// All validation errors, in declaration order. Empty = valid. + public func validate() -> [String] { + var errors: [String] = [] + if deltaEGoodMax < 0 || deltaEWarningMax < 0 { + errors.append(Self.errorNegativeDeltaE) + } + if deltaEGoodMax >= deltaEWarningMax { + errors.append(Self.errorThresholdOrder) + } + return errors + } + + public var isValid: Bool { validate().isEmpty } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift new file mode 100644 index 0000000..f4a679c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Persists `AppSettings` to +/// `~/Library/Application Support/com.gronod.iccery2/settings.json` +/// (issue #5 — the v1 path is never read). +/// +/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls +/// back to defaults. Saving posts `settingsDidChange` so #20 can +/// reclassify swatches. +public final class SettingsStore: Sendable { + + /// Posted on `NotificationCenter.default` after every successful save. + public static let settingsDidChange = + Notification.Name("com.gronod.iccery2.settingsDidChange") + + public let fileURL: URL + + public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) { + self.fileURL = fileURL + } + + public func load() -> AppSettings { + guard let data = try? Data(contentsOf: fileURL), + let settings = try? JSONDecoder().decode(AppSettings.self, from: data) + else { + return .default + } + return settings + } + + /// Validates before persisting — throws `SettingsError` listing + /// every violation; nothing is written on failure. + public func save(_ settings: AppSettings) throws { + let errors = settings.validate() + guard errors.isEmpty else { + throw SettingsError.validationFailed(errors) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try AtomicFileWriter.write(encoder.encode(settings), to: fileURL) + NotificationCenter.default.post(name: Self.settingsDidChange, object: nil) + } + + public enum SettingsError: Error, Equatable { + case validationFailed([String]) + } +} diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index e43c41b..a61a29d 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -7,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") { diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 6d5f619..59f5d9f 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -29,15 +29,7 @@ struct RootView: View { .frame(minWidth: 1100, minHeight: 700) .background(Theme.background) .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") {} diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift new file mode 100644 index 0000000..085e7ff --- /dev/null +++ b/Sources/ICCery/SettingsView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import ICCeryCore + +/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with +/// the full v1 field set; ΔE validation shows inline under the fields. +struct SettingsView: View { + @State var model = SettingsViewModel() + @Environment(\.dismiss) private var dismiss + + private static let instruments: [(code: String, label: String)] = [ + ("i1", "X-Rite i1Pro / i1Pro 2"), + ("p3", "X-Rite i1Pro 3 / 3 Plus"), + ("CM", "ColorMunki"), + ("SS", "Specbos / Spectraval"), + ("20", "Gretag i1Display 2"), + ("22", "X-Rite i1Display Pro / ColorMunki Display"), + ("41", "Datacolor Spyder 4/5"), + ("51", "Spyder X"), + ] + + var body: some View { + VStack(spacing: 0) { + Form { + Section("Argyll") { + HStack { + TextField( + "Bundled sidecars", + text: Binding( + get: { model.settings.argyllBinaryDir ?? "" }, + set: { + model.settings.argyllBinaryDir = + $0.isEmpty ? nil : $0 + } + ) + ) + Button("Browse…") { + if let dir = FileDialogService.shared.selectDirectory() { + model.settings.argyllBinaryDir = dir.path + } + } + } + Text("Leave empty to use the bundled Argyll tools.") + .font(.caption) + .foregroundStyle(.secondary) + + Picker( + "Default instrument", + selection: Binding( + get: { model.settings.defaultInstrument ?? "" }, + set: { + model.settings.defaultInstrument = + $0.isEmpty ? nil : $0 + } + ) + ) { + Text("None").tag("") + ForEach(Self.instruments, id: \.code) { + Text($0.label).tag($0.code) + } + } + Text("Display-only — Stage 2's instrument select is used for actual runs.") + .font(.caption) + .foregroundStyle(.secondary) + + Toggle( + "Enable i1Pro 2 LEDs", + isOn: $model.settings.enableI1Pro2Leds + ) + } + + Section("Verification") { + HStack { + Text("Good ΔE ≤") + TextField( + "2.0", + value: $model.settings.deltaEGoodMax, + format: .number + ) + .frame(width: 60) + Text("Warning ΔE ≤") + TextField( + "5.0", + value: $model.settings.deltaEWarningMax, + format: .number + ) + .frame(width: 60) + } + ForEach(model.validationErrors, id: \.self) { error in + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section("Calibration") { + HStack { + Text("Stale after") + TextField( + "30", + value: $model.settings.calibrationStaleDays, + format: .number + ) + .frame(width: 60) + Text("days") + } + } + + Section("Profile install") { + Picker( + "Install location", + selection: $model.settings.defaultInstallLocation + ) { + Text("User library").tag(InstallLocation.user) + Text("System library").tag(InstallLocation.system) + } + Toggle( + "Ask before overwriting a profile", + isOn: $model.settings.askBeforeOverwriteProfile + ) + Toggle( + "Open ColorSync after install", + isOn: $model.settings.openColorPanelAfterInstall + ) + } + + Section("Logging") { + Picker( + "Log level", + selection: Binding( + get: { model.settings.logLevel }, + set: { model.settings.logLevel = $0 } + ) + ) { + Text("Default").tag(LogLevel?.none) + ForEach(LogLevel.allCases, id: \.self) { + Text($0.rawValue.capitalized).tag(LogLevel?.some($0)) + } + } + HStack { + Button("Open log folder") { model.openLogFolder() } + Button("Copy path") { model.copyLogPath() } + Button("Copy excerpt") { model.copyLogExcerpt() } + } + } + } + .formStyle(.grouped) + + Divider() + + HStack { + if model.savedFlash { + Text("Saved") + .foregroundStyle(.green) + .font(.callout) + } + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + if model.save() { dismiss() } + } + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(width: 560, height: 620) + .background(Theme.background) + } +} diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift new file mode 100644 index 0000000..2f81970 --- /dev/null +++ b/Sources/ICCery/SettingsViewModel.swift @@ -0,0 +1,68 @@ +import AppKit +import Foundation +import ICCeryCore + +/// Backs the Settings sheet (issue #5). Load → edit → save with +/// validation; the log level is applied live via `LogSink` (#158) and a +/// `settingsDidChange` notification fans out to #20. +@MainActor +@Observable +final class SettingsViewModel { + + var settings: AppSettings + var validationErrors: [String] = [] + var savedFlash = false + + private let store: SettingsStore + private let sink: LogSink + + init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) { + self.store = store + self.sink = sink + self.settings = store.load() + } + + /// Persists after validation. Returns false (and shows inline + /// errors) when the form is invalid. + @discardableResult + func save() -> Bool { + validationErrors = settings.validate() + guard validationErrors.isEmpty else { return false } + do { + try store.save(settings) + sink.applySettings(settings) + savedFlash = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + savedFlash = false + } + return true + } catch { + validationErrors = ["Could not save settings: \(error.localizedDescription)"] + return false + } + } + + // MARK: - Log helpers + + var logFileURL: URL { AppPaths.logFile } + + func openLogFolder() { + try? FileManager.default.createDirectory( + at: AppPaths.logDir, withIntermediateDirectories: true + ) + NSWorkspace.shared.selectFile( + AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path + ) + } + + func copyLogPath() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string) + } + + func copyLogExcerpt() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string) + } +} diff --git a/Tests/ICCeryCoreTests/SettingsTests.swift b/Tests/ICCeryCoreTests/SettingsTests.swift new file mode 100644 index 0000000..5fed795 --- /dev/null +++ b/Tests/ICCeryCoreTests/SettingsTests.swift @@ -0,0 +1,164 @@ +import Testing +import Foundation +@testable import ICCeryCore + +private func tempStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-settings-\(UUID().uuidString)") + .appendingPathComponent("settings.json") +} + +@Suite("AppSettings") +struct AppSettingsTests { + @Test func defaults() { + let s = AppSettings.default + #expect(s.argyllBinaryDir == nil) + #expect(s.defaultInstrument == nil) + #expect(s.logLevel == nil) + #expect(s.deltaEGoodMax == 2.0) + #expect(s.deltaEWarningMax == 5.0) + #expect(s.customPresets.isEmpty) + #expect(!s.enableI1Pro2Leds) + #expect(s.calibrationStaleDays == 30) + #expect(s.defaultInstallLocation == .user) + #expect(s.askBeforeOverwriteProfile) + #expect(!s.openColorPanelAfterInstall) + #expect(s.isValid) + } + + @Test func negativeThresholds() { + var s = AppSettings.default + s.deltaEGoodMax = -1 + #expect(s.validate() == [AppSettings.errorNegativeDeltaE]) + s.deltaEGoodMax = 2.0 + s.deltaEWarningMax = -0.5 + // -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too + #expect(s.validate() == [ + AppSettings.errorNegativeDeltaE, + AppSettings.errorThresholdOrder, + ]) + } + + @Test func goodMustBeStrictlyLessThanWarning() { + var s = AppSettings.default + s.deltaEGoodMax = 5.0 + #expect(s.validate() == [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 6.0 + #expect(s.validate() == [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 4.9 + #expect(s.isValid) + } + + @Test func snakeCaseKeys() throws { + let s = AppSettings.default + let data = try JSONEncoder().encode(s) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"delta_e_good_max\"")) + #expect(json.contains("\"default_install_location\"")) + #expect(json.contains("\"enable_i1pro2_leds\"")) + } +} + +@Suite("SettingsStore") +struct SettingsStoreTests { + @Test func roundTrip() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 1.5 + s.defaultInstrument = "p3" + try store.save(s) + #expect(store.load() == s) + } + + @Test func corruptJsonFallsBackToDefaults() throws { + let url = tempStoreURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "{ not json".write(to: url, atomically: true, encoding: .utf8) + #expect(SettingsStore(fileURL: url).load() == .default) + } + + @Test func missingFileReturnsDefaults() { + #expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) + } + + @Test func invalidSettingsNotPersisted() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 9.0 // >= warning 5.0 + #expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + @Test func savePostsNotification() async throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var fired = false + let token = NotificationCenter.default.addObserver( + forName: SettingsStore.settingsDidChange, object: nil, queue: nil + ) { _ in fired = true } + defer { NotificationCenter.default.removeObserver(token) } + try store.save(.default) + #expect(fired) + } +} + +@Suite("LogSink") +struct LogSinkTests { + private func tempLog() -> (URL, LogSink) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-log-\(UUID().uuidString)") + .appendingPathComponent("iccery.log") + return (url, LogSink(fileURL: url)) + } + + @Test func writesFormattedLines() { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "test", message: "hello") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + #expect(content.contains("[INFO] test: hello")) + } + + @Test func levelFilteringIsLive() { + let (url, sink) = tempLog() + sink.setLevel(.error) + sink.write(level: .info, category: "t", message: "hidden") + sink.setLevel(.info) // runtime change, no restart (#158) + sink.write(level: .info, category: "t", message: "shown") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + #expect(!content.contains("hidden")) + #expect(content.contains("shown")) + } + + @Test func rotatesAt5MiBKeeping5Segments() throws { + let (url, sink) = tempLog() + sink.setLevel(.trace) + // Pre-fill the active log just under the cap, then cross it. + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes)) + try big.write(to: url, atomically: true, encoding: .utf8) + + sink.write(level: .info, category: "t", message: "trigger rotation") + #expect(FileManager.default.fileExists( + atPath: url.appendingPathExtension("1").path + )) + // Active log is small again. + let size = try FileManager.default.attributesOfItem( + atPath: url.path + )[.size] as? UInt64 + #expect((size ?? 0) < 1024) + } + + @Test func tailExcerptCaps() throws { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "t", message: "line") + #expect(sink.tailExcerpt(maxBytes: 8).count <= 8) + } +} -- 2.39.5 From 7385cf1640064ff62ca32013b47c76b23ba64204 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:29:59 +0100 Subject: [PATCH 8/8] Wizard state machine & artefact gating (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WizardState: Codable persisted fields — currentStage, basename, cwd, printerName, sessionMode (profile|calibration), profileBasename; WizardStateStore writes wizard_state.json atomically - WizardGating: pure gating — 1 always; 2 on .ti1; 3 on .ti1+.ti2; 4 on .ti3 only (never .ti2 — #109/#110); 5 on .ti3+profile (#69). Forward gated, backward always; Stage 0 a side-trip - WizardViewModel: setTarget sanitises basename (#60) and resolves cwd (#59); locked forward nav shows a warning banner; windowDidBecomeKey re-probes and drops back to deepest unlocked when files vanish (#151) - RootView: NSWindow.didBecomeKeyNotification -> revalidate - Sidebar stepper enabled state now driven by artefact gating - 11 new tests incl. gating matrix + persistence; 71/71 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Files/ArtefactProbe.swift | 14 ++ .../ICCeryCore/Wizard/WizardGating.swift | 59 +++++++ .../ICCeryCore/Wizard/WizardState.swift | 72 +++++++++ Sources/ICCery/RootView.swift | 8 + Sources/ICCery/SidebarView.swift | 4 +- Sources/ICCery/WizardViewModel.swift | 146 ++++++++++++++++-- Tests/ICCeryCoreTests/WizardGatingTests.swift | 127 +++++++++++++++ 7 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift create mode 100644 Tests/ICCeryCoreTests/WizardGatingTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift index a090b31..94f62b0 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -12,6 +12,20 @@ public struct StageArtefacts: Sendable, Equatable { 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, diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift new file mode 100644 index 0000000..91973d0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Artefact-driven stage gating (issue #4, docs/06 §Stages). +/// +/// Navigation is *disk*, not buttons: a stage unlocks only when its +/// predecessor artefacts exist. Forward moves are gated; backward is +/// always allowed. Gating is re-evaluated on window focus and on stage +/// entry (#151 — files can disappear in Finder). +public enum WizardGating { + + /// Whether `stage` is reachable given the probed artefacts. + /// + /// - Stage 0 (calibrate): always — it is out-of-band, not gated. + /// - Stage 1: always. + /// - Stage 2: `.ti1` exists. + /// - Stage 3: `.ti1` **and** `.ti2`. + /// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2` + /// alone never unlocks it; #109/#110). + /// - Stage 5: `.ti3` **and** `.icc`/`.icm`. + public static func isUnlocked( + _ stage: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + switch stage { + case .calibrate: return true + case .generate: return true + case .layOutPrint: return artefacts.stage1Complete + case .measure: return artefacts.stage1Complete && artefacts.stage2Complete + case .buildProfile: return artefacts.stage3Complete + case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete + } + } + + /// Whether `go(to:)` may proceed. Backward moves and the current + /// stage are always allowed; forward moves must be unlocked. + public static func canNavigate( + to target: WizardStage, + from current: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + if target == current { return true } + if target == .calibrate || current == .calibrate { + // Stage 0 is a side-trip, not stepper navigation. + return true + } + if target.rawValue < current.rawValue { return true } + return isUnlocked(target, artefacts: artefacts) + } + + /// The deepest unlocked stepper stage — used when revalidation + /// locks the current stage (#151). + public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage { + for stage in WizardStage.stepperStages.reversed() + where isUnlocked(stage, artefacts: artefacts) { + return stage + } + return .generate + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift new file mode 100644 index 0000000..85da63e --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Session mode (docs/06 §wizardState). `"calibration"` is set while +/// Stage 0 is driving a `CAL_` chart through the same pipeline. +public enum SessionMode: String, Codable, Sendable { + case profile + case calibration +} + +/// Persisted wizard state (docs/06 §wizardState fields) — +/// `wizard_state.json` in app data. +public struct WizardState: Codable, Equatable, Sendable { + /// 0–5 (`WizardStage.rawValue`). + public var currentStage: Int + /// Run name without extension — never invented (#60). + public var basename: String + /// Working directory for artefacts; empty → `resolveSafeCwd` (#59). + public var cwd: String + /// Last spooled printer, for calibration drift history. + public var printerName: String? + public var sessionMode: SessionMode + /// May differ from `basename` after a `.ti3` import (#94). + public var profileBasename: String? + + public init( + currentStage: Int = WizardStage.generate.rawValue, + basename: String = "", + cwd: String = "", + printerName: String? = nil, + sessionMode: SessionMode = .profile, + profileBasename: String? = nil + ) { + self.currentStage = currentStage + self.basename = basename + self.cwd = cwd + self.printerName = printerName + self.sessionMode = sessionMode + self.profileBasename = profileBasename + } + + public static let `default` = WizardState() + + /// The stage a saved `currentStage` resolves to, clamped to a valid + /// value (corrupt ints fall back to Stage 1). + public var stage: WizardStage { + WizardStage(rawValue: currentStage) ?? .generate + } +} + +/// Atomic JSON persistence for `WizardState` (issue #4). +public final class WizardStateStore: Sendable { + public let fileURL: URL + + public init( + fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json") + ) { + self.fileURL = fileURL + } + + public func load() -> WizardState { + guard let data = try? Data(contentsOf: fileURL), + let state = try? JSONDecoder().decode(WizardState.self, from: data) + else { return .default } + return state + } + + public func save(_ state: WizardState) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try AtomicFileWriter.write(encoder.encode(state), to: fileURL) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 59f5d9f..3a38f5c 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Root layout: 270 pt sidebar + main stage area with the notification @@ -28,6 +29,13 @@ 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) { SettingsView() } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0cefc64..7774bed 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -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) } diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 4c405bf..2b0b9dc 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -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? - /// `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) + } } diff --git a/Tests/ICCeryCoreTests/WizardGatingTests.swift b/Tests/ICCeryCoreTests/WizardGatingTests.swift new file mode 100644 index 0000000..4ebf930 --- /dev/null +++ b/Tests/ICCeryCoreTests/WizardGatingTests.swift @@ -0,0 +1,127 @@ +import Testing +import Foundation +@testable import ICCeryCore + +private func artefacts( + ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false +) -> StageArtefacts { + var a = StageArtefacts() + a.stage1Complete = ti1 + a.stage2Complete = ti2 + a.stage3Complete = ti3 + a.stage4Complete = profile + if profile { + a.profilePath = URL(fileURLWithPath: "/x/t.icc") + } + return a +} + +@Suite("WizardGating matrix") +struct WizardGatingTests { + + @Test func emptyProjectOnlyStage1() { + let a = artefacts() + #expect(WizardGating.isUnlocked(.generate, artefacts: a)) + #expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) + for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { + #expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") + } + } + + @Test func ti1UnlocksStage2Only() { + let a = artefacts(ti1: true) + #expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) + #expect(!WizardGating.isUnlocked(.measure, artefacts: a)) + #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) + } + + @Test func stage3NeedsTi1AndTi2() { + #expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) + #expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) + } + + @Test func stage4NeedsTi3NotTi2() { + // #109/#110: .ti2 alone must never unlock Stage 4. + let a = artefacts(ti1: true, ti2: true) + #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) + #expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) + } + + @Test func stage5NeedsTi3AndProfile() { + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) + #expect(WizardGating.isUnlocked( + .verifyInstall, artefacts: artefacts(ti3: true, profile: true) + )) + } + + @Test func forwardGatedBackwardFree() { + let a = artefacts() + #expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) + // Backward always allowed even when artefacts vanished. + #expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) + // Same stage is a no-op. + #expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) + // Stage 0 is a side-trip, never gated. + #expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) + } + + @Test func deepestUnlocked() { + #expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) + #expect(WizardGating.deepestUnlocked( + artefacts: artefacts(ti1: true, ti2: true) + ) == .measure) + #expect(WizardGating.deepestUnlocked( + artefacts: artefacts(ti3: true, profile: true) + ) == .verifyInstall) + } +} + +@Suite("WizardStateStore") +struct WizardStateStoreTests { + private func tempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-wiz-\(UUID().uuidString)") + .appendingPathComponent("wizard_state.json") + } + + @Test func roundTrip() throws { + let url = tempURL() + let store = WizardStateStore(fileURL: url) + var s = WizardState() + s.currentStage = 3 + s.basename = "run-42" + s.cwd = "/tmp/charts" + s.sessionMode = .calibration + s.profileBasename = "imported" + try store.save(s) + #expect(store.load() == s) + } + + @Test func missingFileDefaults() { + let s = WizardStateStore(fileURL: tempURL()).load() + #expect(s == .default) + #expect(s.stage == .generate) + #expect(s.sessionMode == .profile) + } + + @Test func corruptStageFallsBackToGenerate() throws { + let url = tempURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# + .write(to: url, atomically: true, encoding: .utf8) + #expect(WizardStateStore(fileURL: url).load().stage == .generate) + } + + @Test func sessionModeCalibrationRoundTrips() throws { + var s = WizardState(sessionMode: .calibration) + let data = try JSONEncoder().encode(s) + let decoded = try JSONDecoder().decode(WizardState.self, from: data) + #expect(decoded.sessionMode == .calibration) + s.sessionMode = .profile + #expect(s.sessionMode == .profile) + } +} -- 2.39.5