Files
iccery-v2-mac/Sources/ICCery/Print/PrintSessionViewModel.swift
gronod ecbf46723b
macOS CI / build-and-test (push) Skipped
feat(print): #218 Stage 2 paper-source selection + orientation dropdown
- Detect vendor tray keys in CupsParsers with InputSlot/MediaSource/EPIJ_FdSo/EPIJ_PSrc/CNIJMediaSupply roster; EPIJ_FdSo wins over EPIJ_PSrc.
- PrinterTray now carries raw token + display name; PrinterCapabilities exposes trayKey, trayDefault and tray-specific quality maps.
- CupsService enumerates trays from the detected key, enriches labels from PPD, seeds selection from CUPS default, and precomputes per-tray media→quality constraints.
- MediaQualityConstraints resolves quality availability from both selected media and selected tray, falling back to the media-only map.
- TicketWriteResolver emits the selected tray key=token in the locked write order between PageSize and media key.
- PrintPanelService applies the Stage 2 tray token to PMPrintSettings on open and captures the tray back from the full roster plus AP_D_InputSlot.
- PrintSessionViewModel keeps selectedTrayToken, applies capture-back, preserves unknown captured tokens as synthetic trays, and passes tray into spool overrides.
- Stage2View replaces portrait/landscape buttons with an orientation dropdown (printerOrientationSelect) while preserving portrait/landscape semantics and orientation-requested=3|4.
- Add/update core, UI and fixture tests for tray detection, enumeration, PPD labels, defaults, unknown-token synthesis, panel capture, ticket write order and tray-aware quality clamping.
- Update docs/10-print-system.md, docs/11-print-macos.md, docs/21-ui-reference.md and docs/24-issues-invariants.md.

Verified on the macOS 12.7.6 x86_64 CI runner: xcodebuild build-for-testing and ICCeryCoreTests pass (557 tests, 0 failures).
2026-09-22 19:19:33 +01:00

496 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Combine
import Foundation
import ICCeryCore
/// CUPS queue selection, bound print panel, and native headless spool
/// (issues 12–15, 17 / #85, #201).
@MainActor
final class PrintSessionViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
/// Stage 1/2 form state — read for paper seeding/mirroring only;
/// `workflow.pageSize` is the printtarg layout and is never written
/// back from the print side (#183).
weak var workflow: TargetWorkflowViewModel?
@Published var printers: [Printer] = []
@Published var selectedPrinter = "" {
didSet {
// Tickets are queue- and driver-version-specific — a stale
// ticket must never replay onto a re-selected queue (R3).
if selectedPrinter != oldValue {
capturedTickets[oldValue] = nil
}
}
}
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? {
didSet {
// A tray switch can invalidate the current quality pick
// using the tray-specific Epson constraint map (#218).
if selectedTray != oldValue {
clampQualityToMedia()
}
}
}
@Published var selectedMediaType: String? {
didSet {
// A media switch can invalidate the current quality pick —
// re-clamp into the driver's allowed set (#214).
if selectedMediaType != oldValue {
clampQualityToMedia()
}
}
}
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183).
@Published var selectedQuality: String?
@Published var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:]
/// Native print tickets per queue — the spool payload (#201).
/// Session-only; cleared whenever the queue's mirror is.
@Published var capturedTickets: [String: PrintTicket] = [:]
@Published var printNotice: Notice?
@Published var isPrinting = false
/// Session-only job granularity (#201 D5): off → one job per page
/// (per-page notices + error attribution); on → a single job.
@Published var singleJobForAllPages = false
/// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam
/// under UI testing, `NSPrintOperation` otherwise (#201 D8).
/// `internal` so unit tests inject.
var spooler: TargetSpooling
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
#if DEBUG
if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL {
spooler = RecordingTargetSpooler(logURL: logURL)
} else {
spooler = NativeTargetSpooler()
}
#else
spooler = NativeTargetSpooler()
#endif
attachDiagnostics()
}
/// Warn-only spooler diagnostics (manifest drift, oversize page)
/// surface as the in-panel notice. Re-attached per request so an
/// injected spooler picks it up too — and so `spooler` needs no
/// `didSet` (mutating `diagnostics` through the existential would
/// re-fire the observer and recurse).
private func attachDiagnostics() {
spooler.diagnostics = { [weak self] notice in
self?.printNotice = notice
}
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
Task { @MainActor in _ = await enumeratePrinters() }
}
/// Serialized queue enumeration — `listPrinters` uses fixed process
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
/// callers coalesce onto the in-flight task (#146).
@discardableResult
func enumeratePrinters() async -> [Printer]? {
if let pending = printerEnumTask { return await pending.value }
let task = Task { @MainActor [weak self] () -> [Printer]? in
guard let self else { return nil }
do {
let list = try await self.environment.cupsService.listPrinters()
self.printers = list
if !list.contains(where: { $0.name == self.selectedPrinter }) {
self.selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await self.reloadSelectedCapabilities()
return list
} catch {
self.printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
return nil
}
}
printerEnumTask = task
let result = await task.value
printerEnumTask = nil
return result
}
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 || !printerCaps.trays
.contains(where: { $0.id == selectedTray }) {
selectedTray = printerCaps.trayDefault.flatMap { token in
printerCaps.trays.first { $0.token == token }?.id
} ?? printerCaps.trays.first?.id
}
// Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first
// allowed choice.
if selectedQuality == nil {
let allowed = availableQualities
selectedQuality = printerCaps.qualityDefault.flatMap { d in
allowed.contains(where: { $0.id == d }) ? d : nil
} ?? allowed.first?.id
} else {
clampQualityToMedia()
}
// Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) — pageSize + printer changes route here too.
seedPaperSelection()
} catch {
printerCaps = PrinterCapabilities()
}
}
// MARK: - Paper / quality selection (#183)
/// The raw tray token for the currently selected source (#218).
var selectedTrayToken: String? {
guard let id = selectedTray else { return nil }
return printerCaps.trays.first { $0.id == id }?.token
}
/// The quality picker's source (#214/#218): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType` and
/// the current tray token. Equals the full list whenever the queue
/// exposes no per-media/per-tray quality matrix — and never empty
/// while `qualities` is not.
var availableQualities: [PrinterQuality] {
printerCaps.qualities(
forMediaType: selectedMediaType,
trayToken: selectedTrayToken)
}
/// Keep `selectedQuality` inside the allowed set for the current
/// media and tray: keep the pick when still valid, else the driver
/// default when valid, else the first allowed choice (#214/#218).
private func clampQualityToMedia() {
let allowed = availableQualities
guard !allowed.isEmpty else { return }
if let quality = selectedQuality,
allowed.contains(where: { $0.id == quality }) {
return
}
if let fallback = printerCaps.qualityDefault,
allowed.contains(where: { $0.id == fallback }) {
selectedQuality = fallback
} else {
selectedQuality = allowed.first?.id
}
}
/// The tray token for a captured option string, used both for
/// applying panel results and for synthesising unknown selections.
private func capturedTrayToken(from options: String?) -> String? {
guard let options else { return nil }
return CupsParsers.extractTray(
fromOptionsString: options,
preferredKey: printerCaps.trayKey)
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
/// no match → nil (never guess). Called only on pageSize / printer /
/// caps triggers — never on unrelated publishes (R14).
func seedPaperSelection() {
guard let pageSize = workflow?.pageSize else { return }
if pageSize == .custom {
let token = customPaperToken()
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
printerCaps.paperSizes[index].name = token
} else {
printerCaps.paperSizes.append(
PrinterPaperSize(id: 0, name: token))
}
selectedPaperSize = 0
return
}
selectedPaperSize = printerCaps.paperSizes
.first { $0.name == pageSize.rawValue }?.id
}
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
func customPaperToken() -> String {
let w = workflow?.customPageW ?? 0
let h = workflow?.customPageH ?? 0
let wPt = (w * 72.0 / 25.4).rounded()
let hPt = (h * 72.0 / 25.4).rounded()
return "Custom.\(Int(wPt))x\(Int(hPt))"
}
/// The CUPS `PageSize` token for the current Stage 2 pick — live
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
/// name. Resolved into the `PageSize` ticket write and the
/// `PMPaper` match on the native spool path (#201).
var selectedPaperSizeToken: String? {
guard let id = selectedPaperSize else { return nil }
if id == 0 { return customPaperToken() }
return printerCaps.paperSizes.first { $0.id == id }?.name
}
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
mediaType: selectedMediaType,
orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups,
initialSelections: selections,
ticket: ticket,
trays: printerCaps.trays)
else {
printNotice = Notice(
kind: .info,
text: "Printer properties dialog cancelled.",
autoHideAfter: nil
)
return
}
if let selected = result.properties.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.properties.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
// The native ticket rides alongside the mirror (#201).
if let ticket = result.ticket {
capturedTickets[ticket.queue] = ticket
}
if let media = result.properties.options.mediaType {
selectedMediaType = media
}
// Capture-return paper source (#218): a dialog tray
// change updates Stage 2; unknown tokens are appended
// so a driver-only source remains selectable.
if let options = result.properties.options.cupsOptions,
let trayToken = capturedTrayToken(from: options),
!trayToken.isEmpty {
if let known = printerCaps.trays
.first(where: { $0.token == trayToken }) {
selectedTray = known.id
} else {
let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1
let synthetic = PrinterTray(
id: nextID, token: trayToken, name: trayToken)
printerCaps.trays.append(synthetic)
selectedTray = nextID
}
} else if let paperSource = result.properties.options.paperSource,
printerCaps.trays.contains(where: {
$0.id == paperSource
}) {
selectedTray = paperSource
}
// Capture-return (#183/#186): a dialog paper/quality/
// orientation change updates the Stage 2 selections —
// never `workflow.pageSize` (printtarg layout is
// sacred).
if let paper = result.properties.options.paperSize,
let match = printerCaps.paperSizes
.first(where: { $0.name == paper }) {
selectedPaperSize = match.id
}
// A captured quality the driver rejects for the
// (possibly just-captured) media is dropped — keeping
// it would only re-create a failed print (#214).
if let quality = result.properties.options.quality,
printerCaps.allowsQuality(
quality, forMediaType: selectedMediaType) {
selectedQuality = quality
}
if let orientation = result.properties.options.orientation {
printOrientation = orientation
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(kind: .error, text: error.localizedDescription)
}
}
}
func printAllPages(from result: PrinttargResult) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
// `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
if singleJobForAllPages {
// D5 — one job carries every page; a failure names the
// job, not a page (R14).
do {
try await spoolAll(result)
printNotice = Notice(
kind: .info,
text: "Sent \(result.pages.count) page(s) in "
+ "one job to \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed: \(error.localizedDescription)"
)
}
isPrinting = false
self.printTask = nil
return
}
var printed = 0
for page in result.pages {
do {
try await spool(page)
printed += 1
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed on \(page.page.filename): "
+ error.localizedDescription
)
isPrinting = false
self.printTask = nil
return
}
}
printNotice = Notice(
kind: .info,
text: "Sent \(printed) page(s) to \(selectedPrinter).",
autoHideAfter: nil
)
isPrinting = false
self.printTask = nil
}
printTask = task
}
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await spool(page)
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
self.printTask = nil
}
printTask = task
}
private func spool(_ page: GalleryPage) async throws {
let request = try await makeRequest(
pages: [page], title: page.page.filename)
try spooler.spool(request)
wizard.printerName = selectedPrinter
}
private func spoolAll(_ result: PrinttargResult) async throws {
let request = try await makeRequest(
pages: result.pages,
title: result.ti2URL.deletingPathExtension()
.lastPathComponent)
try spooler.spool(request)
wizard.printerName = selectedPrinter
}
/// Assemble the deterministic spool request: the captured ticket
/// (when the panel produced one) plus the Stage 2 overrides and
/// the queue's option-key roster for vendor-key detection (#201).
/// The Stage 2 paper token feeds the `PageSize` write;
/// `workflow.pageSize` remains the printtarg layout input only
/// (#183).
private func makeRequest(
pages: [GalleryPage], title: String
) async throws -> TargetPrintRequest {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let queue = selectedPrinter
let optionKeys = (try? await environment.cupsService
.optionKeys(for: queue)) ?? []
attachDiagnostics()
// Defence-in-depth (#214): an invalid media+quality pair can
// never reach the ticket — substitute the first allowed
// quality when the pick is stale (UI already clamps; this is
// the last gate before the driver).
var quality = selectedQuality
if let q = quality,
!printerCaps.allowsQuality(
q, forMediaType: selectedMediaType,
trayToken: selectedTrayToken) {
quality = availableQualities.first?.id ?? q
}
return TargetPrintRequest(
queue: queue,
displayName: printers.first { $0.name == queue }?.displayName,
pages: pages.map {
TargetPrintPage(
url: $0.fileURL,
expectedWidthMm: $0.page.widthMm,
expectedHeightMm: $0.page.heightMm)
},
jobTitle: "ICCery Target - \(title)",
ticket: capturedTickets[queue],
overrides: TargetPrintOverrides(
paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey,
quality: quality,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
orientation: printOrientation),
optionKeys: optionKeys)
}
}