Skip to main content

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}