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 new file mode 100644 index 0000000..b246c3b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# 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 + +## 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). +- 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` / `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) + +- `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, 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_CMat=3` (Off / No Color Adjustment) +- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4` +- Gutenprint: `StpColorCorrection=Uncorrected` 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 2940ef4..12c8bfb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,11 +1423,16 @@ dependencies = [ [[package]] name = "iccery" -version = "0.7.2" +version = "0.7.3" 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..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" @@ -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..cf13f6f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1152,26 +1152,29 @@ 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; + 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) + crate::print::macos::show_printer_properties(&printer_name, &app).await } #[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..1217e9e 100644 --- a/src-tauri/src/print/macos.rs +++ b/src-tauri/src/print/macos.rs @@ -1,12 +1,522 @@ #![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 objc2_application_services::PMPrintSession; + +use crate::print::{PrintOptions, PrintPropertiesResult}; +pub use crate::print::unix::{get_printer_capabilities, get_printers}; + +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. 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) -> 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 false; + } + + // Verify session has a printer attached + let mut current_printer: PMPrinter = std::ptr::null_mut(); + 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", + printer_status + ); + return false; + } + + const RTLD_DEFAULT: *mut c_void = (-2isize) as *mut c_void; + + // 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 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: lock={:p} main={:p} nolock={:p}", + lock_ptr, main_ptr, nolock_ptr + ); + + // 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 + "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; + + 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; + } + } + } + + if set_success { + break; + } + } + + 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) -> bool { + false +} + + + +/// 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_CMat", + "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 (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="). + 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-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, + display_name: Option<&str>, +) -> Result, String> { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSPrintInfo, NSPrintPanel, NSPrintPanelOptions, NSPrinter}; + use objc2_application_services::{ + PMPageFormat, PMPrinter, PMPrinterCreateFromPrinterID, + PMPrinterGetID, PMPrintSettings, PMPrintSettingsSetValue, + PMPrintSettingsToOptions, PMRelease, PMSessionDefaultPageFormat, + PMSessionDefaultPrintSettings, PMSessionGetCurrentPrinter, + PMSessionSetCurrentPMPrinter, + }; + use objc2_core_foundation::CFString; + use objc2_foundation::NSString; + + let mtm = MainThreadMarker::new() + .ok_or("Print panel must be invoked on the main thread")?; + + // Create a fresh NSPrintInfo and initialize it. + let print_info = NSPrintInfo::new(); + print_info.setUpPrintOperationDefaultValues(); + + // 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 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 { + log::warn!( + "PMPrinterCreateFromPrinterID('{}') and NSPrinter::printerWithName('{}') both failed", + printer_name, dn + ); + return Err(format!( + "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: {:?}). The native print panel cannot be opened.", + 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 + ); + } + } + + // 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. + 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 + // 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"); + let cm_val_ref: &objc2_core_foundation::CFType = &*cm_val; + 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 + ); + } + } + + // 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. + 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)); + + // 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(); + 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 { + if !pm_printer.is_null() { + unsafe { PMRelease(pm_printer as _) }; + } + return Ok(None); + } + + // 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(), + ) + }; + 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(updated_settings, NonNull::new(&mut opts_ptr).unwrap()) + }; + + 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 + )); + } + + 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); + + // 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(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 +/// ColorSync bypass flags and any user-captured PPD options. pub fn build_lp_args( printer_name: &str, tiff_path: &str, @@ -15,7 +525,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 +537,103 @@ 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. 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()); - // 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 = get_lpoptions_output(printer_name); + + // 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_cmat", + "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 +676,189 @@ 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 `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, 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); - Ok(()) + 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 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))?; + + 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_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_CMat, PageSize but not com.apple.*, + // collate, copies, AP_ColorMatchingMode, or empty AP_D_InputSlot. + assert!(filtered.contains("MediaType=92")); + assert!(filtered.contains("EPIJ_CMat=3")); + 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_CMat=3 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_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_CMat=3".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_CMat=3".to_string()), + ..Default::default() + }; + let args = build_lp_args("Test_Printer", "/tmp/target.tif", Some(&opts)); + // 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 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] + 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())); + } + + #[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")) + ); + } } diff --git a/src-tauri/src/print/mod.rs b/src-tauri/src/print/mod.rs index c3e781f..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)] @@ -43,6 +46,18 @@ 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(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrintPropertiesResult { + pub selected_printer: Option, + pub options: PrintOptions, } #[derive(Clone, Default)] diff --git a/src-tauri/src/print/tests.rs b/src-tauri/src/print/tests.rs index 3ab4ff9..adb5aa8 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"); @@ -72,6 +73,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 +83,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 +252,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; @@ -265,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 { @@ -277,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); diff --git a/src-tauri/src/print/unix.rs b/src-tauri/src/print/unix.rs index 93692b8..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") @@ -166,7 +209,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 +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_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") { @@ -216,6 +271,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 +280,54 @@ 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. 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(); + 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().trim_matches('"').to_string(); + let value = value.trim().trim_matches('"').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 +582,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-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, }); } } 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" diff --git a/src/js/printtarg.js b/src/js/printtarg.js index cdae09c..149f020 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, }; } @@ -321,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; @@ -373,8 +384,46 @@ 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 === 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); showNotification("error", `Could not open printer properties: ${err}`);