From ce5e0ef2b5f9a22cb9666240e7fb6ca59c0a15c4 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Wed, 2 Sep 2026 21:21:52 +0100 Subject: [PATCH 01/12] fix(print): open native NSPrintPanel for macOS printer properties (#188) Replace the CUPS web interface fallback with the native macOS NSPrintPanel, pre-configured with AP_ColorMatchingMode=AP_ApplicationColorMatching so the driver's color-management controls are greyed out (application manages color). Capture the user's media type and quality selections from the dialog and feed them into the lp job via PrintOptions.cups_options. - Add objc2-app-kit / objc2-application-services dependencies for NSPrintPanel - Rewrite show_printer_properties to dispatch NSPrintPanel on the main thread - Always add AP_ColorMatchingMode=AP_ApplicationColorMatching in build_lp_args - Forward captured CUPS options with deduplication against explicit fields - Detect Epson EPIJ_Medi / EPIJ_CCor / EPIJ_OSColMat PPD options - Add parse_cups_options_string helper and filtering of internal Apple keys - Update frontend to store and pass captured options per printer Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 40 +++ src-tauri/Cargo.lock | 67 +++++ src-tauri/Cargo.toml | 7 + src-tauri/src/commands.rs | 16 +- src-tauri/src/print/macos.rs | 507 +++++++++++++++++++++++++++++++---- src-tauri/src/print/mod.rs | 6 + src-tauri/src/print/tests.rs | 22 ++ src-tauri/src/print/unix.rs | 68 ++++- src/js/printtarg.js | 42 ++- 9 files changed, 717 insertions(+), 58 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1f771ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# ICCery Agent Notes + +## Build Commands + +- **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required) +- **Rust tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test` +- **Frontend**: `cd src-tauri && npm run build` (or `npm run dev` for development) + +## Architecture Overview + +- **Backend**: Rust + Tauri v2 (`src-tauri/`) +- **Frontend**: Vanilla JS modules (`src/js/`), HTML (`src/index.html`) +- **Print subsystem**: Platform-specific code under `src-tauri/src/print/` + - `mod.rs`: Shared types (`PrintOptions`, `Printer`, `PrinterCapabilities`, `PrinterDevModeStore`) + - `macos.rs`: macOS-specific `lp` spooling and native `NSPrintPanel` integration + - `unix.rs`: Generic Unix/CUPS utilities (printer enumeration, PPD parsing, `lp` args) + - `windows.rs`: Windows-specific printing via Win32 API and DEVMODE + +## macOS Print Properties (Issue #188) + +The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings). +- Pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching` so driver color management is greyed out +- Captures user's media type / quality selections as a CUPS options string +- Captured options are stored in frontend `capturedCupsOptions` map and passed via `PrintOptions.cups_options` +- `build_lp_args` in `macos.rs` always adds `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and forwards captured options + +## Key Dependencies (macOS only) + +- `objc2` 0.6 — MainThreadMarker, rc +- `objc2-app-kit` 0.3.2 — NSPrintPanel, NSPrintInfo, NSPrinter +- `objc2-foundation` 0.3.2 — NSString +- `objc2-core-foundation` 0.3.2 — CFString +- `objc2-application-services` 0.3.2 — PMCore (PMPrintSettings, PMCreatePrintSettings, etc.) + +## PPD Option Detection + +- Epson media type key: `EPIJ_Medi` (in addition to `CNIJMediaType`, `MediaType`, `StpMediaType`) +- Epson color bypass: `EPIJ_CCor=0` or `EPIJ_OSColMat=0` +- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4` +- Gutenprint: `StpColorCorrection=Uncorrected` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2940ef4..4ce34b8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1428,6 +1428,11 @@ dependencies = [ "base64 0.22.1", "image", "log", + "objc2", + "objc2-app-kit", + "objc2-application-services", + "objc2-core-foundation", + "objc2-foundation", "serde", "serde_json", "tauri", @@ -2037,8 +2042,31 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-application-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69282c2b5bc58fba07cb9de2113619532eb551e98efe3d8d695509ef45fbd53b" +dependencies = [ + "bitflags 2.13.1", + "libc", "objc2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-services", "objc2-foundation", ] @@ -2059,6 +2087,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2070,7 +2099,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2107,6 +2138,18 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -2119,6 +2162,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2170,6 +2226,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f8e3e18..056c96c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,13 @@ log = "0.4" base64 = "0.22" image = { version = "0.25", default-features = false, features = ["png", "tiff"] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", features = ["NSPrintInfo", "NSPrintPanel", "NSPrinter"] } +objc2-foundation = "0.3.2" +objc2-core-foundation = "0.3.2" +objc2-application-services = { version = "0.3.2", features = ["PMCore", "PMDefinitions", "PrintCore"] } + [target.'cfg(windows)'.dependencies] windows = { version = "0.61", features = [ "Win32_Foundation", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 00b5868..f9ddce6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1152,26 +1152,30 @@ pub async fn get_printer_capabilities( #[tauri::command] pub async fn show_printer_properties( + app: AppHandle, state: State<'_, crate::print::PrinterDevModeStore>, printer_name: String, -) -> Result<(), String> { +) -> Result, String> { #[cfg(windows)] { - crate::print::windows::show_printer_properties(&printer_name, &state) + let _ = (app, state); + crate::print::windows::show_printer_properties(&printer_name, &state)?; + Ok(None) } #[cfg(target_os = "macos")] { let _ = state; - crate::print::macos::show_printer_properties(&printer_name) + let opts = crate::print::macos::show_printer_properties(&printer_name, &app).await?; + Ok(Some(opts)) } #[cfg(all(unix, not(target_os = "macos")))] { - let _ = (state, printer_name); - Ok(()) + let _ = (app, state, printer_name); + Ok(None) } #[cfg(not(any(windows, unix)))] { - let _ = (state, printer_name); + let _ = (app, state, printer_name); Err("Printer properties dialog is not supported on this platform.".to_string()) } } diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 5aaa172..c84ee17 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -1,12 +1,251 @@ #![allow(dead_code)] +use std::ffi::CStr; use std::path::Path; -use crate::print::PrintOptions; -pub use crate::print::unix::{ - get_printer_capabilities, get_printers, -}; +use std::ptr::NonNull; -/// Construct `lp` command line arguments for target printing on macOS with ColorSync bypass flags. +use crate::print::PrintOptions; +pub use crate::print::unix::{get_printer_capabilities, get_printers}; + +extern "C" { + fn free(ptr: *mut std::ffi::c_void); +} + +/// PPD/driver-relevant CUPS option key names that we forward to `lp` when +/// captured from the native print panel. Keys not in this set (internal Apple +/// ticket keys, generic CUPS bookkeeping, etc.) are discarded. +const RELEVANT_CUPS_OPTION_KEYS: &[&str] = &[ + // Media type + "MediaType", + "CNIJMediaType", + "EPIJ_Medi", + "StpMediaType", + // Input slot / tray + "InputSlot", + "AP_D_InputSlot", + // Paper size + "PageSize", + // Driver color management bypass + "CNIJIntent2", + "CNIJIntent", + "EPIJ_CCor", + "EPIJ_OSColMat", + "ColorCorrection", + "StpColorCorrection", + "EpsonColorMode", + "ColorModel", + // Quality / resolution + "Resolution", + "cupsPrintQuality", + "Quality", + "EPIJ_Quality", + "CNIJQuality", + "StpQuality", + "OutputMode", + // Duplex + "Duplex", + "sides", +]; + +/// Return true if a CUPS option key/value pair captured from the native print +/// panel should be forwarded to the `lp` command. +fn is_relevant_cups_option(key: &str, value: &str) -> bool { + if key.is_empty() { + return false; + } + // Never forward internal Apple ticket keys. + if key.starts_with("com.apple.") { + return false; + } + // We always set AP_ColorMatchingMode ourselves in build_lp_args. + if key == "AP_ColorMatchingMode" { + return false; + } + // Skip empty values (e.g. "AP_D_InputSlot="). + if value.is_empty() { + return false; + } + // Skip generic CUPS bookkeeping keys that don't affect color or quality. + if matches!( + key, + "collate" | "copies" | "pserrorhandler-requested" | "job-sheets" + ) { + return false; + } + // Keep known PPD option keys, or any other non-Apple key that looks + // driver-specific (permissive: unknown driver keys are kept). + RELEVANT_CUPS_OPTION_KEYS.contains(&key) || !key.starts_with("com.") +} + +/// Filter a raw CUPS options string (as produced by PMPrintSettingsToOptions) +/// down to only the PPD-relevant pairs, returning them as a space-separated +/// `"key=value key=value"` string. +fn filter_cups_options_string(raw: &str) -> Option { + let pairs = crate::print::unix::parse_cups_options_string(raw); + let filtered: Vec<(String, String)> = pairs + .into_iter() + .filter(|(k, v)| is_relevant_cups_option(k, v)) + .collect(); + if filtered.is_empty() { + None + } else { + Some( + filtered + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(" "), + ) + } +} + +/// Extract the media type value from a filtered CUPS options string, looking +/// for the first known media-type key. +fn extract_media_type_from_options(options: &str) -> Option { + let pairs = crate::print::unix::parse_cups_options_string(options); + for (key, value) in &pairs { + if matches!( + key.as_str(), + "MediaType" | "CNIJMediaType" | "EPIJ_Medi" | "StpMediaType" + ) { + if !value.is_empty() { + return Some(value.clone()); + } + } + } + None +} + +/// Show the native macOS `NSPrintPanel` for the selected printer, pre-configured +/// with application-managed color so the driver's color-management controls are +/// greyed out. Returns the user's captured print settings as a `PrintOptions` +/// snapshot. +/// +/// This function must be called on the Cocoa main thread. The caller is +/// responsible for dispatching via `AppHandle::run_on_main_thread`. +fn run_native_print_panel(printer_name: &str) -> Result { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter}; + use objc2_application_services::{ + PMCopyPrintSettings, PMCreatePrintSettings, PMRelease, PMPrintSettings, + PMPrintSettingsToOptions, PMPrintSettingsSetValue, + }; + use objc2_core_foundation::CFString; + use objc2_foundation::NSString; + + let mtm = MainThreadMarker::new() + .ok_or("Print panel must be invoked on the main thread")?; + + // Look up the NSPrinter by name. + let name_ns = NSString::from_str(printer_name); + let printer = NSPrinter::printerWithName(&name_ns) + .ok_or_else(|| format!("No NSPrinter found for '{}'", printer_name))?; + + // Create a fresh NSPrintInfo and configure it for the selected printer. + let print_info = NSPrintInfo::new(); + print_info.setPrinter(&printer); + print_info.setUpPrintOperationDefaultValues(); + + // Set AP_ColorMatchingMode = AP_ApplicationColorMatching on the + // underlying PMPrintSettings so the driver's ColorSync / vendor color + // management controls are greyed out in the panel. + let pm_settings_raw: PMPrintSettings = print_info.PMPrintSettings().as_ptr() as PMPrintSettings; + let cm_key = CFString::from_str("AP_ColorMatchingMode"); + let cm_val = CFString::from_str("AP_ApplicationColorMatching"); + let set_status = unsafe { + PMPrintSettingsSetValue(pm_settings_raw, &cm_key, Some(cm_val.as_ref()), false) + }; + if set_status != 0 { + log::warn!( + "PMPrintSettingsSetValue(AP_ColorMatchingMode) returned status {}", + set_status + ); + } + // Sync the PMPrintSettings changes back into the NSPrintInfo object. + print_info.updateFromPMPrintSettings(); + + // Create and configure the print panel. + let panel = NSPrintPanel::printPanel(mtm); + let mut opts = NSPrintPanelOptions::all(); + opts.insert(NSPrintPanelOptions::ShowsPageSetupAccessory); + panel.setOptions(opts); + panel.setDefaultButtonTitle(Some(&NSString::from_str("Use Settings"))); + + // Run the modal dialog. + let response = panel.runModalWithPrintInfo(&print_info); + + // NSModalResponseOK == NSOKButton == 1. + if response != 1 { + return Err("Printer properties dialog was cancelled".to_string()); + } + + // Extract the updated print info from the panel. + let updated_info = panel.printInfo(); + let updated_settings_raw: PMPrintSettings = + updated_info.PMPrintSettings().as_ptr() as PMPrintSettings; + + // PMPrintSettingsToOptions may crash when AP_ColorMatchingMode is present + // in the settings. To avoid this, copy the settings into a fresh + // PMPrintSettings object and then call PMPrintSettingsToOptions on the + // copy. The copy will still contain AP_ColorMatchingMode, but we filter + // it out of the resulting string. + let mut copy_settings: PMPrintSettings = std::ptr::null_mut(); + let copy_status = unsafe { + PMCreatePrintSettings(NonNull::new(&mut copy_settings).unwrap()) + }; + if copy_status != 0 { + return Err(format!( + "PMCreatePrintSettings failed with status {}", + copy_status + )); + } + let copy_result = unsafe { PMCopyPrintSettings(updated_settings_raw, copy_settings) }; + if copy_result != 0 { + unsafe { PMRelease(copy_settings as _) }; + return Err(format!( + "PMCopyPrintSettings failed with status {}", + copy_result + )); + } + + let mut opts_ptr: *mut std::ffi::c_char = std::ptr::null_mut(); + let to_opts_status = unsafe { + PMPrintSettingsToOptions( + copy_settings, + NonNull::new(&mut opts_ptr).unwrap(), + ) + }; + unsafe { PMRelease(copy_settings as _) }; + + if to_opts_status != 0 || opts_ptr.is_null() { + return Err(format!( + "PMPrintSettingsToOptions failed with status {}", + to_opts_status + )); + } + + let opts_cstr = unsafe { CStr::from_ptr(opts_ptr) }; + let opts_str = opts_cstr.to_string_lossy().into_owned(); + unsafe { free(opts_ptr as *mut _) }; + + // Filter to PPD-relevant options. + let cups_options = filter_cups_options_string(&opts_str); + + // Try to extract a media type from the captured options. + let media_type = cups_options + .as_ref() + .and_then(|s| extract_media_type_from_options(s)); + + Ok(PrintOptions { + media_type, + cups_options, + ppd_uncorrected_passthrough: Some(true), + ..Default::default() + }) +} + +/// Construct `lp` command line arguments for target printing on macOS with +/// ColorSync bypass flags and any user-captured PPD options. pub fn build_lp_args( printer_name: &str, tiff_path: &str, @@ -15,7 +254,9 @@ pub fn build_lp_args( let path = Path::new(tiff_path); let title = format!( "ICCery Target - {}", - path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target") + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Profiling Target") ); let mut args = vec![ @@ -25,57 +266,104 @@ pub fn build_lp_args( title, ]; - let ppd_fallback = options - .and_then(|o| o.ppd_uncorrected_passthrough) - .unwrap_or(false); - - // Always apply ColorSync bypass on macOS for targeting + // Always apply ColorSync bypass on macOS for targeting. args.push("-o".to_string()); args.push("AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()); - // Fetch lpoptions for this printer to detect capabilities - if let Ok(out) = std::process::Command::new("lpoptions") - .args(["-p", printer_name, "-l"]) - .output() - { - if out.status.success() { - let output_str = String::from_utf8_lossy(&out.stdout); + // Track which option keys have already been added from cups_options so we + // don't duplicate them from the explicit PrintOptions fields. + let mut added_keys: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(opts) = options { - if let Some(ref media_type) = opts.media_type { - if !media_type.trim().is_empty() { - let key = crate::print::unix::detect_media_type_key(&output_str); + // If we have captured CUPS options from the native print panel, add them + // first. These take precedence over auto-detected defaults. + if let Some(opts) = options { + if let Some(ref cups_opts) = opts.cups_options { + if !cups_opts.trim().is_empty() { + for (key, value) in crate::print::unix::parse_cups_options_string(cups_opts) { + if !key.is_empty() { args.push("-o".to_string()); - args.push(format!("{}={}", key, media_type.trim())); + args.push(format!("{}={}", key, value)); + added_keys.insert(key.to_lowercase()); } } } - - if ppd_fallback { - if let Some((bypass_key, bypass_val)) = - crate::print::unix::detect_driver_color_bypass(&output_str) - { - args.push("-o".to_string()); - args.push(format!("{}={}", bypass_key, bypass_val)); - } - } } } + // Fetch lpoptions for this printer to detect capabilities. + let lpoptions_output: Option = std::process::Command::new("lpoptions") + .args(["-p", printer_name, "-l"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + + // Add media type from explicit options if not already in cups_options. + if let Some(opts) = options { + if let Some(ref media_type) = opts.media_type { + if !media_type.trim().is_empty() + && !added_keys.contains("mediatype") + && !added_keys.contains("cnijmediatype") + && !added_keys.contains("epij_medi") + && !added_keys.contains("stpmediatype") + { + if let Some(ref output_str) = lpoptions_output { + let key = crate::print::unix::detect_media_type_key(output_str); + args.push("-o".to_string()); + args.push(format!("{}={}", key, media_type.trim())); + added_keys.insert(key.to_lowercase()); + } + } + } + } + + // Always detect and add the driver color bypass (not gated on + // ppd_uncorrected_passthrough). If cups_options already contains a color + // bypass key, skip this to avoid duplicates. + let color_bypass_keys = [ + "cnijintent2", + "cnijintent", + "epij_ccor", + "epij_oscolmat", + "colorcorrection", + "stpcolorcorrection", + "epsoncolormode", + ]; + let has_color_bypass = added_keys + .iter() + .any(|k| color_bypass_keys.contains(&k.as_str())); + if !has_color_bypass { + if let Some(ref output_str) = lpoptions_output { + if let Some((bypass_key, bypass_val)) = + crate::print::unix::detect_driver_color_bypass(output_str) + { + args.push("-o".to_string()); + args.push(format!("{}={}", bypass_key, bypass_val)); + added_keys.insert(bypass_key.to_lowercase()); + } + } + } + + // Add orientation and paper size from explicit options if not already + // present in cups_options. if let Some(opts) = options { if let Some(ref orient) = opts.orientation { - args.push("-o".to_string()); - if orient.eq_ignore_ascii_case("landscape") { - args.push("orientation-requested=4".to_string()); - } else { - args.push("orientation-requested=3".to_string()); + if !added_keys.contains("orientation-requested") { + args.push("-o".to_string()); + if orient.eq_ignore_ascii_case("landscape") { + args.push("orientation-requested=4".to_string()); + } else { + args.push("orientation-requested=3".to_string()); + } + added_keys.insert("orientation-requested".to_string()); } } if let Some(ref page_size) = opts.paper_size { - if !page_size.trim().is_empty() { + if !page_size.trim().is_empty() && !added_keys.contains("pagesize") { args.push("-o".to_string()); args.push(format!("PageSize={}", page_size.trim())); + added_keys.insert("pagesize".to_string()); } } } @@ -118,14 +406,139 @@ pub fn print_target( Ok(()) } -/// Open macOS printer queue / properties management or inspect queue options. -pub fn show_printer_properties(printer_name: &str) -> Result<(), String> { - // macOS System Preferences don't show PPD driver options. - // We open the CUPS web interface instead. - let url = format!("http://localhost:631/printers/{}", printer_name); - let _ = std::process::Command::new("open") - .args([&url]) - .spawn(); +/// Open the native macOS `NSPrintPanel` for the selected printer. +/// +/// The panel is pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching` +/// so the driver's color-management controls are greyed out (application manages +/// color). On OK, the user's media type / quality choices are captured and +/// returned as a `PrintOptions` snapshot for the frontend to feed back into +/// `print_target_native`. +/// +/// This function dispatches the dialog to the Cocoa main thread via +/// `AppHandle::run_on_main_thread` and awaits the result. +pub async fn show_printer_properties( + printer_name: &str, + app: &tauri::AppHandle, +) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let printer_name_owned = printer_name.to_string(); - Ok(()) + app.run_on_main_thread(move || { + let result = run_native_print_panel(&printer_name_owned); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to dispatch print panel to main thread: {}", e))?; + + rx.await + .map_err(|e| format!("Print panel channel closed unexpectedly: {}", e))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_cups_options_string_basic() { + let raw = "MediaType=92 EPIJ_CCor=0 com.apple.print.PrintSettings.PMCopies..n.=1 collate=False PageSize=A4 AP_ColorMatchingMode=AP_ApplicationColorMatching AP_D_InputSlot= copies=1"; + let filtered = filter_cups_options_string(raw).unwrap(); + // Should contain MediaType, EPIJ_CCor, PageSize but not com.apple.*, + // collate, copies, AP_ColorMatchingMode, or empty AP_D_InputSlot. + assert!(filtered.contains("MediaType=92")); + assert!(filtered.contains("EPIJ_CCor=0")); + assert!(filtered.contains("PageSize=A4")); + assert!(!filtered.contains("com.apple.")); + assert!(!filtered.contains("collate")); + assert!(!filtered.contains("copies")); + assert!(!filtered.contains("AP_ColorMatchingMode")); + assert!(!filtered.contains("AP_D_InputSlot")); + } + + #[test] + fn test_filter_cups_options_string_empty() { + let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False"; + assert!(filter_cups_options_string(raw).is_none()); + } + + #[test] + fn test_extract_media_type_from_options() { + let opts = "MediaType=92 EPIJ_CCor=0 PageSize=A4"; + assert_eq!( + extract_media_type_from_options(opts), + Some("92".to_string()) + ); + + let opts2 = "EPIJ_Medi=13 PageSize=A4"; + assert_eq!( + extract_media_type_from_options(opts2), + Some("13".to_string()) + ); + + let opts3 = "PageSize=A4"; + assert_eq!(extract_media_type_from_options(opts3), None); + } + + #[test] + fn test_build_lp_args_with_cups_options() { + let opts = PrintOptions { + cups_options: Some("MediaType=92 EPIJ_CCor=0 PageSize=A4".to_string()), + ppd_uncorrected_passthrough: Some(true), + ..Default::default() + }; + let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); + assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + assert!(args.contains(&"MediaType=92".to_string())); + assert!(args.contains(&"EPIJ_CCor=0".to_string())); + assert!(args.contains(&"PageSize=A4".to_string())); + assert_eq!(args.last().unwrap(), "/tmp/target.tif"); + } + + #[test] + fn test_build_lp_args_no_duplicate_media_type() { + // When cups_options contains a MediaType, the explicit media_type + // field should NOT also be added. + let opts = PrintOptions { + cups_options: Some("MediaType=92".to_string()), + media_type: Some("13".to_string()), + ..Default::default() + }; + let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); + // Should contain MediaType=92 (from cups_options) but NOT MediaType=13 + assert!(args.contains(&"MediaType=92".to_string())); + assert!(!args.contains(&"MediaType=13".to_string())); + } + + #[test] + fn test_build_lp_args_no_duplicate_color_bypass() { + // When cups_options contains a color bypass key, the auto-detected + // bypass should NOT also be added. + let opts = PrintOptions { + cups_options: Some("EPIJ_CCor=0".to_string()), + ..Default::default() + }; + let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); + // Should contain EPIJ_CCor=0 exactly once (from cups_options). + let count = args.iter().filter(|a| *a == "EPIJ_CCor=0").count(); + assert_eq!(count, 1); + } + + #[test] + fn test_build_lp_args_colorsync_always_present() { + // Even with no options, AP_ColorMatchingMode should be present. + let args = build_lp_args("Test_Printer", "/tmp/target.tif", None); + assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + } + + #[test] + fn test_build_lp_args_orientation_not_duplicated() { + let opts = PrintOptions { + cups_options: Some("orientation-requested=4".to_string()), + orientation: Some("portrait".to_string()), + ..Default::default() + }; + let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); + // Should contain orientation-requested=4 from cups_options, but NOT + // orientation-requested=3 from the explicit portrait setting. + assert!(args.contains(&"orientation-requested=4".to_string())); + assert!(!args.contains(&"orientation-requested=3".to_string())); + } } diff --git a/src-tauri/src/print/mod.rs b/src-tauri/src/print/mod.rs index c3e781f..aabf40a 100644 --- a/src-tauri/src/print/mod.rs +++ b/src-tauri/src/print/mod.rs @@ -43,6 +43,12 @@ pub struct PrintOptions { pub paper_size: Option, pub media_type: Option, pub ppd_uncorrected_passthrough: Option, + /// Optional CUPS option string ("name=value name=value ...") captured from + /// the native macOS print panel. On macOS these are appended to the `lp` + /// invocation as additional `-o name=value` arguments, taking precedence + /// over auto-detected defaults. On other platforms this field is ignored. + #[serde(default)] + pub cups_options: Option, } #[derive(Clone, Default)] diff --git a/src-tauri/src/print/tests.rs b/src-tauri/src/print/tests.rs index 3ab4ff9..dce9b12 100644 --- a/src-tauri/src/print/tests.rs +++ b/src-tauri/src/print/tests.rs @@ -72,6 +72,7 @@ mod integration_tests { paper_size: Some("A4".to_string()), media_type: Some("1".to_string()), ppd_uncorrected_passthrough: Some(false), + cups_options: Some("MediaType=1".to_string()), }; let json = serde_json::to_string(&opts).expect("Failed to serialize PrintOptions"); @@ -81,6 +82,26 @@ mod integration_tests { assert_eq!(deserialized.orientation.as_deref(), Some("landscape")); assert_eq!(deserialized.paper_size.as_deref(), Some("A4")); assert_eq!(deserialized.media_type.as_deref(), Some("1")); + assert_eq!(deserialized.cups_options.as_deref(), Some("MediaType=1")); + } + + #[test] + fn test_print_options_cups_options_default_none() { + // When cups_options is not set, it should default to None and + // serialize/deserialize correctly. + let opts = PrintOptions { + paper_source: None, + orientation: None, + paper_size: None, + media_type: None, + ppd_uncorrected_passthrough: None, + cups_options: None, + }; + let json = serde_json::to_string(&opts).expect("Failed to serialize"); + assert!(!json.contains("cups_options") || json.contains("\"cups_options\":null")); + let deserialized: PrintOptions = + serde_json::from_str(&json).expect("Failed to deserialize"); + assert_eq!(deserialized.cups_options, None); } #[test] @@ -230,6 +251,7 @@ printer Custom_Queue unknown state\n\ paper_size: None, media_type: Some("4".to_string()), ppd_uncorrected_passthrough: None, + cups_options: None, }; let mut work_buf = retrieved; diff --git a/src-tauri/src/print/unix.rs b/src-tauri/src/print/unix.rs index 93692b8..325c9c9 100644 --- a/src-tauri/src/print/unix.rs +++ b/src-tauri/src/print/unix.rs @@ -166,7 +166,11 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec, Vec (Vec, Vec Vec { let mut media = Vec::new(); for line in ppd_content.lines() { - if line.starts_with("*CNIJMediaType ") || line.starts_with("*MediaType ") || line.starts_with("*StpMediaType ") { + if line.starts_with("*CNIJMediaType ") + || line.starts_with("*MediaType ") + || line.starts_with("*StpMediaType ") + || line.starts_with("*EPIJ_Medi ") + { if let Some((id_part, name_part)) = line.split_once('/') { let id = id_part.split_whitespace().last().unwrap_or("").trim().to_string(); let name = name_part.split(':').next().unwrap_or("").trim().to_string(); @@ -202,6 +210,13 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati Some(("CNIJIntent2", "4")) } else if output.contains("CNIJIntent") { Some(("CNIJIntent", "4")) + } else if output.contains("EPIJ_CCor") { + // Epson driver color-correction key. The "Off (No Color Adjustment)" + // equivalent is the value that disables driver-side color management + // so the application ICC profile passes through unmodified. + Some(("EPIJ_CCor", "0")) + } else if output.contains("EPIJ_OSColMat") { + Some(("EPIJ_OSColMat", "0")) } else if output.contains("ColorCorrection") { Some(("ColorCorrection", "Uncorrected")) } else if output.contains("StpColorCorrection") { @@ -216,6 +231,8 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati pub fn detect_media_type_key(output: &str) -> &'static str { if output.contains("CNIJMediaType") { "CNIJMediaType" + } else if output.contains("EPIJ_Medi") { + "EPIJ_Medi" } else if output.contains("StpMediaType") { "StpMediaType" } else { @@ -223,6 +240,24 @@ pub fn detect_media_type_key(output: &str) -> &'static str { } } +/// Parse a CUPS options string of the form `"name=value name=value ..."` into +/// an ordered vector of `(name, value)` pairs. Whitespace separates pairs and +/// `=` separates the name from the value. Empty values are preserved so the +/// caller can decide whether to forward them to `lp`. +pub fn parse_cups_options_string(options: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for token in options.split_whitespace() { + if let Some((name, value)) = token.split_once('=') { + let name = name.trim().to_string(); + let value = value.trim().to_string(); + if !name.is_empty() { + out.push((name, value)); + } + } + } + out +} + /// Query CUPS printer capabilities via `lpoptions -p -l`. pub fn get_printer_capabilities(printer_name: &str) -> Result { let output = std::process::Command::new("lpoptions") @@ -477,10 +512,39 @@ Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\ #[test] fn test_detect_driver_color_bypass() { assert_eq!(detect_driver_color_bypass("CNIJIntent2: *0 1 2 4"), Some(("CNIJIntent2", "4"))); + assert_eq!(detect_driver_color_bypass("EPIJ_CCor: *0 3 4 6 12"), Some(("EPIJ_CCor", "0"))); assert_eq!(detect_driver_color_bypass("EpsonColorMode: Off *1 2"), Some(("EpsonColorMode", "Off"))); assert_eq!(detect_driver_color_bypass("SomethingElse: 1"), None); } + #[test] + fn test_detect_media_type_key_epson() { + assert_eq!(detect_media_type_key("EPIJ_Medi: *0 92 13"), "EPIJ_Medi"); + assert_eq!(detect_media_type_key("CNIJMediaType: *1 2"), "CNIJMediaType"); + assert_eq!(detect_media_type_key("StpMediaType: *1"), "StpMediaType"); + assert_eq!(detect_media_type_key("MediaType: *Plain"), "MediaType"); + } + + #[test] + fn test_parse_cups_options_string() { + let opts = parse_cups_options_string("MediaType=92 EPIJ_CCor=0 PageSize=A4"); + assert_eq!(opts, vec![ + ("MediaType".to_string(), "92".to_string()), + ("EPIJ_CCor".to_string(), "0".to_string()), + ("PageSize".to_string(), "A4".to_string()), + ]); + + // Empty / malformed tokens are skipped + let empty = parse_cups_options_string(""); + assert!(empty.is_empty()); + + let no_eq = parse_cups_options_string("foo bar baz"); + assert!(no_eq.is_empty()); + + let empty_val = parse_cups_options_string("AP_D_InputSlot="); + assert_eq!(empty_val, vec![("AP_D_InputSlot".to_string(), "".to_string())]); + } + #[test] fn test_build_lp_args_raw() { let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", None); diff --git a/src/js/printtarg.js b/src/js/printtarg.js index cdae09c..d90c43c 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -10,6 +10,11 @@ let stage1Cwd = ""; let currentManifest = null; let discoveredPrinters = []; +// Captured CUPS options from the native macOS print panel, keyed by printer +// name. These are fed back into print_target_native so the lp job uses the +// user's media type / quality / color-bypass selections. +let capturedCupsOptions = {}; + let updateLabelPreviewFn = null; /** @@ -256,6 +261,8 @@ export function initPrinttarg() { const paperSource = trayVal ? parseInt(trayVal, 10) : null; const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false; const mediaType = printerMediaTypeSelect ? printerMediaTypeSelect.value : ""; + const activePrinter = printerSelect ? printerSelect.value : ""; + const cupsOpts = (activePrinter && capturedCupsOptions[activePrinter]) ? capturedCupsOptions[activePrinter] : null; return { paper_source: paperSource, @@ -263,6 +270,7 @@ export function initPrinttarg() { paper_size: pageSizeSelect ? pageSizeSelect.value : null, media_type: mediaType ? mediaType : null, ppd_uncorrected_passthrough: ppdFallback, + cups_options: cupsOpts, }; } @@ -373,11 +381,39 @@ export function initPrinttarg() { try { showNotification("info", `Opening native printer preferences for '${printerName}'...`); - await invoke("show_printer_properties", { printerName }); - showNotification("success", `✓ Printer driver preferences configured for '${printerName}'.`); + const result = await invoke("show_printer_properties", { printerName }); + + if (result) { + // macOS: the backend returned a PrintOptions snapshot with captured + // CUPS options from the native print panel. + if (result.cups_options) { + capturedCupsOptions[printerName] = result.cups_options; + } else { + delete capturedCupsOptions[printerName]; + } + + // If a media type was captured, update the dropdown to match. + if (result.media_type && printerMediaTypeSelect) { + const matchOpt = Array.from(printerMediaTypeSelect.options).find( + o => o.value === result.media_type + ); + if (matchOpt) { + printerMediaTypeSelect.value = result.media_type; + } + } + + showNotification("success", `Printer driver preferences configured for '${printerName}'.`); + } else { + // Windows / Linux: no captured options returned. + showNotification("success", `Printer driver preferences configured for '${printerName}'.`); + } } catch (err) { console.error("[ICCery Print] Failed to open printer properties:", err); - showNotification("error", `Could not open printer properties: ${err}`); + if (String(err).includes("cancelled")) { + showNotification("info", "Printer properties dialog cancelled."); + } else { + showNotification("error", `Could not open printer properties: ${err}`); + } } }); } -- 2.39.5 From 313bdfdee4c18116ed12d82bd937ee76ace4f7c5 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Wed, 2 Sep 2026 22:44:20 +0100 Subject: [PATCH 02/12] fix(print): bind macOS print panel via Core Printing, fix Windows build, and correct Epson bypass (#188) --- .gitea/workflows/build-macos.yml | 9 ++ AGENTS.md | 12 +- src-tauri/src/commands.rs | 7 +- src-tauri/src/print/macos.rs | 222 +++++++++++++++++++++---------- src-tauri/src/print/mod.rs | 9 ++ src-tauri/src/print/tests.rs | 1 + src-tauri/src/print/unix.rs | 96 +++++++++++-- src/js/printtarg.js | 71 ++++++---- 8 files changed, 306 insertions(+), 121 deletions(-) diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index 487dd3a..707d5bc 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -62,6 +62,15 @@ jobs: test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist" test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen" + - name: Run Rust tests + shell: bash + env: + CARGO_INCREMENTAL: "0" + CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target" + run: | + cd src-tauri + cargo test --target ${{ matrix.platform.target }} + - name: Set Release Environment id: set_env shell: bash diff --git a/AGENTS.md b/AGENTS.md index 1f771ac..eed6baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,12 @@ ## macOS Print Properties (Issue #188) The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings). +- The CUPS destination ID is bound to the panel via Core Printing `PMPrinterCreateFromPrinterID` and `PMSessionSetCurrentPMPrinter` +- A `Printer.display_name` (from CUPS `printer-info`) is cached at enumeration as a fallback for `NSPrinter::printerWithName` - Pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching` so driver color management is greyed out -- Captures user's media type / quality selections as a CUPS options string +- Captures user's media type / quality selections as a CUPS options string with `PMPrintSettingsToOptions` +- Returns a `PrintPropertiesResult` with the effective `selected_printer` and captured `PrintOptions` +- Cancellation is returned as `None`, not an error - Captured options are stored in frontend `capturedCupsOptions` map and passed via `PrintOptions.cups_options` - `build_lp_args` in `macos.rs` always adds `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and forwards captured options @@ -29,12 +33,12 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI - `objc2` 0.6 — MainThreadMarker, rc - `objc2-app-kit` 0.3.2 — NSPrintPanel, NSPrintInfo, NSPrinter - `objc2-foundation` 0.3.2 — NSString -- `objc2-core-foundation` 0.3.2 — CFString -- `objc2-application-services` 0.3.2 — PMCore (PMPrintSettings, PMCreatePrintSettings, etc.) +- `objc2-core-foundation` 0.3.2 — CFString, CFType +- `objc2-application-services` 0.3.2 — PMCore (PMPrintSettings, PMPrinter, PMSession, etc.) ## PPD Option Detection - Epson media type key: `EPIJ_Medi` (in addition to `CNIJMediaType`, `MediaType`, `StpMediaType`) -- Epson color bypass: `EPIJ_CCor=0` or `EPIJ_OSColMat=0` +- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment) - Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4` - Gutenprint: `StpColorCorrection=Uncorrected` diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f9ddce6..cf13f6f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1155,18 +1155,17 @@ pub async fn show_printer_properties( app: AppHandle, state: State<'_, crate::print::PrinterDevModeStore>, printer_name: String, -) -> Result, String> { +) -> Result, String> { #[cfg(windows)] { - let _ = (app, state); + let _ = app; crate::print::windows::show_printer_properties(&printer_name, &state)?; Ok(None) } #[cfg(target_os = "macos")] { let _ = state; - let opts = crate::print::macos::show_printer_properties(&printer_name, &app).await?; - Ok(Some(opts)) + crate::print::macos::show_printer_properties(&printer_name, &app).await } #[cfg(all(unix, not(target_os = "macos")))] { diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index c84ee17..0b044cc 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -4,7 +4,7 @@ use std::ffi::CStr; use std::path::Path; use std::ptr::NonNull; -use crate::print::PrintOptions; +use crate::print::{PrintOptions, PrintPropertiesResult}; pub use crate::print::unix::{get_printer_capabilities, get_printers}; extern "C" { @@ -28,6 +28,7 @@ const RELEVANT_CUPS_OPTION_KEYS: &[&str] = &[ // Driver color management bypass "CNIJIntent2", "CNIJIntent", + "EPIJ_CMat", "EPIJ_CCor", "EPIJ_OSColMat", "ColorCorrection", @@ -118,17 +119,24 @@ fn extract_media_type_from_options(options: &str) -> Option { /// Show the native macOS `NSPrintPanel` for the selected printer, pre-configured /// with application-managed color so the driver's color-management controls are -/// greyed out. Returns the user's captured print settings as a `PrintOptions` -/// snapshot. +/// greyed out. Returns the user-selected printer and captured print settings as +/// a `PrintPropertiesResult` snapshot, or `None` if the user cancelled the +/// dialog. /// /// This function must be called on the Cocoa main thread. The caller is /// responsible for dispatching via `AppHandle::run_on_main_thread`. -fn run_native_print_panel(printer_name: &str) -> Result { +fn run_native_print_panel( + printer_name: &str, + display_name: Option<&str>, +) -> Result, String> { use objc2::MainThreadMarker; use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter}; use objc2_application_services::{ - PMCopyPrintSettings, PMCreatePrintSettings, PMRelease, PMPrintSettings, - PMPrintSettingsToOptions, PMPrintSettingsSetValue, + PMPageFormat, PMPrinter, PMPrinterCreateFromPrinterID, + PMPrinterGetID, PMPrintSession, PMPrintSettings, PMPrintSettingsSetValue, + PMPrintSettingsToOptions, PMRelease, PMSessionDefaultPageFormat, + PMSessionDefaultPrintSettings, PMSessionGetCurrentPrinter, + PMSessionSetCurrentPMPrinter, }; use objc2_core_foundation::CFString; use objc2_foundation::NSString; @@ -136,24 +144,82 @@ fn run_native_print_panel(printer_name: &str) -> Result { let mtm = MainThreadMarker::new() .ok_or("Print panel must be invoked on the main thread")?; - // Look up the NSPrinter by name. - let name_ns = NSString::from_str(printer_name); - let printer = NSPrinter::printerWithName(&name_ns) - .ok_or_else(|| format!("No NSPrinter found for '{}'", printer_name))?; - - // Create a fresh NSPrintInfo and configure it for the selected printer. + // Create a fresh NSPrintInfo and initialize it. let print_info = NSPrintInfo::new(); - print_info.setPrinter(&printer); print_info.setUpPrintOperationDefaultValues(); - // Set AP_ColorMatchingMode = AP_ApplicationColorMatching on the - // underlying PMPrintSettings so the driver's ColorSync / vendor color - // management controls are greyed out in the panel. - let pm_settings_raw: PMPrintSettings = print_info.PMPrintSettings().as_ptr() as PMPrintSettings; + // Bind the CUPS destination (Printer ID) to the session. CUPS destination + // IDs are what macOS Core Printing calls "Printer IDs"; they are not the + // human-readable display names used by NSPrinter::printerWithName. + let printer_id_cf = CFString::from_str(printer_name); + let pm_printer: PMPrinter = unsafe { PMPrinterCreateFromPrinterID(&*printer_id_cf) }; + let mut printer_from_id = !pm_printer.is_null(); + + if pm_printer.is_null() { + if let Some(dn) = display_name { + // Fallback: try the human-readable display name via NSPrinter. This is + // less robust than a direct PMPrinter lookup but allows the panel to + // open when the queue is not currently registered in the Core Printing + // session under its CUPS ID. + let name_ns = NSString::from_str(dn); + if let Some(ns_printer) = NSPrinter::printerWithName(&name_ns) { + print_info.setPrinter(&ns_printer); + print_info.setUpPrintOperationDefaultValues(); + } else { + return Err(format!( + "No printer found for '{}' (display name: {:?})", + printer_name, display_name + )); + } + } else { + return Err(format!( + "No printer found for '{}' (display name: {:?})", + printer_name, display_name + )); + } + } + + // Obtain the underlying Core Printing session and settings now that + // NSPrintInfo has been configured. These are valid as long as `print_info` + // is retained. + let pm_session: PMPrintSession = print_info.PMPrintSession().as_ptr() as PMPrintSession; + let pm_settings: PMPrintSettings = print_info.PMPrintSettings().as_ptr() as PMPrintSettings; + let pm_page_format = print_info.PMPageFormat().as_ptr() as PMPageFormat; + + if !pm_printer.is_null() { + let set_status = unsafe { PMSessionSetCurrentPMPrinter(pm_session, pm_printer) }; + if set_status != 0 { + unsafe { PMRelease(pm_printer as _) }; + return Err(format!( + "PMSessionSetCurrentPMPrinter failed with status {}", + set_status + )); + } + + let default_status = unsafe { PMSessionDefaultPrintSettings(pm_session, pm_settings) }; + if default_status != 0 { + log::warn!( + "PMSessionDefaultPrintSettings returned status {}", + default_status + ); + } + + let default_page_status = unsafe { PMSessionDefaultPageFormat(pm_session, pm_page_format) }; + if default_page_status != 0 { + log::warn!( + "PMSessionDefaultPageFormat returned status {}", + default_page_status + ); + } + } + + // Set AP_ColorMatchingMode = AP_ApplicationColorMatching so the driver's + // ColorSync / vendor color management controls are greyed out in the panel. let cm_key = CFString::from_str("AP_ColorMatchingMode"); let cm_val = CFString::from_str("AP_ApplicationColorMatching"); + let cm_val_ref: &objc2_core_foundation::CFType = &*cm_val; let set_status = unsafe { - PMPrintSettingsSetValue(pm_settings_raw, &cm_key, Some(cm_val.as_ref()), false) + PMPrintSettingsSetValue(pm_settings, &cm_key, Some(cm_val_ref), false) }; if set_status != 0 { log::warn!( @@ -161,6 +227,9 @@ fn run_native_print_panel(printer_name: &str) -> Result { set_status ); } + + print_info.updateFromPMPageFormat(); + // Sync the PMPrintSettings changes back into the NSPrintInfo object. print_info.updateFromPMPrintSettings(); @@ -176,48 +245,46 @@ fn run_native_print_panel(printer_name: &str) -> Result { // NSModalResponseOK == NSOKButton == 1. if response != 1 { - return Err("Printer properties dialog was cancelled".to_string()); + if !pm_printer.is_null() { + unsafe { PMRelease(pm_printer as _) }; + } + return Ok(None); } - // Extract the updated print info from the panel. - let updated_info = panel.printInfo(); - let updated_settings_raw: PMPrintSettings = - updated_info.PMPrintSettings().as_ptr() as PMPrintSettings; - - // PMPrintSettingsToOptions may crash when AP_ColorMatchingMode is present - // in the settings. To avoid this, copy the settings into a fresh - // PMPrintSettings object and then call PMPrintSettingsToOptions on the - // copy. The copy will still contain AP_ColorMatchingMode, but we filter - // it out of the resulting string. - let mut copy_settings: PMPrintSettings = std::ptr::null_mut(); - let copy_status = unsafe { - PMCreatePrintSettings(NonNull::new(&mut copy_settings).unwrap()) + // Capture the effective printer from the session. + let mut current_printer: PMPrinter = std::ptr::null_mut(); + let get_status = unsafe { + PMSessionGetCurrentPrinter( + pm_session, + NonNull::new(&mut current_printer).unwrap(), + ) }; - if copy_status != 0 { - return Err(format!( - "PMCreatePrintSettings failed with status {}", - copy_status - )); - } - let copy_result = unsafe { PMCopyPrintSettings(updated_settings_raw, copy_settings) }; - if copy_result != 0 { - unsafe { PMRelease(copy_settings as _) }; - return Err(format!( - "PMCopyPrintSettings failed with status {}", - copy_result - )); - } + let selected_printer = if get_status == 0 && !current_printer.is_null() { + unsafe { + PMPrinterGetID(current_printer).map(|id| format!("{}", id)) + } + } else { + if printer_from_id { + Some(printer_name.to_string()) + } else { + None + } + }; + + // Extract the updated print settings from the panel. + let updated_info = panel.printInfo(); + let updated_settings: PMPrintSettings = + updated_info.PMPrintSettings().as_ptr() as PMPrintSettings; let mut opts_ptr: *mut std::ffi::c_char = std::ptr::null_mut(); let to_opts_status = unsafe { - PMPrintSettingsToOptions( - copy_settings, - NonNull::new(&mut opts_ptr).unwrap(), - ) + PMPrintSettingsToOptions(updated_settings, NonNull::new(&mut opts_ptr).unwrap()) }; - unsafe { PMRelease(copy_settings as _) }; if to_opts_status != 0 || opts_ptr.is_null() { + if !pm_printer.is_null() { + unsafe { PMRelease(pm_printer as _) }; + } return Err(format!( "PMPrintSettingsToOptions failed with status {}", to_opts_status @@ -227,6 +294,9 @@ fn run_native_print_panel(printer_name: &str) -> Result { let opts_cstr = unsafe { CStr::from_ptr(opts_ptr) }; let opts_str = opts_cstr.to_string_lossy().into_owned(); unsafe { free(opts_ptr as *mut _) }; + if !pm_printer.is_null() { + unsafe { PMRelease(pm_printer as _) }; + } // Filter to PPD-relevant options. let cups_options = filter_cups_options_string(&opts_str); @@ -236,12 +306,15 @@ fn run_native_print_panel(printer_name: &str) -> Result { .as_ref() .and_then(|s| extract_media_type_from_options(s)); - Ok(PrintOptions { - media_type, - cups_options, - ppd_uncorrected_passthrough: Some(true), - ..Default::default() - }) + Ok(Some(PrintPropertiesResult { + selected_printer, + options: PrintOptions { + media_type, + cups_options, + ppd_uncorrected_passthrough: Some(true), + ..Default::default() + }, + })) } /// Construct `lp` command line arguments for target printing on macOS with @@ -323,6 +396,7 @@ pub fn build_lp_args( let color_bypass_keys = [ "cnijintent2", "cnijintent", + "epij_cmat", "epij_ccor", "epij_oscolmat", "colorcorrection", @@ -411,20 +485,26 @@ pub fn print_target( /// The panel is pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching` /// so the driver's color-management controls are greyed out (application manages /// color). On OK, the user's media type / quality choices are captured and -/// returned as a `PrintOptions` snapshot for the frontend to feed back into -/// `print_target_native`. +/// returned as a `PrintPropertiesResult` snapshot for the frontend to feed back +/// into `print_target_native`. /// /// This function dispatches the dialog to the Cocoa main thread via /// `AppHandle::run_on_main_thread` and awaits the result. pub async fn show_printer_properties( printer_name: &str, app: &tauri::AppHandle, -) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel::>(); +) -> Result, String> { + // Resolve the human-readable display name before entering the main thread + // so we have a fallback if PMPrinterCreateFromPrinterID fails. + let display_name = crate::print::unix::get_printer_display_name(printer_name); + + let (tx, rx) = tokio::sync::oneshot::channel::, String>>(); let printer_name_owned = printer_name.to_string(); + let display_name_owned = display_name; app.run_on_main_thread(move || { - let result = run_native_print_panel(&printer_name_owned); + let display_name_ref = display_name_owned.as_deref(); + let result = run_native_print_panel(&printer_name_owned, display_name_ref); let _ = tx.send(result); }) .map_err(|e| format!("Failed to dispatch print panel to main thread: {}", e))?; @@ -439,12 +519,12 @@ mod tests { #[test] fn test_filter_cups_options_string_basic() { - let raw = "MediaType=92 EPIJ_CCor=0 com.apple.print.PrintSettings.PMCopies..n.=1 collate=False PageSize=A4 AP_ColorMatchingMode=AP_ApplicationColorMatching AP_D_InputSlot= copies=1"; + let raw = "MediaType=92 EPIJ_CMat=3 com.apple.print.PrintSettings.PMCopies..n.=1 collate=False PageSize=A4 AP_ColorMatchingMode=AP_ApplicationColorMatching AP_D_InputSlot= copies=1"; let filtered = filter_cups_options_string(raw).unwrap(); - // Should contain MediaType, EPIJ_CCor, PageSize but not com.apple.*, + // Should contain MediaType, EPIJ_CMat, PageSize but not com.apple.*, // collate, copies, AP_ColorMatchingMode, or empty AP_D_InputSlot. assert!(filtered.contains("MediaType=92")); - assert!(filtered.contains("EPIJ_CCor=0")); + assert!(filtered.contains("EPIJ_CMat=3")); assert!(filtered.contains("PageSize=A4")); assert!(!filtered.contains("com.apple.")); assert!(!filtered.contains("collate")); @@ -461,7 +541,7 @@ mod tests { #[test] fn test_extract_media_type_from_options() { - let opts = "MediaType=92 EPIJ_CCor=0 PageSize=A4"; + let opts = "MediaType=92 EPIJ_CMat=3 PageSize=A4"; assert_eq!( extract_media_type_from_options(opts), Some("92".to_string()) @@ -480,14 +560,14 @@ mod tests { #[test] fn test_build_lp_args_with_cups_options() { let opts = PrintOptions { - cups_options: Some("MediaType=92 EPIJ_CCor=0 PageSize=A4".to_string()), + cups_options: Some("MediaType=92 EPIJ_CMat=3 PageSize=A4".to_string()), ppd_uncorrected_passthrough: Some(true), ..Default::default() }; let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); assert!(args.contains(&"MediaType=92".to_string())); - assert!(args.contains(&"EPIJ_CCor=0".to_string())); + assert!(args.contains(&"EPIJ_CMat=3".to_string())); assert!(args.contains(&"PageSize=A4".to_string())); assert_eq!(args.last().unwrap(), "/tmp/target.tif"); } @@ -512,12 +592,12 @@ mod tests { // When cups_options contains a color bypass key, the auto-detected // bypass should NOT also be added. let opts = PrintOptions { - cups_options: Some("EPIJ_CCor=0".to_string()), + cups_options: Some("EPIJ_CMat=3".to_string()), ..Default::default() }; let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); - // Should contain EPIJ_CCor=0 exactly once (from cups_options). - let count = args.iter().filter(|a| *a == "EPIJ_CCor=0").count(); + // Should contain EPIJ_CMat=3 exactly once (from cups_options). + let count = args.iter().filter(|a| *a == "EPIJ_CMat=3").count(); assert_eq!(count, 1); } diff --git a/src-tauri/src/print/mod.rs b/src-tauri/src/print/mod.rs index aabf40a..78e1678 100644 --- a/src-tauri/src/print/mod.rs +++ b/src-tauri/src/print/mod.rs @@ -7,6 +7,9 @@ pub struct Printer { pub name: String, pub status: String, pub is_default: bool, + /// Human-readable name from CUPS `printer-info` (macOS display name). + #[serde(default)] + pub display_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -51,6 +54,12 @@ pub struct PrintOptions { pub cups_options: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrintPropertiesResult { + pub selected_printer: Option, + pub options: PrintOptions, +} + #[derive(Clone, Default)] #[allow(dead_code)] pub struct PrinterDevModeStore { diff --git a/src-tauri/src/print/tests.rs b/src-tauri/src/print/tests.rs index dce9b12..91e97eb 100644 --- a/src-tauri/src/print/tests.rs +++ b/src-tauri/src/print/tests.rs @@ -12,6 +12,7 @@ mod integration_tests { name: "Epson-Stylus-Pro-4900".to_string(), status: "Idle".to_string(), is_default: true, + display_name: Some("Epson Stylus Pro 4900".to_string()), }; let json = serde_json::to_string(&printer).expect("Failed to serialize printer"); diff --git a/src-tauri/src/print/unix.rs b/src-tauri/src/print/unix.rs index 325c9c9..7f2b52b 100644 --- a/src-tauri/src/print/unix.rs +++ b/src-tauri/src/print/unix.rs @@ -55,6 +55,8 @@ pub fn parse_lpstat_p(output: &str) -> Vec<(String, String)> { } /// Merge printer lists from `lpstat -e`, `lpstat -p`, and `lpstat -d`. +/// Optionally enriches each printer with a display name fetched from +/// `lpoptions -p `. pub fn merge_printer_info( destinations: &[String], statuses: &[(String, String)], @@ -67,6 +69,18 @@ pub fn merge_printer_info( .map(|(name, status)| (name.as_str(), status.as_str())) .collect(); + fn get_display_name(name: &str) -> Option { + let out = std::process::Command::new("lpoptions") + .args(["-p", name]) + .output() + .ok()?; + if out.status.success() { + extract_printer_display_name(&String::from_utf8_lossy(&out.stdout)) + } else { + None + } + } + let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); @@ -78,6 +92,7 @@ pub fn merge_printer_info( name: name.clone(), status: status.to_string(), is_default, + display_name: get_display_name(name), }); } } @@ -89,6 +104,7 @@ pub fn merge_printer_info( name: name.clone(), status: status.clone(), is_default, + display_name: get_display_name(name), }); } } @@ -96,6 +112,33 @@ pub fn merge_printer_info( result } +/// Extract the CUPS `printer-info` display name from `lpoptions -p ` output. +/// This is the human-readable queue name that macOS uses in the print panel. +pub fn extract_printer_display_name(output: &str) -> Option { + for line in output.lines() { + if let Some(info) = line.strip_prefix("printer-info=") { + let trimmed = info.trim().trim_matches('\''); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +/// Fetch the CUPS `printer-info` display name for a single destination. +pub fn get_printer_display_name(name: &str) -> Option { + let out = std::process::Command::new("lpoptions") + .args(["-p", name]) + .output() + .ok()?; + if out.status.success() { + extract_printer_display_name(&String::from_utf8_lossy(&out.stdout)) + } else { + None + } +} + /// Query available CUPS printers, connection statuses, and default destination. pub fn get_printers() -> Result, String> { let e_output = std::process::Command::new("lpstat") @@ -210,13 +253,10 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati Some(("CNIJIntent2", "4")) } else if output.contains("CNIJIntent") { Some(("CNIJIntent", "4")) - } else if output.contains("EPIJ_CCor") { - // Epson driver color-correction key. The "Off (No Color Adjustment)" - // equivalent is the value that disables driver-side color management - // so the application ICC profile passes through unmodified. - Some(("EPIJ_CCor", "0")) - } else if output.contains("EPIJ_OSColMat") { - Some(("EPIJ_OSColMat", "0")) + } else if output.contains("EPIJ_CMat") { + // Epson "Color Settings" option. The value 3 is "Off (No Color + // Adjustment)" which disables driver-side color management. + Some(("EPIJ_CMat", "3")) } else if output.contains("ColorCorrection") { Some(("ColorCorrection", "Uncorrected")) } else if output.contains("StpColorCorrection") { @@ -241,15 +281,45 @@ pub fn detect_media_type_key(output: &str) -> &'static str { } /// Parse a CUPS options string of the form `"name=value name=value ..."` into -/// an ordered vector of `(name, value)` pairs. Whitespace separates pairs and -/// `=` separates the name from the value. Empty values are preserved so the -/// caller can decide whether to forward them to `lp`. +/// an ordered vector of `(name, value)` pairs. Tokens are separated by +/// whitespace. Values may be quoted with double quotes; the quotes are +/// preserved in the value so the caller can decide whether to strip them. +/// Malformed tokens without `=` are skipped. pub fn parse_cups_options_string(options: &str) -> Vec<(String, String)> { let mut out = Vec::new(); - for token in options.split_whitespace() { + let mut chars = options.chars().peekable(); + + while chars.peek().is_some() { + // Skip leading whitespace. + while chars.peek().map_or(false, |c| c.is_whitespace()) { + chars.next(); + } + + // Collect the token, respecting double-quoted substrings. + let mut token = String::new(); + let mut in_quote = false; + while let Some(c) = chars.peek() { + match c { + '"' => { + in_quote = !in_quote; + token.push(*c); + chars.next(); + } + c if c.is_whitespace() && !in_quote => break, + _ => { + token.push(*c); + chars.next(); + } + } + } + + if token.is_empty() { + continue; + } + if let Some((name, value)) = token.split_once('=') { - let name = name.trim().to_string(); - let value = value.trim().to_string(); + let name = name.trim().trim_matches('"').to_string(); + let value = value.trim().trim_matches('"').to_string(); if !name.is_empty() { out.push((name, value)); } diff --git a/src/js/printtarg.js b/src/js/printtarg.js index d90c43c..149f020 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -329,7 +329,10 @@ export function initPrinttarg() { discoveredPrinters.forEach((p, idx) => { const opt = document.createElement("option"); opt.value = p.name; - opt.textContent = `${p.name}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`; + const label = p.display_name + ? `${p.display_name} (${p.name})` + : p.name; + opt.textContent = `${label}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`; if (p.is_default && !defaultSelected) { opt.selected = true; defaultSelected = true; @@ -383,37 +386,47 @@ export function initPrinttarg() { showNotification("info", `Opening native printer preferences for '${printerName}'...`); const result = await invoke("show_printer_properties", { printerName }); - if (result) { - // macOS: the backend returned a PrintOptions snapshot with captured - // CUPS options from the native print panel. - if (result.cups_options) { - capturedCupsOptions[printerName] = result.cups_options; - } else { - delete capturedCupsOptions[printerName]; - } - - // If a media type was captured, update the dropdown to match. - if (result.media_type && printerMediaTypeSelect) { - const matchOpt = Array.from(printerMediaTypeSelect.options).find( - o => o.value === result.media_type - ); - if (matchOpt) { - printerMediaTypeSelect.value = result.media_type; - } - } - - showNotification("success", `Printer driver preferences configured for '${printerName}'.`); - } else { - // Windows / Linux: no captured options returned. - showNotification("success", `Printer driver preferences configured for '${printerName}'.`); + if (result === null) { + // User cancelled the native panel. + showNotification("info", "Printer properties dialog cancelled."); + return; } + + // macOS: the backend returned a PrintPropertiesResult with the + // effective printer and a PrintOptions snapshot containing captured + // CUPS options from the native print panel. + const effectivePrinter = result.selected_printer || printerName; + + // If the user switched printer in the panel, update the dropdown. + if (result.selected_printer && printerSelect && result.selected_printer !== printerName) { + const exists = Array.from(printerSelect.options).some( + o => o.value === result.selected_printer + ); + if (exists) { + printerSelect.value = result.selected_printer; + } + } + + if (result.options && result.options.cups_options) { + capturedCupsOptions[effectivePrinter] = result.options.cups_options; + } else { + delete capturedCupsOptions[effectivePrinter]; + } + + // If a media type was captured, update the dropdown to match. + if (result.options && result.options.media_type && printerMediaTypeSelect) { + const matchOpt = Array.from(printerMediaTypeSelect.options).find( + o => o.value === result.options.media_type + ); + if (matchOpt) { + printerMediaTypeSelect.value = result.options.media_type; + } + } + + showNotification("success", `Printer driver preferences configured for '${effectivePrinter}'.`); } catch (err) { console.error("[ICCery Print] Failed to open printer properties:", err); - if (String(err).includes("cancelled")) { - showNotification("info", "Printer properties dialog cancelled."); - } else { - showNotification("error", `Could not open printer properties: ${err}`); - } + showNotification("error", `Could not open printer properties: ${err}`); } }); } -- 2.39.5 From 53d5ab20b949f70a12fbd5ce2bb6ad05e8789127 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Wed, 2 Sep 2026 23:04:25 +0100 Subject: [PATCH 03/12] fix(print): add display_name field to Windows Printer initializers --- AGENTS.md | 4 ++++ src-tauri/src/print/windows.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index eed6baa..b1938d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ - `unix.rs`: Generic Unix/CUPS utilities (printer enumeration, PPD parsing, `lp` args) - `windows.rs`: Windows-specific printing via Win32 API and DEVMODE +## Cross-Platform `Printer` Field Notes + +When adding fields to `Printer` in `src-tauri/src/print/mod.rs`, update every platform-specific constructor in `src-tauri/src/print/windows.rs`, `src-tauri/src/print/macos.rs`, and `src-tauri/src/print/unix.rs` to avoid build regressions on any target. Use `..Default::default()` where possible, or explicitly provide values (e.g. `display_name: None` on Windows). + ## macOS Print Properties (Issue #188) The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings). diff --git a/src-tauri/src/print/windows.rs b/src-tauri/src/print/windows.rs index cb3b67a..7526cd5 100644 --- a/src-tauri/src/print/windows.rs +++ b/src-tauri/src/print/windows.rs @@ -128,6 +128,7 @@ pub fn get_printers() -> Result, String> { name, status: "Ready".to_string(), is_default: false, + display_name: None, }); } } @@ -158,6 +159,7 @@ pub fn get_printers() -> Result, String> { name, status: "Ready".to_string(), is_default: false, + display_name: None, }); } } -- 2.39.5 From 227388233074e178dd0f1822b8f9f7a81668c153 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Wed, 2 Sep 2026 23:22:58 +0100 Subject: [PATCH 04/12] fix(print): force application color matching on macOS via PM, NSPrintInfo, and lp --- AGENTS.md | 4 +-- src-tauri/src/print/macos.rs | 52 +++++++++++++++++++++++++++--------- src-tauri/src/print/tests.rs | 12 +++++---- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b1938d2..16e9a5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,12 +25,12 @@ When adding fields to `Printer` in `src-tauri/src/print/mod.rs`, update every pl The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings). - The CUPS destination ID is bound to the panel via Core Printing `PMPrinterCreateFromPrinterID` and `PMSessionSetCurrentPMPrinter` - A `Printer.display_name` (from CUPS `printer-info`) is cached at enumeration as a fallback for `NSPrinter::printerWithName` -- Pre-configured with `AP_ColorMatchingMode=AP_ApplicationColorMatching` so driver color management is greyed out +- Pre-configured with both `AP_ColorMatchingMode=AP_ApplicationColorMatching` and `AP.ColorMatchingMode=AP_ApplicationColorMatching` (dot-notation) as a locked PMPrintSettings value and in the `NSPrintInfo` job ticket, so driver color management is greyed out - Captures user's media type / quality selections as a CUPS options string with `PMPrintSettingsToOptions` - Returns a `PrintPropertiesResult` with the effective `selected_printer` and captured `PrintOptions` - Cancellation is returned as `None`, not an error - Captured options are stored in frontend `capturedCupsOptions` map and passed via `PrintOptions.cups_options` -- `build_lp_args` in `macos.rs` always adds `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and forwards captured options +- `build_lp_args` in `macos.rs` always adds both `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and `-o AP.ColorMatchingMode=AP_ApplicationColorMatching`, and forwards captured options ## Key Dependencies (macOS only) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 0b044cc..92f1cd1 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -58,8 +58,8 @@ fn is_relevant_cups_option(key: &str, value: &str) -> bool { if key.starts_with("com.apple.") { return false; } - // We always set AP_ColorMatchingMode ourselves in build_lp_args. - if key == "AP_ColorMatchingMode" { + // We always set AP_ColorMatchingMode (and dot-notation) ourselves in build_lp_args. + if key == "AP_ColorMatchingMode" || key == "AP.ColorMatchingMode" { return false; } // Skip empty values (e.g. "AP_D_InputSlot="). @@ -213,19 +213,24 @@ fn run_native_print_panel( } } - // Set AP_ColorMatchingMode = AP_ApplicationColorMatching so the driver's - // ColorSync / vendor color management controls are greyed out in the panel. + // Set AP_ColorMatchingMode = AP_ApplicationColorMatching (and the + // dot-notation variant) so the driver\'s ColorSync / vendor color + // management controls are greyed out and locked in the panel. let cm_key = CFString::from_str("AP_ColorMatchingMode"); + let cm_dot_key = CFString::from_str("AP.ColorMatchingMode"); let cm_val = CFString::from_str("AP_ApplicationColorMatching"); let cm_val_ref: &objc2_core_foundation::CFType = &*cm_val; - let set_status = unsafe { - PMPrintSettingsSetValue(pm_settings, &cm_key, Some(cm_val_ref), false) - }; - if set_status != 0 { - log::warn!( - "PMPrintSettingsSetValue(AP_ColorMatchingMode) returned status {}", - set_status - ); + for cm_key_ref in [&cm_key, &cm_dot_key] { + let set_status = unsafe { + PMPrintSettingsSetValue(pm_settings, cm_key_ref, Some(cm_val_ref), true) + }; + if set_status != 0 { + log::warn!( + "PMPrintSettingsSetValue({}) returned status {}", + cm_key_ref, + set_status + ); + } } print_info.updateFromPMPageFormat(); @@ -233,6 +238,24 @@ fn run_native_print_panel( // Sync the PMPrintSettings changes back into the NSPrintInfo object. print_info.updateFromPMPrintSettings(); + // Also write the color-matching keys directly into the NSPrintInfo + // printSettings dictionary. This is the dictionary that AppKit print + // dialog extensions (PDEs) and some raster drivers inspect, so the + // value must appear here as well as in the PMPrintSettings object. + let cm_key_ns = NSString::from_str("AP_ColorMatchingMode"); + let cm_dot_key_ns = NSString::from_str("AP.ColorMatchingMode"); + let cm_val_ns = NSString::from_str("AP_ApplicationColorMatching"); + + // Safety: `NSString` has the same memory layout as its root `AnyObject` + // (`isa`), so we can borrow it as `&AnyObject` for the dictionary. + let as_any = |s: &NSString| -> &objc2::runtime::AnyObject { + unsafe { &*(s as *const _ as *const objc2::runtime::AnyObject) } + }; + + let print_settings = unsafe { print_info.printSettings() }; + print_settings.insert(&cm_key_ns, as_any(&cm_val_ns)); + print_settings.insert(&cm_dot_key_ns, as_any(&cm_val_ns)); + // Create and configure the print panel. let panel = NSPrintPanel::printPanel(mtm); let mut opts = NSPrintPanelOptions::all(); @@ -339,9 +362,12 @@ pub fn build_lp_args( title, ]; - // Always apply ColorSync bypass on macOS for targeting. + // Always apply ColorSync bypass on macOS for targeting. The dot-notation + // variant is a legacy form used by some raster drivers and PDEs. args.push("-o".to_string()); args.push("AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string()); + args.push("-o".to_string()); + args.push("AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string()); // Track which option keys have already been added from cups_options so we // don't duplicate them from the explicit PrintOptions fields. diff --git a/src-tauri/src/print/tests.rs b/src-tauri/src/print/tests.rs index 91e97eb..adb5aa8 100644 --- a/src-tauri/src/print/tests.rs +++ b/src-tauri/src/print/tests.rs @@ -288,9 +288,10 @@ printer Custom_Queue unknown state\n\ assert_eq!(args_raw[1], printer); assert_eq!(args_raw[2], "-t"); assert_eq!(args_raw[3], "ICCery Target - target_page_1.tif"); - assert_eq!(args_raw[4], "-o"); - assert_eq!(args_raw[5], "AP_ColorMatchingMode=AP_ApplicationColorMatching"); - assert_eq!(args_raw[6], path); + assert!(args_raw.contains(&"-o".to_string())); + assert!(args_raw.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + assert!(args_raw.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + assert_eq!(args_raw.last().unwrap(), path); // PPD uncorrected passthrough mode with options let opts = PrintOptions { @@ -300,8 +301,9 @@ printer Custom_Queue unknown state\n\ ..Default::default() }; let args_ppd = macos_build_lp_args(printer, path, Some(&opts)); - assert_eq!(args_ppd[4], "-o"); - assert_eq!(args_ppd[5], "AP_ColorMatchingMode=AP_ApplicationColorMatching"); + assert!(args_ppd.contains(&"-o".to_string())); + assert!(args_ppd.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + assert!(args_ppd.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string())); assert!(args_ppd.contains(&"orientation-requested=4".to_string())); assert!(args_ppd.contains(&"PageSize=A4".to_string())); assert_eq!(args_ppd.last().unwrap(), path); -- 2.39.5 From 8f8c73d46baefdff4cc48fda0c89c3f864d734ee Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Wed, 2 Sep 2026 23:33:39 +0100 Subject: [PATCH 05/12] fix(print): deref Retained for NSPrintInfo printSettings insert --- src-tauri/src/print/macos.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 92f1cd1..0b314bc 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -253,8 +253,8 @@ fn run_native_print_panel( }; let print_settings = unsafe { print_info.printSettings() }; - print_settings.insert(&cm_key_ns, as_any(&cm_val_ns)); - print_settings.insert(&cm_dot_key_ns, as_any(&cm_val_ns)); + print_settings.insert(&*cm_key_ns, as_any(&*cm_val_ns)); + print_settings.insert(&*cm_dot_key_ns, as_any(&*cm_val_ns)); // Create and configure the print panel. let panel = NSPrintPanel::printPanel(mtm); -- 2.39.5 From ff01e97ccf4ede3d2b4627d8907c27aab6c595e3 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 00:19:59 +0100 Subject: [PATCH 06/12] fix(print): use private PMSessionSetColorMatchingMode SPI and pre-select driver color bypass --- AGENTS.md | 4 +- src-tauri/src/print/macos.rs | 138 ++++++++++++++++++++++++++++++++--- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 16e9a5f..1086923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,9 @@ When adding fields to `Printer` in `src-tauri/src/print/mod.rs`, update every pl The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI or System Settings). - The CUPS destination ID is bound to the panel via Core Printing `PMPrinterCreateFromPrinterID` and `PMSessionSetCurrentPMPrinter` - A `Printer.display_name` (from CUPS `printer-info`) is cached at enumeration as a fallback for `NSPrinter::printerWithName` -- Pre-configured with both `AP_ColorMatchingMode=AP_ApplicationColorMatching` and `AP.ColorMatchingMode=AP_ApplicationColorMatching` (dot-notation) as a locked PMPrintSettings value and in the `NSPrintInfo` job ticket, so driver color management is greyed out +- Pre-configured with both `AP_ColorMatchingMode=AP_ApplicationColorMatching` and `AP.ColorMatchingMode=AP_ApplicationColorMatching` (dot-notation) as a locked PMPrintSettings value and in the `NSPrintInfo` job ticket +- Uses the private Core Printing `PMSessionSetColorMatchingMode` / `PMSessionSetColorMatchingModeLock` SPI (resolved at runtime via `dlsym`) to gray out and lock the Color Matching controls; falls back to the public setting if the SPI is absent +- Pre-selects the driver-specific "no color adjustment" PPD option (Canon `CNIJIntent2=4`, Epson `EPIJ_CMat=3`, etc.) in the native panel and on the `lp` command line - Captures user's media type / quality selections as a CUPS options string with `PMPrintSettingsToOptions` - Returns a `PrintPropertiesResult` with the effective `selected_printer` and captured `PrintOptions` - Cancellation is returned as `None`, not an error diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 0b314bc..b382808 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -4,6 +4,8 @@ use std::ffi::CStr; use std::path::Path; use std::ptr::NonNull; +use objc2_application_services::PMPrintSession; + use crate::print::{PrintOptions, PrintPropertiesResult}; pub use crate::print::unix::{get_printer_capabilities, get_printers}; @@ -11,6 +13,77 @@ extern "C" { fn free(ptr: *mut std::ffi::c_void); } +/// Run `lpoptions -p -l` and return the raw PPD option listing, +/// or `None` if the command is not available or the printer is not found. +fn get_lpoptions_output(printer_name: &str) -> Option { + std::process::Command::new("lpoptions") + .args(["-p", printer_name, "-l"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) +} + +#[cfg(target_os = "macos")] +extern "C" { + fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void; +} + +/// Set the Core Printing session's color matching mode to "application" and +/// lock it. This is the private SPI used by Photoshop, Lightroom and X-Rite +/// i1Profiler to gray-out the Color Matching controls in driver PDEs. +/// +/// The symbols are resolved at runtime with `dlsym` so the binary does not +/// hard-depend on an undocumented symbol. +#[cfg(target_os = "macos")] +unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { + use std::ffi::{c_char, c_int, c_void}; + use objc2_core_foundation::CFString; + + // RTLD_DEFAULT is a special handle that searches all loaded images. + const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; + + let name_set = c"PMSessionSetColorMatchingMode"; + let name_lock = c"PMSessionSetColorMatchingModeLock"; + + let set_ptr = dlsym(RTLD_DEFAULT, name_set.as_ptr() as *const c_char); + let lock_ptr = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); + + if set_ptr.is_null() || lock_ptr.is_null() { + log::warn!( + "PMSessionSetColorMatchingMode symbols not found; " + "color controls will not be grayed" + ); + return; + } + + // Likely signatures: + // OSStatus PMSessionSetColorMatchingMode(PMPrintSession, CFStringRef); + // OSStatus PMSessionSetColorMatchingModeLock(PMPrintSession, Boolean); + // The mode value is the public kPMApplicationColorMatching string. + type SetFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; + type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; + + let set_fn: SetFn = std::mem::transmute(set_ptr); + let lock_fn: LockFn = std::mem::transmute(lock_ptr); + + let mode = CFString::from_str("AP_ApplicationColorMatching"); + let status = set_fn(pm_session, &*mode as *const CFString); + if status != 0 { + log::warn!("PMSessionSetColorMatchingMode returned {}", status); + } + + let lock_status = lock_fn(pm_session, 1); // 1 == true + if lock_status != 0 { + log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); + } +} + +#[cfg(not(target_os = "macos"))] +unsafe fn set_session_color_matching_mode(_pm_session: PMPrintSession) {} + + + /// PPD/driver-relevant CUPS option key names that we forward to `lp` when /// captured from the native print panel. Keys not in this set (internal Apple /// ticket keys, generic CUPS bookkeeping, etc.) are discarded. @@ -133,7 +206,7 @@ fn run_native_print_panel( use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter}; use objc2_application_services::{ PMPageFormat, PMPrinter, PMPrinterCreateFromPrinterID, - PMPrinterGetID, PMPrintSession, PMPrintSettings, PMPrintSettingsSetValue, + PMPrinterGetID, PMPrintSettings, PMPrintSettingsSetValue, PMPrintSettingsToOptions, PMRelease, PMSessionDefaultPageFormat, PMSessionDefaultPrintSettings, PMSessionGetCurrentPrinter, PMSessionSetCurrentPMPrinter, @@ -213,9 +286,14 @@ fn run_native_print_panel( } } + // Use the private PMSessionSetColorMatchingMode SPI to gray out and lock + // the Color Matching controls in the driver PDE (ColorSync vs. vendor + // color). This is separate from the AP_ColorMatchingMode spool setting. + unsafe { set_session_color_matching_mode(pm_session); } + // Set AP_ColorMatchingMode = AP_ApplicationColorMatching (and the - // dot-notation variant) so the driver\'s ColorSync / vendor color - // management controls are greyed out and locked in the panel. + // dot-notation variant) so the CUPS backend/cgpdftoraster knows the + // application already handled color matching. let cm_key = CFString::from_str("AP_ColorMatchingMode"); let cm_dot_key = CFString::from_str("AP.ColorMatchingMode"); let cm_val = CFString::from_str("AP_ApplicationColorMatching"); @@ -233,6 +311,28 @@ fn run_native_print_panel( } } + // Pre-select the driver-specific "no color adjustment" PPD option in the + // print settings so the panel shows it as the current choice. + let lpoptions = get_lpoptions_output(printer_name); + if let Some(ref output) = lpoptions { + if let Some((bypass_key, bypass_val)) = + crate::print::unix::detect_driver_color_bypass(output) + { + let bypass_key_cf = CFString::from_str(bypass_key); + let bypass_val_cf = CFString::from_str(bypass_val); + let bypass_val_ref: &objc2_core_foundation::CFType = &*bypass_val_cf; + let set_status = unsafe { + PMPrintSettingsSetValue(pm_settings, &bypass_key_cf, Some(bypass_val_ref), false) + }; + if set_status != 0 { + log::warn!( + "PMPrintSettingsSetValue({}={}) returned status {}", + bypass_key, bypass_val, set_status + ); + } + } + } + print_info.updateFromPMPageFormat(); // Sync the PMPrintSettings changes back into the NSPrintInfo object. @@ -256,6 +356,18 @@ fn run_native_print_panel( print_settings.insert(&*cm_key_ns, as_any(&*cm_val_ns)); print_settings.insert(&*cm_dot_key_ns, as_any(&*cm_val_ns)); + // Also surface the driver-specific color bypass in printSettings so the + // panel PDE sees it even if PMPrintSettings didn't sync it. + if let Some(ref output) = lpoptions { + if let Some((bypass_key, bypass_val)) = + crate::print::unix::detect_driver_color_bypass(output) + { + let bypass_key_ns = NSString::from_str(bypass_key); + let bypass_val_ns = NSString::from_str(bypass_val); + print_settings.insert(&*bypass_key_ns, as_any(&*bypass_val_ns)); + } + } + // Create and configure the print panel. let panel = NSPrintPanel::printPanel(mtm); let mut opts = NSPrintPanelOptions::all(); @@ -390,12 +502,7 @@ pub fn build_lp_args( } // Fetch lpoptions for this printer to detect capabilities. - let lpoptions_output: Option = std::process::Command::new("lpoptions") - .args(["-p", printer_name, "-l"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let lpoptions_output: Option = get_lpoptions_output(printer_name); // Add media type from explicit options if not already in cups_options. if let Some(opts) = options { @@ -625,13 +732,24 @@ mod tests { // Should contain EPIJ_CMat=3 exactly once (from cups_options). let count = args.iter().filter(|a| *a == "EPIJ_CMat=3").count(); assert_eq!(count, 1); + + // Same for Canon's CNIJIntent2 path. + let opts2 = PrintOptions { + cups_options: Some("CNIJIntent2=4".to_string()), + ..Default::default() + }; + let args2 = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts2)); + let count2 = args2.iter().filter(|a| *a == "CNIJIntent2=4").count(); + assert_eq!(count2, 1); } #[test] fn test_build_lp_args_colorsync_always_present() { - // Even with no options, AP_ColorMatchingMode should be present. + // Even with no options, AP_ColorMatchingMode and the dot-notation + // variant should be present. let args = build_lp_args("Test_Printer", "/tmp/target.tif", None); assert!(args.contains(&"AP_ColorMatchingMode=AP_ApplicationColorMatching".to_string())); + assert!(args.contains(&"AP.ColorMatchingMode=AP_ApplicationColorMatching".to_string())); } #[test] -- 2.39.5 From 7b8d9e644a11a2a35615da19785a668d3fcd1714 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 00:28:01 +0100 Subject: [PATCH 07/12] fix(print): correct log::warn! string literal in color matching helper --- src-tauri/src/print/macos.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index b382808..38ceaf6 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -50,10 +50,7 @@ unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { let lock_ptr = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); if set_ptr.is_null() || lock_ptr.is_null() { - log::warn!( - "PMSessionSetColorMatchingMode symbols not found; " - "color controls will not be grayed" - ); + log::warn!("PMSessionSetColorMatchingMode symbols not found; color controls will not be grayed"); return; } -- 2.39.5 From bafed60b20b20869b1ca93280b52ba0d19dfb3b6 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 00:46:37 +0100 Subject: [PATCH 08/12] fix(print): try correct PMSessionSetColorMatchingMode signatures (3-arg, 2-arg+lock, NoLock+lock) to avoid crash --- src-tauri/src/print/macos.rs | 98 +++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 38ceaf6..da3aac9 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -34,46 +34,88 @@ extern "C" { /// i1Profiler to gray-out the Color Matching controls in driver PDEs. /// /// The symbols are resolved at runtime with `dlsym` so the binary does not -/// hard-depend on an undocumented symbol. +/// hard-depend on an undocumented symbol. We try three likely signatures +/// based on the exported symbol names: +/// 1. PMSessionSetColorMatchingMode(session, mode, lock) — single call +/// 2. PMSessionSetColorMatchingMode(session, mode) + PMSessionSetColorMatchingModeLock(session, true) +/// 3. PMSessionSetColorMatchingModeNoLock(session, mode) + PMSessionSetColorMatchingModeLock(session, true) #[cfg(target_os = "macos")] unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { use std::ffi::{c_char, c_int, c_void}; use objc2_core_foundation::CFString; - // RTLD_DEFAULT is a special handle that searches all loaded images. - const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; - - let name_set = c"PMSessionSetColorMatchingMode"; - let name_lock = c"PMSessionSetColorMatchingModeLock"; - - let set_ptr = dlsym(RTLD_DEFAULT, name_set.as_ptr() as *const c_char); - let lock_ptr = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); - - if set_ptr.is_null() || lock_ptr.is_null() { - log::warn!("PMSessionSetColorMatchingMode symbols not found; color controls will not be grayed"); + if pm_session.is_null() { + log::warn!("pm_session is null; skipping private color-matching SPI"); return; } - // Likely signatures: - // OSStatus PMSessionSetColorMatchingMode(PMPrintSession, CFStringRef); - // OSStatus PMSessionSetColorMatchingModeLock(PMPrintSession, Boolean); - // The mode value is the public kPMApplicationColorMatching string. - type SetFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; - type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; - - let set_fn: SetFn = std::mem::transmute(set_ptr); - let lock_fn: LockFn = std::mem::transmute(lock_ptr); - + const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; let mode = CFString::from_str("AP_ApplicationColorMatching"); - let status = set_fn(pm_session, &*mode as *const CFString); - if status != 0 { - log::warn!("PMSessionSetColorMatchingMode returned {}", status); + let mode_ptr = &*mode as *const CFString; + + // Attempt 1: PMSessionSetColorMatchingMode(session, mode, lock) + // The co-existence of the main symbol and Lock strongly suggests a 3-arg variant. + let name_main = c"PMSessionSetColorMatchingMode"; + let name_lock = c"PMSessionSetColorMatchingModeLock"; + let main_ptr = dlsym(RTLD_DEFAULT, name_main.as_ptr() as *const c_char); + let lock_ptr = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); + + if !main_ptr.is_null() && !lock_ptr.is_null() { + // Assume: OSStatus fn(PMPrintSession, CFStringRef, Boolean) + type Fn3 = unsafe extern "C" fn(PMPrintSession, *const CFString, u8) -> c_int; + let fn3: Fn3 = std::mem::transmute(main_ptr); + let status = fn3(pm_session, mode_ptr, 1); + if status == 0 { + log::info!("PMSessionSetColorMatchingMode(mode, lock) succeeded"); + return; + } + log::warn!("PMSessionSetColorMatchingMode(mode, lock) returned {}", status); } - let lock_status = lock_fn(pm_session, 1); // 1 == true - if lock_status != 0 { - log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); + // Attempt 2: PMSessionSetColorMatchingMode(session, mode) + Lock call + if !main_ptr.is_null() && !lock_ptr.is_null() { + // Assume: OSStatus fn(PMPrintSession, CFStringRef) for set + // OSStatus fn(PMPrintSession, Boolean) for lock + type SetFn2 = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; + type LockFn2 = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; + let set_fn: SetFn2 = std::mem::transmute(main_ptr); + let lock_fn: LockFn2 = std::mem::transmute(lock_ptr); + let status = set_fn(pm_session, mode_ptr); + if status == 0 { + let lock_status = lock_fn(pm_session, 1); + if lock_status == 0 { + log::info!("PMSessionSetColorMatchingMode + Lock succeeded"); + return; + } + log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); + } else { + log::warn!("PMSessionSetColorMatchingMode returned {}", status); + } } + + // Attempt 3: PMSessionSetColorMatchingModeNoLock + Lock + let name_nolock = c"PMSessionSetColorMatchingModeNoLock"; + let nolock_ptr = dlsym(RTLD_DEFAULT, name_nolock.as_ptr() as *const c_char); + let lock_ptr3 = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); + if !nolock_ptr.is_null() && !lock_ptr3.is_null() { + type NoLockFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; + type LockFn3 = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; + let nolock_fn: NoLockFn = std::mem::transmute(nolock_ptr); + let lock_fn: LockFn3 = std::mem::transmute(lock_ptr3); + let status = nolock_fn(pm_session, mode_ptr); + if status == 0 { + let lock_status = lock_fn(pm_session, 1); + if lock_status == 0 { + log::info!("PMSessionSetColorMatchingModeNoLock + Lock succeeded"); + return; + } + log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); + } else { + log::warn!("PMSessionSetColorMatchingModeNoLock returned {}", status); + } + } + + log::warn!("All PMSessionSetColorMatchingMode variants failed or missing; color controls will not be grayed"); } #[cfg(not(target_os = "macos"))] -- 2.39.5 From 3e844fdf8bb95d9b46f57bc3474a507723a1b2ae Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 07:35:59 +0100 Subject: [PATCH 09/12] fix(print): extended PMSessionSetColorMatchingMode signature search with mode constants, session validation, and detailed logging --- src-tauri/src/print/macos.rs | 172 +++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index da3aac9..829172e 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -34,88 +34,142 @@ extern "C" { /// i1Profiler to gray-out the Color Matching controls in driver PDEs. /// /// The symbols are resolved at runtime with `dlsym` so the binary does not -/// hard-depend on an undocumented symbol. We try three likely signatures -/// based on the exported symbol names: -/// 1. PMSessionSetColorMatchingMode(session, mode, lock) — single call -/// 2. PMSessionSetColorMatchingMode(session, mode) + PMSessionSetColorMatchingModeLock(session, true) -/// 3. PMSessionSetColorMatchingModeNoLock(session, mode) + PMSessionSetColorMatchingModeLock(session, true) +/// hard-depend on an undocumented symbol. We try multiple signature permutations +/// and mode constants because the exact signature is undocumented. +/// Known exports: PMSessionSetColorMatchingMode, PMSessionSetColorMatchingModeLock, +/// PMSessionSetColorMatchingModeNoLock. #[cfg(target_os = "macos")] unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { use std::ffi::{c_char, c_int, c_void}; use objc2_core_foundation::CFString; + use objc2_application_services::{PMSessionGetCurrentPrinter, PMPrinter}; if pm_session.is_null() { log::warn!("pm_session is null; skipping private color-matching SPI"); return; } + // Verify session has a printer attached + let mut current_printer: PMPrinter = std::ptr::null_mut(); + let printer_status = PMSessionGetCurrentPrinter(pm_session, &mut current_printer); + if printer_status != 0 || current_printer.is_null() { + log::warn!( + "PMSessionGetCurrentPrinter failed ({}) or returned null printer; skipping SPI", + printer_status + ); + return; + } + const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; - let mode = CFString::from_str("AP_ApplicationColorMatching"); - let mode_ptr = &*mode as *const CFString; - // Attempt 1: PMSessionSetColorMatchingMode(session, mode, lock) - // The co-existence of the main symbol and Lock strongly suggests a 3-arg variant. - let name_main = c"PMSessionSetColorMatchingMode"; - let name_lock = c"PMSessionSetColorMatchingModeLock"; - let main_ptr = dlsym(RTLD_DEFAULT, name_main.as_ptr() as *const c_char); - let lock_ptr = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); + // Load all relevant symbols once + let sym_main = c"PMSessionSetColorMatchingMode"; + let sym_lock = c"PMSessionSetColorMatchingModeLock"; + let sym_nolock = c"PMSessionSetColorMatchingModeNoLock"; - if !main_ptr.is_null() && !lock_ptr.is_null() { - // Assume: OSStatus fn(PMPrintSession, CFStringRef, Boolean) - type Fn3 = unsafe extern "C" fn(PMPrintSession, *const CFString, u8) -> c_int; - let fn3: Fn3 = std::mem::transmute(main_ptr); - let status = fn3(pm_session, mode_ptr, 1); - if status == 0 { - log::info!("PMSessionSetColorMatchingMode(mode, lock) succeeded"); - return; - } - log::warn!("PMSessionSetColorMatchingMode(mode, lock) returned {}", status); - } + let main_ptr = dlsym(RTLD_DEFAULT, sym_main.as_ptr() as *const c_char); + let lock_ptr = dlsym(RTLD_DEFAULT, sym_lock.as_ptr() as *const c_char); + let nolock_ptr = dlsym(RTLD_DEFAULT, sym_nolock.as_ptr() as *const c_char); - // Attempt 2: PMSessionSetColorMatchingMode(session, mode) + Lock call - if !main_ptr.is_null() && !lock_ptr.is_null() { - // Assume: OSStatus fn(PMPrintSession, CFStringRef) for set - // OSStatus fn(PMPrintSession, Boolean) for lock - type SetFn2 = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; - type LockFn2 = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; - let set_fn: SetFn2 = std::mem::transmute(main_ptr); - let lock_fn: LockFn2 = std::mem::transmute(lock_ptr); - let status = set_fn(pm_session, mode_ptr); - if status == 0 { - let lock_status = lock_fn(pm_session, 1); - if lock_status == 0 { - log::info!("PMSessionSetColorMatchingMode + Lock succeeded"); + log::info!( + "PMSessionSetColorMatchingMode SPI symbols: main={:p} lock={:p} nolock={:p}", + main_ptr, lock_ptr, nolock_ptr + ); + + // Mode constants to try (order matters: most likely first) + const MODES: &[&str] = &[ + "AP_ApplicationColorMatching", // kPMApplicationColorMatching + "AP_VendorColorMatching", // kPMVendorColorMatching + "AP_ColorSyncMatching", // kPMColorSyncMatching + "AP_NoColorMatching", // hypothetical + "ApplicationColorMatching", // without AP_ prefix + ]; + + for &mode_str in MODES { + let mode = CFString::from_str(mode_str); + let mode_ptr = &*mode as *const CFString; + + // Attempt 1: 3-arg (session, mode, lock) — standard ordering + if !main_ptr.is_null() && !lock_ptr.is_null() { + log::info!( + "Attempt 1: PMSessionSetColorMatchingMode(session, mode, lock) with {}", + mode_str + ); + type Fn3 = unsafe extern "C" fn(PMPrintSession, *const CFString, u8) -> c_int; + let fn3: Fn3 = std::mem::transmute(main_ptr); + let status = fn3(pm_session, mode_ptr, 1); + log::info!(" → status = {}", status); + if status == 0 { + log::info!("Attempt 1 succeeded"); return; } - log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); - } else { - log::warn!("PMSessionSetColorMatchingMode returned {}", status); } - } - // Attempt 3: PMSessionSetColorMatchingModeNoLock + Lock - let name_nolock = c"PMSessionSetColorMatchingModeNoLock"; - let nolock_ptr = dlsym(RTLD_DEFAULT, name_nolock.as_ptr() as *const c_char); - let lock_ptr3 = dlsym(RTLD_DEFAULT, name_lock.as_ptr() as *const c_char); - if !nolock_ptr.is_null() && !lock_ptr3.is_null() { - type NoLockFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; - type LockFn3 = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; - let nolock_fn: NoLockFn = std::mem::transmute(nolock_ptr); - let lock_fn: LockFn3 = std::mem::transmute(lock_ptr3); - let status = nolock_fn(pm_session, mode_ptr); - if status == 0 { - let lock_status = lock_fn(pm_session, 1); - if lock_status == 0 { - log::info!("PMSessionSetColorMatchingModeNoLock + Lock succeeded"); + // Attempt 2: 3-arg lock-first (session, lock, mode) — Carbon sometimes puts Boolean first + if !main_ptr.is_null() && !lock_ptr.is_null() { + log::info!( + "Attempt 2: PMSessionSetColorMatchingMode(session, lock, mode) with {}", + mode_str + ); + type Fn3Rev = unsafe extern "C" fn(PMPrintSession, u8, *const CFString) -> c_int; + let fn3rev: Fn3Rev = std::mem::transmute(main_ptr); + let status = fn3rev(pm_session, 1, mode_ptr); + log::info!(" → status = {}", status); + if status == 0 { + log::info!("Attempt 2 succeeded"); + return; + } + } + + // Attempt 3: 2-arg (session, mode) via main symbol + if !main_ptr.is_null() { + log::info!( + "Attempt 3: PMSessionSetColorMatchingMode(session, mode) with {}", + mode_str + ); + type Fn2 = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; + let fn2: Fn2 = std::mem::transmute(main_ptr); + let status = fn2(pm_session, mode_ptr); + log::info!(" → status = {}", status); + if status == 0 { + // Try to lock separately + if !lock_ptr.is_null() { + type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; + let lock_fn: LockFn = std::mem::transmute(lock_ptr); + let lstatus = lock_fn(pm_session, 1); + log::info!(" → lock status = {}", lstatus); + } + log::info!("Attempt 3 succeeded"); + return; + } + } + + // Attempt 4: NoLock variant (session, mode) + separate lock + if !nolock_ptr.is_null() { + log::info!( + "Attempt 4: PMSessionSetColorMatchingModeNoLock(session, mode) with {}", + mode_str + ); + type NoLockFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; + let nolock_fn: NoLockFn = std::mem::transmute(nolock_ptr); + let status = nolock_fn(pm_session, mode_ptr); + log::info!(" → status = {}", status); + if status == 0 { + if !lock_ptr.is_null() { + type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; + let lock_fn: LockFn = std::mem::transmute(lock_ptr); + let lstatus = lock_fn(pm_session, 1); + log::info!(" → lock status = {}", lstatus); + } + log::info!("Attempt 4 succeeded"); return; } - log::warn!("PMSessionSetColorMatchingModeLock returned {}", lock_status); - } else { - log::warn!("PMSessionSetColorMatchingModeNoLock returned {}", status); } } - log::warn!("All PMSessionSetColorMatchingMode variants failed or missing; color controls will not be grayed"); + log::warn!( + "All PMSessionSetColorMatchingMode variants and mode constants exhausted; color controls will not be grayed" + ); } #[cfg(not(target_os = "macos"))] -- 2.39.5 From 37b0c132638d786f0e21161619fc9a15bacb67e2 Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 09:39:08 +0100 Subject: [PATCH 10/12] fix(print): fix PMSessionGetCurrentPrinter type with .into() and remove unused mut on printer_from_id --- src-tauri/src/print/macos.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 829172e..00f0f47 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -51,7 +51,7 @@ unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { // Verify session has a printer attached let mut current_printer: PMPrinter = std::ptr::null_mut(); - let printer_status = PMSessionGetCurrentPrinter(pm_session, &mut current_printer); + let printer_status = PMSessionGetCurrentPrinter(pm_session, (&mut current_printer).into()); if printer_status != 0 || current_printer.is_null() { log::warn!( "PMSessionGetCurrentPrinter failed ({}) or returned null printer; skipping SPI", @@ -319,7 +319,7 @@ fn run_native_print_panel( // human-readable display names used by NSPrinter::printerWithName. let printer_id_cf = CFString::from_str(printer_name); let pm_printer: PMPrinter = unsafe { PMPrinterCreateFromPrinterID(&*printer_id_cf) }; - let mut printer_from_id = !pm_printer.is_null(); + let printer_from_id = !pm_printer.is_null(); if pm_printer.is_null() { if let Some(dn) = display_name { -- 2.39.5 From 8b01c98463a21d731c62dc65d8373e2c382f16fb Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 12:58:01 +0100 Subject: [PATCH 11/12] fix(print): remove crashing 3-arg PMSessionSetColorMatchingMode calls and fix SPI usage (closes #188) - All PMSessionSetColorMatchingMode* symbols use the 2-arg (PMPrintSession, *const CFString) signature. - PMSessionSetColorMatchingModeLock sets and locks in one call; it does not take a Boolean. - Remove speculative 3-argument transmutes that caused EXC_BAD_ACCESS. - Restrict color-matching modes to application-managed bypass only (AP_ApplicationColorMatching / ApplicationColorMatching). - Preserve the PMSessionGetCurrentPrinter pre-check. - Return bool from set_session_color_matching_mode and log SPI results. - Update non-macOS stub to return bool. - Improve run_native_print_panel error logging when the printer cannot be resolved. - Add unit tests for color-bypass filtering and detection. - Update AGENTS.md with the corrected SPI notes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 3 +- src-tauri/src/print/macos.rs | 193 ++++++++++++++++++----------------- 2 files changed, 100 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1086923..b246c3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,13 +26,14 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI - The CUPS destination ID is bound to the panel via Core Printing `PMPrinterCreateFromPrinterID` and `PMSessionSetCurrentPMPrinter` - A `Printer.display_name` (from CUPS `printer-info`) is cached at enumeration as a fallback for `NSPrinter::printerWithName` - Pre-configured with both `AP_ColorMatchingMode=AP_ApplicationColorMatching` and `AP.ColorMatchingMode=AP_ApplicationColorMatching` (dot-notation) as a locked PMPrintSettings value and in the `NSPrintInfo` job ticket -- Uses the private Core Printing `PMSessionSetColorMatchingMode` / `PMSessionSetColorMatchingModeLock` SPI (resolved at runtime via `dlsym`) to gray out and lock the Color Matching controls; falls back to the public setting if the SPI is absent +- Uses the private Core Printing `PMSessionSetColorMatchingMode` / `PMSessionSetColorMatchingModeLock` / `PMSessionSetColorMatchingModeNoLock` SPI (resolved at runtime via `dlsym`) to gray out and lock the Color Matching controls; all three symbols use the 2-argument `(PMPrintSession, *const CFString)` signature; `PMSessionSetColorMatchingModeLock` sets and locks in one call; `NoLock` sets the mode without locking; falls back to the public `PMPrintSettingsSetValue` setting if the SPI is absent - Pre-selects the driver-specific "no color adjustment" PPD option (Canon `CNIJIntent2=4`, Epson `EPIJ_CMat=3`, etc.) in the native panel and on the `lp` command line - Captures user's media type / quality selections as a CUPS options string with `PMPrintSettingsToOptions` - Returns a `PrintPropertiesResult` with the effective `selected_printer` and captured `PrintOptions` - Cancellation is returned as `None`, not an error - Captured options are stored in frontend `capturedCupsOptions` map and passed via `PrintOptions.cups_options` - `build_lp_args` in `macos.rs` always adds both `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` and `-o AP.ColorMatchingMode=AP_ApplicationColorMatching`, and forwards captured options +- Only `AP_ApplicationColorMatching` and `ApplicationColorMatching` are passed to the private SPI; `AP_ColorSyncMatching` and `AP_VendorColorMatching` are intentionally avoided because they would enable color management on profiling targets ## Key Dependencies (macOS only) diff --git a/src-tauri/src/print/macos.rs b/src-tauri/src/print/macos.rs index 00f0f47..1217e9e 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -34,19 +34,21 @@ extern "C" { /// i1Profiler to gray-out the Color Matching controls in driver PDEs. /// /// The symbols are resolved at runtime with `dlsym` so the binary does not -/// hard-depend on an undocumented symbol. We try multiple signature permutations -/// and mode constants because the exact signature is undocumented. -/// Known exports: PMSessionSetColorMatchingMode, PMSessionSetColorMatchingModeLock, -/// PMSessionSetColorMatchingModeNoLock. +/// hard-depend on an undocumented symbol. All three exported variants +/// (PMSessionSetColorMatchingMode, PMSessionSetColorMatchingModeLock, +/// PMSessionSetColorMatchingModeNoLock) use the same 2-argument C signature: +/// `(PMPrintSession, *const CFString) -> i32`. The "Lock" variant sets the +/// mode and locks the UI in a single call; "NoLock" sets it without locking. +/// We only attempt application-managed color-matching modes. #[cfg(target_os = "macos")] -unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { - use std::ffi::{c_char, c_int, c_void}; +unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) -> bool { + use std::ffi::{c_char, c_void}; use objc2_core_foundation::CFString; use objc2_application_services::{PMSessionGetCurrentPrinter, PMPrinter}; if pm_session.is_null() { log::warn!("pm_session is null; skipping private color-matching SPI"); - return; + return false; } // Verify session has a printer attached @@ -57,123 +59,80 @@ unsafe fn set_session_color_matching_mode(pm_session: PMPrintSession) { "PMSessionGetCurrentPrinter failed ({}) or returned null printer; skipping SPI", printer_status ); - return; + return false; } const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; - // Load all relevant symbols once - let sym_main = c"PMSessionSetColorMatchingMode"; + // Load all relevant symbols once. Order matches the priority below: + // Lock first (sets and locks in one call), then main, then NoLock. let sym_lock = c"PMSessionSetColorMatchingModeLock"; + let sym_main = c"PMSessionSetColorMatchingMode"; let sym_nolock = c"PMSessionSetColorMatchingModeNoLock"; - let main_ptr = dlsym(RTLD_DEFAULT, sym_main.as_ptr() as *const c_char); let lock_ptr = dlsym(RTLD_DEFAULT, sym_lock.as_ptr() as *const c_char); + let main_ptr = dlsym(RTLD_DEFAULT, sym_main.as_ptr() as *const c_char); let nolock_ptr = dlsym(RTLD_DEFAULT, sym_nolock.as_ptr() as *const c_char); log::info!( - "PMSessionSetColorMatchingMode SPI symbols: main={:p} lock={:p} nolock={:p}", - main_ptr, lock_ptr, nolock_ptr + "PMSessionSetColorMatchingMode SPI symbols: lock={:p} main={:p} nolock={:p}", + lock_ptr, main_ptr, nolock_ptr ); - // Mode constants to try (order matters: most likely first) + // Only application-managed color-matching modes are appropriate for + // profiling targets. ColorSync or vendor modes would apply color + // management and corrupt the target patches. const MODES: &[&str] = &[ "AP_ApplicationColorMatching", // kPMApplicationColorMatching - "AP_VendorColorMatching", // kPMVendorColorMatching - "AP_ColorSyncMatching", // kPMColorSyncMatching - "AP_NoColorMatching", // hypothetical "ApplicationColorMatching", // without AP_ prefix ]; + // All three symbols have the same (PMPrintSession, *const CFString) signature. + // PMSessionSetColorMatchingModeLock sets and locks in one call, so try it first. + type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32; + + let candidates = [ + ("PMSessionSetColorMatchingModeLock", lock_ptr), + ("PMSessionSetColorMatchingMode", main_ptr), + ("PMSessionSetColorMatchingModeNoLock", nolock_ptr), + ]; + + let mut set_success = false; + for &mode_str in MODES { let mode = CFString::from_str(mode_str); let mode_ptr = &*mode as *const CFString; - // Attempt 1: 3-arg (session, mode, lock) — standard ordering - if !main_ptr.is_null() && !lock_ptr.is_null() { - log::info!( - "Attempt 1: PMSessionSetColorMatchingMode(session, mode, lock) with {}", - mode_str - ); - type Fn3 = unsafe extern "C" fn(PMPrintSession, *const CFString, u8) -> c_int; - let fn3: Fn3 = std::mem::transmute(main_ptr); - let status = fn3(pm_session, mode_ptr, 1); - log::info!(" → status = {}", status); - if status == 0 { - log::info!("Attempt 1 succeeded"); - return; - } - } - - // Attempt 2: 3-arg lock-first (session, lock, mode) — Carbon sometimes puts Boolean first - if !main_ptr.is_null() && !lock_ptr.is_null() { - log::info!( - "Attempt 2: PMSessionSetColorMatchingMode(session, lock, mode) with {}", - mode_str - ); - type Fn3Rev = unsafe extern "C" fn(PMPrintSession, u8, *const CFString) -> c_int; - let fn3rev: Fn3Rev = std::mem::transmute(main_ptr); - let status = fn3rev(pm_session, 1, mode_ptr); - log::info!(" → status = {}", status); - if status == 0 { - log::info!("Attempt 2 succeeded"); - return; - } - } - - // Attempt 3: 2-arg (session, mode) via main symbol - if !main_ptr.is_null() { - log::info!( - "Attempt 3: PMSessionSetColorMatchingMode(session, mode) with {}", - mode_str - ); - type Fn2 = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; - let fn2: Fn2 = std::mem::transmute(main_ptr); - let status = fn2(pm_session, mode_ptr); - log::info!(" → status = {}", status); - if status == 0 { - // Try to lock separately - if !lock_ptr.is_null() { - type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; - let lock_fn: LockFn = std::mem::transmute(lock_ptr); - let lstatus = lock_fn(pm_session, 1); - log::info!(" → lock status = {}", lstatus); + for (name, fn_ptr) in candidates { + if !fn_ptr.is_null() { + let set_fn: ModeFn = std::mem::transmute(fn_ptr); + let status = set_fn(pm_session, mode_ptr); + log::info!("{}({}) returned {}", name, mode_str, status); + if status == 0 { + set_success = true; + break; } - log::info!("Attempt 3 succeeded"); - return; } } - // Attempt 4: NoLock variant (session, mode) + separate lock - if !nolock_ptr.is_null() { - log::info!( - "Attempt 4: PMSessionSetColorMatchingModeNoLock(session, mode) with {}", - mode_str - ); - type NoLockFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> c_int; - let nolock_fn: NoLockFn = std::mem::transmute(nolock_ptr); - let status = nolock_fn(pm_session, mode_ptr); - log::info!(" → status = {}", status); - if status == 0 { - if !lock_ptr.is_null() { - type LockFn = unsafe extern "C" fn(PMPrintSession, u8) -> c_int; - let lock_fn: LockFn = std::mem::transmute(lock_ptr); - let lstatus = lock_fn(pm_session, 1); - log::info!(" → lock status = {}", lstatus); - } - log::info!("Attempt 4 succeeded"); - return; - } + if set_success { + break; } } - log::warn!( - "All PMSessionSetColorMatchingMode variants and mode constants exhausted; color controls will not be grayed" - ); + if !set_success { + log::warn!( + "All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed" + ); + } + + set_success } #[cfg(not(target_os = "macos"))] -unsafe fn set_session_color_matching_mode(_pm_session: PMPrintSession) {} +unsafe fn set_session_color_matching_mode(_pm_session: PMPrintSession) -> bool { + false +} @@ -332,14 +291,22 @@ fn run_native_print_panel( print_info.setPrinter(&ns_printer); print_info.setUpPrintOperationDefaultValues(); } else { + log::warn!( + "PMPrinterCreateFromPrinterID('{}') and NSPrinter::printerWithName('{}') both failed", + printer_name, dn + ); return Err(format!( - "No printer found for '{}' (display name: {:?})", + "No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.", printer_name, display_name )); } } else { + log::warn!( + "PMPrinterCreateFromPrinterID('{}') failed and no display name was available", + printer_name + ); return Err(format!( - "No printer found for '{}' (display name: {:?})", + "No printer found for '{}' (display name: {:?}). The native print panel cannot be opened.", printer_name, display_name )); } @@ -382,7 +349,10 @@ fn run_native_print_panel( // Use the private PMSessionSetColorMatchingMode SPI to gray out and lock // the Color Matching controls in the driver PDE (ColorSync vs. vendor // color). This is separate from the AP_ColorMatchingMode spool setting. - unsafe { set_session_color_matching_mode(pm_session); } + let spi_ok = unsafe { set_session_color_matching_mode(pm_session) }; + if !spi_ok { + log::warn!("Native color-matching controls may not be locked; PMPrintSettingsSetValue fallback will be used"); + } // Set AP_ColorMatchingMode = AP_ApplicationColorMatching (and the // dot-notation variant) so the CUPS backend/cgpdftoraster knows the @@ -858,4 +828,37 @@ mod tests { assert!(args.contains(&"orientation-requested=4".to_string())); assert!(!args.contains(&"orientation-requested=3".to_string())); } + + #[test] + fn test_filter_cups_options_string_keeps_color_bypass_keys() { + let raw = "com.apple.print.PrintSettings.PMCopies..n.=1 collate=False CNIJIntent2=4 EPIJ_CMat=3 ColorCorrection=Uncorrected PageSize=A4"; + let filtered = filter_cups_options_string(raw).unwrap(); + assert!(filtered.contains("CNIJIntent2=4")); + assert!(filtered.contains("EPIJ_CMat=3")); + assert!(filtered.contains("ColorCorrection=Uncorrected")); + assert!(filtered.contains("PageSize=A4")); + assert!(!filtered.contains("com.apple.")); + assert!(!filtered.contains("collate")); + } + + #[test] + fn test_detect_driver_color_bypass_canon_and_epson() { + let canon = "CNIJIntent2/Color Mode: 1 4 3 *StpColorCorrection/Color Correction: None Correct Uncorrected"; + assert_eq!( + crate::print::unix::detect_driver_color_bypass(canon), + Some(("CNIJIntent2", "4")) + ); + + let epson = "EPIJ_CMat/Color Settings: 1 2 *3 StpColorCorrection/Color Correction: None Correct Uncorrected"; + assert_eq!( + crate::print::unix::detect_driver_color_bypass(epson), + Some(("EPIJ_CMat", "3")) + ); + + let gutenprint = "StpColorCorrection/Color Correction: None Correct *Uncorrected"; + assert_eq!( + crate::print::unix::detect_driver_color_bypass(gutenprint), + Some(("StpColorCorrection", "Uncorrected")) + ); + } } -- 2.39.5 From a4612bf0658e4480699717fe0e5753a55ecef08b Mon Sep 17 00:00:00 2001 From: Gordon Bolton Date: Thu, 3 Sep 2026 13:30:03 +0100 Subject: [PATCH 12/12] chore: bump version to 0.7.3 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 87b3c2a..fe6b2bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4ce34b8..12c8bfb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.7.2" +version = "0.7.3" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 056c96c..f28689b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.7.2" +version = "0.7.3" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 30e8614..ceabb62 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.7.2", + "version": "0.7.3", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" -- 2.39.5