cpdb-rs 0.1.1

Safe Rust bindings for OpenPrinting cpdb-libs — Common Print Dialog Backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
//! Async CPDB client.
//!
//! [`CpdbClient`] discovers all CPDB backends on the D-Bus session bus
//! and provides methods for printer enumeration, capability querying,
//! and job submission.
//!
//! # Example
//!
//! ```rust,no_run
//! use cpdb_rs::CpdbClient;
//!
//! # async fn example() -> cpdb_rs::Result<()> {
//! let client = CpdbClient::new().await?;
//! let printers = client.get_all_printers().await?;
//! for p in &printers {
//!     println!("{} [{}]", p.name, p.id);
//! }
//! # Ok(())
//! # }
//! ```

use std::collections::HashMap;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;

use futures_util::{Stream, StreamExt, stream::SelectAll};
use zbus::zvariant::OwnedFd;

use crate::error::{CpdbError, Result};
use crate::events::{DiscoveryEvent, PrinterSnapshot};
use crate::media::MediaCollection;
use crate::options::OptionsCollection;
use crate::proxy::PrintBackendProxy;
use crate::types::PrinterState;

/// The inactivity window after which a CPDB backend process auto-exits.
///
/// Backends terminate themselves after this long with no D-Bus activity.
/// Users of [`CpdbClient::discovery_stream`] don't need to think about
/// this — the returned stream spawns a keep-alive task that pings at
/// `BACKEND_INACTIVITY_TIMEOUT / 3`. Exposed for callers that drive
/// their own keep-alive loops (see the `discover_printers` example).
pub const BACKEND_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(30);

const RETRY_INTERVAL_MS: Duration = Duration::from_millis(200);

type RawPrinterTuple = (String, String, String, String, String, bool, String, String);

/// Retries a D-Bus proxy call once if the backend returns `UnknownMethod`.
///
/// This handles the activation race where systemd has started the backend
/// process but it hasn't registered its methods yet. The retry is safe for
/// all call sites because `UnknownMethod` guarantees no side-effect occurred
/// (no job was created, no state was mutated). Arguments are evaluated twice
/// on retry, which is fine since they are all side-effect-free references.
macro_rules! retry_dbus {
    ($proxy:expr, $method:ident($($arg:expr),*)) => {{
        let __proxy = &($proxy);
        let mut __result = __proxy.$method($($arg),*).await;
        if let Err(zbus::Error::MethodError(ref __n, _, _)) = __result {
            if __n.as_str() == "org.freedesktop.DBus.Error.UnknownMethod" {
                tokio::time::sleep(RETRY_INTERVAL_MS).await;
                __result = __proxy.$method($($arg),*).await;
            }
        }
        __result
    }};
}

/// A connected CPDB client managing proxies to all discovered print backends.
///
/// Created via [`CpdbClient::new()`]. The client is [`Clone`]-able - cloning
/// shares the underlying D-Bus connection.
///
/// # Usage
///
/// ```rust,no_run
/// # use cpdb_rs::CpdbClient;
/// # async fn example() -> cpdb_rs::Result<()> {
/// let client = CpdbClient::new().await?;
///
/// // Initial population
/// let printers = client.get_all_printers().await?;
///
/// // Fetch capabilities when the user selects a printer
/// let (options, media) = client.get_printer_details(&printers[0].id, "CUPS").await?;
///
/// // Submit a print job
/// let settings = [("copies", "1"), ("media", "iso_a4_210x297mm")];
/// let (job_id, fd) = client.print_fd(&printers[0].id, "CUPS", &settings, "My Doc").await?;
/// # Ok(()) }
/// ```
#[derive(Clone)]
pub struct CpdbClient {
    backends: Vec<BackendHandle>,
}

/// Internal handle for a single backend (e.g. CUPS).
#[derive(Clone)]
struct BackendHandle {
    /// Full D-Bus service name, e.g. `"org.openprinting.Backend.CUPS"`.
    service_name: String,
    /// The zbus-generated proxy for this backend's PrintBackend interface.
    proxy: PrintBackendProxy<'static>,
}

impl CpdbClient {
    /// Connect to the D-Bus session bus and discover all CPDB backends.
    ///
    /// 1. Opens a session bus connection.
    /// 2. Calls `ListActivatableNames` to find `org.openprinting.Backend.*` services.
    /// 3. Creates a [`PrintBackendProxy`] for each discovered backend.
    ///
    /// Backends that fail to connect are logged and skipped.
    ///
    /// # Errors
    ///
    /// Returns [`CpdbError::DbusError`] if the session bus itself is unavailable.
    pub async fn new() -> Result<Self> {
        let connection = zbus::Connection::session().await.map_err(CpdbError::from)?;

        let dbus = zbus::fdo::DBusProxy::new(&connection)
            .await
            .map_err(CpdbError::from)?;
        let names = dbus
            .list_activatable_names()
            .await
            .map_err(CpdbError::from)?;

        let backend_names: Vec<String> = names
            .iter()
            .filter(|n| n.starts_with("org.openprinting.Backend."))
            .map(|n| n.to_string())
            .collect();

        let mut backends = Vec::new();
        for name in &backend_names {
            let bus_name = match zbus::names::BusName::try_from(name.clone()) {
                Ok(n) => n,
                Err(_) => continue,
            };
            match PrintBackendProxy::builder(&connection)
                .destination(bus_name)?
                .path("/")?
                .build()
                .await
            {
                Ok(proxy) => {
                    backends.push(BackendHandle {
                        service_name: name.clone(),
                        proxy,
                    });
                }
                Err(e) => {
                    log::warn!("cpdb-rs: skipping backend {}: {}", name, e);
                }
            }
        }

        Ok(Self { backends })
    }

    /// Returns the number of connected backends.
    pub fn backend_count(&self) -> usize {
        self.backends.len()
    }

    /// Fetches all known printers from all connected backends.
    ///
    /// This is the **initial population** method - equivalent to what the C
    /// library's `fetchPrinterListFromBackend()` does. It calls `GetAllPrinters`
    /// on each backend and unpacks the variant-wrapped printer data into
    /// [`PrinterSnapshot`]s.
    ///
    /// Use this to populate the printer list.
    /// Use [`discovery_stream()`](Self::discovery_stream) for live updates after that.
    ///
    /// # Errors
    ///
    /// Returns errors if a D-Bus call fails. Backends that fail individually
    /// are skipped, and printers from working backends are still returned.
    pub async fn get_all_printers(&self) -> Result<Vec<PrinterSnapshot>> {
        let mut printers = Vec::new();

        for bh in &self.backends {
            let result = retry_dbus!(bh.proxy, get_all_printers());

            let (_count, raw_printers) = match result {
                Ok(v) => v,
                Err(e) => {
                    log::error!(
                        "cpdb-rs: error fetching printers from {}: {}",
                        bh.service_name,
                        e
                    );
                    continue;
                }
            };

            for val in raw_printers {
                let tuple: std::result::Result<RawPrinterTuple, zbus::zvariant::Error> =
                    val.0.try_into();
                let raw = match tuple {
                    Ok(r) => r,
                    Err(e) => {
                        log::error!("cpdb-rs: error unpacking printer variant: {}", e);
                        continue;
                    }
                };

                printers.push(PrinterSnapshot {
                    id: raw.0,
                    name: raw.1,
                    info: raw.2,
                    location: raw.3,
                    make_model: raw.4,
                    accepting_jobs: raw.5,
                    state: PrinterState::from(raw.6),
                    backend: raw.7,
                });
            }
        }

        Ok(printers)
    }

    /// Like [`get_all_printers()`](Self::get_all_printers) but returns only
    /// printers matching the current filter state.
    ///
    /// Call [`show_remote_printers(false)`](Self::show_remote_printers) or
    /// [`show_temporary_printers(false)`](Self::show_temporary_printers)
    /// first to set the filter, then call this to get the filtered list.
    pub async fn get_filtered_printers(&self) -> Result<Vec<PrinterSnapshot>> {
        let mut printers = Vec::new();

        for bh in &self.backends {
            let result = retry_dbus!(bh.proxy, get_filtered_printer_list());

            let (_count, raw_printers) = match result {
                Ok(v) => v,
                Err(e) => {
                    log::error!(
                        "cpdb-rs: error fetching filtered printers from {}: {}",
                        bh.service_name,
                        e
                    );
                    continue;
                }
            };

            for val in raw_printers {
                let tuple: std::result::Result<RawPrinterTuple, zbus::zvariant::Error> =
                    val.0.try_into();
                let raw = match tuple {
                    Ok(r) => r,
                    Err(e) => {
                        log::error!("cpdb-rs: error unpacking filtered printer variant: {}", e);
                        continue;
                    }
                };

                printers.push(PrinterSnapshot {
                    id: raw.0,
                    name: raw.1,
                    info: raw.2,
                    location: raw.3,
                    make_model: raw.4,
                    accepting_jobs: raw.5,
                    state: PrinterState::from(raw.6),
                    backend: raw.7,
                });
            }
        }

        Ok(printers)
    }

    /// Returns a merged stream of [`DiscoveryEvent`]s from all backends.
    ///
    /// The stream emits events as printers are added, removed, or change
    /// state. After subscribing to signals, it calls `doListing(true)` on
    /// each backend to trigger initial `PrinterAdded` emissions.
    ///
    /// A background task is automatically spawned to ping the backends
    /// periodically, preventing them from timing out. When this stream is
    /// dropped, the keep-alive task is cancelled.
    ///
    /// # Errors
    ///
    /// Returns [`CpdbError::DbusError`] if subscribing to D-Bus signals fails.
    pub async fn discovery_stream(&self) -> Result<DiscoveryStream> {
        let mut all: SelectAll<futures_util::stream::BoxStream<'static, DiscoveryEvent>> =
            SelectAll::new();

        for bh in &self.backends {
            // Subscribe to PrinterAdded signals
            let added = bh
                .proxy
                .receive_printer_added()
                .await
                .map_err(CpdbError::from)?;
            all.push(
                added
                    .filter_map(|sig| async move {
                        match sig.args() {
                            Ok(a) => Some(DiscoveryEvent::PrinterAdded(PrinterSnapshot {
                                id: a.printer_id.to_string(),
                                name: a.printer_name.to_string(),
                                info: a.printer_info.to_string(),
                                location: a.printer_location.to_string(),
                                make_model: a.printer_make_and_model.to_string(),
                                accepting_jobs: a.printer_is_accepting_jobs,
                                state: PrinterState::from(a.printer_state),
                                backend: a.backend_name.to_string(),
                            })),
                            Err(e) => {
                                log::debug!("cpdb-rs: failed to parse PrinterAdded signal: {}", e);
                                None
                            }
                        }
                    })
                    .boxed(),
            );

            // Subscribe to PrinterRemoved signals
            let removed = bh
                .proxy
                .receive_printer_removed()
                .await
                .map_err(CpdbError::from)?;
            all.push(
                removed
                    .filter_map(|sig| async move {
                        match sig.args() {
                            Ok(a) => Some(DiscoveryEvent::PrinterRemoved {
                                id: a.printer_id.to_string(),
                                backend: a.backend_name.to_string(),
                            }),
                            Err(e) => {
                                log::debug!(
                                    "cpdb-rs: failed to parse PrinterRemoved signal: {}",
                                    e
                                );
                                None
                            }
                        }
                    })
                    .boxed(),
            );

            // Subscribe to PrinterStateChanged signals
            let changed = bh
                .proxy
                .receive_printer_state_changed()
                .await
                .map_err(CpdbError::from)?;
            all.push(
                changed
                    .filter_map(|sig| async move {
                        match sig.args() {
                            Ok(a) => Some(DiscoveryEvent::PrinterStateChanged {
                                id: a.printer_id.to_string(),
                                backend: a.backend_name.to_string(),
                                state: PrinterState::from(a.printer_state),
                                accepting_jobs: a.printer_is_accepting_jobs,
                            }),
                            Err(e) => {
                                log::debug!(
                                    "cpdb-rs: failed to parse PrinterStateChanged signal: {}",
                                    e
                                );
                                None
                            }
                        }
                    })
                    .boxed(),
            );
        }

        for bh in &self.backends {
            if let Err(e) = bh.proxy.do_listing(true).await {
                log::warn!(
                    "cpdb-rs: do_listing failed for {}, discovery stream may be empty: {}",
                    bh.service_name,
                    e
                );
            }
        }

        let client = self.clone();
        let keep_alive_task = tokio::spawn(async move {
            // Ping at 1/3 of the backend timeout so a single delayed tick
            // doesn't let the backend exit. /2 was too tight — a stalled
            // runtime task could miss the window entirely.
            let ping_interval = Duration::from_secs(BACKEND_INACTIVITY_TIMEOUT.as_secs() / 3);
            loop {
                tokio::time::sleep(ping_interval).await;
                client.keep_alive_all().await;
            }
        });

        Ok(DiscoveryStream {
            inner: all,
            keep_alive_task,
        })
    }

    /// Keeps all backends alive to prevent them from auto-exiting.
    ///
    /// CPDB backends automatically exit after a period of inactivity (typically 30 seconds).
    /// If you are listening to a `discovery_stream()`, you should call this method
    /// periodically (e.g. every 10 seconds) to ensure the backends stay running
    /// and continue emitting discovery signals.
    pub async fn keep_alive_all(&self) {
        for bh in &self.backends {
            let _ = bh.proxy.keep_alive().await;
        }
    }

    /// Fetches all options and media for a printer in a single D-Bus call.
    ///
    /// This calls the backend's `GetAllOptions` method, which returns both
    /// the printer's capabilities (duplex, color mode, etc.) and its
    /// supported paper sizes with margin information.
    ///
    /// # Arguments
    ///
    /// * `printer_id` - The printer's unique ID (from [`PrinterSnapshot::id`]).
    /// * `backend` - The backend name, e.g. `"CUPS"` or the full service
    ///   name `"org.openprinting.Backend.CUPS"`.
    ///
    /// # Errors
    ///
    /// * [`CpdbError::BackendError`] if no backend matches `backend`.
    /// * [`CpdbError::DbusError`] if the D-Bus call fails.
    pub async fn get_printer_details(
        &self,
        printer_id: &str,
        backend: &str,
    ) -> Result<(OptionsCollection, MediaCollection)> {
        let proxy = self.proxy_for(backend)?;
        let (_n_opts, raw_opts, _n_media, raw_media) =
            retry_dbus!(proxy, get_all_options(printer_id)).map_err(CpdbError::from)?;
        Ok((
            OptionsCollection::from_dbus(raw_opts),
            MediaCollection::from_dbus(raw_media),
        ))
    }

    /// Fetches localized labels for a printer's options and choices.
    ///
    /// Returns a map of internal name -> human-readable label, e.g.
    /// `{"sides" -> "Two-Sided", "one-sided" -> "Off", ...}`.
    ///
    /// # Arguments
    ///
    /// * `printer_id` - The printer's unique ID.
    /// * `backend` - The backend name.
    /// * `locale` - A POSIX locale string, e.g. `"en_US"` or `"de_DE"`.
    pub async fn get_translations(
        &self,
        printer_id: &str,
        backend: &str,
        locale: &str,
    ) -> Result<HashMap<String, String>> {
        let proxy = self.proxy_for(backend)?;
        retry_dbus!(proxy, get_all_translations(printer_id, locale)).map_err(CpdbError::from)
    }

    /// Returns the default printer ID for a specific backend.
    pub async fn get_default_printer(&self, backend: &str) -> Result<String> {
        let proxy = self.proxy_for(backend)?;
        retry_dbus!(proxy, get_default_printer()).map_err(CpdbError::from)
    }

    /// Submits a print job and returns a writable file descriptor.
    ///
    /// The backend creates a CUPS job and returns the write end of a
    /// socketpair. The caller writes the document data into `fd` and
    /// closes it when done - the backend reads from the other end and
    /// forwards it to the print system.
    ///
    /// # Arguments
    ///
    /// * `printer_id` - The printer's unique ID.
    /// * `backend` - The backend name.
    /// * `settings` - Print settings as key-value pairs, e.g.
    ///   `[("copies", "2"), ("media", "iso_a4_210x297mm")]`.
    /// * `title` - The job title shown in the print queue.
    ///
    /// # Returns
    ///
    /// A tuple of `(job_id, fd)` where `job_id` is the CUPS job ID
    /// string and `fd` is the writable end of the socketpair.
    pub async fn print_fd(
        &self,
        printer_id: &str,
        backend: &str,
        settings: &[(&str, &str)],
        title: &str,
    ) -> Result<(String, OwnedFd)> {
        let proxy = self.proxy_for(backend)?;
        let (job_id, fd) = retry_dbus!(
            proxy,
            print_fd(printer_id, settings.len() as i32, settings, title)
        )
        .map_err(CpdbError::from)?;
        Ok((job_id, fd))
    }

    /// Submits a print job and returns a Unix domain socket path to write the document to.
    ///
    /// The caller must connect to the returned socket path and write the document
    /// data, closing the stream when finished.
    pub async fn print_socket(
        &self,
        printer_id: &str,
        backend: &str,
        settings: &[(&str, &str)],
        title: &str,
    ) -> Result<(String, String)> {
        let proxy = self.proxy_for(backend)?;
        let (job_id, socket_path) = retry_dbus!(
            proxy,
            print_socket(printer_id, settings.len() as i32, settings, title)
        )
        .map_err(CpdbError::from)?;
        Ok((job_id, socket_path))
    }

    /// Sets the visibility of remote printers on all connected backends.
    ///
    /// When `visible` is `false`, printers discovered via DNS-SD / mDNS
    /// on remote hosts are hidden from discovery signals.
    pub async fn show_remote_printers(&self, visible: bool) {
        for b in &self.backends {
            let _ = b.proxy.show_remote_printers(visible).await;
        }
    }

    /// Sets the visibility of temporary (auto-discovered) printers on
    /// all connected backends.
    pub async fn show_temporary_printers(&self, visible: bool) {
        for b in &self.backends {
            let _ = b.proxy.show_temporary_printers(visible).await;
        }
    }

    /// Finds the proxy for a backend by name.
    ///
    /// Accepts either a short name (`"CUPS"`) or a full D-Bus service
    /// name (`"org.openprinting.Backend.CUPS"`).
    fn proxy_for(&self, backend: &str) -> Result<&PrintBackendProxy<'static>> {
        self.backends
            .iter()
            .find(|b| {
                if b.service_name == backend {
                    return true;
                }
                if let Some(idx) = b.service_name.rfind('.') {
                    &b.service_name[idx + 1..] == backend
                } else {
                    false
                }
            })
            .map(|b| &b.proxy)
            .ok_or_else(|| {
                CpdbError::BackendError(format!("No backend found matching '{}'", backend))
            })
    }
}

/// A stream of live discovery events that keeps connected backends alive.
pub struct DiscoveryStream {
    inner: SelectAll<futures_util::stream::BoxStream<'static, DiscoveryEvent>>,
    keep_alive_task: tokio::task::JoinHandle<()>,
}

impl Stream for DiscoveryStream {
    type Item = DiscoveryEvent;

    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        Pin::new(&mut self.inner).poll_next(cx)
    }
}

impl Drop for DiscoveryStream {
    fn drop(&mut self) {
        self.keep_alive_task.abort();
    }
}