fix(print): macOS Preferences crash and color-management hardening (closes #188) #194
@@ -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
|
||||
|
||||
@@ -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`
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+68
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Option<crate::print::PrintPropertiesResult>, 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())
|
||||
}
|
||||
}
|
||||
|
||||
+766
-33
@@ -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 <printer> -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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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<String> {
|
||||
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<Option<PrintPropertiesResult>, 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<String> = std::collections::HashSet::new();
|
||||
|
||||
// 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, value));
|
||||
added_keys.insert(key.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch lpoptions for this printer to detect capabilities.
|
||||
let lpoptions_output: Option<String> = 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() {
|
||||
let key = crate::print::unix::detect_media_type_key(&output_str);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ppd_fallback {
|
||||
// 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)
|
||||
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 {
|
||||
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<Option<PrintPropertiesResult>, 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::<Result<Option<PrintPropertiesResult>, 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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -43,6 +46,18 @@ pub struct PrintOptions {
|
||||
pub paper_size: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub ppd_uncorrected_passthrough: Option<bool>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PrintPropertiesResult {
|
||||
pub selected_printer: Option<String>,
|
||||
pub options: PrintOptions,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
+136
-2
@@ -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 <name>`.
|
||||
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<String> {
|
||||
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 <name>` output.
|
||||
/// This is the human-readable queue name that macOS uses in the print panel.
|
||||
pub fn extract_printer_display_name(output: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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<Vec<Printer>, String> {
|
||||
let e_output = std::process::Command::new("lpstat")
|
||||
@@ -166,7 +209,11 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSiz
|
||||
name: clean_val.to_string(),
|
||||
});
|
||||
}
|
||||
} else if key_name.eq_ignore_ascii_case("CNIJMediaType") || key_name.eq_ignore_ascii_case("MediaType") || key_name.eq_ignore_ascii_case("StpMediaType") {
|
||||
} else if key_name.eq_ignore_ascii_case("CNIJMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("MediaType")
|
||||
|| key_name.eq_ignore_ascii_case("StpMediaType")
|
||||
|| key_name.eq_ignore_ascii_case("EPIJ_Medi")
|
||||
{
|
||||
for val in values {
|
||||
let clean_val = val.trim_start_matches('*');
|
||||
media_types.push(PrinterMediaType {
|
||||
@@ -184,7 +231,11 @@ pub fn parse_lpoptions_l(output: &str) -> (Vec<PrinterTray>, Vec<PrinterPaperSiz
|
||||
pub fn parse_ppd_media_types(ppd_content: &str) -> Vec<PrinterMediaType> {
|
||||
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 <printer> -l`.
|
||||
pub fn get_printer_capabilities(printer_name: &str) -> Result<PrinterCapabilities, String> {
|
||||
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);
|
||||
|
||||
@@ -128,6 +128,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -158,6 +159,7 @@ pub fn get_printers() -> Result<Vec<Printer>, String> {
|
||||
name,
|
||||
status: "Ready".to_string(),
|
||||
is_default: false,
|
||||
display_name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+52
-3
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user