cosmic-settings/cosmic-settings/src/pages/printers/backend.rs
Lionel DARNIS 981e63a154 feat(printers): niveaux d'encre interroges directement sur l'imprimante
La file driverless ne relaie pas marker-levels: le backend resout l'URI dnssd en point d'acces IPP via ippfind puis lit marker-names/levels/colors via ipptool (timeout 8 s). Le tiroir affiche pastille de couleur, jauge et pourcentage par cartouche; bloc masque si l'imprimante n'expose rien.
2026-08-08 17:01:41 +02:00

536 lines
17 KiB
Rust

// Copyright 2026 Lionel DARNIS <lionel@wopr.io>
// SPDX-License-Identifier: GPL-3.0-only
//! CUPS backend for the printers page.
//!
//! Read-only operations (destinations, jobs) use the `cups_rs` bindings to
//! libcups. Mutating operations (add, remove, default, pause) go through the
//! CUPS command line tools, which authenticate transparently against the
//! local scheduler for users in the CUPS system group.
use tokio::process::Command;
pub use cups_rs::{JobStatus, PrinterState};
const TEST_PAGE: &str = "/usr/share/cups/data/testprint";
/// `printer-type` bit marking destinations that CUPS merely discovered on the
/// network (dnssd browsing). Those are not real queues: they cannot be removed
/// or configured and vanish on their own, so the page must not list them.
const CUPS_PRINTER_DISCOVERED: u32 = 0x1000000;
/// libcups keeps process-wide global state; serialize every FFI call.
static CUPS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn cups_lock() -> std::sync::MutexGuard<'static, ()> {
CUPS_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A printer queue known to the CUPS scheduler.
#[derive(Clone, Debug)]
pub struct Printer {
pub name: String,
pub info: String,
pub make_and_model: Option<String>,
pub location: Option<String>,
pub device_uri: Option<String>,
pub state: PrinterState,
pub state_reasons: Vec<String>,
pub accepting_jobs: bool,
pub is_default: bool,
}
/// An active print job.
#[derive(Clone, Debug)]
pub struct Job {
pub id: i32,
pub title: String,
pub dest: String,
pub status: JobStatus,
}
/// A device that a printer queue can be created for.
#[derive(Clone, Debug)]
pub struct Device {
pub uri: String,
pub info: String,
}
/// A printer driver (PPD or driverless profile).
#[derive(Clone, Debug)]
pub struct Driver {
pub id: String,
pub description: String,
}
/// Read operations (blocking FFI; call from a blocking task).
pub fn printers_sync() -> Result<Vec<Printer>, String> {
let _guard = cups_lock();
let destinations = cups_rs::get_all_destinations().map_err(|err| err.to_string())?;
Ok(destinations
.iter()
.filter(|dest| {
!dest
.get_option("printer-type")
.and_then(|value| value.parse::<u32>().ok())
.is_some_and(|printer_type| printer_type & CUPS_PRINTER_DISCOVERED != 0)
})
.map(|dest| Printer {
name: dest.name.clone(),
info: dest
.info()
.filter(|info| !info.is_empty())
.cloned()
.unwrap_or_else(|| dest.name.clone()),
make_and_model: dest.make_and_model().cloned(),
location: dest.location().cloned(),
device_uri: dest.device_uri().cloned(),
state: dest.state(),
state_reasons: dest
.state_reasons()
.into_iter()
.filter(|reason| reason != "none")
.collect(),
accepting_jobs: dest.is_accepting_jobs(),
is_default: dest.is_default,
})
.collect())
}
pub fn jobs_sync() -> Result<Vec<Job>, String> {
let _guard = cups_lock();
let jobs = cups_rs::get_active_jobs(None).map_err(|err| err.to_string())?;
Ok(jobs
.into_iter()
.map(|job| Job {
id: job.id,
title: job.title,
dest: job.dest,
status: job.status,
})
.collect())
}
pub fn cancel_job_sync(job_id: i32) -> Result<(), String> {
let _guard = cups_lock();
cups_rs::job::cancel_job(job_id).map_err(|err| err.to_string())
}
pub async fn print_test_page(printer: &str) -> Result<(), String> {
// Submit through `lp` so CUPS sniffs the format itself: the test page is
// a cups-filters PDF banner template (#PDF-BANNER), not PostScript, and
// cups_rs only allows a hardcoded list of document formats.
run_cli("lp", &["-d", printer, TEST_PAGE]).await
}
/// Discovery and administration (CUPS command line tools).
pub async fn devices() -> Result<Vec<Device>, String> {
let output = run_cli_capture("lpinfo", &["-v"]).await?;
let mut devices: Vec<Device> = Vec::new();
for line in output.lines() {
let Some((_, uri)) = line.split_once(char::is_whitespace) else {
continue;
};
let uri = uri.trim();
if !uri.contains("://") {
continue;
}
let info = device_info(uri);
// One entry per physical device: the same printer is advertised over
// several schemes (dnssd, ipp, ipps…). Prefer the dnssd URI, which
// keeps working across IP changes.
if let Some(existing) = devices.iter_mut().find(|device| device.info == info) {
if uri.starts_with("dnssd://") && !existing.uri.starts_with("dnssd://") {
existing.uri = uri.to_string();
}
continue;
}
devices.push(Device {
info,
uri: uri.to_string(),
});
}
Ok(devices)
}
pub async fn drivers() -> Result<Vec<Driver>, String> {
let output = run_cli_capture("lpinfo", &["-m"]).await?;
let mut drivers: Vec<Driver> = output
.lines()
.filter_map(|line| {
let (id, description) = line.split_once(char::is_whitespace)?;
Some(Driver {
id: id.trim().to_string(),
description: description.trim().to_string(),
})
})
.collect();
// Offer the driverless IPP Everywhere profile first.
drivers.sort_by_key(|driver| driver.id != "everywhere");
Ok(drivers)
}
pub async fn add_printer(name: &str, uri: &str, driver: &str) -> Result<(), String> {
run_cli("lpadmin", &["-p", name, "-E", "-v", uri, "-m", driver]).await
}
pub async fn remove_printer(name: &str) -> Result<(), String> {
match run_cli("lpadmin", &["-x", name]).await {
Ok(()) => Ok(()),
// A queue that vanished between listing and removal is already gone;
// treat it as a successful removal instead of an error.
Err(err)
if err.contains("n'existe pas") || err.to_lowercase().contains("not exist") =>
{
Ok(())
}
Err(err) => Err(err),
}
}
/// Default job options supported by a queue, with their current values.
///
/// Values are taken from the queue's PPD view (`lpoptions -l`): `PageSize`,
/// `MediaType`, `ColorModel`, `cupsPrintQuality` and `Duplex`, which
/// `lpadmin -o` can persist.
#[derive(Clone, Debug, Default)]
pub struct PrinterOptions {
pub media: Vec<String>,
pub media_default: Option<String>,
pub media_types: Vec<String>,
pub media_type_default: Option<String>,
pub color_models: Vec<String>,
pub color_model_default: Option<String>,
pub qualities: Vec<String>,
pub quality_default: Option<String>,
pub sides: Vec<String>,
pub sides_default: Option<String>,
}
pub async fn printer_options(name: &str) -> Result<PrinterOptions, String> {
let output = run_cli_capture("lpoptions", &["-p", name, "-l"]).await?;
let mut options = PrinterOptions::default();
for line in output.lines() {
let Some((key, values)) = line.split_once(':') else {
continue;
};
let key = key.split('/').next().unwrap_or(key).trim();
let parsed: Vec<(String, bool)> = values
.split_whitespace()
.map(|value| {
(
value.trim_start_matches('*').to_string(),
value.starts_with('*'),
)
})
.collect();
let (list, default) = match key {
"PageSize" => (&mut options.media, &mut options.media_default),
"MediaType" => (&mut options.media_types, &mut options.media_type_default),
"ColorModel" => (&mut options.color_models, &mut options.color_model_default),
"cupsPrintQuality" => (&mut options.qualities, &mut options.quality_default),
"Duplex" => (&mut options.sides, &mut options.sides_default),
_ => continue,
};
for (value, selected) in parsed {
// Custom.WIDTHxHEIGHT placeholders are not selectable values.
if value.starts_with("Custom.") {
continue;
}
if selected {
*default = Some(value.clone());
}
list.push(value);
}
}
Ok(options)
}
/// Set a queue-wide default job option (PageSize, cupsPrintQuality, Duplex…).
pub async fn set_default_option(printer: &str, option: &str, value: &str) -> Result<(), String> {
run_cli(
"lpadmin",
&["-p", printer, "-o", &format!("{option}={value}")],
)
.await
}
/// A consumable reported by the printer through the IPP marker attributes.
#[derive(Clone, Debug)]
pub struct Marker {
pub name: String,
/// 0-100; negative values mean unknown.
pub level: i32,
/// "#RRGGBB" when the printer advertises a color.
pub color: Option<String>,
}
/// Query ink levels directly from the printer.
///
/// The scheduler does not relay marker levels for driverless queues, so the
/// printer's IPP endpoint is resolved (dnssd -> ippfind) and queried with
/// ipptool. Returns an empty list when the printer does not report markers.
pub async fn markers(device_uri: &str) -> Vec<Marker> {
let Some(endpoint) = resolve_endpoint(device_uri).await else {
return Vec::new();
};
let names = query_marker_attribute(&endpoint, "marker-names").await;
let raw_levels = query_marker_attribute(&endpoint, "marker-levels").await;
let colors = query_marker_attribute(&endpoint, "marker-colors").await;
let levels: Vec<i32> = raw_levels
.iter()
.filter_map(|value| value.trim().parse::<i32>().ok())
.collect();
if levels.is_empty() {
return Vec::new();
}
(0..levels.len())
.map(|index| Marker {
name: names
.get(index)
.cloned()
.unwrap_or_else(|| format!("marker {}", index + 1)),
level: levels[index],
color: colors.get(index).cloned().filter(|value| value.starts_with('#')),
})
.collect()
}
/// Resolve a device URI into a queryable IPP endpoint.
async fn resolve_endpoint(device_uri: &str) -> Option<String> {
if device_uri.starts_with("ipp://") || device_uri.starts_with("ipps://") {
return Some(device_uri.to_string());
}
let rest = device_uri.strip_prefix("dnssd://")?;
let name = rest.split(['/', '?']).next()?;
let name = percent_decode(name.split("._").next()?);
let output = run_cli_capture(
"timeout",
&["8", "ippfind", "_ipp._tcp", "_ipps._tcp", "-N", &name, "-p"],
)
.await
.ok()?;
let mut fallback = None;
for line in output.lines() {
let line = line.trim();
if line.starts_with("ipps://") {
return Some(line.to_string());
}
if fallback.is_none() && line.starts_with("ipp://") {
fallback = Some(line.to_string());
}
}
fallback
}
/// Run one ipptool Get-Printer-Attributes query and return the displayed
/// values of a single marker attribute.
async fn query_marker_attribute(endpoint: &str, attribute: &str) -> Vec<String> {
let test_file = std::env::temp_dir().join(format!("cosmic-settings-printers-{attribute}.test"));
let content = format!(
"{{\n\
\x20 NAME \"{attribute}\"\n\
\x20 OPERATION Get-Printer-Attributes\n\
\x20 GROUP operation-attributes-tag\n\
\x20 ATTR charset attributes-charset utf-8\n\
\x20 ATTR naturalLanguage attributes-natural-language en\n\
\x20 ATTR uri printer-uri $uri\n\
\x20 ATTR keyword requested-attributes {attribute}\n\
\x20 STATUS successful-ok\n\
\x20 DISPLAY {attribute}\n\
}}\n"
);
if tokio::fs::write(&test_file, content).await.is_err() {
return Vec::new();
}
let Some(file_path) = test_file.to_str() else {
return Vec::new();
};
let Ok(output) =
run_cli_capture("timeout", &["8", "ipptool", endpoint, file_path]).await
else {
return Vec::new();
};
parse_display_values(&output).unwrap_or_default()
}
/// Extract the value cell of a single-DISPLAY ipptool table: the line
/// following the dashes separator, split on commas.
fn parse_display_values(output: &str) -> Option<Vec<String>> {
let mut lines = output.lines();
while let Some(line) = lines.next() {
if !line.trim().is_empty() && line.trim().chars().all(|c| c == '-') {
let values = lines.next()?.trim();
return Some(values.split(',').map(|value| value.trim().to_string()).collect());
}
}
None
}
pub async fn set_default(name: &str) -> Result<(), String> {
run_cli("lpadmin", &["-d", name]).await
}
pub async fn set_enabled(name: &str, enabled: bool) -> Result<(), String> {
if enabled {
run_cli("cupsenable", &[name]).await
} else {
run_cli("cupsdisable", &[name]).await
}
}
/// Sanitize a display name into a valid CUPS queue name.
///
/// No deduplication: `lpadmin` modifies the queue when the name already
/// exists, which also promotes the temporary queues CUPS auto-creates for
/// discovered devices into permanent ones.
pub fn queue_name(raw: &str) -> String {
let mut sanitized = String::with_capacity(raw.len());
for c in raw.chars() {
if c.is_alphanumeric() {
sanitized.push(c);
} else if matches!(c, '[' | ']' | '(' | ')') {
// Brackets usually wrap a model identifier; drop them so
// "series [32C998]" becomes "series_32C998", not "series__32C998_".
continue;
} else {
sanitized.push('_');
}
}
let base = sanitized
.split('_')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("_");
if base.is_empty() {
"Printer".to_string()
} else {
base
}
}
async fn run_cli(program: &str, args: &[&str]) -> Result<(), String> {
let output = run_cli_capture(program, args).await?;
drop(output);
Ok(())
}
async fn run_cli_capture(program: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.output()
.await
.map_err(|err| format!("{program}: {err}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
return Ok(stdout);
}
// Ignore deprecation notices lpadmin prints on stderr for PPD drivers.
let message = stderr
.lines()
.filter(|line| !line.contains("deprecated"))
.collect::<Vec<_>>()
.join(" ");
Err(if message.trim().is_empty() {
format!("{program} {}", args.join(" "))
} else {
message
})
}
fn device_info(uri: &str) -> String {
if let Some(rest) = uri.strip_prefix("dnssd://") {
let name = rest.split(['/', '?']).next().unwrap_or(rest);
let name = name.split("._").next().unwrap_or(name);
let decoded = percent_decode(name);
if !decoded.is_empty() {
return decoded;
}
}
if let Some(rest) = uri.strip_prefix("usb://") {
let path = rest.split('?').next().unwrap_or(rest);
let decoded = percent_decode(path).replace('/', " ");
if !decoded.trim().is_empty() {
return decoded;
}
}
if uri.starts_with("cups-pdf") {
return "PDF".to_string();
}
// ipp/ipps/socket/lpd/http(s) URIs: keep the host label and drop the
// dnssd-style service suffix ("Name._ipp._tcp.local" -> "Name").
if let Some((_, rest)) = uri.split_once("://")
&& let Some(host) = rest.split(['/', '?']).next()
{
let decoded = percent_decode(host);
let label = decoded.split("._").next().unwrap_or(&decoded);
let label = label.trim_end_matches(".local");
if !label.is_empty() && !label.chars().all(|c| c.is_ascii_digit() || c == '.' || c == ':') {
return label.to_string();
}
}
percent_decode(uri)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hex = &input[index + 1..index + 3];
if let Ok(value) = u8::from_str_radix(hex, 16) {
decoded.push(value);
index += 3;
continue;
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).into_owned()
}