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.
536 lines
17 KiB
Rust
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()
|
|
}
|