feat(print): macOS/Linux CUPS raw print spooler and printer enumeration (#27) #30

Merged
gronod merged 4 commits from feature/issue-27-cups-raw-print into development 2026-08-23 14:47:12 +01:00
4 changed files with 403 additions and 15 deletions
+50 -2
View File
@@ -344,12 +344,60 @@ pub async fn run_profcheck(
#[tauri::command]
pub async fn get_windows_printers() -> Result<Vec<String>, String> {
crate::print::get_printers()
#[cfg(windows)]
{
crate::print::windows::get_printers()
}
#[cfg(not(windows))]
{
Err("Windows native printing is only supported on Windows.".to_string())
}
}
#[tauri::command]
pub async fn print_target_windows(printer_name: String, tiff_path: String) -> Result<(), String> {
crate::print::print_target(&printer_name, &tiff_path)
#[cfg(windows)]
{
crate::print::windows::print_target(&printer_name, &tiff_path)
}
#[cfg(not(windows))]
{
let _ = (printer_name, tiff_path);
Err("Windows native printing is only supported on Windows.".to_string())
}
}
#[tauri::command]
pub async fn get_cups_printers() -> Result<Vec<crate::print::Printer>, String> {
#[cfg(unix)]
{
crate::print::unix::get_printers()
}
#[cfg(not(unix))]
{
Err("CUPS printing is only supported on macOS and Linux.".to_string())
}
}
#[tauri::command]
pub async fn print_target_cups(
printer_name: String,
tiff_path: String,
ppd_uncorrected_passthrough: Option<bool>,
) -> Result<(), String> {
#[cfg(unix)]
{
crate::print::unix::print_target(
&printer_name,
&tiff_path,
ppd_uncorrected_passthrough.unwrap_or(false),
)
}
#[cfg(not(unix))]
{
let _ = (printer_name, tiff_path, ppd_uncorrected_passthrough);
Err("CUPS printing is only supported on macOS and Linux.".to_string())
}
}
#[cfg(test)]
+2
View File
@@ -26,6 +26,8 @@ pub fn run() {
commands::run_profcheck,
commands::get_windows_printers,
commands::print_target_windows,
commands::get_cups_printers,
commands::print_target_cups,
settings::load_settings,
settings::save_settings,
])
+11 -13
View File
@@ -1,16 +1,14 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Printer {
pub name: String,
pub status: String,
pub is_default: bool,
}
#[cfg(windows)]
pub mod windows;
#[cfg(windows)]
pub use windows::*;
#[cfg(not(windows))]
pub fn get_printers() -> Result<Vec<String>, String> {
Err("Direct native printer querying is only supported on Windows in this build.".to_string())
}
#[cfg(not(windows))]
pub fn print_target(printer_name: &str, tiff_path: &str) -> Result<(), String> {
let _ = (printer_name, tiff_path);
Err("Direct native raw printing is currently implemented for Windows. macOS/Linux CUPS support is planned for the next sub-task.".to_string())
}
#[cfg(unix)]
pub mod unix;
+340
View File
@@ -0,0 +1,340 @@
use std::path::Path;
use crate::print::Printer;
/// Parse `lpstat -e` output into a list of printer destination names.
pub fn parse_lpstat_e(output: &str) -> Vec<String> {
output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
/// Parse `lpstat -d` output to determine the default system printer destination.
pub fn parse_lpstat_d(output: &str) -> Option<String> {
for line in output.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("system default destination:") {
let dest = rest.trim();
if !dest.is_empty() {
return Some(dest.to_string());
}
}
}
None
}
/// Parse `lpstat -p` output to extract printer names and status indicators.
pub fn parse_lpstat_p(output: &str) -> Vec<(String, String)> {
let mut results = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("printer ") {
let mut parts = rest.split_whitespace();
if let Some(name) = parts.next() {
let name = name.to_string();
let lower = rest.to_lowercase();
let status = if lower.contains("is idle") || lower.contains("idle") {
"Idle".to_string()
} else if lower.contains("is printing") || lower.contains("now printing") || lower.contains("printing") {
"Printing".to_string()
} else if lower.contains("disabled") || lower.contains("stopped") {
"Stopped".to_string()
} else {
"Unknown".to_string()
};
results.push((name, status));
}
}
}
results
}
/// Merge printer lists from `lpstat -e`, `lpstat -p`, and `lpstat -d`.
pub fn merge_printer_info(
destinations: &[String],
statuses: &[(String, String)],
default_dest: Option<&str>,
) -> Vec<Printer> {
use std::collections::HashMap;
let status_map: HashMap<&str, &str> = statuses
.iter()
.map(|(name, status)| (name.as_str(), status.as_str()))
.collect();
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for name in destinations {
if seen.insert(name.clone()) {
let status = status_map.get(name.as_str()).copied().unwrap_or("Idle");
let is_default = default_dest.map_or(false, |d| d == name);
result.push(Printer {
name: name.clone(),
status: status.to_string(),
is_default,
});
}
}
for (name, status) in statuses {
if seen.insert(name.clone()) {
let is_default = default_dest.map_or(false, |d| d == name);
result.push(Printer {
name: name.clone(),
status: status.clone(),
is_default,
});
}
}
result
}
/// Query available CUPS printers, connection statuses, and default destination.
pub fn get_printers() -> Result<Vec<Printer>, String> {
// 1. Run lpstat -e
let e_output = std::process::Command::new("lpstat")
.arg("-e")
.output();
let destinations = match e_output {
Ok(out) if out.status.success() => {
parse_lpstat_e(&String::from_utf8_lossy(&out.stdout))
}
_ => Vec::new(),
};
// 2. Run lpstat -p
let p_output = std::process::Command::new("lpstat")
.arg("-p")
.output();
let statuses = match p_output {
Ok(out) => {
parse_lpstat_p(&String::from_utf8_lossy(&out.stdout))
}
Err(e) => {
if destinations.is_empty() {
return Err(format!("Failed to execute 'lpstat': {}", e));
}
Vec::new()
}
};
// 3. Run lpstat -d
let d_output = std::process::Command::new("lpstat")
.arg("-d")
.output();
let default_dest = match d_output {
Ok(out) => parse_lpstat_d(&String::from_utf8_lossy(&out.stdout)),
_ => None,
};
Ok(merge_printer_info(&destinations, &statuses, default_dest.as_deref()))
}
/// Construct `lp` command line arguments for target printing.
pub fn build_lp_args(
printer_name: &str,
tiff_path: &str,
ppd_uncorrected_passthrough: bool,
) -> Vec<String> {
let path = Path::new(tiff_path);
let title = format!(
"ICCery Target - {}",
path.file_name().and_then(|n| n.to_str()).unwrap_or("Profiling Target")
);
let mut args = vec![
"-d".to_string(),
printer_name.to_string(),
"-t".to_string(),
title,
];
if ppd_uncorrected_passthrough {
args.push("-o".to_string());
args.push("ColorModel=Gray".to_string());
args.push("-o".to_string());
args.push("cm-calibration".to_string());
} else {
args.push("-o".to_string());
args.push("raw".to_string());
}
args.push(tiff_path.to_string());
args
}
/// Spool target TIFF to CUPS printer bypassing ColorSync and PPD color management.
pub fn print_target(
printer_name: &str,
tiff_path: &str,
ppd_uncorrected_passthrough: bool,
) -> Result<(), String> {
let path = Path::new(tiff_path);
if !path.exists() {
return Err(format!("Target TIFF file not found: {}", tiff_path));
}
let args = build_lp_args(printer_name, tiff_path, ppd_uncorrected_passthrough);
let output = std::process::Command::new("lp")
.args(&args)
.output()
.map_err(|e| format!("Failed to execute 'lp' command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let msg = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if !stdout.trim().is_empty() {
stdout.trim().to_string()
} else {
format!("exited with status code {}", output.status.code().unwrap_or(-1))
};
return Err(format!("CUPS print job failed: {}", msg));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lpstat_e() {
let sample = "Epson-Stylus-SX420W\nHP_Officejet_Pro_8600\n\n";
let parsed = parse_lpstat_e(sample);
assert_eq!(parsed, vec!["Epson-Stylus-SX420W", "HP_Officejet_Pro_8600"]);
}
#[test]
fn test_parse_lpstat_d() {
let sample_valid = "system default destination: Epson-Stylus-SX420W\n";
assert_eq!(
parse_lpstat_d(sample_valid),
Some("Epson-Stylus-SX420W".to_string())
);
let sample_none = "no system default destination\n";
assert_eq!(parse_lpstat_d(sample_none), None);
let sample_empty = "";
assert_eq!(parse_lpstat_d(sample_empty), None);
}
#[test]
fn test_parse_lpstat_p() {
let sample = "\
printer Epson-Stylus-SX420W is idle. enabled since Thu 28 May 2026 12:52:43 BST\n\
printer HP_LaserJet now printing HP_LaserJet-42. enabled since ...\n\
printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\
";
let parsed = parse_lpstat_p(sample);
assert_eq!(
parsed,
vec![
("Epson-Stylus-SX420W".to_string(), "Idle".to_string()),
("HP_LaserJet".to_string(), "Printing".to_string()),
("Zebra_Label".to_string(), "Stopped".to_string()),
]
);
}
#[test]
fn test_merge_printer_info() {
let destinations = vec![
"Epson-Stylus-SX420W".to_string(),
"HP_LaserJet".to_string(),
"Virtual_PDF".to_string(),
];
let statuses = vec![
("Epson-Stylus-SX420W".to_string(), "Idle".to_string()),
("HP_LaserJet".to_string(), "Printing".to_string()),
("Zebra_Label".to_string(), "Stopped".to_string()),
];
let default_dest = Some("Epson-Stylus-SX420W");
let merged = merge_printer_info(&destinations, &statuses, default_dest);
assert_eq!(merged.len(), 4);
assert_eq!(
merged[0],
Printer {
name: "Epson-Stylus-SX420W".to_string(),
status: "Idle".to_string(),
is_default: true,
}
);
assert_eq!(
merged[1],
Printer {
name: "HP_LaserJet".to_string(),
status: "Printing".to_string(),
is_default: false,
}
);
assert_eq!(
merged[2],
Printer {
name: "Virtual_PDF".to_string(),
status: "Idle".to_string(),
is_default: false,
}
);
assert_eq!(
merged[3],
Printer {
name: "Zebra_Label".to_string(),
status: "Stopped".to_string(),
is_default: false,
}
);
}
#[test]
fn test_build_lp_args_raw() {
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", false);
assert_eq!(
args,
vec![
"-d",
"Epson-Stylus-SX420W",
"-t",
"ICCery Target - target.tif",
"-o",
"raw",
"/tmp/target.tif"
]
);
}
#[test]
fn test_build_lp_args_ppd_fallback() {
let args = build_lp_args("Epson-Stylus-SX420W", "/tmp/target.tif", true);
assert_eq!(
args,
vec![
"-d",
"Epson-Stylus-SX420W",
"-t",
"ICCery Target - target.tif",
"-o",
"ColorModel=Gray",
"-o",
"cm-calibration",
"/tmp/target.tif"
]
);
}
#[test]
fn test_print_target_file_not_found() {
let res = print_target("Epson-Stylus-SX420W", "/non/existent/target.tif", false);
assert!(res.is_err());
assert!(res.unwrap_err().contains("not found"));
}
}