feat(printers): page native de gestion des imprimantes (cups_rs + CUPS CLI)

Nouvelle page Imprimantes: liste et etat des files CUPS, ajout (decouverte lpinfo, pilote driverless IPP Everywhere par defaut), suppression, imprimante par defaut, suspension/reprise, page de test et file des travaux avec annulation. Lectures via cups_rs (FFI libcups serialisee), mutations via lpadmin/cupsenable. i18n en/fr, sous-commande 'cosmic-settings printers'.
This commit is contained in:
Lionel DARNIS 2026-08-08 10:24:38 +02:00
parent f9b27c8d4d
commit 26dcaa6fd3
9 changed files with 1280 additions and 16 deletions

127
Cargo.lock generated
View file

@ -278,7 +278,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -289,7 +289,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -648,7 +648,7 @@ dependencies = [
"anyhow", "anyhow",
"arrayvec", "arrayvec",
"log", "log",
"nom", "nom 8.0.0",
"num-rational", "num-rational",
"v_frame", "v_frame",
] ]
@ -692,6 +692,26 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 2.13.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.3",
"shlex 1.3.0",
"syn 2.0.118",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@ -1008,6 +1028,15 @@ dependencies = [
"shlex 2.0.1", "shlex 2.0.1",
] ]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]] [[package]]
name = "cfb" name = "cfb"
version = "0.7.3" version = "0.7.3"
@ -1080,6 +1109,17 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@ -1511,6 +1551,7 @@ dependencies = [
"cosmic-settings-sound", "cosmic-settings-sound",
"cosmic-settings-upower-subscription", "cosmic-settings-upower-subscription",
"cosmic-settings-wallpaper", "cosmic-settings-wallpaper",
"cups_rs",
"derive_setters", "derive_setters",
"dirs", "dirs",
"eyre", "eyre",
@ -1527,7 +1568,7 @@ dependencies = [
"image", "image",
"indexmap 2.14.0", "indexmap 2.14.0",
"intmap", "intmap",
"itertools", "itertools 0.14.0",
"itoa", "itoa",
"jiff", "jiff",
"libcosmic-yoda", "libcosmic-yoda",
@ -1907,6 +1948,19 @@ dependencies = [
"uncased", "uncased",
] ]
[[package]]
name = "cups_rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ad179868ddceb8948487316c7094aa251e3efb0ecf15cab88b08f22eabf724"
dependencies = [
"bindgen",
"chrono",
"libc",
"pkg-config",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "cursor-icon" name = "cursor-icon"
version = "1.2.0" version = "1.2.0"
@ -2122,7 +2176,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -2265,7 +2319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -2595,7 +2649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6d3a3635983a889f065aa9ce760384713f23a9b4a04f696f86c39a5d7a6a5a" checksum = "fc6d3a3635983a889f065aa9ce760384713f23a9b4a04f696f86c39a5d7a6a5a"
dependencies = [ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"nom", "nom 8.0.0",
] ]
[[package]] [[package]]
@ -2821,6 +2875,12 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.16.0" version = "0.16.0"
@ -3988,6 +4048,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@ -4363,7 +4432,7 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -4801,6 +4870,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -4944,6 +5019,16 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@ -5001,7 +5086,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -5749,6 +5834,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.118",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -6003,7 +6098,7 @@ dependencies = [
"built", "built",
"cfg-if", "cfg-if",
"interpolate_name", "interpolate_name",
"itertools", "itertools 0.14.0",
"libc", "libc",
"libfuzzer-sys", "libfuzzer-sys",
"log", "log",
@ -6361,7 +6456,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -6830,7 +6925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -7064,10 +7159,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -7453,7 +7548,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -8123,7 +8218,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View file

@ -36,6 +36,7 @@ cosmic-settings-sound = { path = "../pages/sound", optional = true }
cosmic-settings-upower-subscription = { path = "../subscriptions/upower", optional = true } cosmic-settings-upower-subscription = { path = "../subscriptions/upower", optional = true }
cosmic-settings-wallpaper = { path = "../pages/wallpapers" } cosmic-settings-wallpaper = { path = "../pages/wallpapers" }
cosmic-settings-daemon-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } cosmic-settings-daemon-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true }
cups_rs = { version = "0.3.0", optional = true }
derive_setters = "0.1.9" derive_setters = "0.1.9"
dirs = "6.0.0" dirs = "6.0.0"
eyre = "0.6.12" eyre = "0.6.12"
@ -130,6 +131,7 @@ linux = [
"page-legacy-applications", "page-legacy-applications",
"page-networking", "page-networking",
"page-power", "page-power",
"page-printers",
"page-region", "page-region",
"page-sound", "page-sound",
"page-users", "page-users",
@ -174,6 +176,7 @@ page-networking = [
"dep:zbus", "dep:zbus",
] ]
page-power = ["dep:upower_dbus", "dep:zbus"] page-power = ["dep:upower_dbus", "dep:zbus"]
page-printers = ["dep:cups_rs"]
page-region = [ page-region = [
"gettext", "gettext",
"dep:locales-rs", "dep:locales-rs",

View file

@ -16,6 +16,8 @@ use crate::pages::input;
use crate::pages::networking; use crate::pages::networking;
#[cfg(feature = "page-power")] #[cfg(feature = "page-power")]
use crate::pages::power; use crate::pages::power;
#[cfg(feature = "page-printers")]
use crate::pages::printers;
#[cfg(feature = "page-sound")] #[cfg(feature = "page-sound")]
use crate::pages::sound; use crate::pages::sound;
use crate::pages::{self, applications, system, time}; use crate::pages::{self, applications, system, time};
@ -116,6 +118,8 @@ impl SettingsApp {
} }
#[cfg(feature = "page-power")] #[cfg(feature = "page-power")]
PageCommands::Power => self.pages.page_id::<power::Page>(), PageCommands::Power => self.pages.page_id::<power::Page>(),
#[cfg(feature = "page-printers")]
PageCommands::Printers => self.pages.page_id::<printers::Page>(),
#[cfg(feature = "page-region")] #[cfg(feature = "page-region")]
PageCommands::RegionLanguage => self.pages.page_id::<time::region::Page>(), PageCommands::RegionLanguage => self.pages.page_id::<time::region::Page>(),
#[cfg(feature = "page-sound")] #[cfg(feature = "page-sound")]
@ -225,6 +229,8 @@ impl cosmic::Application for SettingsApp {
app.insert_page::<power::Page>(); app.insert_page::<power::Page>();
#[cfg(feature = "page-input")] #[cfg(feature = "page-input")]
app.insert_page::<input::Page>(); app.insert_page::<input::Page>();
#[cfg(feature = "page-printers")]
app.insert_page::<printers::Page>();
app.insert_page::<applications::Page>(); app.insert_page::<applications::Page>();
app.insert_page::<time::Page>(); app.insert_page::<time::Page>();
app.insert_page::<system::Page>(); app.insert_page::<system::Page>();
@ -654,6 +660,13 @@ impl cosmic::Application for SettingsApp {
} }
} }
#[cfg(feature = "page-printers")]
crate::pages::Message::Printers(message) => {
if let Some(page) = self.pages.page_mut::<printers::Page>() {
return page.update(message).map(Into::into);
}
}
#[cfg(feature = "page-networking")] #[cfg(feature = "page-networking")]
crate::pages::Message::Vpn(message) => { crate::pages::Message::Vpn(message) => {
if let Some(page) = self.pages.page_mut::<networking::vpn::Page>() { if let Some(page) = self.pages.page_mut::<networking::vpn::Page>() {

View file

@ -122,6 +122,9 @@ pub enum PageCommands {
/// Power settings page /// Power settings page
#[cfg(feature = "page-power")] #[cfg(feature = "page-power")]
Power, Power,
/// Printers settings page
#[cfg(feature = "page-printers")]
Printers,
/// Region & Language settings page /// Region & Language settings page
#[cfg(feature = "page-region")] #[cfg(feature = "page-region")]
RegionLanguage, RegionLanguage,

View file

@ -17,6 +17,8 @@ pub mod input;
pub mod networking; pub mod networking;
#[cfg(feature = "page-power")] #[cfg(feature = "page-power")]
pub mod power; pub mod power;
#[cfg(feature = "page-printers")]
pub mod printers;
#[cfg(feature = "page-sound")] #[cfg(feature = "page-sound")]
pub mod sound; pub mod sound;
pub mod system; pub mod system;
@ -82,6 +84,8 @@ pub enum Message {
PanelApplet(desktop::panel::applets_inner::Message), PanelApplet(desktop::panel::applets_inner::Message),
#[cfg(feature = "page-power")] #[cfg(feature = "page-power")]
Power(power::Message), Power(power::Message),
#[cfg(feature = "page-printers")]
Printers(printers::Message),
#[cfg(feature = "page-region")] #[cfg(feature = "page-region")]
Region(time::region::Message), Region(time::region::Message),
#[cfg(feature = "page-sound")] #[cfg(feature = "page-sound")]

View file

@ -0,0 +1,285 @@
// 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";
/// 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()
.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 fn print_test_page_sync(printer: &str) -> Result<(), String> {
let _guard = cups_lock();
let dest = cups_rs::get_destination(printer).map_err(|err| err.to_string())?;
let job = cups_rs::create_job(&dest, "COSMIC Settings test page")
.map_err(|err| err.to_string())?;
job.submit_file(TEST_PAGE, "application/postscript")
.map_err(|err| err.to_string())
}
/// Discovery and administration (CUPS command line tools).
pub async fn devices() -> Result<Vec<Device>, String> {
let output = run_cli_capture("lpinfo", &["-v"]).await?;
Ok(output
.lines()
.filter_map(|line| {
let (_, uri) = line.split_once(char::is_whitespace)?;
let uri = uri.trim();
uri.contains("://").then(|| Device {
info: device_info(uri),
uri: uri.to_string(),
})
})
.collect())
}
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> {
run_cli("lpadmin", &["-x", name]).await
}
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 and make it unique
/// against the current printer list.
pub fn unique_name(raw: &str, existing: &[Printer]) -> String {
let sanitized: String = raw
.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
let base = if sanitized.is_empty() {
"Printer".to_string()
} else {
sanitized
};
let mut candidate = base.clone();
let mut index = 2;
while existing.iter().any(|printer| printer.name == candidate) {
candidate = format!("{base}_{index}");
index += 1;
}
candidate
}
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();
}
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()
}

View file

@ -0,0 +1,772 @@
// Copyright 2026 Lionel DARNIS <lionel@wopr.io>
// SPDX-License-Identifier: GPL-3.0-only
//! Printers page: manage CUPS queues, the default printer, and print jobs.
mod backend;
use cosmic::iced::{Alignment, Length, Subscription};
use cosmic::widget::{self, text};
use cosmic::{Apply, Element, Task, theme};
use cosmic_settings_page::{self as page, Section, section};
use slotmap::SlotMap;
use std::time::Duration;
use backend::{JobStatus, PrinterState};
const DRIVERLESS: &str = "everywhere";
const DRIVER_LIST_LIMIT: usize = 200;
const REFRESH_EVERY: Duration = Duration::from_secs(8);
#[derive(Default)]
struct AddFlow {
active: bool,
loading: bool,
submitting: bool,
devices: Vec<backend::Device>,
selected_device: Option<usize>,
name: String,
drivers: Vec<backend::Driver>,
driver_filter: String,
selected_driver: String,
error: Option<String>,
}
pub enum Dialog {
RemovePrinter { name: String },
}
#[derive(Default)]
pub struct Page {
entity: page::Entity,
printers: Vec<backend::Printer>,
jobs: Vec<backend::Job>,
selected: Option<String>,
notice: Option<String>,
dialog: Option<Dialog>,
add: AddFlow,
}
#[derive(Clone, Debug)]
pub enum Message {
Reload,
Printers(Result<Vec<backend::Printer>, String>),
Jobs(Result<Vec<backend::Job>, String>),
Select(Option<String>),
SetDefault(String),
ToggleEnabled(String, bool),
PrintTestPage(String),
Remove(String),
ConfirmRemove,
CancelDialog,
CancelJob(i32),
OperationDone(Result<(), String>),
OpenAdd,
Devices(Result<Vec<backend::Device>, String>),
Drivers(Result<Vec<backend::Driver>, String>),
SelectDevice(usize),
NameInput(String),
DriverFilter(String),
SelectDriver(String),
SubmitAdd,
AddDone(Result<String, String>),
}
impl From<Message> for crate::pages::Message {
fn from(message: Message) -> Self {
crate::pages::Message::Printers(message)
}
}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> page::Info {
page::Info::new("printers", "printer-symbolic")
.title(fl!("printers"))
.description(fl!("xdg-entry-printers-comment"))
}
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![
sections.insert(printers_section()),
sections.insert(selected_printer_section()),
sections.insert(jobs_section()),
])
}
fn set_id(&mut self, entity: page::Entity) {
self.entity = entity;
}
fn on_enter(&mut self) -> Task<crate::pages::Message> {
self.reload()
}
fn on_context_drawer_close(&mut self) -> Task<crate::pages::Message> {
self.add = AddFlow::default();
Task::none()
}
fn dialog(&self) -> Option<Element<'_, crate::pages::Message>> {
let Dialog::RemovePrinter { name } = self.dialog.as_ref()?;
let confirm = widget::button::destructive(fl!("printers-remove"))
.on_press(crate::pages::Message::from(Message::ConfirmRemove));
let cancel = widget::button::standard(fl!("cancel"))
.on_press(crate::pages::Message::from(Message::CancelDialog));
Some(
widget::dialog()
.title(fl!("printers-remove-confirm"))
.body(fl!(
"printers-remove-confirm-description",
name = name.to_string()
))
.primary_action(confirm)
.secondary_action(cancel)
.apply(Element::from),
)
}
fn context_drawer(&self) -> Option<cosmic::app::ContextDrawer<'_, crate::pages::Message>> {
if !self.add.active {
return None;
}
Some(
cosmic::app::context_drawer(
self.add_view().map(crate::pages::Message::Printers),
crate::pages::Message::CloseContextDrawer,
)
.title(fl!("printers-add")),
)
}
fn subscription(&self, _core: &cosmic::Core) -> Subscription<crate::pages::Message> {
cosmic::iced::time::every(REFRESH_EVERY)
.map(|_| crate::pages::Message::Printers(Message::Reload))
}
}
impl Page {
pub fn update(&mut self, message: Message) -> cosmic::Task<crate::Message> {
match message {
Message::Reload => return self.reload().map(Into::into),
Message::Printers(Ok(printers)) => {
if let Some(selected) = self.selected.as_deref()
&& !printers.iter().any(|printer| printer.name == selected)
{
self.selected = None;
}
self.printers = printers;
}
Message::Printers(Err(err)) | Message::Jobs(Err(err)) => {
self.notice = Some(fl!("printers-error", error = err));
}
Message::Jobs(Ok(jobs)) => {
self.jobs = jobs;
}
Message::Select(name) => {
self.selected = name;
}
Message::SetDefault(name) => {
self.notice = None;
return Self::admin(async move { backend::set_default(&name).await });
}
Message::ToggleEnabled(name, enabled) => {
self.notice = None;
return Self::admin(async move { backend::set_enabled(&name, enabled).await });
}
Message::PrintTestPage(name) => {
self.notice = None;
return cosmic::task::future(async move {
Message::OperationDone(tokio::task::spawn_blocking(move || {
backend::print_test_page_sync(&name)
})
.await
.unwrap_or_else(|err| Err(err.to_string())))
})
.map(crate::pages::Message::Printers)
.map(Into::into);
}
Message::Remove(name) => {
self.dialog = Some(Dialog::RemovePrinter { name });
}
Message::CancelDialog => {
self.dialog = None;
}
Message::ConfirmRemove => {
self.dialog = None;
if let Some(name) = self.selected_printer_name() {
return Self::admin(async move { backend::remove_printer(&name).await });
}
}
Message::CancelJob(id) => {
return cosmic::task::future(async move {
Message::OperationDone(tokio::task::spawn_blocking(move || {
backend::cancel_job_sync(id)
})
.await
.unwrap_or_else(|err| Err(err.to_string())))
})
.map(crate::pages::Message::Printers)
.map(Into::into);
}
Message::OperationDone(result) => {
if let Err(err) = result {
self.notice = Some(fl!("printers-error", error = err));
}
return self.reload().map(Into::into);
}
Message::OpenAdd => {
self.add = AddFlow {
active: true,
loading: true,
selected_driver: DRIVERLESS.to_string(),
..Default::default()
};
let open_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
crate::app::Message::OpenContextDrawer(self.entity),
);
return Task::batch([
cosmic::task::future(async { Message::Devices(backend::devices().await) })
.map(crate::pages::Message::Printers)
.map(Into::into),
cosmic::task::future(async { Message::Drivers(backend::drivers().await) })
.map(crate::pages::Message::Printers)
.map(Into::into),
open_drawer,
]);
}
Message::Devices(Ok(devices)) => {
self.add.devices = devices;
self.add.loading = false;
}
Message::Devices(Err(err)) => {
self.add.loading = false;
self.add.error = Some(err);
}
Message::Drivers(Ok(drivers)) => {
self.add.drivers = drivers;
}
Message::Drivers(Err(err)) => {
self.add.error = Some(err);
}
Message::SelectDevice(index) => {
if let Some(device) = self.add.devices.get(index) {
self.add.name = backend::unique_name(&device.info, &self.printers);
self.add.selected_device = Some(index);
}
}
Message::NameInput(value) => {
self.add.name = value;
}
Message::DriverFilter(value) => {
self.add.driver_filter = value;
}
Message::SelectDriver(id) => {
self.add.selected_driver = id;
}
Message::SubmitAdd => {
let Some(device) = self
.add
.selected_device
.and_then(|index| self.add.devices.get(index))
else {
return Task::none();
};
let name = backend::unique_name(self.add.name.trim(), &self.printers);
let uri = device.uri.clone();
let driver = self.add.selected_driver.clone();
self.add.submitting = true;
self.add.error = None;
return cosmic::task::future(async move {
Message::AddDone(backend::add_printer(&name, &uri, &driver).await.map(|_| name))
})
.map(crate::pages::Message::Printers)
.map(Into::into);
}
Message::AddDone(result) => match result {
Ok(name) => {
self.add = AddFlow::default();
self.selected = Some(name.clone());
self.notice = Some(fl!("printers-added", name = name));
let close_drawer: cosmic::Task<crate::Message> = cosmic::task::message(
crate::Message::from(crate::pages::Message::CloseContextDrawer),
);
return Task::batch([self.reload().map(Into::into), close_drawer]);
}
Err(err) => {
self.add.submitting = false;
self.add.error = Some(err);
}
},
}
Task::none()
}
fn selected_printer_name(&self) -> Option<String> {
// Removal is confirmed from the dialog; operate on the selected printer.
match self.dialog.as_ref() {
Some(Dialog::RemovePrinter { name }) => Some(name.clone()),
None => self.selected.clone(),
}
}
fn reload(&self) -> Task<crate::pages::Message> {
Task::batch([
cosmic::task::future(async {
Message::Printers(
tokio::task::spawn_blocking(backend::printers_sync)
.await
.unwrap_or_else(|err| Err(err.to_string())),
)
})
.map(crate::pages::Message::Printers),
cosmic::task::future(async {
Message::Jobs(
tokio::task::spawn_blocking(backend::jobs_sync)
.await
.unwrap_or_else(|err| Err(err.to_string())),
)
})
.map(crate::pages::Message::Printers),
])
}
fn admin(
operation: impl std::future::Future<Output = Result<(), String>> + Send + 'static,
) -> cosmic::Task<crate::Message> {
cosmic::task::future(async move { Message::OperationDone(operation.await) })
.map(crate::pages::Message::Printers)
.map(Into::into)
}
fn add_view(&self) -> Element<'_, Message> {
let spacing = theme::spacing();
let mut content = widget::column::with_children(Vec::new()).spacing(spacing.space_s);
content = content.push(text::body(fl!("printers-device")));
let mut devices = widget::list_column();
if self.add.loading {
devices = devices.add(text::caption(fl!("printers-loading")));
} else if self.add.devices.is_empty() {
devices = devices.add(text::caption(fl!("printers-no-devices")));
} else {
for (index, device) in self.add.devices.iter().enumerate() {
let selected = self.add.selected_device == Some(index);
let control: Element<'_, Message> = if selected {
widget::icon::from_name("object-select-symbolic")
.size(16)
.into()
} else {
widget::space::horizontal()
.width(Length::Fixed(16.0))
.into()
};
devices = devices.add(
widget::list::button(
widget::settings::item::builder(device.info.to_string())
.description(device.uri.to_string())
.control(control),
)
.on_press(Message::SelectDevice(index)),
);
}
}
content = content.push(widget::scrollable(devices).height(Length::Fixed(220.0)));
content = content.push(
widget::text_input("", &self.add.name)
.label(fl!("printers-name"))
.on_input(Message::NameInput),
);
content = content.push(text::body(fl!("printers-driver")));
content = content.push(
widget::search_input("", &self.add.driver_filter)
.on_input(Message::DriverFilter)
.on_clear(Message::DriverFilter(String::new())),
);
let filter = self.add.driver_filter.to_lowercase();
let mut drivers = widget::list_column();
let mut shown = 0usize;
for driver in self.add.drivers.iter().filter(|driver| {
filter.is_empty()
|| driver.description.to_lowercase().contains(&filter)
|| driver.id.to_lowercase().contains(&filter)
}) {
let selected = self.add.selected_driver == driver.id;
let control: Element<'_, Message> = if selected {
widget::icon::from_name("object-select-symbolic").size(16).into()
} else {
widget::space::horizontal()
.width(Length::Fixed(16.0))
.into()
};
drivers = drivers.add(
widget::list::button(
widget::settings::item::builder(driver.description.to_string())
.description(driver.id.to_string())
.control(control),
)
.on_press(Message::SelectDriver(driver.id.clone())),
);
shown += 1;
if shown == DRIVER_LIST_LIMIT {
break;
}
}
if shown == 0 {
drivers = drivers.add(text::caption(fl!("printers-no-matching-drivers")));
}
content = content.push(widget::scrollable(drivers).height(Length::Fixed(260.0)));
if let Some(error) = self.add.error.as_deref() {
content = content.push(text::caption(error.to_string()));
}
let can_submit = self.add.selected_device.is_some()
&& !self.add.name.trim().is_empty()
&& !self.add.selected_driver.is_empty()
&& !self.add.submitting;
let label = if self.add.submitting {
fl!("printers-add-in-progress")
} else {
fl!("printers-submit")
};
content = content.push(
widget::row::with_children(vec![
widget::space::horizontal().apply(Element::from),
widget::button::suggested(label)
.on_press_maybe(can_submit.then_some(Message::SubmitAdd))
.apply(Element::from),
])
.spacing(spacing.space_s),
);
content.into()
}
}
fn state_label(state: PrinterState, descriptions: &slab::Slab<String>, keys: &StateKeys) -> String {
match state {
PrinterState::Idle => descriptions[keys.idle].clone(),
PrinterState::Processing => descriptions[keys.processing].clone(),
PrinterState::Stopped => descriptions[keys.stopped].clone(),
PrinterState::Unknown => descriptions[keys.unknown].clone(),
}
}
struct StateKeys {
idle: usize,
processing: usize,
stopped: usize,
unknown: usize,
}
fn printer_icon(printer: &backend::Printer) -> &'static str {
match printer.state {
PrinterState::Processing => "printer-printing-symbolic",
PrinterState::Stopped => "printer-error-symbolic",
_ => "printer-symbolic",
}
}
fn printers_section() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
add = fl!("printers-add");
default = fl!("printers-default");
make_default = fl!("printers-make-default");
no_printers = fl!("printers-no-printers");
not_accepting = fl!("printers-not-accepting");
idle = fl!("printers-state-idle");
processing = fl!("printers-state-processing");
stopped = fl!("printers-state-stopped");
unknown = fl!("printers-state-unknown");
});
let keys = StateKeys {
idle,
processing,
stopped,
unknown,
};
Section::default()
.title(fl!("printers"))
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let mut list = widget::list_column();
if let Some(notice) = page.notice.as_deref() {
list = list.add(text::caption(notice.to_string()));
}
if page.printers.is_empty() {
list = list.add(text::body(descriptions[no_printers].clone()));
}
for printer in &page.printers {
let is_selected = page.selected.as_deref() == Some(printer.name.as_str());
let mut parts: Vec<String> = Vec::new();
if let Some(model) = printer.make_and_model.as_deref()
&& !model.is_empty()
{
parts.push(model.to_string());
}
parts.push(state_label(printer.state, descriptions, &keys));
if !printer.accepting_jobs {
parts.push(descriptions[not_accepting].clone());
}
let control: Element<'_, crate::pages::Message> = if printer.is_default {
widget::button::suggested(descriptions[default].clone())
.apply(Element::from)
} else {
widget::button::link(descriptions[make_default].clone())
.on_press(Message::SetDefault(printer.name.clone()).into())
.apply(Element::from)
};
let row = widget::settings::item::builder(printer.info.to_string())
.description(parts.join(" · "))
.icon(widget::icon::from_name(printer_icon(printer)).size(24))
.control(control);
let message = if is_selected {
Message::Select(None)
} else {
Message::Select(Some(printer.name.clone()))
};
list = list.add(widget::list::button(row).on_press(message.into()));
}
list = list.add(
widget::row::with_children(vec![
widget::space::horizontal().apply(Element::from),
widget::button::suggested(descriptions[add].clone())
.on_press(Message::OpenAdd.into())
.apply(Element::from),
])
.spacing(theme::spacing().space_s),
);
list.apply(Element::from)
})
}
fn selected_printer_section() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
make_default = fl!("printers-make-default");
pause = fl!("printers-pause");
resume = fl!("printers-resume");
remove = fl!("printers-remove");
test_page = fl!("printers-test-page");
idle = fl!("printers-state-idle");
processing = fl!("printers-state-processing");
stopped = fl!("printers-state-stopped");
unknown = fl!("printers-state-unknown");
});
let keys = StateKeys {
idle,
processing,
stopped,
unknown,
};
Section::default()
.title(fl!("printers-details"))
.descriptions(descriptions)
.show_while::<Page>(|page| page.selected.is_some())
.view::<Page>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let Some(printer) = page
.selected
.as_deref()
.and_then(|name| page.printers.iter().find(|p| p.name == name))
else {
return Element::from(widget::column::with_children(Vec::new()));
};
let spacing = theme::spacing();
let mut column = widget::list_column();
column = column.add(
widget::settings::flex_item(
fl!("printers-state"),
text::body(state_label(printer.state, descriptions, &keys)),
)
.align_items(Alignment::Center),
);
if let Some(location) = printer.location.as_deref()
&& !location.is_empty()
{
column = column.add(
widget::settings::flex_item(fl!("printers-location"), text::body(location.to_string()))
.align_items(Alignment::Center),
);
}
if let Some(uri) = printer.device_uri.as_deref() {
column = column.add(
widget::settings::flex_item(fl!("printers-device-uri"), text::body(uri.to_string()))
.align_items(Alignment::Center),
);
}
if !printer.state_reasons.is_empty() {
column = column.add(
widget::settings::flex_item(
fl!("printers-state-reasons"),
text::body(printer.state_reasons.join(", ")),
)
.align_items(Alignment::Center),
);
}
let name = printer.name.clone();
let mut actions: Vec<Element<'_, crate::pages::Message>> = Vec::new();
if !printer.is_default {
actions.push(
widget::button::standard(descriptions[make_default].clone())
.on_press(Message::SetDefault(name.clone()).into())
.apply(Element::from),
);
}
let paused = printer.state == PrinterState::Stopped;
actions.push(if paused {
widget::button::standard(descriptions[resume].clone())
.on_press(Message::ToggleEnabled(name.clone(), true).into())
.apply(Element::from)
} else {
widget::button::standard(descriptions[pause].clone())
.on_press(Message::ToggleEnabled(name.clone(), false).into())
.apply(Element::from)
});
actions.push(
widget::button::standard(descriptions[test_page].clone())
.on_press(Message::PrintTestPage(name.clone()).into())
.apply(Element::from),
);
actions.push(widget::space::horizontal().apply(Element::from));
actions.push(
widget::button::destructive(descriptions[remove].clone())
.on_press(Message::Remove(name).into())
.apply(Element::from),
);
column = column.add(widget::row::with_children(actions).spacing(spacing.space_s));
column.apply(Element::from)
})
}
fn job_status_label(status: JobStatus) -> String {
match status {
JobStatus::Pending => fl!("printers-job-pending"),
JobStatus::Processing => fl!("printers-job-processing"),
JobStatus::Held => fl!("printers-job-held"),
JobStatus::Stopped => fl!("printers-job-stopped"),
JobStatus::Completed => fl!("printers-job-completed"),
JobStatus::Canceled => fl!("printers-job-canceled"),
JobStatus::Aborted => fl!("printers-job-aborted"),
JobStatus::Unknown => fl!("printers-job-unknown"),
}
}
fn jobs_section() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
cancel_job = fl!("printers-cancel-job");
});
Section::default()
.title(fl!("printers-jobs"))
.descriptions(descriptions)
.show_while::<Page>(|page| !page.jobs.is_empty())
.view::<Page>(move |_binder, page, section| {
let descriptions = &section.descriptions;
let mut list = widget::list_column();
for job in &page.jobs {
let cancel_button = widget::icon::from_name("window-close-symbolic")
.apply(widget::button::icon)
.on_press(Message::CancelJob(job.id).into());
let title = if job.title.is_empty() {
format!("#{}", job.id)
} else {
job.title.clone()
};
list = list.add(
widget::settings::item::builder(title)
.description(format!("{} · {}", job.dest, job_status_label(job.status)))
.control(widget::tooltip::tooltip(
cancel_button,
text::caption(descriptions[cancel_job].clone()),
widget::tooltip::Position::Top,
)),
);
}
list.apply(Element::from)
})
}
impl page::AutoBind<crate::pages::Message> for Page {}

View file

@ -642,6 +642,50 @@ sound-usb-audio = USB Audio
# Profiles for sound card devices # Profiles for sound card devices
sound-device-profiles = Device profiles sound-device-profiles = Device profiles
# Printers settings page
printers = Printers
xdg-entry-printers-comment = Manage printers and print jobs
printers-add = Add printer
printers-add-in-progress = Adding printer…
printers-added = Printer “{ $name }” added
printers-cancel-job = Cancel job
printers-default = Default
printers-details = Printer details
printers-device = Device
printers-device-uri = Device URI
printers-driver = Driver
printers-error = Operation failed: { $error }
printers-job-aborted = Aborted
printers-job-canceled = Canceled
printers-job-completed = Completed
printers-job-held = Held
printers-job-pending = Pending
printers-job-processing = Printing
printers-job-stopped = Stopped
printers-job-unknown = Unknown
printers-jobs = Print jobs
printers-loading = Searching for devices…
printers-location = Location
printers-make-default = Set as default
printers-name = Name
printers-no-devices = No devices found
printers-no-matching-drivers = No matching drivers
printers-no-printers = No printers configured
printers-not-accepting = Not accepting jobs
printers-pause = Pause
printers-remove = Remove printer
printers-remove-confirm = Remove this printer?
printers-remove-confirm-description = “{ $name }” will be removed from this system. Print jobs queued on it will be discarded.
printers-resume = Resume
printers-state = State
printers-state-idle = Idle
printers-state-processing = Printing
printers-state-reasons = State reasons
printers-state-stopped = Paused
printers-state-unknown = Unknown
printers-submit = Add
printers-test-page = Print test page
# Power & Battery settings page # Power & Battery settings page
power = Power & battery power = Power & battery

View file

@ -429,6 +429,51 @@ sound-alerts = Alertes
sound-applications = Applications sound-applications = Applications
.desc = Volumes et paramètres d'application .desc = Volumes et paramètres d'application
## Printers
printers = Imprimantes
xdg-entry-printers-comment = Gérer les imprimantes et les travaux d'impression
printers-add = Ajouter une imprimante
printers-add-in-progress = Ajout de l'imprimante…
printers-added = Imprimante « { $name } » ajoutée
printers-cancel-job = Annuler le travail
printers-default = Par défaut
printers-details = Détails de l'imprimante
printers-device = Périphérique
printers-device-uri = URI du périphérique
printers-driver = Pilote
printers-error = Échec de l'opération : { $error }
printers-job-aborted = Interrompu
printers-job-canceled = Annulé
printers-job-completed = Terminé
printers-job-held = Retenu
printers-job-pending = En attente
printers-job-processing = Impression en cours
printers-job-stopped = Arrêté
printers-job-unknown = Inconnu
printers-jobs = Travaux d'impression
printers-loading = Recherche des périphériques…
printers-location = Emplacement
printers-make-default = Définir par défaut
printers-name = Nom
printers-no-devices = Aucun périphérique trouvé
printers-no-matching-drivers = Aucun pilote ne correspond
printers-no-printers = Aucune imprimante configurée
printers-not-accepting = N'accepte pas les travaux
printers-pause = Suspendre
printers-remove = Supprimer l'imprimante
printers-remove-confirm = Supprimer cette imprimante ?
printers-remove-confirm-description = « { $name } » sera supprimée du système. Les travaux en attente sur cette imprimante seront abandonnés.
printers-resume = Reprendre
printers-state = État
printers-state-idle = Disponible
printers-state-processing = Impression en cours
printers-state-reasons = Causes de l'état
printers-state-stopped = En pause
printers-state-unknown = Inconnu
printers-submit = Ajouter
printers-test-page = Imprimer une page de test
## Power ## Power
power = Alimentation power = Alimentation