Author SHA1 Message Date
gronod d4261ba2c9 Implement M7 consolidation (#79–#86)
Extract ArgyllRunner.runStreamingTool and collapse toolFailed errors.
Share ProcessManager process factory and ROW_COLORS_JSON emission.
Add JSONFileStore, CalibrationIdentity, PresetMapping, and ArgsBuilder.
Unify Notice-backed print/finish banners, ProcessLogView, and log sinks.
Split PrintSessionViewModel out of TargetWorkflowViewModel.
Merge XYZ/Lab Codable types, drop StagePlaceholderView, and add unit tests.
2026-09-10 17:44:26 +00:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2e8b07c8f3 fix(calibration): observe WizardViewModel directly in CalibrationView
macOS CI / build-and-test (push) Failing after 5m47s
macOS CI / package (push) Skipped
The Milestone 6 calibration UI test could not find `btnCreateLayout` because
`CalibrationView` only bound `CalibrationViewModel`. Its `canGenerate`
computed property reads `wizard.basename` and `wizard.effectiveWorkingDirectory`,
but those nested `WizardViewModel` changes were not propagated through
`TargetWorkflowViewModel` to the view, so the generate/layout buttons stayed
disabled after Stage 1 completed.

Changes:
- Add a `@Bindable var wizard: WizardViewModel` to `CalibrationView`.
- Pass `workflow.wizard` from `WizardStageContent` into `CalibrationView`.
- Replace `.disabled(!model.canGenerate)` with direct reads of
  `wizard.basename`, `wizard.effectiveWorkingDirectory`, and `model.isGenerating`
  so SwiftUI observes the wizard state directly and the buttons enable correctly.

Fixes the CI failure in
`Milestone6CalibrationUITests.testCalibrationDashboardOpensAndCanGenerate`.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 14:38:30 +01:00
gronod 03d6558708 ci(macos): export system CA bundle for artifact upload
macOS CI / build-and-test (push) Failing after 11m21s
macOS CI / package (push) Skipped
Export macOS system certificates to NODE_EXTRA_CA_CERTS before uploading the DMG artifact so the actions/upload-artifact step trusts the Gitea server's TLS chain.
2026-09-10 12:34:59 +01:00
gronod e0012dc344 Merge pull request 'Apply v1 ICCery app icon, About icon, and DMG scene' (#78) from feat/77-apply-v1-icon into develop
macOS CI / build-and-test (push) Successful in 10m1s
macOS CI / package (push) Failing after 4m42s
Reviewed-on: #78
2026-09-10 11:45:29 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e424eefa5a feat: apply v1 ICCery app icon and DMG scene
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 3m50s
Port the original cone icon into the macOS asset catalog, About dialog, and DMG artwork so v2 retains the established ICCery identity.

Issue #77

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 11:39:46 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 63ec0bde63 fix(calibration): strong task capture and bind WizardViewModel for stage transitions (#32)
The Milestone 6 calibration UI test was flaky/consistent in failing because
`generateTarget` / `computeCurves` used `Task { @MainActor [weak self] in`
with an early `guard let self else { return }`. On a loaded host the task
could be dropped before it resumed, leaving the UI stuck on the calibration
dashboard with no error shown.

Changes:
- Capture `self` strongly in `CalibrationViewModel` async tasks so they run
  to completion and update `wizard.go(to:)` / `restoreCalibration()`.
- Split `RootView.stageContent` into `WizardStageContent` with its own
  `@Bindable var model: WizardViewModel` so `model.stage` mutations are
  observed reliably, rather than relying on observation through the parent
  `TargetWorkflowViewModel`.

Fixes the CI failure in
`Milestone6CalibrationUITests.testCalibrationDashboardOpensAndCanGenerate`.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 11:06:04 +01:00
gronod 6309f9fd12 chore: ignore docs/megaplans/ directory
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 30s
2026-09-10 09:51:28 +01:00
gronod 43d35515e1 Merge remote-tracking branch 'origin/develop' into develop
macOS CI / build-and-test (push) Failing after 17m16s
macOS CI / package (push) Skipped
2026-09-10 09:27:44 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a110eb81e6 fix(packaging): skip notarization entirely for ad-hoc builds
Notarization requires a Developer ID signature. When CODESIGN_IDENTITY
is unset/"-" or any NOTARIZE_* secret is missing, exit the script
without printing a misleading "skipping" line — the DMG is already
usable.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 09:24:39 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d40f0052d2 ci(macos): restore workflow, fix test signing and packaging (#32)
macOS CI / build-and-test (push) Successful in 10m54s
macOS CI / package (push) Failing after 5m2s
- Keep CI active on develop (push + PR) and tags v*.
- Generate the project with `xcodegen generate --spec project.yml`.
- build-for-testing / test-without-building share one derivedDataPath,
  destination, universal archs, and ad-hoc signing flags. The XCTest
  runner is never re-signed with --options runtime.
- Package job runs package-release.sh after tests pass and uploads the
  ICCery-*.dmg the script actually writes.
- README documents the env vars the pipeline reads.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 09:01:43 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 80408dd682 fix(packaging): use fixed derived-data path, correct sign/verify flow
package-release.sh previously parsed -showBuildSettings for the app path,
which could disagree with the actual build. Now use a single
derivedDataPath and locate Release/ICCery.app directly. Add Developer ID
signing with --timestamp and a post-sign verify, while leaving sidecars
ad-hoc. Re-verify sidecars after app signing and re-ad-hoc any that were
stripped. Install dmgbuild in a venv and use DMG_APP env.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 09:01:43 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2ffcabbd52 fix(packaging): stage the built .app into the DMG (#32)
dmgbuild never set `files`, so the volume mounted empty. Export DMG_APP
from package-release.sh and require it in dmgbuild-settings.py, copying
the bundle by its source path. Keep the Applications symlink and the
optional background fallback.

Verified by mounting a test image and seeing ICCery.app + Applications.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 09:01:43 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 85f0de631c fix(packaging): make sidecar sign/verify recursive (#32, #165)
The Argyll tree inside the bundle is nested (macos-universal/, mocks/),
so a top-level-only scan left real binaries unverified. Scan recursively
under Contents/Resources/Argyll, hard-fail on any unsigned Mach-O or a
missing/empty sidecar dir, and sign fetched tools recursively too.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 09:01:43 +01:00
gronod f7ba49d3d5 Merge pull request 'fix(uitest): resolve AboutHelp close button lookup on runner' (#75) from fix/m7-ci-uitest-runner-sign into develop
macOS CI / build-and-test (push) Successful in 9m0s
macOS CI / package (push) Failing after 2m58s
Reviewed-on: #75
2026-09-10 08:07:35 +01:00
gronod 3b4e61ef02 Merge pull request 'refactor: extract launchApp helper in AboutHelpUITests' (#74) from fix/m7-ci-uitest-runner-sign into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #74
2026-09-10 07:14:39 +01:00
gronod 288e534d4d Merge pull request 'Fix/m7 ci uitest runner sign' (#73) from fix/m7-ci-uitest-runner-sign into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #73
2026-09-10 06:59:06 +01:00
gronod 77fd8937f0 Merge pull request 'ci: do not manually re-sign UI test runner; keep app re-sign with entitlements' (#72) from fix/m7-ci-uitest-runner-sign into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #72
2026-09-10 03:33:18 +01:00
56 changed files with 1438 additions and 833 deletions
+13
View File
@@ -67,8 +67,21 @@ jobs:
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }} NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Prepare Node CA bundle
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
/System/Library/Keychains/SystemRootCertificates.keychain \
/Library/Keychains/System.keychain \
> "$NODE_CA_FILE" 2>/dev/null || true
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
fi
- name: Upload DMG artifact - name: Upload DMG artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
with: with:
name: iccery-dmg name: iccery-dmg
path: ICCery-*.dmg path: ICCery-*.dmg
+1
View File
@@ -27,3 +27,4 @@ ICCery.xcodeproj/
Release/ Release/
notarization/ notarization/
build/ build/
docs/megaplans/
+1
View File
@@ -16,6 +16,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` | | M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility | | M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile | | M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | | Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.** Issue **16 is not an M3 or M6 exit gate.**
@@ -0,0 +1,35 @@
import Foundation
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum
/// `-d` / `-u` / `-r` still mean different things per binary.
public enum ArgsBuilder {
/// `["-f", value]` when `value` is non-nil.
public static func option(_ flag: String, _ value: String?) -> [String] {
guard let value else { return [] }
return [flag, value]
}
/// `["-f", trimmed]` when trimmed is non-empty.
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else { return [] }
return [flag, raw]
}
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
public static func optionUnlessApprox(
_ flag: String,
_ value: Double?,
skip: Double,
epsilon: Double = 0.001,
format: String = "%.2f"
) -> [String] {
guard let value, abs(value - skip) >= epsilon else { return [] }
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
}
/// Bare flag when `when` is true.
public static func flag(_ flag: String, when: Bool) -> [String] {
when ? [flag] : []
}
}
@@ -2,43 +2,41 @@ import Foundation
/// Errors from `ArgyllRunner` executions. /// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String]) case toolFailed(tool: String, code: Int32, logs: [String])
case missingArtefact(String) case missingArtefact(String)
case malformedManifest(String) case malformedManifest(String)
case instrumentDetectionFailed(String) case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case printcalFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
case profcheckUnparseable case profcheckUnparseable
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .processFailed(let code, _): case .toolFailed(let tool, let code, let logs):
return "Process exited with code \(code)" let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
?? "exited with code \(code)"
switch tool {
case "chartread":
return "Chartread failed: \(detail)"
case "average":
return "Averaging failed: \(detail)"
case "colprof":
return "Profile creation failed: \(detail)"
case "printcal":
return "Calibration curve computation failed: \(detail)"
case "applycal":
return "Apply calibration failed: \(detail)"
case "iccgamut":
return "Gamut extraction failed: \(detail)"
case "profcheck":
return "Profile verification failed: \(detail)"
default:
return "Process exited with code \(code)"
}
case .missingArtefact(let path): case .missingArtefact(let path):
return "Expected output file was not created: \(path)" return "Expected output file was not created: \(path)"
case .malformedManifest(let reason): case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)" return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason): case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(reason)" return "Instrument detection failed: \(reason)"
case .chartreadFailed(let reason):
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .printcalFailed(let reason):
return "Calibration curve computation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
return "Gamut extraction failed: \(reason)"
case .profcheckFailed(let reason):
return "Profile verification failed: \(reason)"
case .profcheckUnparseable: case .profcheckUnparseable:
return "Profile verification produced unparseable output" return "Profile verification produced unparseable output"
} }
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
self.binaryResolver = binaryResolver self.binaryResolver = binaryResolver
} }
// MARK: - Shared streaming loop (issue #79)
private func runStreamingTool(
name: String,
id: String,
arguments: [String],
workingDirectory: URL?,
flushPartialLines: Bool = false,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> CollectedRun {
let binaryURL = binaryResolver.resolve(name)
await ensureNotRunning(id: id)
let events = processManager.events()
try await processManager.runStreaming(
id: id,
binary: binaryURL,
arguments: arguments,
workingDirectory: workingDirectory
)
let run = await collect(
id: id,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: flushPartialLines
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.toolFailed(
tool: name,
code: run.exitCode ?? -1,
logs: run.lines
)
}
return run
}
private func requireArtefact(_ url: URL) throws -> URL {
guard FileManager.default.fileExists(atPath: url.path) else {
throw ArgyllRunnerError.missingArtefact(url.path)
}
return url
}
// MARK: - targen (Stage 1) // MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1` /// Runs `targen` streaming, collecting logs and verifying `.ti1`
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename) let processId = ProcessID.targen(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "targen",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else { return try requireArtefact(ti1URL)
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
} }
// MARK: - printtarg (Stage 2) // MARK: - printtarg (Stage 2)
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config) let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename) let processId = ProcessID.printtarg(cleanBasename)
let run = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "printtarg",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch) let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
}
let manifest: PrinttargManifest let manifest: PrinttargManifest
do { do {
@@ -339,28 +357,16 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL { ) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename) let processId = ProcessID.average(config.basename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "average",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
}
let canonical = cwd.appendingPathComponent("\(config.basename).ti3") let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else { return try requireArtefact(canonical)
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
} }
// MARK: - colprof (Stage 4) // MARK: - colprof (Stage 4)
@@ -374,29 +380,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename) let processId = ProcessID.colprof(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "colprof",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
flushPartialLines: true,
onLogBatch: onLogBatch
) )
let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`. // Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69. // `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
@@ -456,16 +448,20 @@ public struct ArgyllRunner: Sendable {
if Task.isCancelled { if Task.isCancelled {
throw CancellationError() throw CancellationError()
} }
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
result.stderr.isEmpty tool: "applycal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)" ? "applycal exited with code \(result.exitCode)"
: result.stderr : result.stderr]
) )
} }
guard fm.fileExists(atPath: tmpURL.path) else { guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
"applycal did not create temp profile" tool: "applycal",
code: -1,
logs: ["applycal did not create temp profile"]
) )
} }
@@ -473,8 +469,10 @@ public struct ArgyllRunner: Sendable {
let size = attrs?[.size] as? UInt64 ?? 0 let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else { guard size >= 128 else {
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
"calibrated profile is too small (\(size) bytes)" tool: "applycal",
code: -1,
logs: ["calibrated profile is too small (\(size) bytes)"]
) )
} }
@@ -486,7 +484,11 @@ public struct ArgyllRunner: Sendable {
} }
} catch { } catch {
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription) throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: [error.localizedDescription]
)
} }
return inputURL return inputURL
@@ -503,30 +505,16 @@ public struct ArgyllRunner: Sendable {
let cwd = profileURL.deletingLastPathComponent() let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config) let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem) let processId = ProcessID.iccgamut(stem: stem)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "iccgamut",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam") let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else { return try requireArtefact(gamURL)
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
} }
// MARK: - profcheck (Stage 5 verification) // MARK: - profcheck (Stage 5 verification)
@@ -539,30 +527,18 @@ public struct ArgyllRunner: Sendable {
let cwd = config.ti3URL.deletingLastPathComponent() let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path let ti3Path = config.ti3URL.path
let iccURL = Self.resolveProfileForVerification(config.iccURL) let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL) let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config) let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path) let processId = ProcessID.profcheck(ti3Path: ti3Path)
let run = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "profcheck",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines) let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
@@ -572,15 +548,6 @@ public struct ArgyllRunner: Sendable {
return report return report
} }
private static func resolveProfileForVerification(_ url: URL) -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return url }
let alt = url.pathExtension.lowercased() == "icc"
? url.deletingPathExtension().appendingPathExtension("icm")
: url.deletingPathExtension().appendingPathExtension("icc")
return fm.fileExists(atPath: alt.path) ? alt : url
}
// MARK: - chartread (Stage 3 interactive) // MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events. /// Runs `chartread` and returns an `AsyncStream` of typed events.
@@ -596,7 +563,7 @@ public struct ArgyllRunner: Sendable {
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch { } catch {
return AsyncStream { continuation in return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription))) continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish() continuation.finish()
} }
} }
@@ -606,7 +573,7 @@ public struct ArgyllRunner: Sendable {
args = try ChartreadArgs.build(config: config) args = try ChartreadArgs.build(config: config)
} catch { } catch {
return AsyncStream { continuation in return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription))) continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish() continuation.finish()
} }
} }
@@ -637,7 +604,7 @@ public struct ArgyllRunner: Sendable {
workingDirectory: cwd workingDirectory: cwd
) )
} catch { } catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription))) continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish() continuation.finish()
return return
} }
@@ -724,7 +691,11 @@ public struct ArgyllRunner: Sendable {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path))) continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
} }
} else { } else {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)"))) continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "chartread",
code: exitCode ?? -1,
logs: ["chartread exited with code \(exitCode ?? -1)"]
)))
} }
continuation.finish() continuation.finish()
} }
@@ -768,30 +739,19 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL { ) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)" let cleanBasename = try PathSecurity.sanitizeBasename(
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename) CalibrationIdentity.prefix(config.basename)
let binaryURL = binaryResolver.resolve("targen") )
let processId = ProcessID.targen(cleanBasename) let processId = ProcessID.targen(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "targen",
let events = processManager.events() id: processId,
try await processManager.runStreaming( arguments: args,
id: processId, workingDirectory: cwd,
binary: binaryURL, onLogBatch: onLogBatch
arguments: args,
workingDirectory: cwd
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else { return try requireArtefact(ti1URL)
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
} }
/// Computes a `.cal` curve from a measured `CAL_*.ti3`. /// Computes a `.cal` curve from a measured `CAL_*.ti3`.
@@ -805,7 +765,7 @@ public struct ArgyllRunner: Sendable {
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let binaryURL = binaryResolver.resolve("printcal") let binaryURL = binaryResolver.resolve("printcal")
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)" let calBasename = CalibrationIdentity.prefix(config.ti3Basename)
let processId = ProcessID.printcal(calBasename) let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId) await ensureNotRunning(id: processId)
@@ -821,10 +781,12 @@ public struct ArgyllRunner: Sendable {
} }
guard result.exitCode == 0 else { guard result.exitCode == 0 else {
throw ArgyllRunnerError.printcalFailed( throw ArgyllRunnerError.toolFailed(
result.stderr.isEmpty tool: "printcal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)" ? "printcal exited with code \(result.exitCode)"
: result.stderr : result.stderr]
) )
} }
@@ -69,7 +69,7 @@ public enum PrinttargArgs {
} }
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"), if !CalibrationIdentity.isCalibration(cleanBasename),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines), let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty { !cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal]) args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
@@ -78,6 +78,18 @@ public enum ArtefactProbe {
return nil return nil
} }
/// Resolve an explicit profile URL, flipping `.icc` `.icm` when the
/// requested path is missing (#69 / issue #83).
public static func resolveProfile(
_ url: URL,
fileManager: FileManager = .default
) -> URL {
if fileManager.fileExists(atPath: url.path) { return url }
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
return fileManager.fileExists(atPath: alt.path) ? alt : url
}
/// Default extension for a *new* profile on macOS (#69). /// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc" public static let defaultProfileExtension = "icc"
@@ -0,0 +1,84 @@
import Foundation
/// Policy when a JSON file exists but cannot be decoded.
public enum JSONCorruptPolicy: Sendable {
/// Return `defaultValue` and leave the file untouched.
case replaceWithDefault
/// Throw the decode error. Callers must not overwrite the file.
case throwCorrupt
}
/// Shared pretty-printed JSON file façade used by settings, wizard state,
/// and verification history.
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
public let fileURL: URL
public let corrupt: JSONCorruptPolicy
private let defaultValue: @Sendable () -> T
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
fileURL: URL,
corrupt: JSONCorruptPolicy,
defaultValue: @escaping @Sendable () -> T,
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
) {
self.fileURL = fileURL
self.corrupt = corrupt
self.defaultValue = defaultValue
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = dateEncoding
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
self.decoder = decoder
}
/// Encodes `value` with the shared pretty / sorted-keys encoder.
public func encodePretty(_ value: T) throws -> Data {
try encoder.encode(value)
}
public func load() throws -> T {
let fm = FileManager.default
guard fm.fileExists(atPath: fileURL.path) else {
return defaultValue()
}
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
}
public func save(_ value: T) throws {
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
}
}
extension JSONEncoder {
/// Pretty-printed, sorted-keys encoder used by preset export.
public static func icceryPretty() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
@@ -51,58 +51,6 @@ public struct PatchColor: Codable, Sendable, Equatable {
} }
} }
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable { public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int public let bands: Int
public let startNM: Double public let startNM: Double
@@ -154,11 +154,9 @@ public enum ColorDifference {
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent. /// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? { public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab { if let lab = color.lab { return lab }
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil } guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z)) return LabColorMath.xyzToLab(xyz)
} }
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double { private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
@@ -1,7 +1,8 @@
import Foundation import Foundation
/// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork. /// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork.
public struct XYZColor: Sendable, Equatable { /// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
public struct XYZColor: Codable, Sendable, Equatable {
public let x: Double public let x: Double
public let y: Double public let y: Double
public let z: Double public let z: Double
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
self.y = y self.y = y
self.z = z self.z = z
} }
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
} }
/// CIELab value (D50). /// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
public struct LabColor: Sendable, Equatable { public struct LabColor: Codable, Sendable, Equatable {
public let l: Double public let l: Double
public let a: Double public let a: Double
public let b: Double public let b: Double
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
self.a = a self.a = a
self.b = b self.b = b
} }
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
} }
/// JSON aliases used by `chartread` row payloads.
public typealias CIEXYZ = XYZColor
public typealias CIELab = LabColor
/// sRGB colour in 01 display space. /// sRGB colour in 01 display space.
public struct DisplayRGB: Sendable, Equatable { public struct DisplayRGB: Sendable, Equatable {
public let r: Double public let r: Double
@@ -136,17 +136,14 @@ public actor ProcessManager {
) throws { ) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) } guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process() let prepared = makeProcess(
let stdinPipe = Pipe() binary: binary,
let stdoutPipe = Pipe() arguments: arguments,
let stderrPipe = Pipe() workingDirectory: workingDirectory,
process.executableURL = binary environment: environment,
process.arguments = arguments includeStdin: true
process.currentDirectoryURL = workingDirectory )
process.standardInput = stdinPipe let process = prepared.process
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug( AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" "spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
@@ -154,13 +151,13 @@ public actor ProcessManager {
children[id] = RunningChild( children[id] = RunningChild(
process: process, process: process,
stdin: stdinPipe.fileHandleForWriting, stdin: prepared.stdinPipe?.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(), stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder() stderrDecoder: ProcessLineDecoder()
) )
let stdoutHandle = stdoutPipe.fileHandleForReading let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading let stderrHandle = prepared.stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData let data = handle.availableData
guard let self else { return } guard let self else { return }
@@ -172,21 +169,13 @@ public actor ProcessManager {
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) } Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
} }
process.terminationHandler = { [weak self] proc in attachExitWatchdog(process) { [weak self] code in
guard let self else { return } guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) } Task { await self.didTerminate(id: id, code: code) }
} }
do { do {
try process.run() try process.run()
// Fallback watchdog: very fast child exits can race past the
// terminationHandler delivery on a loaded host. waitUntilExit()
// blocks the detached thread and guarantees didTerminate runs.
Task.detached { [weak self, process] in
process.waitUntilExit()
guard let self else { return }
await self.didTerminate(id: id, code: process.terminationStatus)
}
} catch { } catch {
preKillHooks.removeValue(forKey: id) preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id) children.removeValue(forKey: id)
@@ -210,15 +199,16 @@ public actor ProcessManager {
) async throws -> CapturedResult { ) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) } guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process() let prepared = makeProcess(
let stdoutPipe = Pipe() binary: binary,
let stderrPipe = Pipe() arguments: arguments,
process.executableURL = binary workingDirectory: workingDirectory,
process.arguments = arguments environment: environment,
process.currentDirectoryURL = workingDirectory includeStdin: false
process.standardOutput = stdoutPipe )
process.standardError = stderrPipe let process = prepared.process
process.environment = childEnvironment(extra: environment) let stdoutPipe = prepared.stdoutPipe
let stderrPipe = prepared.stderrPipe
AppLogger(category: "process").debug( AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" "spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
@@ -275,20 +265,12 @@ public actor ProcessManager {
} }
} }
let box = Box() let box = Box()
capturedProcess.terminationHandler = { proc in attachExitWatchdog(capturedProcess) { status in
_ = box.resume(with: proc.terminationStatus) _ = box.resume(with: status)
} }
do { do {
try process.run() try process.run()
// Fallback watchdog: very fast child exits can race past the
// terminationHandler delivery on a loaded host. waitUntilExit()
// blocks the detached thread and resumes the box if the handler
// did not already do so (#50, #52).
Task.detached { [capturedProcess] in
capturedProcess.waitUntilExit()
_ = box.resume(with: capturedProcess.terminationStatus)
}
} catch { } catch {
_ = box.resume(with: -1) _ = box.resume(with: -1)
captured.removeValue(forKey: id) captured.removeValue(forKey: id)
@@ -371,12 +353,7 @@ public actor ProcessManager {
guard var child = children[id], !child.finalized else { return } guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() { if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) { emitStdoutLine(id: id, line: tail)
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
} }
if let tail = child.stderrDecoder.flushPartial() { if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail)) emit(.stderr(id: id, line: tail))
@@ -447,6 +424,65 @@ public actor ProcessManager {
// MARK: - Internals // MARK: - Internals
private struct PreparedProcess {
let process: Process
let stdinPipe: Pipe?
let stdoutPipe: Pipe
let stderrPipe: Pipe
}
private func makeProcess(
binary: URL,
arguments: [String],
workingDirectory: URL?,
environment: [String: String],
includeStdin: Bool
) -> PreparedProcess {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let stdinPipe {
process.standardInput = stdinPipe
}
process.environment = childEnvironment(extra: environment)
return PreparedProcess(
process: process,
stdinPipe: stdinPipe,
stdoutPipe: stdoutPipe,
stderrPipe: stderrPipe
)
}
/// terminationHandler can lose a fast-exit race on a loaded host;
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
private func attachExitWatchdog(
_ process: Process,
onExit: @escaping @Sendable (Int32) -> Void
) {
process.terminationHandler = { proc in
onExit(proc.terminationStatus)
}
Task.detached { [process] in
process.waitUntilExit()
onExit(process.terminationStatus)
}
}
private func emitStdoutLine(id: String, line: String) {
if line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: line))
}
}
private func childEnvironment(extra: [String: String]) -> [String: String] { private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1" env["ARGYLL_NOT_INTERACTIVE"] = "1"
@@ -478,15 +514,14 @@ public actor ProcessManager {
let log = AppLogger(category: "subprocess") let log = AppLogger(category: "subprocess")
for line in lines { for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) { if !isStderr {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8) emitStdoutLine(id: id, line: line)
emit(.jsonRow(id: id, payload: payload)) if !line.hasPrefix(Self.rowColorsPrefix) {
} else if isStderr { log.info("[\(id)] \(line)")
}
} else {
log.warn("[\(id)] \(line)") log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line)) emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
} }
} }
} }
@@ -528,11 +563,7 @@ public actor ProcessManager {
// Flush unterminated tail lines. // Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder), if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() { let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) { emitStdoutLine(id: id, line: tail)
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), if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() { let tail = decoder.finish() {
@@ -0,0 +1,54 @@
import Foundation
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
///
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
/// and the runner all derive identity from this type. Do not add a second
/// `hasPrefix("CAL_")` ternary elsewhere.
public struct CalibrationIdentity: Equatable, Sendable {
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
public var originalBasename: String
/// Always `CAL_{original}` when original is non-empty.
public var calibrationBasename: String
public init(originalBasename: String, calibrationBasename: String) {
self.originalBasename = originalBasename
self.calibrationBasename = calibrationBasename
}
public static func isCalibration(_ basename: String) -> Bool {
basename.hasPrefix("CAL_")
}
/// The only place that adds a `CAL_` prefix.
public static func prefix(_ original: String) -> String {
if original.isEmpty { return original }
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
}
/// Strip a single leading `CAL_` if present.
public static func strip(_ basename: String) -> String {
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
}
/// Derive identity from the live wizard basename and the persisted
/// original. A non-empty persisted original wins over a `CAL_` live
/// name (Force Quit mid-calibration).
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
if liveBasename.isEmpty && persistedOriginal.isEmpty {
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
}
let original: String
if liveBasename.hasPrefix("CAL_") {
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
} else if liveBasename.isEmpty {
original = persistedOriginal
} else {
original = liveBasename
}
return CalibrationIdentity(
originalBasename: original,
calibrationBasename: prefix(original)
)
}
}
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
args.append(contentsOf: ["-l", "\(inkLimit)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename) args.append(calBasename)
return args return args
} }
@@ -26,18 +26,13 @@ public enum ColprofArgs {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"] var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm]) args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality]) args.append(contentsOf: ["-q", config.quality])
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) { if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() { switch fwa.lowercased() {
case "none", "": case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty { if fwa.isEmpty {
args.append("-f") args.append("-f")
} }
@@ -46,32 +41,20 @@ public enum ColprofArgs {
} }
} }
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty { args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
args.append(contentsOf: ["-i", illuminant]) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces), if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" { !inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond]) args.append(contentsOf: ["-c", inputCond])
} }
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces), if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" { !outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond]) args.append(contentsOf: ["-d", outputCond])
} }
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
if let description = profileDescription, !description.isEmpty { args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename) args.append(cleanBasename)
return args return args
@@ -107,7 +107,7 @@ public enum PrintcalArgs {
} }
args.append(contentsOf: ["-o", config.outputURL.path]) args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename) args.append(calBasename)
return args return args
} }
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
private var records: [VerificationRecord] = [] private var records: [VerificationRecord] = []
private let capacity: Int private let capacity: Int
private let encoder: JSONEncoder private let fileStore: JSONFileStore<[VerificationRecord]>
private let decoder: JSONDecoder
public init( public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"), url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
) { ) {
self.url = url self.url = url
self.capacity = capacity self.capacity = capacity
self.fileStore = JSONFileStore(
self.encoder = JSONEncoder() fileURL: url,
self.encoder.dateEncodingStrategy = .iso8601 corrupt: .throwCorrupt,
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] defaultValue: { [] },
dateEncoding: .iso8601,
self.decoder = JSONDecoder() dateDecoding: .iso8601
self.decoder.dateDecodingStrategy = .iso8601 )
} }
/// Loads records from disk. Returns the existing cache if already loaded. /// Loads records from disk. Returns the existing cache if already loaded.
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
/// is never overwritten in that case. /// is never overwritten in that case.
public func load() throws -> [VerificationRecord] { public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records } guard records.isEmpty else { return records }
let fm = FileManager.default guard FileManager.default.fileExists(atPath: url.path) else { return [] }
guard fm.fileExists(atPath: url.path), records = try fileStore.load()
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records return records
} }
@@ -106,8 +103,7 @@ public actor VerificationHistoryStore {
/// Writes `records` through a temp file and rename. /// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws { private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records) try fileStore.save(records)
try AtomicFileWriter.write(data, to: url)
} }
private func csvRow(_ fields: [String]) -> String { private func csvRow(_ fields: [String]) -> String {
@@ -0,0 +1,178 @@
import Foundation
extension TargenConfig {
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
/// `nil` when the preset omitted them so argv builders skip the flag.
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
self.init(
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
patchCount: preset.patchCount,
whitePatches: preset.whitePatches,
blackPatches: preset.blackPatches,
greySteps: preset.greySteps,
singleChannelSteps: preset.singleChannelSteps,
neutralSteps: preset.neutralSteps,
neutralConcentration: preset.neutralConcentration,
preconditioningProfile: preset.preconditioningProfile,
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil,
ofpsAdaptation: preset.ofpsAdaptation,
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
totalInkLimit: preset.totalInkLimit,
darkEmphasis: preset.darkEmphasis,
devicePower: preset.devicePower,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension PrinttargConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
calibrationFile: String?,
label: String? = nil
) {
let page: PageSize
let customW: Double
let customH: Double
if let size = PageSize(rawValue: preset.pageSize) {
page = size
customW = 210
customH = 297
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
page = .custom
customW = w
customH = h
} else {
page = .a4
customW = 210
customH = 297
}
let layout: LayoutOrder
let seed = preset.randomSeed ?? 1
if preset.noRandomize == true {
layout = .raster
} else if seed == 1 {
layout = .deterministic
} else {
layout = .customSeed
}
self.init(
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
pageSize: page,
customPageWidth: customW,
customPageHeight: customH,
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
dpi: preset.dpi,
layoutOrder: layout,
customSeed: seed,
label: label,
calibrationFile: calibrationFile,
calibrationEmbedOnly: false,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension ColprofConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
description: String? = nil,
copyright: String? = nil
) {
self.init(
algorithm: preset.colprofAlgorithm ?? "l",
quality: preset.colprofQuality ?? "m",
intent: Self.nilIfEmpty(preset.colprofIntent),
fwa: preset.colprofFwa,
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
observer: Self.nilIfEmpty(preset.colprofObserver),
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
description: description,
copyright: copyright,
basename: basename,
workingDirectory: workingDirectory
)
}
private static func nilIfEmpty(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
}
extension PageSize {
/// `"210x297"` custom page parse used by presets (issue #82).
public static func parseCustom(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
}
}
extension ProfilingPreset {
/// Snapshot of the three live configs plus calibration toggles.
public init(
id: String,
name: String,
description: String,
targen: TargenConfig,
printtarg: PrinttargConfig,
colprof: ColprofConfig,
calibrationFile: String?,
applyCalibration: Bool?
) {
let pageSize: String
if printtarg.pageSize == .custom {
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
} else {
pageSize = printtarg.pageSize.rawValue
}
self.init(
id: id,
name: name,
description: description,
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: targen.patchCount,
whitePatches: targen.whitePatches,
blackPatches: targen.blackPatches,
greySteps: targen.greySteps,
singleChannelSteps: targen.singleChannelSteps,
neutralSteps: targen.neutralSteps,
neutralConcentration: targen.neutralConcentration,
preconditioningProfile: targen.preconditioningProfile,
ofpsHighQuality: targen.ofpsHighQuality,
ofpsAdaptation: targen.ofpsAdaptation,
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
totalInkLimit: targen.totalInkLimit,
darkEmphasis: targen.darkEmphasis,
devicePower: targen.devicePower,
instrument: printtarg.instrument.rawValue,
pageSize: pageSize,
bitDepth: printtarg.bitDepth.rawValue,
dpi: printtarg.dpi,
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
noRandomize: printtarg.layoutOrder == .raster,
calibrationFile: calibrationFile,
applyCalibration: applyCalibration,
colprofAlgorithm: colprof.algorithm,
colprofQuality: colprof.quality,
colprofIntent: colprof.intent,
colprofFwa: colprof.fwa,
colprofIlluminant: colprof.illuminant,
colprofObserver: colprof.observer,
colprofInputViewingCond: colprof.inputViewingCond,
colprofOutputViewingCond: colprof.outputViewingCond
)
}
}
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
/// Single-preset pretty JSON export. /// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data { public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder() return try JSONEncoder.icceryPretty().encode(preset)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
} }
/// Parses + validates a preset from JSON. The preset is assigned a /// Parses + validates a preset from JSON. The preset is assigned a
@@ -4,8 +4,8 @@ import Foundation
/// `~/Library/Application Support/com.gronod.iccery2/settings.json` /// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read). /// (issue #5 the v1 path is never read).
/// ///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls /// Writes are atomic (`JSONFileStore` `AtomicFileWriter`). Invalid/corrupt
/// back to defaults. Saving posts `settingsDidChange` so #20 can /// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches. /// reclassify swatches.
public final class SettingsStore: Sendable { public final class SettingsStore: Sendable {
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
Notification.Name("com.gronod.iccery2.settingsDidChange") Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL public let fileURL: URL
private let store: JSONFileStore<AppSettings>
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) { public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
} }
public func load() -> AppSettings { public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL), (try? store.load()) ?? .default
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
} }
/// Validates before persisting throws `SettingsError` listing /// Validates before persisting throws `SettingsError` listing
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
guard errors.isEmpty else { guard errors.isEmpty else {
throw SettingsError.validationFailed(errors) throw SettingsError.validationFailed(errors)
} }
let encoder = JSONEncoder() try store.save(settings)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil) NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
} }
@@ -74,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
/// Atomic JSON persistence for `WizardState` (issue #4). /// Atomic JSON persistence for `WizardState` (issue #4).
public final class WizardStateStore: Sendable { public final class WizardStateStore: Sendable {
public let fileURL: URL public let fileURL: URL
private let store: JSONFileStore<WizardState>
public init( public init(
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json") fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
) { ) {
self.fileURL = fileURL self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
} }
public func load() -> WizardState { public func load() -> WizardState {
guard let data = try? Data(contentsOf: fileURL), (try? store.load()) ?? .default
let state = try? JSONDecoder().decode(WizardState.self, from: data)
else { return .default }
return state
} }
public func save(_ state: WizardState) throws { public func save(_ state: WizardState) throws {
let encoder = JSONEncoder() try store.save(state)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
} }
} }
@@ -1,51 +1,61 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "icon_16x16.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "icon_16x16@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "16x16" "size" : "16x16"
}, },
{ {
"filename" : "icon_32x32.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "icon_32x32@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "32x32" "size" : "32x32"
}, },
{ {
"filename" : "icon_128x128.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "icon_128x128@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "128x128" "size" : "128x128"
}, },
{ {
"filename" : "icon_256x256.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "icon_256x256@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "256x256" "size" : "256x256"
}, },
{ {
"filename" : "icon_512x512.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "1x", "scale" : "1x",
"size" : "512x512" "size" : "512x512"
}, },
{ {
"filename" : "icon_512x512@2x.png",
"idiom" : "mac", "idiom" : "mac",
"scale" : "2x", "scale" : "2x",
"size" : "512x512" "size" : "512x512"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+7 -4
View File
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
@@ -9,10 +10,12 @@ struct AboutView: View {
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
Image("ICCery-logo") if let icon = NSImage(named: NSImage.applicationIconName) {
.resizable() Image(nsImage: icon)
.scaledToFit() .resizable()
.frame(height: 64) .scaledToFit()
.frame(height: 64)
}
Text("ICCery") Text("ICCery")
.font(.title) .font(.title)
+7 -2
View File
@@ -4,6 +4,7 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@Bindable var model: CalibrationViewModel @Bindable var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -51,11 +52,15 @@ struct CalibrationView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() } Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate") .accessibilityIdentifier("btnCalGenerate")
.disabled(!model.canGenerate) .disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() } Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout") .accessibilityIdentifier("btnCalLayout")
.disabled(!model.canGenerate) .disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() } Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure") .accessibilityIdentifier("btnCalMeasure")
+22 -28
View File
@@ -50,14 +50,15 @@ final class CalibrationViewModel {
return cwd.appendingPathComponent("\(calBasename).ti3") return cwd.appendingPathComponent("\(calBasename).ti3")
} }
private var calBasename: String { private var identity: CalibrationIdentity {
if wizard.basename.hasPrefix("CAL_") { return wizard.basename } CalibrationIdentity.parse(
let original = !wizard.calibrationOriginalBasename.isEmpty liveBasename: wizard.basename,
? wizard.calibrationOriginalBasename persistedOriginal: wizard.calibrationOriginalBasename
: wizard.basename )
return "CAL_\(original)"
} }
private var calBasename: String { identity.calibrationBasename }
private var calOutputURL: URL? { private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil } guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal") return cwd.appendingPathComponent("\(calBasename).cal")
@@ -68,13 +69,12 @@ final class CalibrationViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
// Snapshot the original (pre-CAL_) basename before changing the live one. // Snapshot the original (pre-CAL_) basename before changing the live one.
if !wizard.basename.hasPrefix("CAL_") { let identity = CalibrationIdentity.parse(
wizard.calibrationOriginalBasename = wizard.basename liveBasename: wizard.basename,
} else if wizard.calibrationOriginalBasename.isEmpty { persistedOriginal: wizard.calibrationOriginalBasename
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4)) )
} wizard.calibrationOriginalBasename = identity.originalBasename
let original = wizard.calibrationOriginalBasename wizard.basename = identity.calibrationBasename
wizard.basename = "CAL_\(original)"
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true isGenerating = true
@@ -87,20 +87,17 @@ final class CalibrationViewModel {
whitePatches: whitePatches, whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis, includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue, inkLimit: inkLimitValue,
basename: original, basename: identity.originalBasename,
workingDirectory: cwd workingDirectory: cwd
) )
Task { @MainActor [weak self] in Task { @MainActor in
guard let self else { return }
defer { self.isGenerating = false } defer { self.isGenerating = false }
do { do {
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in _ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in self?.calibrationLog.append(contentsOf: batch)
self?.calibrationLog.append(contentsOf: batch) })
}
}
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.") self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint) self.wizard.go(to: .layOutPrint)
@@ -160,16 +157,13 @@ final class CalibrationViewModel {
channelLimits: [] channelLimits: []
) )
Task { @MainActor [weak self] in Task { @MainActor in
guard let self else { return }
defer { self.isComputing = false } defer { self.isComputing = false }
do { do {
let url = try await self.environment.runner.runPrintcal(config: config) { batch in let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in self?.calibrationLog.append(contentsOf: batch)
self?.calibrationLog.append(contentsOf: batch) })
}
}
self.computedCalURL = url self.computedCalURL = url
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile self.profile.applyCalibration = self.applyToProfile
+20 -20
View File
@@ -29,13 +29,8 @@ final class FileDialogService {
/// `selectTargetFile` **save** panel for the new `.ti1` target. /// `selectTargetFile` **save** panel for the new `.ti1` target.
func selectTargetFile(startingAt start: URL? = nil) -> URL? { func selectTargetFile(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
panel.nameFieldStringValue = "target.ti1" message: "Choose the .ti1 target file to create")
panel.allowedContentTypes = utTypes(["ti1"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Choose the .ti1 target file to create"
return run(panel)
} }
/// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140). /// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140).
@@ -66,12 +61,7 @@ final class FileDialogService {
/// `selectCsvSavePath` verification-history CSV export. /// `selectCsvSavePath` verification-history CSV export.
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? { func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
panel.nameFieldStringValue = "verification-history.csv"
panel.allowedContentTypes = utTypes(["csv"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
return run(panel)
} }
/// `selectCalFile` `.cal` calibration curves. /// `selectCalFile` `.cal` calibration curves.
@@ -88,17 +78,27 @@ final class FileDialogService {
/// `btnExportActivePreset` save a `.json` preset file. /// `btnExportActivePreset` save a `.json` preset file.
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? { func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "\(name).json", extensions: ["json"], startingAt: start,
panel.nameFieldStringValue = "\(name).json" message: "Export this preset as JSON")
panel.allowedContentTypes = utTypes(["json"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Export this preset as JSON"
return run(panel)
} }
// MARK: - Internals (private not a shared public picker API) // MARK: - Internals (private not a shared public picker API)
private func save(
named: String,
extensions: [String],
startingAt start: URL?,
message: String? = nil
) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = named
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = false
panel.directoryURL = start
if let message { panel.message = message }
return run(panel)
}
private func open( private func open(
extensions: [String], extensions: [String],
startingAt start: URL?, startingAt start: URL?,
@@ -70,8 +70,7 @@ final class MeasurementWorkflowViewModel {
var passSnapshots: [URL] = [] var passSnapshots: [URL] = []
var isFinishing = false var isFinishing = false
var finishNotice: String? var finishNotice: Notice?
var finishNoticeIsError = false
var resumedFromTi2 = false var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -400,7 +399,6 @@ final class MeasurementWorkflowViewModel {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true isFinishing = true
finishNotice = nil finishNotice = nil
finishNoticeIsError = false
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
@@ -420,10 +418,8 @@ final class MeasurementWorkflowViewModel {
) )
canonical = try await self.environment.runner.runAverage( canonical = try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: { [weak self] batch in onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in self?.chartreadLog.append(contentsOf: batch)
self?.chartreadLog.append(contentsOf: batch)
}
} }
) )
} }
@@ -432,7 +428,11 @@ final class MeasurementWorkflowViewModel {
if self.wizard.isUnlocked(.buildProfile) { if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile) self.wizard.go(to: .buildProfile)
} else { } else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready." self.finishNotice = Notice(
kind: .info,
text: "Finished: \(canonical.lastPathComponent) ready.",
autoHideAfter: nil
)
} }
} catch { } catch {
// Fallback to pass 1 promotion if averaging failed. // Fallback to pass 1 promotion if averaging failed.
@@ -445,15 +445,24 @@ final class MeasurementWorkflowViewModel {
) )
self.discoverPassSnapshots() self.discoverPassSnapshots()
self.wizard.refreshGating() self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass." self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Averaging failed — promoted first pass.",
autoHideAfter: nil
)
} catch { } catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)" self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
} }
} else { } else {
self.finishNotice = "Finish failed: \(error.localizedDescription)" self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
} }
} }
self.isFinishing = false self.isFinishing = false
@@ -0,0 +1,181 @@
import Foundation
import Observation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor
@Observable
final class PrintSessionViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
var printers: [Printer] = []
var selectedPrinter = ""
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice?
var isPrinting = false
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
}
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
} catch {
printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
}
}
}
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = Notice(
kind: .info,
text: "Printer properties dialog cancelled.",
autoHideAfter: nil
)
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(kind: .error, text: error.localizedDescription)
}
}
}
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index, pageSize: pageSize)
printed += 1
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed on \(page.page.filename): "
+ error.localizedDescription
)
isPrinting = false
return
}
}
printNotice = Notice(
kind: .info,
text: "Sent \(printed) page(s) to \(selectedPrinter).",
autoHideAfter: nil
)
isPrinting = false
}
printTask = task
}
func printPage(_ page: GalleryPage, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice(
kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed: \(error.localizedDescription)"
)
}
isPrinting = false
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
wizard.printerName = selectedPrinter
}
}
+27
View File
@@ -0,0 +1,27 @@
import SwiftUI
/// Shared monospaced process-log disclosure used by Stage 1 and Stage 2.
struct ProcessLogView: View {
let lines: [String]
var minHeight: CGFloat = 120
var maxHeight: CGFloat = 200
var containerId: String
var logId: String
var body: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(lines.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: minHeight, maxHeight: maxHeight)
.accessibilityIdentifier(logId)
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier(containerId)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
/// Shared hop for coalesced Argyll log batches (issue #80).
/// The runner invokes the sink off the main actor; this is the single hop back.
enum ProcessRunSupport {
static func logSink(
_ apply: @escaping @MainActor @Sendable ([String]) -> Void
) -> @Sendable ([String]) -> Void {
{ batch in
Task { @MainActor in apply(batch) }
}
}
@MainActor
static func runLogged<T>(
setRunning: (Bool) -> Void,
resetLog: () -> Void,
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
work: (@escaping @Sendable ([String]) -> Void) async throws -> T
) async throws -> T {
setRunning(true)
resetLog()
defer { setRunning(false) }
return try await work(logSink(onLog))
}
}
+25 -29
View File
@@ -123,11 +123,15 @@ final class ProfileWorkflowViewModel {
func applyPreset(_ preset: ProfilingPreset?) { func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return } guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l" let config = ColprofConfig(
quality = preset.colprofQuality ?? "m" preset: preset,
intent = preset.colprofIntent ?? "" basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
if let fwa = preset.colprofFwa { )
algorithm = config.algorithm
quality = config.quality
intent = config.intent ?? ""
if let fwa = config.fwa {
switch fwa.lowercased() { switch fwa.lowercased() {
case "none": fwaSelection = .none case "none": fwaSelection = .none
case "": fwaSelection = .empty case "": fwaSelection = .empty
@@ -138,11 +142,10 @@ final class ProfileWorkflowViewModel {
fwaCustomPath = fwa fwaCustomPath = fwa
} }
} }
illuminant = config.illuminant ?? ""
illuminant = preset.colprofIlluminant ?? "" observer = config.observer ?? ""
observer = preset.colprofObserver ?? "" inputViewingCond = config.inputViewingCond ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? "" outputViewingCond = config.outputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
} }
/// Stage 4 form values for saving into a custom preset. /// Stage 4 form values for saving into a custom preset.
@@ -205,16 +208,13 @@ final class ProfileWorkflowViewModel {
defer { self.isColprofRunning = false } defer { self.isColprofRunning = false }
do { do {
let url = try await runner.runColprof(config: config) { [weak self] batch in let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in guard let self else { return }
guard let self else { return } self.colprofLog.append(contentsOf: batch)
self.colprofLog.append(contentsOf: batch) if let last = batch.last {
if let last = batch.last { self.updateProgress(ColprofProgressClassifier.classify(line: last))
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
}
} }
} })
var finalProfileURL = url var finalProfileURL = url
@@ -231,11 +231,9 @@ final class ProfileWorkflowViewModel {
// Gamut extraction is best-effort for Stage 5 / M6 viewer. // Gamut extraction is best-effort for Stage 5 / M6 viewer.
do { do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL) let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in self?.colprofLog.append(contentsOf: batch)
self?.colprofLog.append(contentsOf: batch) })
}
}
self.createdGamutURL = gamURL self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)") self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch { } catch {
@@ -327,11 +325,9 @@ final class ProfileWorkflowViewModel {
defer { self.isProfcheckRunning = false } defer { self.isProfcheckRunning = false }
do { do {
let report = try await runner.runProfcheck(config: config) { [weak self] batch in let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
Task { @MainActor [weak self] in self?.colprofLog.append(contentsOf: batch)
self?.colprofLog.append(contentsOf: batch) })
}
}
self.profcheckReport = report self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) { if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record) let updated = try await self.environment.historyStore.append(record)
+13 -5
View File
@@ -29,7 +29,7 @@ struct RootView: View {
if let notice = model.notice { if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice) NoticeBanner(notice: notice, onClose: model.dismissNotice)
} }
stageContent WizardStageContent(model: model, workflow: workflow)
} }
} }
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
@@ -61,8 +61,16 @@ struct RootView: View {
} }
} }
@ViewBuilder }
private var stageContent: some View {
/// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View {
@Bindable var model: WizardViewModel
var workflow: TargetWorkflowViewModel
var body: some View {
switch model.stage { switch model.stage {
case .generate: case .generate:
Stage1View(workflow: workflow) Stage1View(workflow: workflow)
@@ -75,9 +83,9 @@ struct RootView: View {
case .verifyInstall: case .verifyInstall:
Stage5View(model: workflow.profile) Stage5View(model: workflow.profile)
case .calibrate: case .calibrate:
CalibrationView(model: workflow.calibration) CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
@unknown default: @unknown default:
StagePlaceholderView(stage: model.stage) Stage1View(workflow: workflow)
} }
} }
} }
+7 -14
View File
@@ -238,19 +238,12 @@ struct Stage1View: View {
} }
private var logSection: some View { private var logSection: some View {
DisclosureGroup("Process log") { ProcessLogView(
ScrollView { lines: workflow.targenLog,
Text(workflow.targenLog.joined(separator: "\n")) minHeight: 120,
.font(.system(.caption, design: .monospaced)) maxHeight: 200,
.foregroundStyle(Theme.text) containerId: "targenLogContainer",
.frame(maxWidth: .infinity, alignment: .leading) logId: "targenLog"
.textSelection(.enabled) )
}
.frame(minHeight: 120, maxHeight: 200)
.accessibilityIdentifier("targenLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenLogContainer")
} }
} }
+47 -50
View File
@@ -171,20 +171,13 @@ struct Stage2View: View {
} }
private var logSection: some View { private var logSection: some View {
DisclosureGroup("Process log") { ProcessLogView(
ScrollView { lines: workflow.printtargLog,
Text(workflow.printtargLog.joined(separator: "\n")) minHeight: 100,
.font(.system(.caption, design: .monospaced)) maxHeight: 180,
.foregroundStyle(Theme.text) containerId: "printtargLogContainer",
.frame(maxWidth: .infinity, alignment: .leading) logId: "printtargLog"
.textSelection(.enabled) )
}
.frame(minHeight: 100, maxHeight: 180)
.accessibilityIdentifier("printtargLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargLogContainer")
} }
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58) // MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@@ -219,18 +212,18 @@ struct Stage2View: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) { HStack(spacing: 12) {
Text("Print").font(.headline).foregroundStyle(Theme.text) Text("Print").font(.headline).foregroundStyle(Theme.text)
if let notice = workflow.printNotice { if let notice = workflow.print.printNotice {
Image(systemName: workflow.printNoticeIsError Image(systemName: notice.kind == .error
? "xmark.circle.fill" : "info.circle.fill") ? "xmark.circle.fill" : "info.circle.fill")
.foregroundStyle(workflow.printNoticeIsError .foregroundStyle(notice.kind == .error
? .red : .blue) ? .red : .blue)
.accessibilityIdentifier("printNotificationIcon") .accessibilityIdentifier("printNotificationIcon")
Text(notice) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(workflow.printNoticeIsError .foregroundStyle(notice.kind == .error
? .red : .secondary) ? .red : .secondary)
.accessibilityIdentifier("printNotificationText") .accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice) .accessibilityValue(notice.text)
} }
Spacer() Spacer()
} }
@@ -238,21 +231,21 @@ struct Stage2View: View {
// Printer row: select + status + refresh + Preferences. // Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) { HStack(spacing: 10) {
Picker("Printer", selection: $workflow.selectedPrinter) { Picker("Printer", selection: $workflow.print.selectedPrinter) {
ForEach(workflow.printers, id: \.name) { printer in ForEach(workflow.print.printers, id: \.name) { printer in
Text(printer.displayName ?? printer.name) Text(printer.displayName ?? printer.name)
.tag(printer.name) .tag(printer.name)
} }
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.selectedPrinter) { _, _ in .onChange(of: workflow.print.selectedPrinter) { _, _ in
workflow.selectedTray = nil workflow.print.selectedTray = nil
workflow.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
} }
if let selected = workflow.printers if let selected = workflow.print.printers
.first(where: { $0.name == workflow.selectedPrinter }) { .first(where: { $0.name == workflow.print.selectedPrinter }) {
Text(selected.status.rawValue) Text(selected.status.rawValue)
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 3) .padding(.horizontal, 8).padding(.vertical, 3)
@@ -260,33 +253,33 @@ struct Stage2View: View {
.clipShape(Capsule()) .clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge") .accessibilityIdentifier("printerStatusBadge")
} }
Button(action: workflow.refreshPrinters) { Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
.help("Refresh printer list") .help("Refresh printer list")
.accessibilityIdentifier("btnRefreshPrinters") .accessibilityIdentifier("btnRefreshPrinters")
Button(action: workflow.openPrinterPreferences) { Button(action: workflow.print.openPrinterPreferences) {
Image(systemName: "gearshape") Image(systemName: "gearshape")
} }
.help("Printer properties — bound NSPrintPanel") .help("Printer properties — bound NSPrintPanel")
.disabled(workflow.selectedPrinter.isEmpty) .disabled(workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrinterProperties") .accessibilityIdentifier("btnPrinterProperties")
} }
// Tray / media / orientation from queue capabilities. // Tray / media / orientation from queue capabilities.
HStack(spacing: 14) { HStack(spacing: 14) {
if !workflow.printerCaps.trays.isEmpty { if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.selectedTray) { Picker("Tray", selection: $workflow.print.selectedTray) {
ForEach(workflow.printerCaps.trays, id: \.id) { ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
.frame(maxWidth: 200) .frame(maxWidth: 200)
.accessibilityIdentifier("printerTraySelect") .accessibilityIdentifier("printerTraySelect")
} }
if !workflow.printerCaps.mediaTypes.isEmpty { if !workflow.print.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.selectedMediaType) { Picker("Media", selection: $workflow.print.selectedMediaType) {
ForEach(workflow.printerCaps.mediaTypes, id: \.id) { ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
@@ -296,27 +289,31 @@ struct Stage2View: View {
.accessibilityIdentifier("printerMediaTypeSelect") .accessibilityIdentifier("printerMediaTypeSelect")
} }
HStack(spacing: 0) { HStack(spacing: 0) {
Button("Portrait") { workflow.printOrientation = "portrait" } Button("Portrait") { workflow.print.printOrientation = "portrait" }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray) .tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait") .accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.printOrientation = "landscape" } Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray) .tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape") .accessibilityIdentifier("btnOrientLandscape")
} }
Spacer() Spacer()
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Button(action: workflow.printAllPages) { Button(action: {
Label(workflow.isPrinting ? "Printing…" : "Print All", if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
systemImage: "printer") systemImage: "printer")
} }
.controlSize(.large) .controlSize(.large)
.disabled(workflow.isPrinting .disabled(workflow.print.isPrinting
|| workflow.printtargResult == nil || workflow.printtargResult == nil
|| workflow.selectedPrinter.isEmpty) || workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll") .accessibilityIdentifier("btnPrintAll")
Spacer() Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() } Button("Advance to Stage 3") { workflow.advanceToStage3() }
@@ -333,8 +330,8 @@ struct Stage2View: View {
.task(id: workflow.printtargResult?.pages.count) { .task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it // Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2). // changes (e.g. resume from .ti2).
if workflow.printers.isEmpty, workflow.printtargResult != nil { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.refreshPrinters() workflow.print.refreshPrinters()
} }
} }
} }
@@ -364,9 +361,9 @@ private struct GalleryPageView: View {
Text("\(page.page.patches) patches · " + Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm") "\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary) .font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.printPage(page) } Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
.disabled(workflow.isPrinting .disabled(workflow.print.isPrinting
|| workflow.selectedPrinter.isEmpty) || workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)") .accessibilityIdentifier("btnPrintPage-\(page.index)")
} }
.padding(8) .padding(8)
+2 -2
View File
@@ -371,9 +371,9 @@ struct Stage3View: View {
} }
if let notice = model.finishNotice { if let notice = model.finishNotice {
Text(notice) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green) .foregroundStyle(notice.kind == .error ? .red : .green)
} }
} }
.padding(16) .padding(16)
-24
View File
@@ -1,24 +0,0 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (issues #7#31); Stage 0 lands in M6 (issue #29).
struct StagePlaceholderView: View {
let stage: WizardStage
var body: some View {
VStack(spacing: 16) {
Image(systemName: stage.symbolName)
.font(.system(size: 44))
.foregroundStyle(Theme.accent)
Text(stage.title)
.font(.title2)
.foregroundStyle(Theme.text)
Text("This stage is not implemented yet — see the milestone plan.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
}
}
+68 -274
View File
@@ -89,30 +89,6 @@ final class TargetWorkflowViewModel {
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false var resumedFromTi2 = false
// MARK: - Print panel (issue 17)
/// CUPS destinations from `lpstat` (#printerSelect).
var printers: [Printer] = []
/// Selected queue name.
var selectedPrinter = ""
/// Capabilities of the selected queue (#printerTraySelect /
/// #printerMediaTypeSelect / PageSize source).
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
var printOrientation = "portrait"
/// Per-queue captured `key=value` strings from Preferences replayed
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
var capturedCupsOptions: [String: String] = [:]
/// In-panel notice (#printNotification) cancel info, not error.
var printNotice: String?
var printNoticeIsError = false
var isPrinting = false
/// Strong reference to the active print task so the unstructured
/// `Task` is not dropped before it resumes.
private var printTask: Task<Void, Never>?
// MARK: - Presets // MARK: - Presets
var presets: [ProfilingPreset] = [] var presets: [ProfilingPreset] = []
@@ -130,6 +106,8 @@ final class TargetWorkflowViewModel {
var profile: ProfileWorkflowViewModel var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
var calibration: CalibrationViewModel! var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
@@ -142,6 +120,7 @@ final class TargetWorkflowViewModel {
wizard: wizard, wizard: wizard,
environment: environment environment: environment
) )
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
self.calibration = nil self.calibration = nil
self.calibration = CalibrationViewModel( self.calibration = CalibrationViewModel(
workflow: self, workflow: self,
@@ -233,10 +212,12 @@ final class TargetWorkflowViewModel {
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
do { do {
let url = try await runner.runTargen(config: config) { [weak self] batch in let url = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.targenRunning = $0 },
self?.targenLog.append(contentsOf: batch) resetLog: { self.targenLog = [] },
} onLog: { self.targenLog.append(contentsOf: $0) }
) { onLog in
try await runner.runTargen(config: config, onLogBatch: onLog)
} }
wizard.setTarget( wizard.setTarget(
basename: config.basename, basename: config.basename,
@@ -247,8 +228,8 @@ final class TargetWorkflowViewModel {
} catch { } catch {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
} }
targenRunning = false
} }
} }
@@ -284,8 +265,6 @@ final class TargetWorkflowViewModel {
} else { } else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning) wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
} }
} catch let error as CGATSParseError {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} catch { } catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} }
@@ -366,22 +345,21 @@ final class TargetWorkflowViewModel {
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
do { do {
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in let result = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.printtargRunning = $0 },
self?.printtargLog.append(contentsOf: batch) resetLog: { self.printtargLog = [] },
} onLog: { self.printtargLog.append(contentsOf: $0) }
) { onLog in
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
} }
printtargResult = result printtargResult = result
wizard.refreshGating() wizard.refreshGating()
wizard.showNotice( wizard.showNotice(
"Layout created — \(result.manifest.pages.count) page(s) ready.") "Layout created — \(result.manifest.pages.count) page(s) ready.")
} catch { } catch {
// Stay on Stage 2: non-zero exit, malformed manifest, or
// missing .ti2 must never advance the wizard (#156).
wizard.showNotice( wizard.showNotice(
"printtarg failed: \(error.localizedDescription)", kind: .error) "printtarg failed: \(error.localizedDescription)", kind: .error)
} }
printtargRunning = false
} }
} }
@@ -391,160 +369,6 @@ final class TargetWorkflowViewModel {
wizard.go(to: .measure) wizard.go(to: .measure)
} }
// MARK: - Print panel actions (issue 17)
/// `#btnRefreshPrinters` re-enumerate CUPS destinations and load
/// capabilities for the selection. Auto-runs when the panel first
/// appears with a manifest.
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
} catch {
printNotice = "Could not list printers: \(error.localizedDescription)"
printNoticeIsError = true
}
}
}
/// Capabilities for `selectedPrinter` trays / media / sizes feed
/// the selects.
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
// Default selections only when the captured options didn't
// already pin them (Preferences round-trip wins).
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
/// `#btnPrinterProperties` bound NSPrintPanel ("Use Settings").
/// Cancel info notice, never an error, cache untouched. On OK the
/// captured options are stored per-queue; a panel-side queue switch
/// updates `printerSelect` when the returned CUPS id is in the list.
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = "Printer properties dialog cancelled."
printNoticeIsError = false
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = "Settings captured for \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = error.localizedDescription
printNoticeIsError = true
}
}
}
/// `#btnPrintAll` spool every gallery TIFF, sequentially. Stops on
/// the first failure so the user sees which page failed.
func printAllPages() {
guard let result = printtargResult, !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index)
printed += 1
} catch {
printNotice = "Print failed on \(page.page.filename): "
+ error.localizedDescription
printNoticeIsError = true
isPrinting = false
return
}
}
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
printNoticeIsError = false
isPrinting = false
}
printTask = task
}
/// `#btnPrintPage-N` one TIFF.
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index)
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = "Print failed: \(error.localizedDescription)"
printNoticeIsError = true
}
isPrinting = false
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
// For Stage 5 history (#95): record which queue printed.
wizard.printerName = selectedPrinter
}
// MARK: - Presets // MARK: - Presets
func reloadPresets() { func reloadPresets() {
@@ -554,55 +378,57 @@ final class TargetWorkflowViewModel {
/// Applies every Stage 1/2 field of the preset to the live form /// Applies every Stage 1/2 field of the preset to the live form
/// (bidirectional the draft preset's dpi=150 must be visible). /// (bidirectional the draft preset's dpi=150 must be visible).
func applyPreset(_ preset: ProfilingPreset) { func applyPreset(_ preset: ProfilingPreset) {
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom applyTargenForm(targen)
customPatchCount = preset.patchCount let printtarg = PrinttargConfig(
whitePatches = preset.whitePatches preset: preset,
blackPatches = preset.blackPatches basename: wizard.basename,
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil workingDirectory: wizard.effectiveWorkingDirectory,
singleChannelSteps = preset.singleChannelSteps ?? 5 calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil
singleChannelEnabled = preset.singleChannelSteps != nil )
neutralSteps = preset.neutralSteps ?? 3 applyPrinttargForm(printtarg)
neutralStepsEnabled = preset.neutralSteps != nil
neutralConcentration = preset.neutralConcentration ?? 0.50
neutralConcEnabled = preset.neutralConcentration != nil
preconditioningProfile = preset.preconditioningProfile
highQuality = preset.ofpsHighQuality == true
adaptation = preset.ofpsAdaptation ?? 0.10
adaptationEnabled = preset.ofpsAdaptation != nil
algorithm = preset.fullSpreadAlgorithm
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
totalInkLimit = preset.totalInkLimit ?? 320
inkLimitEnabled = preset.totalInkLimit != nil
darkEmphasis = preset.darkEmphasis ?? 1.0
darkEmphasisEnabled = preset.darkEmphasis != nil
devicePower = preset.devicePower ?? 1.0
devicePowerEnabled = preset.devicePower != nil
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
if let size = PageSize(rawValue: preset.pageSize) {
pageSize = size
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
pageSize = .custom; customPageW = w; customPageH = h
} else {
pageSize = .a4
}
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
tiffDpi = preset.dpi
if preset.noRandomize == true {
layoutOrder = .raster
} else if (preset.randomSeed ?? 1) == 1 {
layoutOrder = .deterministic
} else {
layoutOrder = .customSeed
}
customSeed = preset.randomSeed ?? 1
profile.applyPreset(preset) profile.applyPreset(preset)
selectedPresetID = preset.id selectedPresetID = preset.id
} }
private func applyTargenForm(_ config: TargenConfig) {
colourSpace = config.colourSpace
patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom
customPatchCount = config.patchCount
whitePatches = config.whitePatches
blackPatches = config.blackPatches
greySteps = config.greySteps ?? 5
greyStepsEnabled = config.greySteps != nil
singleChannelSteps = config.singleChannelSteps ?? 5
singleChannelEnabled = config.singleChannelSteps != nil
neutralSteps = config.neutralSteps ?? 3
neutralStepsEnabled = config.neutralSteps != nil
neutralConcentration = config.neutralConcentration ?? 0.50
neutralConcEnabled = config.neutralConcentration != nil
preconditioningProfile = config.preconditioningProfile
highQuality = config.ofpsHighQuality == true
adaptation = config.ofpsAdaptation ?? 0.10
adaptationEnabled = config.ofpsAdaptation != nil
algorithm = config.fullSpreadAlgorithm ?? .ofps
totalInkLimit = config.totalInkLimit ?? 320
inkLimitEnabled = config.totalInkLimit != nil
darkEmphasis = config.darkEmphasis ?? 1.0
darkEmphasisEnabled = config.darkEmphasis != nil
devicePower = config.devicePower ?? 1.0
devicePowerEnabled = config.devicePower != nil
}
private func applyPrinttargForm(_ config: PrinttargConfig) {
instrument = config.instrument
pageSize = config.pageSize
customPageW = config.customPageWidth
customPageH = config.customPageHeight
bitDepth = config.bitDepth
tiffDpi = config.dpi
layoutOrder = config.layoutOrder
customSeed = config.customSeed
}
/// Snapshot of the live Stage 1/2 form as a custom preset. /// Snapshot of the live Stage 1/2 form as a custom preset.
func saveCurrentAsPreset() { func saveCurrentAsPreset() {
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines) let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -614,39 +440,11 @@ final class TargetWorkflowViewModel {
id: "custom-\(UUID().uuidString.lowercased())", id: "custom-\(UUID().uuidString.lowercased())",
name: name, name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines), description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb", targen: buildTargenConfig(),
patchCount: effectivePatchCount, printtarg: buildPrinttargConfig(),
whitePatches: whitePatches, colprof: profile.buildColprofConfig(),
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm.presetValue,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
instrument: instrument.rawValue,
pageSize: pageSize == .custom
? "\(Int(customPageW))x\(Int(customPageH))"
: pageSize.rawValue,
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster,
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile, calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil, applyCalibration: profile.applyCalibration ? true : nil
colprofAlgorithm: profile.algorithm,
colprofQuality: profile.quality,
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
colprofFwa: profile.fwaValue,
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
) )
do { do {
try environment.presetStore.saveCustom(preset) try environment.presetStore.saveCustom(preset)
@@ -710,10 +508,6 @@ final class TargetWorkflowViewModel {
} }
static func parseCustomPage(_ raw: String) -> (Double, Double)? { static func parseCustomPage(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x") PageSize.parseCustom(raw)
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
} }
} }
+8 -4
View File
@@ -67,8 +67,12 @@ final class WizardViewModel {
self.calibrationOriginalBasename = s.calibrationOriginalBasename self.calibrationOriginalBasename = s.calibrationOriginalBasename
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore // A Force Quit mid-calibration leaves a CAL_ basename behind; restore
// the original before the UI can do anything with it (#29). // the original before the UI can do anything with it (#29).
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty { if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename let identity = CalibrationIdentity.parse(
liveBasename: basename,
persistedOriginal: calibrationOriginalBasename
)
basename = identity.originalBasename
calibrationOriginalBasename = "" calibrationOriginalBasename = ""
sessionMode = .profile sessionMode = .profile
stage = .generate stage = .generate
@@ -128,7 +132,7 @@ final class WizardViewModel {
/// refused and the original basename is restored (#29). /// refused and the original basename is restored (#29).
func go(to target: WizardStage) { func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return } guard target != .calibrate else { enterCalibration(); return }
if basename.hasPrefix("CAL_") { if CalibrationIdentity.isCalibration(basename) {
guard !calibrationOriginalBasename.isEmpty else { guard !calibrationOriginalBasename.isEmpty else {
showNotice( showNotice(
"Cannot leave calibration — the original target name is missing.", "Cannot leave calibration — the original target name is missing.",
@@ -152,7 +156,7 @@ final class WizardViewModel {
} }
func enterCalibration() { func enterCalibration() {
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty { if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
calibrationOriginalBasename = basename calibrationOriginalBasename = basename
} }
sessionMode = .calibration sessionMode = .calibration
@@ -122,3 +122,29 @@ struct ArtefactFilesTests {
)) ))
} }
} }
@Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
@Test("basename probe prefers .icm")
func icmWins() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
#expect(url?.pathExtension == "icm")
}
@Test("explicit missing .icc flips to sibling .icm")
func flipExtension() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
#expect(resolved.path == icm.path)
}
}
@@ -0,0 +1,92 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("JSONFileStore")
struct JSONFileStoreTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
@Test("Corrupt file with replaceWithDefault returns default")
func corruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
#expect(try store.load() == .default)
#expect(FileManager.default.fileExists(atPath: url.path))
}
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] }
)
#expect(throws: DecodingError.self) {
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
}
@Test("Pretty sorted keys")
func prettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\""))
}
}
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("persisted original wins")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
}
@Test("empty live does not invent a name")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
}
+58
View File
@@ -230,3 +230,61 @@ struct PresetMigrationTests {
#expect(back.dpi == 150) #expect(back.dpi == 150)
} }
} }
@Suite("Preset mapping")
struct PresetMappingTests {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB,
basename: "t",
workingDirectory: nil,
calibrationFile: nil
)
#expect(cfg.dpi == 150)
#expect(cfg.layoutOrder == .deterministic)
}
@Test("Nil optional targen fields stay nil")
func optionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.greySteps == nil)
#expect(cfg.singleChannelSteps == nil)
#expect(cfg.neutralSteps == nil)
#expect(cfg.totalInkLimit == nil)
#expect(cfg.darkEmphasis == nil)
#expect(cfg.devicePower == nil)
}
@Test("Custom page and FWA survive a config round-trip")
func roundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297"
preset.colprofFwa = "D50"
preset.greySteps = nil
let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil)
let printtarg = PrinttargConfig(
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
)
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 210)
#expect(colprof.fwa == "D50")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
description: preset.description,
targen: targen,
printtarg: printtarg,
colprof: colprof,
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
#expect(back.dpi == preset.dpi)
#expect(back.colourSpace == "cmyk")
#expect(back.pageSize == "210x297")
#expect(back.colprofFwa == "D50")
#expect(back.greySteps == nil)
}
}
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660 400" width="660" height="400">
<defs>
<!-- Logo Clip Path -->
<clipPath id="coneClip">
<path d="M 50 82 L 110 82 L 80 142 Z" />
</clipPath>
<!-- Logo Text Gradient -->
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00AEEF" />
<stop offset="100%" stop-color="#0066CC" />
</linearGradient>
<!-- Arrow Drop Shadow Filter -->
<filter id="arrowShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Solid Background (matches app --bg-color: #1e1e1e) -->
<rect width="660" height="400" fill="#1e1e1e" />
<!-- Top-Left Branding Logo & Text (standard header height) -->
<g transform="translate(24, 18) scale(0.44)">
<!-- Drop Shadow -->
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
<!-- Ice Cream Cone (Waffle) -->
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
<line x1="40" y1="80" x2="120" y2="160" />
<line x1="55" y1="80" x2="135" y2="160" />
<line x1="70" y1="80" x2="150" y2="160" />
<line x1="85" y1="80" x2="165" y2="160" />
<line x1="120" y1="80" x2="40" y2="160" />
<line x1="105" y1="80" x2="25" y2="160" />
<line x1="90" y1="80" x2="10" y2="160" />
<line x1="75" y1="80" x2="-5" y2="160" />
</g>
<!-- CMYK Scoops (C, M, Y) -->
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
<!-- Black (Key) Cherry -->
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
<!-- Highlights on scoops for 3D effect -->
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
<!-- Text: ICCery -->
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
</g>
<!-- Center White Arrow (Pointing right between App Icon and Applications folder) -->
<g filter="url(#arrowShadow)">
<path d="M 285 214
L 342 214
L 342 201
Q 342 198 345 199.5
L 380 217.5
Q 383 219 383 220
Q 383 221 380 222.5
L 345 240.5
Q 342 242 342 239
L 342 226
L 285 226
Q 282 226 282 223
L 282 217
Q 282 214 285 214 Z"
fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+3 -3
View File
@@ -32,7 +32,7 @@ if background and not os.path.exists(background):
icon = None icon = None
# Window size is enough for the app icon and the Applications alias. # Window size is enough for the app icon and the Applications alias.
window_rect = ((100, 100), (640, 480)) window_rect = ((100, 100), (660, 400))
# Use icon view without extra chrome. # Use icon view without extra chrome.
default_view = 'icon-view' default_view = 'icon-view'
@@ -45,8 +45,8 @@ sidebar_width = 180
# Position the .app on the left and the Applications alias on the right. # Position the .app on the left and the Applications alias on the right.
icon_locations = { icon_locations = {
'ICCery.app': (140, 240), 'ICCery.app': (180, 220),
'Applications': (500, 240), 'Applications': (480, 220),
} }
# Symlink to /Applications for drag-and-drop install. # Symlink to /Applications for drag-and-drop install.
+5 -4
View File
@@ -132,8 +132,11 @@ dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
echo "DMG: $PWD/$DMG" echo "DMG: $PWD/$DMG"
# Optional notarization/stapling when credentials are present. # Notarization requires a Developer ID signature. If the app was ad-hoc
if [ -n "${NOTARIZE_APPLE_ID:-}" ] && \ # signed or any notarization secret is missing, skip silently — the DMG is
# still usable for local/testing installs.
if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ] && \
[ -n "${NOTARIZE_APPLE_ID:-}" ] && \
[ -n "${NOTARIZE_PASSWORD:-}" ] && \ [ -n "${NOTARIZE_PASSWORD:-}" ] && \
[ -n "${APPLE_TEAM_ID:-}" ]; then [ -n "${APPLE_TEAM_ID:-}" ]; then
echo "==> Submitting $DMG for notarization" echo "==> Submitting $DMG for notarization"
@@ -144,6 +147,4 @@ if [ -n "${NOTARIZE_APPLE_ID:-}" ] && \
--wait --wait
xcrun stapler staple "$DMG" xcrun stapler staple "$DMG"
echo "==> Stapled $DMG" echo "==> Stapled $DMG"
else
echo "==> Notarization credentials not set; skipping"
fi fi