Compare commits

...
4 Commits
Author SHA1 Message Date
gronod 55ffe879ac ci: re-enable Gitea Actions macOS workflow with ad-hoc signing
U1: move .gitea/workflows.disabled/macos.yml back to .gitea/workflows/macos.yml

Keeps existing macos-14 runner, CODE_SIGNING_ALLOWED=YES, CODE_SIGN_IDENTITY='-',

CODE_SIGNING_REQUIRED=YES, xattr -cr and hardened runtime codesign block.

Generated with [Devin](https://devin.ai)
2026-09-10 02:07:08 +01:00
gronod 120f713b46 Merge pull request 'fix: persist and restore CAL_ session original basename' (#69) from fix/29-cal-basename-restore into develop
Generated with [Devin](https://devin.ai)
2026-09-10 02:06:47 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b52720700f Temporarily disable Gitea workflows by renaming .gitea/workflows
This is a reversible change so CI does not trigger on this branch.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 01:36:16 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f027693fa9 fix: persist and restore CAL_ session original basename
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 2m20s
Store the pre-CAL_ basename in WizardState and WizardViewModel so a
Force Quit or successful printcal cannot leave a CAL_* name active.
On relaunch, on navigation to .buildProfile / .verifyInstall / .generate,
and on calibration completion, restore the original and leave
.calibration mode. Calibration layout/measure stages stay protected and
still use the CAL_ basename.

Closes #29.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 01:31:21 +01:00
6 changed files with 159 additions and 19 deletions
@@ -21,6 +21,9 @@ public struct WizardState: Codable, Equatable, Sendable {
public var sessionMode: SessionMode public var sessionMode: SessionMode
/// May differ from `basename` after a `.ti3` import (#94). /// May differ from `basename` after a `.ti3` import (#94).
public var profileBasename: String? public var profileBasename: String?
/// The pre-`CAL_` basename, persisted so a crash/relaunch can
/// restore the original (#29).
public var calibrationOriginalBasename: String = ""
public init( public init(
currentStage: Int = WizardStage.generate.rawValue, currentStage: Int = WizardStage.generate.rawValue,
@@ -28,7 +31,8 @@ public struct WizardState: Codable, Equatable, Sendable {
cwd: String = "", cwd: String = "",
printerName: String? = nil, printerName: String? = nil,
sessionMode: SessionMode = .profile, sessionMode: SessionMode = .profile,
profileBasename: String? = nil profileBasename: String? = nil,
calibrationOriginalBasename: String = ""
) { ) {
self.currentStage = currentStage self.currentStage = currentStage
self.basename = basename self.basename = basename
@@ -36,6 +40,7 @@ public struct WizardState: Codable, Equatable, Sendable {
self.printerName = printerName self.printerName = printerName
self.sessionMode = sessionMode self.sessionMode = sessionMode
self.profileBasename = profileBasename self.profileBasename = profileBasename
self.calibrationOriginalBasename = calibrationOriginalBasename
} }
public static let `default` = WizardState() public static let `default` = WizardState()
+17 -16
View File
@@ -27,8 +27,6 @@ final class CalibrationViewModel {
var isComputing = false var isComputing = false
var lastError: String? var lastError: String?
private var originalBasename: String = ""
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
self.profile = profile self.profile = profile
@@ -53,7 +51,11 @@ final class CalibrationViewModel {
} }
private var calBasename: String { private var calBasename: String {
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)" if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
let original = !wizard.calibrationOriginalBasename.isEmpty
? wizard.calibrationOriginalBasename
: wizard.basename
return "CAL_\(original)"
} }
private var calOutputURL: URL? { private var calOutputURL: URL? {
@@ -65,8 +67,14 @@ final class CalibrationViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
originalBasename = wizard.basename // Snapshot the original (pre-CAL_) basename before changing the live one.
wizard.basename = calBasename if !wizard.basename.hasPrefix("CAL_") {
wizard.calibrationOriginalBasename = wizard.basename
} else if wizard.calibrationOriginalBasename.isEmpty {
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
}
let original = wizard.calibrationOriginalBasename
wizard.basename = "CAL_\(original)"
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true isGenerating = true
@@ -79,7 +87,7 @@ final class CalibrationViewModel {
whitePatches: whitePatches, whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis, includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue, inkLimit: inkLimitValue,
basename: originalBasename, basename: original,
workingDirectory: cwd workingDirectory: cwd
) )
@@ -102,7 +110,7 @@ final class CalibrationViewModel {
"Calibration target failed: \(error.localizedDescription)", "Calibration target failed: \(error.localizedDescription)",
kind: .error kind: .error
) )
self.restoreProfileBasename() self.wizard.restoreCalibration()
} }
} }
} }
@@ -166,6 +174,7 @@ final class CalibrationViewModel {
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.") self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration()
} catch { } catch {
self.lastError = error.localizedDescription self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
@@ -190,18 +199,10 @@ final class CalibrationViewModel {
} }
func returnToProfiling() { func returnToProfiling() {
restoreProfileBasename() wizard.restoreCalibration()
wizard.sessionMode = .profile
wizard.go(to: .generate) wizard.go(to: .generate)
} }
private func restoreProfileBasename() {
if !originalBasename.isEmpty {
wizard.basename = originalBasename
originalBasename = ""
}
}
private var inkLimitValue: Int? { private var inkLimitValue: Int? {
guard colourSpace == .cmyk else { return nil } guard colourSpace == .cmyk else { return nil }
return Int(inkLimit) return Int(inkLimit)
+46 -2
View File
@@ -5,7 +5,8 @@ import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06). /// Wizard state machine + artefact gating (issue #4, docs/06).
/// ///
/// `wizardState` fields (`currentStage`, `basename`, `cwd`, /// `wizardState` fields (`currentStage`, `basename`, `cwd`,
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to /// `printerName`, `sessionMode`, `profileBasename`,
/// `calibrationOriginalBasename`) are persisted to
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` — /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
/// navigation is disk, not buttons. /// navigation is disk, not buttons.
@MainActor @MainActor
@@ -35,6 +36,10 @@ final class WizardViewModel {
var profileBasename: String? { var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } } didSet { if profileBasename != oldValue { persist() } }
} }
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } }
}
// MARK: - Ephemeral // MARK: - Ephemeral
@@ -59,6 +64,15 @@ final class WizardViewModel {
self.printerName = s.printerName self.printerName = s.printerName
self.sessionMode = s.sessionMode self.sessionMode = s.sessionMode
self.profileBasename = s.profileBasename self.profileBasename = s.profileBasename
self.calibrationOriginalBasename = s.calibrationOriginalBasename
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
// the original before the UI can do anything with it (#29).
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename
calibrationOriginalBasename = ""
sessionMode = .profile
stage = .generate
}
refreshGating() refreshGating()
// A restored stage may have been locked since (#151). // A restored stage may have been locked since (#151).
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate { if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
@@ -108,8 +122,25 @@ final class WizardViewModel {
/// `navigateToStage(n)` — refuses locked forward moves with a /// `navigateToStage(n)` — refuses locked forward moves with a
/// warning banner; backward is always allowed (docs/06). /// warning banner; backward is always allowed (docs/06).
///
/// If the live basename has a `CAL_` prefix, only `.calibrate`,
/// `.layOutPrint`, and `.measure` are allowed; any other target is
/// 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_") {
guard !calibrationOriginalBasename.isEmpty else {
showNotice(
"Cannot leave calibration — the original target name is missing.",
kind: .warning
)
return
}
if target == .generate || target == .buildProfile || target == .verifyInstall {
restoreCalibration()
return
}
}
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) { if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
stage = target stage = target
} else { } else {
@@ -121,6 +152,9 @@ final class WizardViewModel {
} }
func enterCalibration() { func enterCalibration() {
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
calibrationOriginalBasename = basename
}
sessionMode = .calibration sessionMode = .calibration
stage = .calibrate stage = .calibrate
} }
@@ -130,6 +164,15 @@ final class WizardViewModel {
stage = .generate stage = .generate
} }
/// Restore the original profile basename and leave calibration mode.
func restoreCalibration() {
if !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename
calibrationOriginalBasename = ""
}
sessionMode = .profile
}
/// Open the 3D gamut viewer (issue #28). /// Open the 3D gamut viewer (issue #28).
func openGamut(profileGamURL: URL? = nil) { func openGamut(profileGamURL: URL? = nil) {
self.gamutProfileURL = profileGamURL self.gamutProfileURL = profileGamURL
@@ -177,7 +220,8 @@ final class WizardViewModel {
cwd: workingDirectory?.path ?? "", cwd: workingDirectory?.path ?? "",
printerName: printerName, printerName: printerName,
sessionMode: sessionMode, sessionMode: sessionMode,
profileBasename: profileBasename profileBasename: profileBasename,
calibrationOriginalBasename: calibrationOriginalBasename
) )
try? stateStore.save(state) try? stateStore.save(state)
} }
@@ -0,0 +1,89 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor
struct WizardCalibrationSessionTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-cal-state-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json")
}
private func tempDir() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-cal-dir-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: url, withIntermediateDirectories: true
)
return url
}
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
func relaunchRestoresOriginal() throws {
let url = tempURL()
let store = WizardStateStore(fileURL: url)
var saved = WizardState(
currentStage: WizardStage.calibrate.rawValue,
basename: "CAL_DemoTarget",
cwd: "/tmp/charts",
sessionMode: .calibration,
calibrationOriginalBasename: "DemoTarget"
)
try store.save(saved)
let model = WizardViewModel(stateStore: store)
#expect(model.basename == "DemoTarget")
#expect(model.calibrationOriginalBasename == "")
#expect(model.sessionMode == .profile)
#expect(model.stage == .generate)
}
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
func goToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir()
let url = tempURL()
let store = WizardStateStore(fileURL: url)
let model = WizardViewModel(stateStore: store)
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
model.calibrationOriginalBasename = "DemoTarget"
model.basename = "CAL_DemoTarget"
model.sessionMode = .calibration
model.stage = .calibrate
model.go(to: .buildProfile)
#expect(model.basename == "DemoTarget")
#expect(model.calibrationOriginalBasename == "")
#expect(model.sessionMode == .profile)
#expect(model.stage == .calibrate)
}
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
func goToLayoutStaysCal() throws {
let dir = try tempDir()
let url = tempURL()
let store = WizardStateStore(fileURL: url)
let model = WizardViewModel(stateStore: store)
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
model.calibrationOriginalBasename = "DemoTarget"
model.basename = "CAL_DemoTarget"
model.sessionMode = .calibration
model.stage = .calibrate
model.go(to: .layOutPrint)
#expect(model.basename == "CAL_DemoTarget")
#expect(model.calibrationOriginalBasename == "DemoTarget")
#expect(model.sessionMode == .calibration)
#expect(model.stage == .layOutPrint)
}
}
@@ -95,6 +95,7 @@ struct WizardStateStoreTests {
s.cwd = "/tmp/charts" s.cwd = "/tmp/charts"
s.sessionMode = .calibration s.sessionMode = .calibration
s.profileBasename = "imported" s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal"
try store.save(s) try store.save(s)
#expect(store.load() == s) #expect(store.load() == s)
} }