Compare commits

..
Author SHA1 Message Date
gronod 12061a03ac fix(print): restore captured ticket when reopening properties dialog (#217)
macOS CI / build-and-test (push) Skipped
The bound NSPrintPanel always opened on queue driver defaults —
capturedTickets[queue] was stored but never passed back, so every
vendor-PDE setting outside the four mirrored fields was discarded
on reopen (RC1), and pre-selection writes were never validated into
the ticket before runModal (RC2).

Extract the pre-modal configuration into a testable
configureBoundPanel seam with the spooler's S4→S7 order: ticket
restore (warn-only, unlike the spooler's fatal try — a stale ticket
must not keep the dialog from opening → Stage 2 selections (D6
parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings
→ applyPaper → suppression ②–⑤.

ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions
output and serialised ticket sizes pre/post modal for the XP-55
RC3 verification.

Refs #217
EOF
)
2026-09-22 17:47:09 +01:00
gronod f7f6a8bd87 chore: bump MARKETING_VERSION to 2.0.3
macOS CI / package (push) Successful in 3m37s
macOS CI / build-and-test (push) Skipped
2026-09-18 11:06:42 +01:00
gronod ed91ebf807 test(print): scroll stage-2 via synthesized scroll wheel (#215)
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 24m1s
press(forDuration:thenDragTo:) is inert on macOS — content-drag
scrolling is iOS-only, so the helper burned its 20s budget with the
gallery frame never moving. The scroll wheel is the mechanism the
platform supports: one scroll(byDeltaX:deltaY:) call on a coordinate
inside app.scrollViews["stage-2"] per poll iteration, negative deltaY
to move content up off the Dock zone.
2026-09-18 10:38:57 +01:00
gronod 77c065f433 docs(agents): add remote test build procedure for CI runner host
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 36m50s
- SSH access: localadmin@192.168.0.172, credentials in ~/Projects/build-host.env
- sshpass usage via SSHPASS env var (never command-line)
- Remote checkout sync: git checkout + scp uncommitted changes
- xcodegen PATH workaround: prepend ~/.local/xcodegen/2.38.0/xcodegen/bin (#109)
- Full xcodebuild build-for-testing + codesign + test-without-building sequence mirroring .gitea/workflows/macos.yml
- Unit tests only (ICCeryCoreTests) —
2026-09-18 09:55:22 +01:00
9 changed files with 395 additions and 98 deletions
+47
View File
@@ -93,6 +93,53 @@ codesign -dvv <sidecar>
``` ```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Remote test builds (runner host)
Test builds done as part of change implementations run on the CI runner
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
with Xcode 14.2, and CI failures there are often environmental and do not
reproduce locally.
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
environment.
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
the `SSHPASS` env var — never on the command line:
```sh
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
```
First connection needs `-o StrictHostKeyChecking=accept-new`.
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
full history needed by `scripts/version.sh`). Before testing, sync it to
the same base commit as the local work:
`git fetch origin && git checkout <local base sha>`, then `scp` each
locally-changed file over its remote counterpart (uncommitted work
transfers this way — there is no push).
- `xcodegen` is **not** on PATH: prepend
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
impossible on macOS 12 — #109).
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
`DERIVED=build/DerivedData-test`:
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
-derivedDataPath "$DERIVED"`.
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
GUI session and fail environmentally on the VM (runs 29700, 29804,
42183) — do not use them for verification unless asked.
- xcodebuild takes minutes — run the ssh command in a background shell
and poll rather than blocking.
## Private ColorSync SPI ## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
+1 -1
View File
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|---|---| |---|---|
| Product | ICCery v2 for macOS | | Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` | | Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 | | Version | 2.0.3 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) | | Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner | | CI | Gitea Actions `macos-12` runner |
+18 -9
View File
@@ -186,17 +186,9 @@ struct ColorSyncSuppressor {
func captureOptions( func captureOptions(
from settings: PMPrintSettings from settings: PMPrintSettings
) -> (cupsOptions: String?, mediaType: String?) { ) -> (cupsOptions: String?, mediaType: String?) {
guard let toOptions = optionsResolver() else { guard let unfiltered = rawOptionsDump(from: settings) else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return (nil, nil) return (nil, nil)
} }
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered) let filtered = CupsOptionsFilter.filter(unfiltered)
return ( return (
filtered.isEmpty ? nil : filtered, filtered.isEmpty ? nil : filtered,
@@ -204,6 +196,23 @@ struct ColorSyncSuppressor {
) )
} }
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
/// `nil` when the symbol or the call fails.
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return nil
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return nil
}
defer { free(raw) }
return String(cString: raw)
}
// MARK: - dlsym // MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
+158 -56
View File
@@ -32,6 +32,19 @@ struct PrintPanelInitialSelections {
var orientation: String? var orientation: String?
} }
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
/// key roster, the Stage 2 pre-selections, and the session's captured
/// ticket — everything `makePanelPrintInfo` needs before the modal.
struct PrintPanelRequest {
let queue: String
let displayName: String?
let optionKeys: Set<String>
let initialSelections: PrintPanelInitialSelections
/// The queue's previously captured ticket — rehydrated before the
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
let ticket: PrintTicket?
}
/// Preferences → native `NSPrintPanel` bound to the selected CUPS /// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11). /// queue (issue 13, docs/11).
/// ///
@@ -58,7 +71,8 @@ struct PrintPanelService {
displayName: String?, displayName: String?,
cupsService: CupsService, cupsService: CupsService,
initialSelections: PrintPanelInitialSelections = initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections() PrintPanelInitialSelections(),
ticket: PrintTicket? = nil
) async throws -> PanelCaptureResult? { ) async throws -> PanelCaptureResult? {
#if DEBUG #if DEBUG
if UITestHooks.printPanelStubbed { if UITestHooks.printPanelStubbed {
@@ -75,83 +89,139 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue)) let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? [] ?? []
return try runNativePanel( return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys, PrintPanelRequest(
initialSelections: initialSelections) queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket))
} }
// MARK: - Panel // MARK: - Panel
private func runNativePanel( /// The pre-modal half of `runNativePanel` — a fully configured
queue: String, /// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
displayName: String?, /// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
optionKeys: Set<String>, /// assert ticket restore / Stage 2 precedence without driving the
initialSelections: PrintPanelInitialSelections /// modal (#217). The caller owns `printer` — it must stay alive
) throws -> PanelCaptureResult? { /// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
let printInfo = NSPrintInfo() let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// ① Bind the session to the selected CUPS queue (docs/11). // ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMTicketBridge.makePrinter(queue: queue) { if let printer {
pmPrinter = printer try PMTicketBridge.bind(printer: printer, to: printInfo)
let session = PMTicketBridge.session(printInfo) configureBoundPanel(
let settings = PMTicketBridge.settings(printInfo) printInfo, request: request, printer: printer)
do {
try PMTicketBridge.bind(printer: printer, to: printInfo)
} catch {
PMTicketBridge.release(printer)
throw error
}
// Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue // Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect). // (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn( AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — " "Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)") + "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
throw PrintPanelError.noPrinterFound( throw PrintPanelError.noPrinterFound(
displayName ?? queue) request.displayName ?? request.queue)
} }
printInfo.printer = nsPrinter printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues() printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
} }
return (printInfo, true)
}
/// Post-bind configuration in locked order (#217 — mirrors the
/// spooler's S4→S7): ticket restore → Stage 2 selections →
/// validate → paper → suppression ②–⑤. Internal so unit tests can
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
/// need a bound printer and skip when it is absent, while ticket
/// restore / selections / validate still run on the session-less
/// `NSPrintInfo`.
func configureBoundPanel(
_ printInfo: NSPrintInfo,
request: PrintPanelRequest,
printer: PMPrinter?
) {
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
// Rehydrate the captured ticket *before* the Stage 2 writes
// (#217 — restore-then-override). Warn-only, unlike the
// spooler's fatal `try`: a stale/corrupt ticket must not keep
// the dialog from opening. `restore` itself refuses a
// cross-queue replay (R3).
if let ticket = request.ticket {
do {
try PMTicketBridge.restore(ticket, into: printInfo)
} catch {
AppLogger.shared.warn(
"Print panel: ticket restore failed — "
+ "opening on queue defaults "
+ "(\(error.localizedDescription))")
}
}
// Initial selections — Stage 2 wins over the restored ticket
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
request.initialSelections, to: settings,
optionKeys: request.optionKeys)
// Reconcile the pre-writes into the ticket before the modal
// (#217 RC2): the vendor PDE derives pane state from a
// validated ticket. Warn-only — log when the driver adjusts
// so a companion-key desync is diagnosable.
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(session, settings, &changed)
if changed.boolValue {
AppLogger.shared.info(
"Print panel: driver adjusted the pre-selections")
}
printInfo.updateFromPMPrintSettings()
if let printer, let paperToken = request.initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
// ②–⑤ ColourSync suppression. The SPI needs a session with a
// current printer to attach to; the SetValue/mirror layers
// are warn-only and run regardless.
if printer != nil {
suppressor.applySPIMode(to: session) // ②
}
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
let driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: request.optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
private func runNativePanel(
_ request: PrintPanelRequest
) throws -> PanelCaptureResult? {
// The PM printer handle is held through the modal and
// released on every path.
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
defer { defer {
if let printer = pmPrinter { if let printer = pmPrinter {
PMTicketBridge.release(printer) PMTicketBridge.release(printer)
} }
} }
// ②–⑤ ColourSync suppression — only on the PM path: the SPI let (printInfo, boundViaPM) = try makePanelPrintInfo(
// and PMPrintSettingsSetValue need a session with a current for: request, boundTo: pmPrinter)
// printer to attach to.
var settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "pre-modal")
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
let panel = NSPrintPanel() let panel = NSPrintPanel()
panel.options = [ panel.options = [
@@ -174,8 +244,9 @@ struct PrintPanelService {
var cupsOptions: String? var cupsOptions: String?
var mediaType: String? var mediaType: String?
if boundViaPM { if boundViaPM {
settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(from: settings) let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo))
cupsOptions = captured.cupsOptions cupsOptions = captured.cupsOptions
mediaType = captured.mediaType mediaType = captured.mediaType
} }
@@ -183,8 +254,8 @@ struct PrintPanelService {
let resolvedQueue = boundViaPM let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID( ? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo), session: PMTicketBridge.session(printInfo),
fallback: queue) fallback: request.queue)
: queue : request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could // ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise // never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above. // failure must not lose the Stage 2 mirror above.
@@ -243,4 +314,35 @@ struct PrintPanelService {
} }
} }
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
/// `PMPrintSettingsToOptions` output and the serialised
/// settings/page-format byte counts (#217 diagnostics for the
/// Epson RC3 question: does the PDE source pane state from the
/// session ticket or lpoptions?). DEBUG-only, warn-only.
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
#if DEBUG
guard ProcessInfo.processInfo.environment[
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
let raw = suppressor.rawOptionsDump(
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
var settingsSize = -1
var ref: Unmanaged<CFData>?
if PMPrintSettingsCreateDataRepresentation(
PMTicketBridge.settings(printInfo), &ref,
kPMDataFormatXMLDefault) == noErr, let ref {
settingsSize = (ref.takeRetainedValue() as Data).count
}
var formatSize = -1
var formatRef: Unmanaged<CFData>?
if PMPageFormatCreateDataRepresentation(
PMTicketBridge.pageFormat(printInfo), &formatRef,
kPMDataFormatXMLDefault) == noErr, let formatRef {
formatSize = (formatRef.takeRetainedValue() as Data).count
}
AppLogger.shared.info(
"Print panel dump (\(phase)): settings=\(settingsSize)B "
+ "pageFormat=\(formatSize)B options: \(raw)")
#endif
}
} }
@@ -231,13 +231,15 @@ final class PrintSessionViewModel: ObservableObject {
quality: selectedQuality, quality: selectedQuality,
mediaType: selectedMediaType, mediaType: selectedMediaType,
orientation: printOrientation) orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
.showProperties( .showProperties(
queue: queue, displayName: displayName, queue: queue, displayName: displayName,
cupsService: cups, cupsService: cups,
initialSelections: selections) initialSelections: selections,
ticket: ticket)
else { else {
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
@@ -0,0 +1,148 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
/// Issue #217 — the panel's pre-modal seam
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
/// captured `PrintTicket` before the Stage 2 writes, validate the
/// result, and refuse a foreign-queue ticket — all without driving
/// the modal or needing a real queue (`printer: nil` exercises
/// restore → selections → validate on a session-less `NSPrintInfo`).
@MainActor
final class PrintPanelTicketTests: XCTestCase {
private let service = PrintPanelService()
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else a synthetic token for a
/// queue-less environment (same convention as PrintTicketTests).
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
private func makeTicket(
queue: String,
key: String = "EPIJ_Qual",
value: String = "305"
) throws -> PrintTicket {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: PMTicketBridge.settings(source),
context: "PrintPanelTicketTests"))
return try PMTicketBridge.serialise(source, queue: queue)
}
private func request(
ticket: PrintTicket?,
selections: PrintPanelInitialSelections =
PrintPanelInitialSelections()
) -> PrintPanelRequest {
PrintPanelRequest(
queue: "Panel_Q", displayName: nil, optionKeys: [],
initialSelections: selections, ticket: ticket)
}
/// RC1 — a captured vendor key is present in the configured
/// printInfo before the modal would open.
func testTicketRestoredPreModal() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"305")
}
/// D6 parity — Stage 2 selections win over the restored ticket.
func testStage2WinsOverTicket() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
var selections = PrintPanelInitialSelections()
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
service.configureBoundPanel(
printInfo,
request: request(ticket: ticket, selections: selections),
printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"303")
}
/// R3 — a ticket captured for another queue is refused; the
/// destination's settings stay untouched and nothing throws.
func testCrossQueueTicketRefusedAtPanel() throws {
let printInfo = NSPrintInfo()
let bound = boundQueue(of: printInfo)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(printInfo),
context: "PrintPanelTicketTests"))
let ticket = try makeTicket(queue: foreign)
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"999")
}
/// First-ever open — no ticket, selections still land and the
/// post-selections validate keeps them readable.
func testNoTicketKeepsSelections() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
selections.orientation = "landscape"
service.configureBoundPanel(
printInfo,
request: request(ticket: nil, selections: selections),
printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PageSize", in: settings), "A4")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual", in: settings), "303")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "orientation-requested", in: settings), "4")
// The suppression writes still land on the ticket so the PDE
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "AP_ColorMatchingMode", in: settings),
"AP_ApplicationColorMatching")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PMColorMatchingMode", in: settings),
"APCustomColorMatching")
}
}
+18 -29
View File
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
return recordedSpoolLog() return recordedSpoolLog()
} }
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button /// Scrolls `stage-2` with the synthesized scroll wheel so
/// moves up, clear of the Dock collision zone at the window's /// `identifier`'s button moves up, clear of the Dock collision
/// bottom edge (#132). /// zone at the window's bottom edge (#132).
/// ///
/// macOS overlay scrollbars are not in the AX tree — never use /// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on /// `app.scrollBars` — and a click-drag does not scroll a macOS
/// this LazyVGrid, so the scroll is a real drag on the gallery /// ScrollView (content-drag scrolling is iOS-only); the scroll
/// cell's content. A stale/off-screen AX frame resolves to a screen /// wheel is the mechanism the platform supports (#215). A
/// point that can be a Dock icon — a coordinate click there once /// stale/off-screen AX frame resolves to a screen point that can
/// opened Calendar instead of Print. Callers must click only when /// be a Dock icon — a coordinate click there once opened Calendar
/// the returned element `isHittable`; never coordinate-click a /// instead of Print. Callers must click only when the returned
/// stale frame. /// element `isHittable`; never coordinate-click a stale frame.
@discardableResult @discardableResult
private func scrollStage2UntilHittable( private func scrollStage2UntilHittable(
_ identifier: String, _ identifier: String,
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
var button = app.buttons[identifier] var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let scrollView = app.scrollViews["stage-2"]
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
button.frame.maxY < windowBottom - 80 { button.frame.maxY < windowBottom - 80 {
return button return button
} }
// Grab the upper half of the cell (the TIFF, not the Print // Wheel-down inside the stage-2 viewport: content moves
// button / Dock) and drag toward the top of the window. // UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves // toward the document bottom (#215).
// the Dock zone. scrollView.coordinate(withNormalizedOffset:
if cell.isHittable { CGVector(dx: 0.5, dy: 0.5))
let start = cell.coordinate(withNormalizedOffset: .scroll(byDeltaX: 0, deltaY: -60)
CGVector(dx: 0.5, dy: 0.25))
let end = start.withOffset(CGVector(dx: 0, dy: -280))
start.press(forDuration: 0.15, thenDragTo: end)
} else {
// Cell not hit-testable: drag the stage-2 content
// directly — still content, still never scrollBars.
let scrollView = app.scrollViews["stage-2"]
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55))
.press(forDuration: 0.15, thenDragTo:
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4)) RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier] button = app.buttons[identifier]
} }
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.2" MARKETING_VERSION: "2.0.3"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or # Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version. # `git describe` string); About shows it next to the marketing version.
+1 -1
View File
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
fi fi
echo "==> Building DMG" echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')" VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')" BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
# Tagged/described builds carry the tag in the DMG name so prerelease # Tagged/described builds carry the tag in the DMG name so prerelease
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain # artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain