Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12061a03ac | ||
|
|
f7f6a8bd87 | ||
|
|
ed91ebf807 | ||
|
|
77c065f433 |
@@ -93,6 +93,53 @@ codesign -dvv <sidecar>
|
||||
```
|
||||
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
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||
|
||||
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Version | 2.0.0 |
|
||||
| Version | 2.0.3 |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||
| CI | Gitea Actions `macos-12` runner |
|
||||
|
||||
@@ -186,17 +186,9 @@ struct ColorSyncSuppressor {
|
||||
func captureOptions(
|
||||
from settings: PMPrintSettings
|
||||
) -> (cupsOptions: String?, mediaType: String?) {
|
||||
guard let toOptions = optionsResolver() else {
|
||||
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
||||
+ "panel options not captured")
|
||||
guard let unfiltered = rawOptionsDump(from: settings) else {
|
||||
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)
|
||||
return (
|
||||
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
|
||||
|
||||
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
||||
|
||||
@@ -32,6 +32,19 @@ struct PrintPanelInitialSelections {
|
||||
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
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
@@ -58,7 +71,8 @@ struct PrintPanelService {
|
||||
displayName: String?,
|
||||
cupsService: CupsService,
|
||||
initialSelections: PrintPanelInitialSelections =
|
||||
PrintPanelInitialSelections()
|
||||
PrintPanelInitialSelections(),
|
||||
ticket: PrintTicket? = nil
|
||||
) async throws -> PanelCaptureResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
@@ -75,83 +89,139 @@ struct PrintPanelService {
|
||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||
?? []
|
||||
return try runNativePanel(
|
||||
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||
initialSelections: initialSelections)
|
||||
PrintPanelRequest(
|
||||
queue: queue, displayName: display,
|
||||
optionKeys: optionKeys,
|
||||
initialSelections: initialSelections, ticket: ticket))
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
private func runNativePanel(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>,
|
||||
initialSelections: PrintPanelInitialSelections
|
||||
) throws -> PanelCaptureResult? {
|
||||
/// The pre-modal half of `runNativePanel` — a fully configured
|
||||
/// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
|
||||
/// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
|
||||
/// assert ticket restore / Stage 2 precedence without driving the
|
||||
/// modal (#217). The caller owns `printer` — it must stay alive
|
||||
/// through the modal.
|
||||
func makePanelPrintInfo(
|
||||
for request: PrintPanelRequest,
|
||||
boundTo printer: PMPrinter?
|
||||
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
var boundViaPM = false
|
||||
|
||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||
if let printer = PMTicketBridge.makePrinter(queue: queue) {
|
||||
pmPrinter = printer
|
||||
let session = PMTicketBridge.session(printInfo)
|
||||
let settings = PMTicketBridge.settings(printInfo)
|
||||
|
||||
do {
|
||||
if let printer {
|
||||
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
|
||||
configureBoundPanel(
|
||||
printInfo, request: request, printer: printer)
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
// Warn — the display name can resolve a *different* queue
|
||||
// (#186 E2: diagnosable, not a proven defect).
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||
"Print panel: PM binding unavailable for "
|
||||
+ "'\(request.queue)' — "
|
||||
+ "falling back to NSPrinter(displayName)")
|
||||
guard let displayName,
|
||||
guard let displayName = request.displayName,
|
||||
let nsPrinter = NSPrinter(name: displayName)
|
||||
else {
|
||||
throw PrintPanelError.noPrinterFound(
|
||||
displayName ?? queue)
|
||||
request.displayName ?? request.queue)
|
||||
}
|
||||
printInfo.printer = nsPrinter
|
||||
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 {
|
||||
if let printer = pmPrinter {
|
||||
PMTicketBridge.release(printer)
|
||||
}
|
||||
}
|
||||
|
||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||
// and PMPrintSettingsSetValue need a session with a current
|
||||
// printer to attach to.
|
||||
var settings = PMTicketBridge.settings(printInfo)
|
||||
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 (printInfo, boundViaPM) = try makePanelPrintInfo(
|
||||
for: request, boundTo: pmPrinter)
|
||||
|
||||
debugDump(printInfo, when: "pre-modal")
|
||||
|
||||
let panel = NSPrintPanel()
|
||||
panel.options = [
|
||||
@@ -174,8 +244,9 @@ struct PrintPanelService {
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
settings = PMTicketBridge.settings(printInfo)
|
||||
let captured = suppressor.captureOptions(from: settings)
|
||||
debugDump(printInfo, when: "post-OK")
|
||||
let captured = suppressor.captureOptions(
|
||||
from: PMTicketBridge.settings(printInfo))
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
@@ -183,8 +254,8 @@ struct PrintPanelService {
|
||||
let resolvedQueue = boundViaPM
|
||||
? PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: queue)
|
||||
: queue
|
||||
fallback: request.queue)
|
||||
: request.queue
|
||||
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||
// never carry (#201). Warn-only via `try?`: a serialise
|
||||
// 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,
|
||||
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)
|
||||
initialSelections: selections,
|
||||
ticket: ticket)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
|
||||
return recordedSpoolLog()
|
||||
}
|
||||
|
||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||
/// moves up, clear of the Dock collision zone at the window's
|
||||
/// bottom edge (#132).
|
||||
/// Scrolls `stage-2` with the synthesized scroll wheel so
|
||||
/// `identifier`'s button moves up, clear of the Dock collision
|
||||
/// zone at the window's bottom edge (#132).
|
||||
///
|
||||
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||
/// point that can be a Dock icon — a coordinate click there once
|
||||
/// opened Calendar instead of Print. Callers must click only when
|
||||
/// the returned element `isHittable`; never coordinate-click a
|
||||
/// stale frame.
|
||||
/// `app.scrollBars` — and a click-drag does not scroll a macOS
|
||||
/// ScrollView (content-drag scrolling is iOS-only); the scroll
|
||||
/// wheel is the mechanism the platform supports (#215). A
|
||||
/// stale/off-screen AX frame resolves to a screen point that can
|
||||
/// be a Dock icon — a coordinate click there once opened Calendar
|
||||
/// instead of Print. Callers must click only when the returned
|
||||
/// element `isHittable`; never coordinate-click a stale frame.
|
||||
@discardableResult
|
||||
private func scrollStage2UntilHittable(
|
||||
_ identifier: String,
|
||||
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
|
||||
var button = app.buttons[identifier]
|
||||
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||
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)
|
||||
while Date() < deadline {
|
||||
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
|
||||
button.frame.maxY < windowBottom - 80 {
|
||||
return button
|
||||
}
|
||||
// Grab the upper half of the cell (the TIFF, not the Print
|
||||
// button / Dock) and drag toward the top of the window.
|
||||
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||
// the Dock zone.
|
||||
if cell.isHittable {
|
||||
let start = cell.coordinate(withNormalizedOffset:
|
||||
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"]
|
||||
// Wheel-down inside the stage-2 viewport: content moves
|
||||
// UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
|
||||
// toward the document bottom (#215).
|
||||
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)))
|
||||
}
|
||||
CGVector(dx: 0.5, dy: 0.5))
|
||||
.scroll(byDeltaX: 0, deltaY: -60)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
button = app.buttons[identifier]
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ targets:
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.2"
|
||||
MARKETING_VERSION: "2.0.3"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||
# `git describe` string); About shows it next to the marketing version.
|
||||
|
||||
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||
fi
|
||||
|
||||
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')"
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user