cpdb_rs/client.rs
1//! Async CPDB client.
2//!
3//! [`CpdbClient`] discovers all CPDB backends on the D-Bus session bus
4//! and provides methods for printer enumeration, capability querying,
5//! and job submission.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use cpdb_rs::CpdbClient;
11//!
12//! # async fn example() -> cpdb_rs::Result<()> {
13//! let client = CpdbClient::new().await?;
14//! let printers = client.get_all_printers().await?;
15//! for p in &printers {
16//! println!("{} [{}]", p.name, p.id);
17//! }
18//! # Ok(())
19//! # }
20//! ```
21
22use std::collections::HashMap;
23use std::pin::Pin;
24use std::task::{Context, Poll};
25use std::time::Duration;
26
27use futures_util::{Stream, StreamExt, stream::SelectAll};
28use zbus::zvariant::OwnedFd;
29
30use crate::error::{CpdbError, Result};
31use crate::events::{DiscoveryEvent, PrinterSnapshot};
32use crate::media::MediaCollection;
33use crate::options::OptionsCollection;
34use crate::proxy::PrintBackendProxy;
35use crate::types::PrinterState;
36
37/// The inactivity window after which a CPDB backend process auto-exits.
38///
39/// Backends terminate themselves after this long with no D-Bus activity.
40/// Users of [`CpdbClient::discovery_stream`] don't need to think about
41/// this — the returned stream spawns a keep-alive task that pings at
42/// `BACKEND_INACTIVITY_TIMEOUT / 3`. Exposed for callers that drive
43/// their own keep-alive loops (see the `discover_printers` example).
44pub const BACKEND_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(30);
45
46const RETRY_INTERVAL_MS: Duration = Duration::from_millis(200);
47
48type RawPrinterTuple = (String, String, String, String, String, bool, String, String);
49
50/// Retries a D-Bus proxy call once if the backend returns `UnknownMethod`.
51///
52/// This handles the activation race where systemd has started the backend
53/// process but it hasn't registered its methods yet. The retry is safe for
54/// all call sites because `UnknownMethod` guarantees no side-effect occurred
55/// (no job was created, no state was mutated). Arguments are evaluated twice
56/// on retry, which is fine since they are all side-effect-free references.
57macro_rules! retry_dbus {
58 ($proxy:expr, $method:ident($($arg:expr),*)) => {{
59 let __proxy = &($proxy);
60 let mut __result = __proxy.$method($($arg),*).await;
61 if let Err(zbus::Error::MethodError(ref __n, _, _)) = __result {
62 if __n.as_str() == "org.freedesktop.DBus.Error.UnknownMethod" {
63 tokio::time::sleep(RETRY_INTERVAL_MS).await;
64 __result = __proxy.$method($($arg),*).await;
65 }
66 }
67 __result
68 }};
69}
70
71/// A connected CPDB client managing proxies to all discovered print backends.
72///
73/// Created via [`CpdbClient::new()`]. The client is [`Clone`]-able - cloning
74/// shares the underlying D-Bus connection.
75///
76/// # Usage
77///
78/// ```rust,no_run
79/// # use cpdb_rs::CpdbClient;
80/// # async fn example() -> cpdb_rs::Result<()> {
81/// let client = CpdbClient::new().await?;
82///
83/// // Initial population
84/// let printers = client.get_all_printers().await?;
85///
86/// // Fetch capabilities when the user selects a printer
87/// let (options, media) = client.get_printer_details(&printers[0].id, "CUPS").await?;
88///
89/// // Submit a print job
90/// let settings = [("copies", "1"), ("media", "iso_a4_210x297mm")];
91/// let (job_id, fd) = client.print_fd(&printers[0].id, "CUPS", &settings, "My Doc").await?;
92/// # Ok(()) }
93/// ```
94#[derive(Clone)]
95pub struct CpdbClient {
96 backends: Vec<BackendHandle>,
97}
98
99/// Internal handle for a single backend (e.g. CUPS).
100#[derive(Clone)]
101struct BackendHandle {
102 /// Full D-Bus service name, e.g. `"org.openprinting.Backend.CUPS"`.
103 service_name: String,
104 /// The zbus-generated proxy for this backend's PrintBackend interface.
105 proxy: PrintBackendProxy<'static>,
106}
107
108impl CpdbClient {
109 /// Connect to the D-Bus session bus and discover all CPDB backends.
110 ///
111 /// 1. Opens a session bus connection.
112 /// 2. Calls `ListActivatableNames` to find `org.openprinting.Backend.*` services.
113 /// 3. Creates a [`PrintBackendProxy`] for each discovered backend.
114 ///
115 /// Backends that fail to connect are logged and skipped.
116 ///
117 /// # Errors
118 ///
119 /// Returns [`CpdbError::DbusError`] if the session bus itself is unavailable.
120 pub async fn new() -> Result<Self> {
121 let connection = zbus::Connection::session().await.map_err(CpdbError::from)?;
122
123 let dbus = zbus::fdo::DBusProxy::new(&connection)
124 .await
125 .map_err(CpdbError::from)?;
126 let names = dbus
127 .list_activatable_names()
128 .await
129 .map_err(CpdbError::from)?;
130
131 let backend_names: Vec<String> = names
132 .iter()
133 .filter(|n| n.starts_with("org.openprinting.Backend."))
134 .map(|n| n.to_string())
135 .collect();
136
137 let mut backends = Vec::new();
138 for name in &backend_names {
139 let bus_name = match zbus::names::BusName::try_from(name.clone()) {
140 Ok(n) => n,
141 Err(_) => continue,
142 };
143 match PrintBackendProxy::builder(&connection)
144 .destination(bus_name)?
145 .path("/")?
146 .build()
147 .await
148 {
149 Ok(proxy) => {
150 backends.push(BackendHandle {
151 service_name: name.clone(),
152 proxy,
153 });
154 }
155 Err(e) => {
156 log::warn!("cpdb-rs: skipping backend {}: {}", name, e);
157 }
158 }
159 }
160
161 Ok(Self { backends })
162 }
163
164 /// Returns the number of connected backends.
165 pub fn backend_count(&self) -> usize {
166 self.backends.len()
167 }
168
169 /// Fetches all known printers from all connected backends.
170 ///
171 /// This is the **initial population** method - equivalent to what the C
172 /// library's `fetchPrinterListFromBackend()` does. It calls `GetAllPrinters`
173 /// on each backend and unpacks the variant-wrapped printer data into
174 /// [`PrinterSnapshot`]s.
175 ///
176 /// Use this to populate the printer list.
177 /// Use [`discovery_stream()`](Self::discovery_stream) for live updates after that.
178 ///
179 /// # Errors
180 ///
181 /// Returns errors if a D-Bus call fails. Backends that fail individually
182 /// are skipped, and printers from working backends are still returned.
183 pub async fn get_all_printers(&self) -> Result<Vec<PrinterSnapshot>> {
184 let mut printers = Vec::new();
185
186 for bh in &self.backends {
187 let result = retry_dbus!(bh.proxy, get_all_printers());
188
189 let (_count, raw_printers) = match result {
190 Ok(v) => v,
191 Err(e) => {
192 log::error!(
193 "cpdb-rs: error fetching printers from {}: {}",
194 bh.service_name,
195 e
196 );
197 continue;
198 }
199 };
200
201 for val in raw_printers {
202 let tuple: std::result::Result<RawPrinterTuple, zbus::zvariant::Error> =
203 val.0.try_into();
204 let raw = match tuple {
205 Ok(r) => r,
206 Err(e) => {
207 log::error!("cpdb-rs: error unpacking printer variant: {}", e);
208 continue;
209 }
210 };
211
212 printers.push(PrinterSnapshot {
213 id: raw.0,
214 name: raw.1,
215 info: raw.2,
216 location: raw.3,
217 make_model: raw.4,
218 accepting_jobs: raw.5,
219 state: PrinterState::from(raw.6),
220 backend: raw.7,
221 });
222 }
223 }
224
225 Ok(printers)
226 }
227
228 /// Like [`get_all_printers()`](Self::get_all_printers) but returns only
229 /// printers matching the current filter state.
230 ///
231 /// Call [`show_remote_printers(false)`](Self::show_remote_printers) or
232 /// [`show_temporary_printers(false)`](Self::show_temporary_printers)
233 /// first to set the filter, then call this to get the filtered list.
234 pub async fn get_filtered_printers(&self) -> Result<Vec<PrinterSnapshot>> {
235 let mut printers = Vec::new();
236
237 for bh in &self.backends {
238 let result = retry_dbus!(bh.proxy, get_filtered_printer_list());
239
240 let (_count, raw_printers) = match result {
241 Ok(v) => v,
242 Err(e) => {
243 log::error!(
244 "cpdb-rs: error fetching filtered printers from {}: {}",
245 bh.service_name,
246 e
247 );
248 continue;
249 }
250 };
251
252 for val in raw_printers {
253 let tuple: std::result::Result<RawPrinterTuple, zbus::zvariant::Error> =
254 val.0.try_into();
255 let raw = match tuple {
256 Ok(r) => r,
257 Err(e) => {
258 log::error!("cpdb-rs: error unpacking filtered printer variant: {}", e);
259 continue;
260 }
261 };
262
263 printers.push(PrinterSnapshot {
264 id: raw.0,
265 name: raw.1,
266 info: raw.2,
267 location: raw.3,
268 make_model: raw.4,
269 accepting_jobs: raw.5,
270 state: PrinterState::from(raw.6),
271 backend: raw.7,
272 });
273 }
274 }
275
276 Ok(printers)
277 }
278
279 /// Returns a merged stream of [`DiscoveryEvent`]s from all backends.
280 ///
281 /// The stream emits events as printers are added, removed, or change
282 /// state. After subscribing to signals, it calls `doListing(true)` on
283 /// each backend to trigger initial `PrinterAdded` emissions.
284 ///
285 /// A background task is automatically spawned to ping the backends
286 /// periodically, preventing them from timing out. When this stream is
287 /// dropped, the keep-alive task is cancelled.
288 ///
289 /// # Errors
290 ///
291 /// Returns [`CpdbError::DbusError`] if subscribing to D-Bus signals fails.
292 pub async fn discovery_stream(&self) -> Result<DiscoveryStream> {
293 let mut all: SelectAll<futures_util::stream::BoxStream<'static, DiscoveryEvent>> =
294 SelectAll::new();
295
296 for bh in &self.backends {
297 // Subscribe to PrinterAdded signals
298 let added = bh
299 .proxy
300 .receive_printer_added()
301 .await
302 .map_err(CpdbError::from)?;
303 all.push(
304 added
305 .filter_map(|sig| async move {
306 match sig.args() {
307 Ok(a) => Some(DiscoveryEvent::PrinterAdded(PrinterSnapshot {
308 id: a.printer_id.to_string(),
309 name: a.printer_name.to_string(),
310 info: a.printer_info.to_string(),
311 location: a.printer_location.to_string(),
312 make_model: a.printer_make_and_model.to_string(),
313 accepting_jobs: a.printer_is_accepting_jobs,
314 state: PrinterState::from(a.printer_state),
315 backend: a.backend_name.to_string(),
316 })),
317 Err(e) => {
318 log::debug!("cpdb-rs: failed to parse PrinterAdded signal: {}", e);
319 None
320 }
321 }
322 })
323 .boxed(),
324 );
325
326 // Subscribe to PrinterRemoved signals
327 let removed = bh
328 .proxy
329 .receive_printer_removed()
330 .await
331 .map_err(CpdbError::from)?;
332 all.push(
333 removed
334 .filter_map(|sig| async move {
335 match sig.args() {
336 Ok(a) => Some(DiscoveryEvent::PrinterRemoved {
337 id: a.printer_id.to_string(),
338 backend: a.backend_name.to_string(),
339 }),
340 Err(e) => {
341 log::debug!(
342 "cpdb-rs: failed to parse PrinterRemoved signal: {}",
343 e
344 );
345 None
346 }
347 }
348 })
349 .boxed(),
350 );
351
352 // Subscribe to PrinterStateChanged signals
353 let changed = bh
354 .proxy
355 .receive_printer_state_changed()
356 .await
357 .map_err(CpdbError::from)?;
358 all.push(
359 changed
360 .filter_map(|sig| async move {
361 match sig.args() {
362 Ok(a) => Some(DiscoveryEvent::PrinterStateChanged {
363 id: a.printer_id.to_string(),
364 backend: a.backend_name.to_string(),
365 state: PrinterState::from(a.printer_state),
366 accepting_jobs: a.printer_is_accepting_jobs,
367 }),
368 Err(e) => {
369 log::debug!(
370 "cpdb-rs: failed to parse PrinterStateChanged signal: {}",
371 e
372 );
373 None
374 }
375 }
376 })
377 .boxed(),
378 );
379 }
380
381 for bh in &self.backends {
382 if let Err(e) = bh.proxy.do_listing(true).await {
383 log::warn!(
384 "cpdb-rs: do_listing failed for {}, discovery stream may be empty: {}",
385 bh.service_name,
386 e
387 );
388 }
389 }
390
391 let client = self.clone();
392 let keep_alive_task = tokio::spawn(async move {
393 // Ping at 1/3 of the backend timeout so a single delayed tick
394 // doesn't let the backend exit. /2 was too tight — a stalled
395 // runtime task could miss the window entirely.
396 let ping_interval = Duration::from_secs(BACKEND_INACTIVITY_TIMEOUT.as_secs() / 3);
397 loop {
398 tokio::time::sleep(ping_interval).await;
399 client.keep_alive_all().await;
400 }
401 });
402
403 Ok(DiscoveryStream {
404 inner: all,
405 keep_alive_task,
406 })
407 }
408
409 /// Keeps all backends alive to prevent them from auto-exiting.
410 ///
411 /// CPDB backends automatically exit after a period of inactivity (typically 30 seconds).
412 /// If you are listening to a `discovery_stream()`, you should call this method
413 /// periodically (e.g. every 10 seconds) to ensure the backends stay running
414 /// and continue emitting discovery signals.
415 pub async fn keep_alive_all(&self) {
416 for bh in &self.backends {
417 let _ = bh.proxy.keep_alive().await;
418 }
419 }
420
421 /// Fetches all options and media for a printer in a single D-Bus call.
422 ///
423 /// This calls the backend's `GetAllOptions` method, which returns both
424 /// the printer's capabilities (duplex, color mode, etc.) and its
425 /// supported paper sizes with margin information.
426 ///
427 /// # Arguments
428 ///
429 /// * `printer_id` - The printer's unique ID (from [`PrinterSnapshot::id`]).
430 /// * `backend` - The backend name, e.g. `"CUPS"` or the full service
431 /// name `"org.openprinting.Backend.CUPS"`.
432 ///
433 /// # Errors
434 ///
435 /// * [`CpdbError::BackendError`] if no backend matches `backend`.
436 /// * [`CpdbError::DbusError`] if the D-Bus call fails.
437 pub async fn get_printer_details(
438 &self,
439 printer_id: &str,
440 backend: &str,
441 ) -> Result<(OptionsCollection, MediaCollection)> {
442 let proxy = self.proxy_for(backend)?;
443 let (_n_opts, raw_opts, _n_media, raw_media) =
444 retry_dbus!(proxy, get_all_options(printer_id)).map_err(CpdbError::from)?;
445 Ok((
446 OptionsCollection::from_dbus(raw_opts),
447 MediaCollection::from_dbus(raw_media),
448 ))
449 }
450
451 /// Fetches localized labels for a printer's options and choices.
452 ///
453 /// Returns a map of internal name -> human-readable label, e.g.
454 /// `{"sides" -> "Two-Sided", "one-sided" -> "Off", ...}`.
455 ///
456 /// # Arguments
457 ///
458 /// * `printer_id` - The printer's unique ID.
459 /// * `backend` - The backend name.
460 /// * `locale` - A POSIX locale string, e.g. `"en_US"` or `"de_DE"`.
461 pub async fn get_translations(
462 &self,
463 printer_id: &str,
464 backend: &str,
465 locale: &str,
466 ) -> Result<HashMap<String, String>> {
467 let proxy = self.proxy_for(backend)?;
468 retry_dbus!(proxy, get_all_translations(printer_id, locale)).map_err(CpdbError::from)
469 }
470
471 /// Returns the default printer ID for a specific backend.
472 pub async fn get_default_printer(&self, backend: &str) -> Result<String> {
473 let proxy = self.proxy_for(backend)?;
474 retry_dbus!(proxy, get_default_printer()).map_err(CpdbError::from)
475 }
476
477 /// Submits a print job and returns a writable file descriptor.
478 ///
479 /// The backend creates a CUPS job and returns the write end of a
480 /// socketpair. The caller writes the document data into `fd` and
481 /// closes it when done - the backend reads from the other end and
482 /// forwards it to the print system.
483 ///
484 /// # Arguments
485 ///
486 /// * `printer_id` - The printer's unique ID.
487 /// * `backend` - The backend name.
488 /// * `settings` - Print settings as key-value pairs, e.g.
489 /// `[("copies", "2"), ("media", "iso_a4_210x297mm")]`.
490 /// * `title` - The job title shown in the print queue.
491 ///
492 /// # Returns
493 ///
494 /// A tuple of `(job_id, fd)` where `job_id` is the CUPS job ID
495 /// string and `fd` is the writable end of the socketpair.
496 pub async fn print_fd(
497 &self,
498 printer_id: &str,
499 backend: &str,
500 settings: &[(&str, &str)],
501 title: &str,
502 ) -> Result<(String, OwnedFd)> {
503 let proxy = self.proxy_for(backend)?;
504 let (job_id, fd) = retry_dbus!(
505 proxy,
506 print_fd(printer_id, settings.len() as i32, settings, title)
507 )
508 .map_err(CpdbError::from)?;
509 Ok((job_id, fd))
510 }
511
512 /// Submits a print job and returns a Unix domain socket path to write the document to.
513 ///
514 /// The caller must connect to the returned socket path and write the document
515 /// data, closing the stream when finished.
516 pub async fn print_socket(
517 &self,
518 printer_id: &str,
519 backend: &str,
520 settings: &[(&str, &str)],
521 title: &str,
522 ) -> Result<(String, String)> {
523 let proxy = self.proxy_for(backend)?;
524 let (job_id, socket_path) = retry_dbus!(
525 proxy,
526 print_socket(printer_id, settings.len() as i32, settings, title)
527 )
528 .map_err(CpdbError::from)?;
529 Ok((job_id, socket_path))
530 }
531
532 /// Sets the visibility of remote printers on all connected backends.
533 ///
534 /// When `visible` is `false`, printers discovered via DNS-SD / mDNS
535 /// on remote hosts are hidden from discovery signals.
536 pub async fn show_remote_printers(&self, visible: bool) {
537 for b in &self.backends {
538 let _ = b.proxy.show_remote_printers(visible).await;
539 }
540 }
541
542 /// Sets the visibility of temporary (auto-discovered) printers on
543 /// all connected backends.
544 pub async fn show_temporary_printers(&self, visible: bool) {
545 for b in &self.backends {
546 let _ = b.proxy.show_temporary_printers(visible).await;
547 }
548 }
549
550 /// Finds the proxy for a backend by name.
551 ///
552 /// Accepts either a short name (`"CUPS"`) or a full D-Bus service
553 /// name (`"org.openprinting.Backend.CUPS"`).
554 fn proxy_for(&self, backend: &str) -> Result<&PrintBackendProxy<'static>> {
555 self.backends
556 .iter()
557 .find(|b| {
558 if b.service_name == backend {
559 return true;
560 }
561 if let Some(idx) = b.service_name.rfind('.') {
562 &b.service_name[idx + 1..] == backend
563 } else {
564 false
565 }
566 })
567 .map(|b| &b.proxy)
568 .ok_or_else(|| {
569 CpdbError::BackendError(format!("No backend found matching '{}'", backend))
570 })
571 }
572}
573
574/// A stream of live discovery events that keeps connected backends alive.
575pub struct DiscoveryStream {
576 inner: SelectAll<futures_util::stream::BoxStream<'static, DiscoveryEvent>>,
577 keep_alive_task: tokio::task::JoinHandle<()>,
578}
579
580impl Stream for DiscoveryStream {
581 type Item = DiscoveryEvent;
582
583 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
584 Pin::new(&mut self.inner).poll_next(cx)
585 }
586}
587
588impl Drop for DiscoveryStream {
589 fn drop(&mut self) {
590 self.keep_alive_task.abort();
591 }
592}