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.
|
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`.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
|
||||||
let session = PMTicketBridge.session(printInfo)
|
|
||||||
let settings = PMTicketBridge.settings(printInfo)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
||||||
} catch {
|
configureBoundPanel(
|
||||||
PMTicketBridge.release(printer)
|
printInfo, request: request, printer: 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
|
||||||
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"]
|
|
||||||
scrollView.coordinate(withNormalizedOffset:
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
CGVector(dx: 0.5, dy: 0.55))
|
CGVector(dx: 0.5, dy: 0.5))
|
||||||
.press(forDuration: 0.15, thenDragTo:
|
.scroll(byDeltaX: 0, deltaY: -60)
|
||||||
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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user