cpdb_sys/printer.rs
1//! Safe wrapper around `cpdb_printer_obj_t`.
2//!
3//! # Ownership and lifetimes
4//!
5//! Printers come in two flavours:
6//!
7//! - **Borrowed** — returned by [`crate::Frontend::find_printer`],
8//! [`crate::Frontend::get_printer`], [`crate::Frontend::get_printers`],
9//! and the default-printer accessors. The underlying C object is owned by
10//! the frontend's hash table; the Rust binding carries the frontend's
11//! lifetime so the borrow checker enforces that the printer cannot outlive
12//! its frontend.
13//! - **Owned** — returned by [`Printer::load_from_file`]. The C object was
14//! allocated independently; Rust frees it via `cpdbDeletePrinterObj` on
15//! drop. Owned printers have a `'static` lifetime.
16//!
17//! `Printer` deliberately does not implement [`Send`] or [`Sync`]. Most
18//! cpdb-libs methods on a printer object mutate shared state (the printer's
19//! settings table is shared with the frontend), and the C library does not
20//! lock internally. If you need to dispatch printer operations from
21//! multiple threads, wrap a single printer in a [`std::sync::Mutex`].
22
23use super::bindings as ffi;
24use super::callbacks::{self, AcquireCompletion};
25use super::frontend::Frontend;
26use super::util;
27use crate::error::{CpdbError, Result};
28use crate::options::OptionsCollection;
29use libc::c_char;
30use std::collections::HashMap;
31use std::ffi::{CStr, CString};
32use std::marker::PhantomData;
33use std::mem::MaybeUninit;
34use std::os::fd::{FromRawFd, OwnedFd};
35use std::ptr::NonNull;
36
37/// Page margins in hundredths of a millimetre.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Margin {
40 /// Top margin.
41 pub top: i32,
42 /// Bottom margin.
43 pub bottom: i32,
44 /// Left margin.
45 pub left: i32,
46 /// Right margin.
47 pub right: i32,
48}
49
50/// One or more [`Margin`] entries returned by the backend for a media type.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct Margins {
53 /// Every margin set the backend reports for the queried media.
54 pub entries: Vec<Margin>,
55}
56
57/// Media dimensions in hundredths of a millimetre.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct MediaSize {
60 /// Width.
61 pub width: i32,
62 /// Length.
63 pub length: i32,
64}
65
66/// Handle returned by [`Printer::print_fd`].
67///
68/// The caller writes the document data to [`PrintFdHandle::fd`] and then
69/// drops the handle to close it.
70#[derive(Debug)]
71pub struct PrintFdHandle {
72 /// A writable file descriptor that consumes the print job data.
73 pub fd: OwnedFd,
74 /// The backend-assigned job ID, or an empty string when not provided.
75 pub job_id: String,
76 /// Optional auxiliary socket path the backend may return.
77 pub socket_path: Option<String>,
78}
79
80/// Handle returned by [`Printer::print_socket`].
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct PrintSocketHandle {
83 /// Path to a Unix-domain socket the caller writes the job data to.
84 pub socket_path: String,
85 /// The backend-assigned job ID, or an empty string when not provided.
86 pub job_id: String,
87}
88
89/// An owned snapshot of a printer's translation table.
90///
91/// Built by walking `cpdb_printer_obj.translations` once and copying every
92/// `(key, value)` pair into Rust-owned `String`s. After construction the
93/// map holds no raw pointers and can be freely stored or sent across
94/// threads.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct TranslationMap {
97 /// The locale this map was captured for, when available.
98 pub locale: Option<String>,
99 /// Translation entries: source string → localised string.
100 pub entries: HashMap<String, String>,
101}
102
103impl TranslationMap {
104 /// `true` when no entries are present.
105 pub fn is_empty(&self) -> bool {
106 self.entries.is_empty()
107 }
108
109 /// Number of entries in the map.
110 pub fn len(&self) -> usize {
111 self.entries.len()
112 }
113
114 /// Looks up the translation for `key`.
115 pub fn get(&self, key: &str) -> Option<&str> {
116 self.entries.get(key).map(String::as_str)
117 }
118}
119
120/// A safe handle to a cpdb printer object.
121///
122/// See [the module docs](self) for the ownership and lifetime model.
123#[derive(Debug)]
124pub struct Printer<'frontend> {
125 raw: NonNull<ffi::cpdb_printer_obj_t>,
126 owned: bool,
127 // Borrowed printers borrow from a `Frontend`; using a non-`Send`/`Sync`
128 // marker also keeps owned printers off other threads, which matches
129 // cpdb-libs' lack of internal locking.
130 _marker: PhantomData<&'frontend Frontend>,
131}
132
133impl<'frontend> Printer<'frontend> {
134 // ─── Constructors ────────────────────────────────────────────────────────
135
136 /// Wraps a printer object that is owned by a frontend's hash table.
137 ///
138 /// The returned printer borrows from the frontend and will NOT be freed
139 /// by Rust on drop.
140 pub(crate) fn from_raw_borrowed(raw: *mut ffi::cpdb_printer_obj_t) -> Result<Self> {
141 let raw = NonNull::new(raw).ok_or(CpdbError::NullPointer)?;
142 Ok(Self {
143 raw,
144 owned: false,
145 _marker: PhantomData,
146 })
147 }
148
149 /// Wraps a printer object that the binding will free on drop.
150 ///
151 /// Used for printers loaded from a pickle file.
152 pub(crate) fn from_raw_owned(raw: *mut ffi::cpdb_printer_obj_t) -> Result<Self> {
153 let raw = NonNull::new(raw).ok_or(CpdbError::NullPointer)?;
154 Ok(Self {
155 raw,
156 owned: true,
157 _marker: PhantomData,
158 })
159 }
160
161 /// Returns the raw pointer for use within this crate.
162 #[doc(hidden)]
163 pub fn as_raw(&self) -> *mut ffi::cpdb_printer_obj_t {
164 self.raw.as_ptr()
165 }
166
167 /// `true` when this binding will free the underlying C object on drop.
168 #[doc(hidden)]
169 pub(crate) fn is_owned(&self) -> bool {
170 self.owned
171 }
172
173 // ─── Field accessors ─────────────────────────────────────────────────────
174
175 /// The backend-assigned printer ID (stable across sessions).
176 pub fn id(&self) -> Result<String> {
177 self.read_str_field(|p| unsafe { (*p).id })
178 }
179
180 /// The human-readable printer name.
181 pub fn name(&self) -> Result<String> {
182 self.read_str_field(|p| unsafe { (*p).name })
183 }
184
185 /// The physical location string supplied by the backend.
186 pub fn location(&self) -> Result<String> {
187 self.read_str_field(|p| unsafe { (*p).location })
188 }
189
190 /// A free-form description (`info` in the C struct).
191 pub fn description(&self) -> Result<String> {
192 self.read_str_field(|p| unsafe { (*p).info })
193 }
194
195 /// The printer's make and model.
196 pub fn make_and_model(&self) -> Result<String> {
197 self.read_str_field(|p| unsafe { (*p).make_and_model })
198 }
199
200 /// The backend name this printer belongs to (e.g. `"CUPS"`).
201 pub fn backend_name(&self) -> Result<String> {
202 self.read_str_field(|p| unsafe { (*p).backend_name })
203 }
204
205 /// The cached state string (`idle`, `processing`, ...).
206 ///
207 /// For an authoritative answer use [`Printer::get_updated_state`].
208 pub fn cached_state(&self) -> Result<String> {
209 self.read_str_field(|p| unsafe { (*p).state })
210 }
211
212 /// Reads an optional NUL-terminated string field from the printer struct.
213 fn read_str_field<F>(&self, accessor: F) -> Result<String>
214 where
215 F: FnOnce(*mut ffi::cpdb_printer_obj_t) -> *const c_char,
216 {
217 let ptr = accessor(self.raw.as_ptr());
218 // SAFETY: `ptr` is borrowed from the printer object; the read above
219 // is just a field deref. `cstr_to_string` is null-tolerant.
220 match unsafe { util::cstr_to_string(ptr) } {
221 Ok(s) => Ok(s),
222 Err(CpdbError::NullPointer) => Ok(String::new()),
223 Err(e) => Err(e),
224 }
225 }
226
227 // ─── State ───────────────────────────────────────────────────────────────
228
229 /// Queries the current state from the backend.
230 ///
231 /// The returned string is owned by Rust; the cpdb-libs allocation is
232 /// freed inside this call.
233 pub fn get_updated_state(&self) -> Result<String> {
234 // SAFETY: cpdbGetState returns a borrowed string owned by the printer object.
235 unsafe {
236 let raw = ffi::cpdbGetState(self.raw.as_ptr());
237 util::cstr_to_string(raw)
238 }
239 }
240
241 /// `true` when the printer is accepting jobs.
242 pub fn is_accepting_jobs(&self) -> Result<bool> {
243 // SAFETY: pointer is non-null.
244 Ok(unsafe { ffi::cpdbIsAcceptingJobs(self.raw.as_ptr()) } != 0)
245 }
246
247 // ─── Defaults ────────────────────────────────────────────────────────────
248
249 /// Marks this printer as the user's default. Returns `true` on success.
250 pub fn set_user_default(&self) -> Result<bool> {
251 // SAFETY: pointer is non-null.
252 Ok(unsafe { ffi::cpdbSetUserDefaultPrinter(self.raw.as_ptr()) } != 0)
253 }
254
255 /// Marks this printer as the system-wide default. Returns `true` on success.
256 pub fn set_system_default(&self) -> Result<bool> {
257 // SAFETY: pointer is non-null.
258 Ok(unsafe { ffi::cpdbSetSystemDefaultPrinter(self.raw.as_ptr()) } != 0)
259 }
260
261 // ─── Job submission ──────────────────────────────────────────────────────
262
263 /// Submits a file as a print job with no extra options.
264 ///
265 /// Returns the backend-assigned job ID string.
266 pub fn print_file(&self, file_path: &str) -> Result<String> {
267 let c_path = CString::new(file_path)?;
268 // SAFETY: cpdbPrintFile returns a `g_strdup`'d job ID we own.
269 unsafe {
270 let id = ffi::cpdbPrintFile(self.raw.as_ptr(), c_path.as_ptr());
271 util::cstr_to_string_and_g_free(id)
272 .map_err(|_| CpdbError::JobFailed("cpdbPrintFile returned null".into()))
273 }
274 }
275
276 /// Streams a print job over a file descriptor.
277 ///
278 /// cpdb-libs hands back a writable file descriptor; the caller writes
279 /// the job's data to it and drops the returned handle to close it.
280 /// The backend job ID and an optional auxiliary socket path are also
281 /// returned.
282 pub fn print_fd(&self, title: &str) -> Result<PrintFdHandle> {
283 let c_title = CString::new(title)?;
284 let mut jobid_ptr: *mut c_char = std::ptr::null_mut();
285 let mut socket_ptr: *mut c_char = std::ptr::null_mut();
286 // SAFETY: pointers are non-null; output params receive
287 // `g_strdup`'d strings we own.
288 let fd = unsafe {
289 ffi::cpdbPrintFD(
290 self.raw.as_ptr(),
291 &mut jobid_ptr,
292 c_title.as_ptr(),
293 &mut socket_ptr,
294 )
295 };
296 if fd < 0 {
297 // Defensive cleanup in case cpdb-libs allocated before failing.
298 unsafe {
299 if !jobid_ptr.is_null() {
300 glib_sys::g_free(jobid_ptr as glib_sys::gpointer);
301 }
302 if !socket_ptr.is_null() {
303 glib_sys::g_free(socket_ptr as glib_sys::gpointer);
304 }
305 }
306 return Err(CpdbError::JobFailed(
307 "cpdbPrintFD returned an invalid fd".into(),
308 ));
309 }
310 let job_id = if jobid_ptr.is_null() {
311 String::new()
312 } else {
313 unsafe { util::cstr_to_string_and_g_free(jobid_ptr) }.unwrap_or_default()
314 };
315 let socket_path = if socket_ptr.is_null() {
316 None
317 } else {
318 unsafe { util::cstr_to_string_and_g_free(socket_ptr) }.ok()
319 };
320 // SAFETY: cpdb-libs returned a valid fd we now own.
321 let fd = unsafe { OwnedFd::from_raw_fd(fd) };
322 Ok(PrintFdHandle {
323 fd,
324 job_id,
325 socket_path,
326 })
327 }
328
329 /// Streams a print job over a Unix-domain socket.
330 ///
331 /// cpdb-libs returns a socket path the caller connects to and writes
332 /// the job data through, plus the backend-assigned job ID.
333 pub fn print_socket(&self, title: &str) -> Result<PrintSocketHandle> {
334 let c_title = CString::new(title)?;
335 let mut jobid_ptr: *mut c_char = std::ptr::null_mut();
336 // SAFETY: cpdb returns a `g_strdup`'d socket path we own.
337 let socket_ptr =
338 unsafe { ffi::cpdbPrintSocket(self.raw.as_ptr(), &mut jobid_ptr, c_title.as_ptr()) };
339 if socket_ptr.is_null() {
340 if !jobid_ptr.is_null() {
341 unsafe { glib_sys::g_free(jobid_ptr as glib_sys::gpointer) };
342 }
343 return Err(CpdbError::JobFailed(
344 "cpdbPrintSocket returned a null socket path".into(),
345 ));
346 }
347 let socket_path = unsafe { util::cstr_to_string_and_g_free(socket_ptr) }?;
348 let job_id = if jobid_ptr.is_null() {
349 String::new()
350 } else {
351 unsafe { util::cstr_to_string_and_g_free(jobid_ptr) }.unwrap_or_default()
352 };
353 Ok(PrintSocketHandle {
354 socket_path,
355 job_id,
356 })
357 }
358
359 /// Submits a job with a per-call set of options and an explicit title.
360 ///
361 /// Options are applied to the printer's settings table via
362 /// `cpdbAddSettingToPrinter` before submission, so they take effect for
363 /// this job and persist for subsequent jobs on the same printer until
364 /// cleared via [`Printer::clear_setting`].
365 ///
366 /// Returns the backend-assigned job ID string.
367 pub fn submit_job(
368 &self,
369 file_path: &str,
370 options: &[(&str, &str)],
371 title: &str,
372 ) -> Result<String> {
373 let c_path = CString::new(file_path)?;
374 let c_title = CString::new(title)?;
375 for (key, value) in options {
376 let k = CString::new(*key)?;
377 let v = CString::new(*value)?;
378 // SAFETY: pointers are non-null; CStrings live until end of loop body.
379 unsafe { ffi::cpdbAddSettingToPrinter(self.raw.as_ptr(), k.as_ptr(), v.as_ptr()) };
380 }
381 // SAFETY: cpdbPrintFileWithJobTitle returns a `g_strdup`'d job ID we own.
382 unsafe {
383 let id = ffi::cpdbPrintFileWithJobTitle(
384 self.raw.as_ptr(),
385 c_path.as_ptr(),
386 c_title.as_ptr(),
387 );
388 util::cstr_to_string_and_g_free(id)
389 .map_err(|_| CpdbError::JobFailed("cpdbPrintFileWithJobTitle returned null".into()))
390 }
391 }
392
393 // ─── Options ─────────────────────────────────────────────────────────────
394
395 /// Returns the default value for a named option, if the option exists.
396 pub fn get_option(&self, option_name: &str) -> Result<Option<String>> {
397 let c_name = CString::new(option_name)?;
398 // SAFETY: `cpdbGetOption` returns a borrowed pointer into the printer's
399 // option table; we must NOT free it. Reading `default_value` is a
400 // simple field deref.
401 unsafe {
402 let opt = ffi::cpdbGetOption(self.raw.as_ptr(), c_name.as_ptr());
403 if opt.is_null() {
404 return Ok(None);
405 }
406 let dv = (*opt).default_value;
407 if dv.is_null() {
408 Ok(None)
409 } else {
410 util::cstr_to_string(dv).map(Some)
411 }
412 }
413 }
414
415 /// Returns the default value for a named option, freeing the GLib string.
416 pub fn get_default(&self, option_name: &str) -> Result<String> {
417 let c_name = CString::new(option_name)?;
418 // SAFETY: `cpdbGetDefault` returns a `g_strdup`'d string we own.
419 unsafe {
420 let v = ffi::cpdbGetDefault(self.raw.as_ptr(), c_name.as_ptr());
421 util::cstr_to_string_and_g_free(v)
422 }
423 }
424
425 /// Returns the *current* (setting-or-default) value for a named option.
426 pub fn get_current(&self, option_name: &str) -> Result<String> {
427 let c_name = CString::new(option_name)?;
428 // SAFETY: `cpdbGetCurrent` returns a `g_strdup`'d string we own.
429 unsafe {
430 let v = ffi::cpdbGetCurrent(self.raw.as_ptr(), c_name.as_ptr());
431 util::cstr_to_string_and_g_free(v)
432 }
433 }
434
435 /// Asynchronously asks the backend to populate the printer's full
436 /// option table. Returns immediately; no completion notification.
437 ///
438 /// Use [`Printer::acquire_details_with`] if you need to be told when
439 /// the operation finishes.
440 pub fn acquire_details(&self) {
441 // SAFETY: passing a null callback is documented as valid.
442 unsafe {
443 ffi::cpdbAcquireDetails(self.raw.as_ptr(), None, std::ptr::null_mut());
444 }
445 }
446
447 /// Asynchronously populates the option table, firing `completion` when
448 /// the backend responds.
449 ///
450 /// `completion` is invoked from cpdb-libs' D-Bus listener thread with
451 /// `success = true` when the backend reported success. Panics inside
452 /// the closure are caught and absorbed.
453 pub fn acquire_details_with<F>(&self, completion: F)
454 where
455 F: FnOnce(&Printer<'_>, bool) + Send + 'static,
456 {
457 let boxed: Box<AcquireCompletion> = Box::new(completion);
458 let user_data = callbacks::into_completion_user_data(boxed);
459 // SAFETY: `user_data` was produced for this exact callback firing;
460 // the trampoline reclaims and frees it.
461 unsafe {
462 ffi::cpdbAcquireDetails(
463 self.raw.as_ptr(),
464 Some(callbacks::acquire_trampoline),
465 user_data,
466 );
467 }
468 }
469
470 /// Returns an owned snapshot of every option on this printer.
471 ///
472 /// Call [`Printer::acquire_details`] before this so the option table is
473 /// populated by the backend.
474 pub fn get_options_collection(&self) -> Result<OptionsCollection> {
475 // SAFETY: `cpdbGetAllOptions` returns a borrowed pointer to the
476 // printer's `options` field; the collection copies all data before
477 // returning, so we never retain the pointer.
478 unsafe {
479 let opts = ffi::cpdbGetAllOptions(self.raw.as_ptr());
480 let opts = NonNull::new(opts).ok_or_else(|| {
481 CpdbError::BackendError(
482 "cpdbGetAllOptions returned null — call acquire_details() first".into(),
483 )
484 })?;
485 OptionsCollection::from_raw(opts.as_ptr())
486 }
487 }
488
489 // ─── Per-printer settings ────────────────────────────────────────────────
490
491 /// Reads a per-printer setting, or returns `None` when unset.
492 pub fn get_setting(&self, name: &str) -> Result<Option<String>> {
493 let c_name = CString::new(name)?;
494 // SAFETY: `cpdbGetSetting` returns a borrowed pointer into the
495 // printer's settings table; we must NOT free it.
496 unsafe {
497 let v = ffi::cpdbGetSetting(self.raw.as_ptr(), c_name.as_ptr());
498 if v.is_null() {
499 Ok(None)
500 } else {
501 util::cstr_to_string(v).map(Some)
502 }
503 }
504 }
505
506 /// Inserts or overwrites a per-printer setting.
507 pub fn add_setting(&self, name: &str, value: &str) -> Result<()> {
508 let c_name = CString::new(name)?;
509 let c_val = CString::new(value)?;
510 // SAFETY: pointers are non-null; the CStrings outlive the call.
511 unsafe { ffi::cpdbAddSettingToPrinter(self.raw.as_ptr(), c_name.as_ptr(), c_val.as_ptr()) };
512 Ok(())
513 }
514
515 /// Removes a per-printer setting. Returns `Ok(true)` when it existed.
516 pub fn clear_setting(&self, name: &str) -> Result<bool> {
517 let c_name = CString::new(name)?;
518 // SAFETY: pointers are non-null; the CString outlives the call.
519 let existed =
520 unsafe { ffi::cpdbClearSettingFromPrinter(self.raw.as_ptr(), c_name.as_ptr()) };
521 Ok(existed != 0)
522 }
523
524 // ─── Media ───────────────────────────────────────────────────────────────
525
526 /// Returns the descriptive name of a media type, if known.
527 pub fn get_media(&self, media_name: &str) -> Result<Option<String>> {
528 let c_name = CString::new(media_name)?;
529 // SAFETY: `cpdbGetMedia` returns a borrowed pointer into the
530 // printer's media table.
531 unsafe {
532 let m = ffi::cpdbGetMedia(self.raw.as_ptr(), c_name.as_ptr());
533 if m.is_null() {
534 return Ok(None);
535 }
536 let name_ptr = (*m).name;
537 if name_ptr.is_null() {
538 Ok(None)
539 } else {
540 util::cstr_to_string(name_ptr).map(Some)
541 }
542 }
543 }
544
545 /// Returns the media size for a named media type.
546 ///
547 /// Both dimensions are in hundredths of a millimetre.
548 pub fn get_media_size(&self, media_name: &str) -> Result<MediaSize> {
549 let c_name = CString::new(media_name)?;
550 let (mut width, mut length): (i32, i32) = (0, 0);
551 // SAFETY: passing valid pointers to two stack-allocated `i32`s.
552 let rc = unsafe {
553 ffi::cpdbGetMediaSize(self.raw.as_ptr(), c_name.as_ptr(), &mut width, &mut length)
554 };
555 if rc == 0 {
556 Ok(MediaSize { width, length })
557 } else {
558 Err(CpdbError::NotFound(format!("media size '{media_name}'")))
559 }
560 }
561
562 /// Returns every margin entry the backend reports for a named media type.
563 pub fn get_media_margins(&self, media_name: &str) -> Result<Margins> {
564 let c_name = CString::new(media_name)?;
565 let mut raw_margins: *mut ffi::cpdb_margin_t = std::ptr::null_mut();
566 // SAFETY: passing valid pointers; cpdb-libs writes to `raw_margins`.
567 let count = unsafe {
568 ffi::cpdbGetMediaMargins(self.raw.as_ptr(), c_name.as_ptr(), &mut raw_margins)
569 };
570 if count <= 0 || raw_margins.is_null() {
571 return Err(CpdbError::NotFound(format!("media margins '{media_name}'")));
572 }
573 let mut out = Vec::with_capacity(count as usize);
574 // SAFETY: cpdb-libs guarantees `count` valid entries at `raw_margins`.
575 for i in 0..(count as isize) {
576 let m = unsafe { &*raw_margins.offset(i) };
577 out.push(Margin {
578 top: m.top,
579 bottom: m.bottom,
580 left: m.left,
581 right: m.right,
582 });
583 }
584 Ok(Margins { entries: out })
585 }
586
587 // ─── Translations ────────────────────────────────────────────────────────
588
589 /// Asynchronously fetches translations for the given locale.
590 ///
591 /// Use [`Printer::acquire_translations_with`] if you need to be told
592 /// when the operation finishes.
593 pub fn acquire_translations(&self, locale: &str) -> Result<()> {
594 let c_locale = CString::new(locale)?;
595 // SAFETY: passing a null callback is documented as valid.
596 unsafe {
597 ffi::cpdbAcquireTranslations(
598 self.raw.as_ptr(),
599 c_locale.as_ptr(),
600 None,
601 std::ptr::null_mut(),
602 );
603 }
604 Ok(())
605 }
606
607 /// Asynchronously fetches translations, firing `completion` on success/failure.
608 ///
609 /// `completion` is invoked from cpdb-libs' D-Bus listener thread.
610 /// Panics inside the closure are caught and absorbed.
611 pub fn acquire_translations_with<F>(&self, locale: &str, completion: F) -> Result<()>
612 where
613 F: FnOnce(&Printer<'_>, bool) + Send + 'static,
614 {
615 let c_locale = CString::new(locale)?;
616 let boxed: Box<AcquireCompletion> = Box::new(completion);
617 let user_data = callbacks::into_completion_user_data(boxed);
618 // SAFETY: `user_data` was produced for this exact callback firing.
619 unsafe {
620 ffi::cpdbAcquireTranslations(
621 self.raw.as_ptr(),
622 c_locale.as_ptr(),
623 Some(callbacks::acquire_trampoline),
624 user_data,
625 );
626 }
627 Ok(())
628 }
629
630 /// Returns the human-readable label for an option in the given locale.
631 pub fn get_option_translation(&self, option: &str, locale: &str) -> Result<Option<String>> {
632 let c_opt = CString::new(option)?;
633 let c_locale = CString::new(locale)?;
634 // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
635 unsafe {
636 let t =
637 ffi::cpdbGetOptionTranslation(self.raw.as_ptr(), c_opt.as_ptr(), c_locale.as_ptr());
638 translation_to_option(t)
639 }
640 }
641
642 /// Returns the human-readable label for a specific option choice.
643 pub fn get_choice_translation(
644 &self,
645 option: &str,
646 choice: &str,
647 locale: &str,
648 ) -> Result<Option<String>> {
649 let c_opt = CString::new(option)?;
650 let c_choice = CString::new(choice)?;
651 let c_locale = CString::new(locale)?;
652 // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
653 unsafe {
654 let t = ffi::cpdbGetChoiceTranslation(
655 self.raw.as_ptr(),
656 c_opt.as_ptr(),
657 c_choice.as_ptr(),
658 c_locale.as_ptr(),
659 );
660 translation_to_option(t)
661 }
662 }
663
664 /// Like [`get_option_translation`], but only consults the in-memory
665 /// translation table — never falls through to the backend over D-Bus.
666 ///
667 /// Returns `None` if the option label has not been loaded yet. Call
668 /// [`Printer::get_all_translations`] (or
669 /// [`Printer::acquire_translations_with`]) first to populate the table.
670 ///
671 /// [`get_option_translation`]: Self::get_option_translation
672 pub fn get_option_translation_from_table(
673 &self,
674 option: &str,
675 locale: &str,
676 ) -> Result<Option<String>> {
677 let c_opt = CString::new(option)?;
678 let c_locale = CString::new(locale)?;
679 // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
680 unsafe {
681 let t = ffi::cpdbGetOptionTranslationFromTable(
682 self.raw.as_ptr(),
683 c_opt.as_ptr(),
684 c_locale.as_ptr(),
685 );
686 translation_to_option(t)
687 }
688 }
689
690 /// Like [`get_choice_translation`], but only consults the in-memory
691 /// translation table — never falls through to the backend over D-Bus.
692 ///
693 /// [`get_choice_translation`]: Self::get_choice_translation
694 pub fn get_choice_translation_from_table(
695 &self,
696 option: &str,
697 choice: &str,
698 locale: &str,
699 ) -> Result<Option<String>> {
700 let c_opt = CString::new(option)?;
701 let c_choice = CString::new(choice)?;
702 let c_locale = CString::new(locale)?;
703 // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
704 unsafe {
705 let t = ffi::cpdbGetChoiceTranslationFromTable(
706 self.raw.as_ptr(),
707 c_opt.as_ptr(),
708 c_choice.as_ptr(),
709 c_locale.as_ptr(),
710 );
711 translation_to_option(t)
712 }
713 }
714
715 /// Returns the human-readable label for an option group.
716 pub fn get_group_translation(&self, group: &str, locale: &str) -> Result<Option<String>> {
717 let c_group = CString::new(group)?;
718 let c_locale = CString::new(locale)?;
719 // SAFETY: cpdb returns a `g_strdup`'d translation string we own.
720 unsafe {
721 let t = ffi::cpdbGetGroupTranslation(
722 self.raw.as_ptr(),
723 c_group.as_ptr(),
724 c_locale.as_ptr(),
725 );
726 translation_to_option(t)
727 }
728 }
729
730 /// Synchronously populates every translation for the given locale.
731 pub fn get_all_translations(&self, locale: &str) -> Result<()> {
732 let c_locale = CString::new(locale)?;
733 // SAFETY: pointers are non-null.
734 unsafe { ffi::cpdbGetAllTranslations(self.raw.as_ptr(), c_locale.as_ptr()) };
735 Ok(())
736 }
737
738 /// Returns an owned snapshot of the printer's cached translation table.
739 ///
740 /// Call [`Printer::get_all_translations`] (or
741 /// [`Printer::acquire_translations_with`]) first to populate the table.
742 /// Returns an empty map when no translations have been loaded.
743 pub fn translations(&self) -> TranslationMap {
744 // SAFETY: dereferencing the printer struct's `locale` and
745 // `translations` fields is sound; we only read borrowed pointers.
746 let raw = self.raw.as_ptr();
747 let locale_ptr = unsafe { (*raw).locale };
748 let table = unsafe { (*raw).translations } as *mut glib_sys::GHashTable;
749
750 let locale = if locale_ptr.is_null() {
751 None
752 } else {
753 unsafe { util::cstr_to_string(locale_ptr) }.ok()
754 };
755
756 if table.is_null() {
757 return TranslationMap {
758 locale,
759 entries: HashMap::new(),
760 };
761 }
762
763 let mut entries: HashMap<String, String> = HashMap::new();
764 // SAFETY: iterator is initialised on the stack and iterated
765 // synchronously; we copy all data into owned Strings before
766 // returning. The table is not mutated during this loop.
767 unsafe {
768 let mut iter = MaybeUninit::<glib_sys::GHashTableIter>::uninit();
769 glib_sys::g_hash_table_iter_init(iter.as_mut_ptr(), table);
770 let mut iter = iter.assume_init();
771
772 let mut key: glib_sys::gpointer = std::ptr::null_mut();
773 let mut value: glib_sys::gpointer = std::ptr::null_mut();
774 while glib_sys::g_hash_table_iter_next(&mut iter, &mut key, &mut value) != 0 {
775 if key.is_null() || value.is_null() {
776 continue;
777 }
778 let k = CStr::from_ptr(key as *const c_char)
779 .to_string_lossy()
780 .into_owned();
781 let v = CStr::from_ptr(value as *const c_char)
782 .to_string_lossy()
783 .into_owned();
784 entries.insert(k, v);
785 }
786 }
787 TranslationMap { locale, entries }
788 }
789
790 // ─── Debug helpers ───────────────────────────────────────────────────────
791
792 /// Dumps a human-readable description of this printer to stderr via
793 /// `cpdbDebugPrinter`. Useful for diagnostics; production code should
794 /// inspect the typed accessors instead.
795 pub fn debug_dump(&self) {
796 // SAFETY: pointer is non-null.
797 unsafe { ffi::cpdbDebugPrinter(self.raw.as_ptr()) };
798 }
799
800 /// Dumps the printer's basic option set to stdout via
801 /// `cpdbPrintBasicOptions`. Diagnostics-only.
802 pub fn dump_basic_options(&self) {
803 // SAFETY: pointer is non-null.
804 unsafe { ffi::cpdbPrintBasicOptions(self.raw.as_ptr()) };
805 }
806
807 // ─── Persistence ─────────────────────────────────────────────────────────
808
809 /// Serialises this printer to a file (`cpdbPicklePrinterToFile`).
810 pub fn pickle_to_file(&self, path: &str, frontend: &Frontend) -> Result<()> {
811 let c_path = CString::new(path)?;
812 // SAFETY: both pointers are non-null and live for the duration of the call.
813 unsafe {
814 ffi::cpdbPicklePrinterToFile(self.raw.as_ptr(), c_path.as_ptr(), frontend.as_raw());
815 }
816 Ok(())
817 }
818
819 /// Loads a printer that was previously serialised via [`pickle_to_file`].
820 ///
821 /// The returned printer is *owned* — it is freed when dropped.
822 ///
823 /// [`pickle_to_file`]: Self::pickle_to_file
824 pub fn load_from_file(path: &str) -> Result<Printer<'static>> {
825 let c_path = CString::new(path)?;
826 // SAFETY: `cpdbResurrectPrinterFromFile` returns an owned printer.
827 let raw = unsafe { ffi::cpdbResurrectPrinterFromFile(c_path.as_ptr()) };
828 if raw.is_null() {
829 return Err(CpdbError::NotFound(format!("pickled printer at {path}")));
830 }
831 Printer::<'static>::from_raw_owned(raw)
832 }
833}
834
835/// Converts a cpdb-libs-allocated translation string into `Option<String>`,
836/// freeing the underlying buffer.
837///
838/// # Safety
839/// `ptr` must be null or a GLib-allocated NUL-terminated string we own.
840unsafe fn translation_to_option(ptr: *mut c_char) -> Result<Option<String>> {
841 if ptr.is_null() {
842 Ok(None)
843 } else {
844 unsafe { util::cstr_to_string_and_g_free(ptr) }.map(Some)
845 }
846}
847
848impl Drop for Printer<'_> {
849 fn drop(&mut self) {
850 if self.owned {
851 // SAFETY: we own the pointer and have not aliased it.
852 unsafe { ffi::cpdbDeletePrinterObj(self.raw.as_ptr()) };
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn from_raw_borrowed_rejects_null() {
863 let r = Printer::from_raw_borrowed(std::ptr::null_mut());
864 assert!(matches!(r, Err(CpdbError::NullPointer)));
865 }
866
867 #[test]
868 fn from_raw_owned_rejects_null() {
869 let r = Printer::from_raw_owned(std::ptr::null_mut());
870 assert!(matches!(r, Err(CpdbError::NullPointer)));
871 }
872
873 // `load_from_file` calls `cpdbResurrectPrinterFromFile` — real FFI.
874 // Miri cannot interpret it, so skip there.
875 #[test]
876 #[cfg_attr(miri, ignore)]
877 fn load_from_nonexistent_file_returns_error() {
878 let r = Printer::load_from_file("/tmp/cpdb-rs-nonexistent-pickle-file");
879 assert!(r.is_err());
880 }
881}