git.lucas.co / cce-desktop-portal
Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git

src/print.rs (18.4K)

  1 //! `org.freedesktop.impl.portal.Print`: the print dialog as a cce popup, and
  2 //! the job sent to CUPS with `lp`.
  3 //!
  4 //! The portal splits printing in two. `PreparePrint` asks the user how to
  5 //! print before the app has rendered anything (so the page count is not
  6 //! known yet) and answers GtkPrintSettings plus a token; the app renders
  7 //! with those settings and calls `Print` with the token and an fd of the
  8 //! result (a PDF). A `Print` without a known token asks first.
  9 //!
 10 //! The dialog is a `cce-cloud --json` panel: page 0 lists the destinations —
 11 //! "Save as PDF", then each CUPS queue (`lpstat -e`, default first) — and
 12 //! each opens a page of its own options (copies, pages, two-sided where the
 13 //! queue has a Duplex option) ending in its own Print / Save button. The
 14 //! panel reports only the button that closed it plus every control's value,
 15 //! so a per-destination page is what says which destination was chosen
 16 //! (`print:<i>`), and its controls carry the same prefix (`<i>.copies`).
 17 //!
 18 //! Page ranges are rendered by the app (GtkPrintOperation renders only the
 19 //! pages the settings name), so the job is sent without them; copies and
 20 //! two-sided are the printer's, so `lp -n` and `-o sides=` carry them.
 21 //! "Save as PDF" asks for a path with `cce-files --save` and copies the
 22 //! rendered file there.
 23 
 24 use std::collections::HashMap;
 25 use std::process::Stdio;
 26 use std::sync::Arc;
 27 
 28 use serde_json::json;
 29 use tokio::io::AsyncWriteExt;
 30 use tokio::sync::Mutex;
 31 use zbus::interface;
 32 use zbus::zvariant::{OwnedFd, OwnedValue, Str};
 33 
 34 const RESPONSE_OK: u32 = 0;
 35 const RESPONSE_CANCELLED: u32 = 1;
 36 const RESPONSE_OTHER: u32 = 2;
 37 
 38 /// GtkPrintSettings' name for the print-to-file destination.
 39 pub const TO_FILE: &str = "Print to File";
 40 
 41 /// A place the dialog can send the job.
 42 #[derive(Debug, Clone, PartialEq)]
 43 pub struct Destination {
 44     /// The CUPS queue, or [`TO_FILE`].
 45     pub name: String,
 46     pub label: String,
 47     pub duplex: bool,
 48 }
 49 
 50 /// What the dialog decided, as the app gets it back and `Print` reads it.
 51 #[derive(Debug, Clone, PartialEq)]
 52 pub struct Choice {
 53     pub printer: String,
 54     pub copies: u32,
 55     /// 1-based, inclusive; `None` for all pages.
 56     pub range: Option<(u32, u32)>,
 57     pub duplex: bool,
 58     /// Print to File: where to.
 59     pub output: Option<String>,
 60 }
 61 
 62 impl Choice {
 63     /// As GtkPrintSettings: string values throughout, page ranges 0-based.
 64     pub fn settings(&self) -> HashMap<String, OwnedValue> {
 65         let mut s: Vec<(&str, String)> = vec![
 66             ("printer", self.printer.clone()),
 67             ("n-copies", self.copies.to_string()),
 68             ("duplex", if self.duplex { "horizontal" } else { "simplex" }.to_string()),
 69         ];
 70         match self.range {
 71             Some((from, to)) => {
 72                 s.push(("print-pages", "ranges".into()));
 73                 s.push(("page-ranges", format!("{}-{}", from.saturating_sub(1), to.saturating_sub(1))));
 74             }
 75             None => s.push(("print-pages", "all".into())),
 76         }
 77         if let Some(path) = &self.output {
 78             s.push(("output-file-format", "pdf".into()));
 79             s.push(("output-uri", format!("file://{path}")));
 80         }
 81         s.into_iter().map(|(k, v)| (k.to_string(), OwnedValue::from(Str::from(v)))).collect()
 82     }
 83 
 84     /// The `lp` argv for a rendered file at `path`.
 85     pub fn lp_args(&self, title: &str, path: &str) -> Vec<String> {
 86         let mut a = vec!["-d".into(), self.printer.clone(), "-n".into(), self.copies.max(1).to_string()];
 87         if !title.is_empty() {
 88             a.extend(["-t".into(), title.to_string()]);
 89         }
 90         if self.duplex {
 91             a.extend(["-o".into(), "sides=two-sided-long-edge".into()]);
 92         }
 93         a.extend(["--".into(), path.to_string()]);
 94         a
 95     }
 96 }
 97 
 98 // ── destinations ───────────────────────────────────────────────────────────
 99 
100 /// `lpstat -e` (one queue per line) with `lpstat -d`'s default first.
101 pub fn order_queues(queues: &str, default_line: &str) -> Vec<String> {
102     let default = default_line.rsplit(':').next().map(str::trim).filter(|d| !d.is_empty() && !d.contains(' '));
103     let mut names: Vec<String> = queues.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect();
104     if let Some(d) = default {
105         if let Some(i) = names.iter().position(|n| n == d) {
106             let dflt = names.remove(i);
107             names.insert(0, dflt);
108         }
109     }
110     names
111 }
112 
113 async fn run(cmd: &str, args: &[&str]) -> String {
114     match tokio::process::Command::new(cmd).args(args).output().await {
115         Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
116         Err(_) => String::new(),
117     }
118 }
119 
120 /// Every place a job can go: Save as PDF, then the queues.
121 async fn destinations() -> Vec<Destination> {
122     let mut out = vec![Destination { name: TO_FILE.into(), label: "Save as PDF".into(), duplex: false }];
123     let queues = order_queues(&run("lpstat", &["-e"]).await, &run("lpstat", &["-d"]).await);
124     for q in queues {
125         // `lpoptions -l` lists the queue's PPD options; Duplex is the one
126         // two-sided printing needs.
127         let opts = run("lpoptions", &["-p", &q, "-l"]).await;
128         let duplex = opts.lines().any(|l| l.starts_with("Duplex/") || l.starts_with("Duplex:"));
129         out.push(Destination { label: q.clone(), name: q, duplex });
130     }
131     out
132 }
133 
134 // ── the dialog ─────────────────────────────────────────────────────────────
135 
136 /// The `cce-cloud --json` panel for `dests`: a destination list leading to
137 /// one options page each. With a single destination the list is skipped.
138 pub fn dialog_layout(title: &str, dests: &[Destination]) -> serde_json::Value {
139     let heading = if title.is_empty() { "Print".to_string() } else { format!("Print “{title}”") };
140     let single = dests.len() == 1;
141     let mut pages = Vec::new();
142     if !single {
143         let mut widgets = vec![json!({"type": "label", "text": heading})];
144         for (i, d) in dests.iter().enumerate() {
145             widgets.push(json!({"type": "button", "text": d.label, "id": format!("go:{i}"), "target_page": i + 1}));
146         }
147         pages.push(json!({"title": "Print", "widgets": widgets}));
148     }
149     for (i, d) in dests.iter().enumerate() {
150         let mut widgets = Vec::new();
151         if !single {
152             widgets.push(json!({"type": "button", "text": "Back", "id": format!("back:{i}"), "target_page": 0}));
153         }
154         widgets.push(json!({"type": "label", "text": if single { heading.clone() } else { d.label.clone() }}));
155         let to_file = d.name == TO_FILE;
156         if !to_file {
157             widgets.push(json!({"type": "spinbox", "text": "Copies", "id": format!("{i}.copies"), "value": 1, "min": 1, "max": 99, "step": 1}));
158         }
159         widgets.push(json!({"type": "checkbox", "text": "All pages", "id": format!("{i}.all"), "checked": true}));
160         widgets.push(json!({"type": "spinbox", "text": "From page", "id": format!("{i}.from"), "value": 1, "min": 1, "max": 9999, "step": 1}));
161         widgets.push(json!({"type": "spinbox", "text": "To page", "id": format!("{i}.to"), "value": 1, "min": 1, "max": 9999, "step": 1}));
162         if d.duplex {
163             widgets.push(json!({"type": "checkbox", "text": "Two-sided", "id": format!("{i}.duplex"), "checked": false}));
164         }
165         let go = if to_file { "Save…" } else { "Print" };
166         widgets.push(json!({"type": "button", "text": go, "id": format!("print:{i}")}));
167         pages.push(json!({"title": d.label, "widgets": widgets}));
168     }
169     json!({"pages": pages})
170 }
171 
172 /// The panel's answer as a [`Choice`] (without a save path yet), or `None`
173 /// when it was dismissed or closed by something other than a Print button.
174 pub fn parse_answer(stdout: &str, dests: &[Destination]) -> Option<Choice> {
175     let v: serde_json::Value = serde_json::from_str(stdout.lines().next()?.trim()).ok()?;
176     let i: usize = v.get("button")?.as_str()?.strip_prefix("print:")?.parse().ok()?;
177     let dest = dests.get(i)?;
178     let num = |k: &str| v.get("spinboxes").and_then(|s| s.get(format!("{i}.{k}"))).and_then(|n| n.as_f64()).map(|n| n.max(1.0) as u32);
179     let flag = |k: &str| v.get("checkboxes").and_then(|s| s.get(format!("{i}.{k}"))).and_then(|b| b.as_bool());
180     let range = match flag("all") {
181         Some(false) => {
182             let (a, b) = (num("from").unwrap_or(1), num("to").unwrap_or(1));
183             Some((a.min(b), a.max(b)))
184         }
185         _ => None,
186     };
187     Some(Choice {
188         printer: dest.name.clone(),
189         copies: num("copies").unwrap_or(1),
190         range,
191         duplex: dest.duplex && flag("duplex").unwrap_or(false),
192         output: None,
193     })
194 }
195 
196 fn tool(env: &str, name: &str) -> std::path::PathBuf {
197     if let Some(bin) = std::env::var_os(env).filter(|b| !b.is_empty()) {
198         return bin.into();
199     }
200     std::env::var_os("HOME")
201         .map(|h| std::path::PathBuf::from(h).join(".local/bin").join(name))
202         .filter(|p| p.is_file())
203         .unwrap_or_else(|| name.into())
204 }
205 
206 /// Run `cmd` with `input` on stdin; its stdout, or `None` if it would not run.
207 async fn ask(cmd: std::path::PathBuf, args: &[&str], input: Option<String>) -> Option<String> {
208     let mut c = tokio::process::Command::new(&cmd);
209     c.args(args).stdout(Stdio::piped()).stderr(Stdio::inherit()).kill_on_drop(true);
210     c.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
211     let mut child = c.spawn().map_err(|e| log::error!("running {}: {e}", cmd.display())).ok()?;
212     if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
213         let _ = stdin.write_all(text.as_bytes()).await;
214     }
215     let out = child.wait_with_output().await.ok()?;
216     Some(String::from_utf8_lossy(&out.stdout).into_owned())
217 }
218 
219 /// A save path ends in `.pdf`.
220 pub fn pdf_path(path: &str) -> String {
221     if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }
222 }
223 
224 /// Show the dialog; `None` when the user backed out anywhere.
225 async fn choose(title: &str) -> Option<Choice> {
226     let dests = destinations().await;
227     let layout = dialog_layout(title, &dests);
228     let answer = ask(tool("CCE_CHOOSER_BIN", "cce-cloud"), &["--json"], Some(layout.to_string())).await?;
229     let mut choice = parse_answer(&answer, &dests)?;
230     if choice.printer == TO_FILE {
231         let docs = std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Documents"));
232         let start = docs.filter(|d| d.is_dir()).map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
233         let mut args = vec!["--save"];
234         if !start.is_empty() {
235             args.push(&start);
236         }
237         let path = ask(tool("CCE_FILES_BIN", "cce-files"), &args, None).await?;
238         let path = path.lines().next().unwrap_or("").trim().to_string();
239         if path.is_empty() {
240             return None;
241         }
242         choice.output = Some(pdf_path(&path));
243     }
244     Some(choice)
245 }
246 
247 // ── the portal ─────────────────────────────────────────────────────────────
248 
249 #[derive(Default)]
250 pub struct PrintPortal {
251     prepared: Arc<Mutex<(u32, HashMap<u32, Choice>)>>,
252 }
253 
254 fn results(choice: &Choice, page_setup: HashMap<String, OwnedValue>, token: Option<u32>) -> HashMap<String, OwnedValue> {
255     let mut r = HashMap::from([
256         ("settings".to_string(), OwnedValue::try_from(zbus::zvariant::Value::from(choice.settings())).expect("no fds")),
257         ("page-setup".to_string(), OwnedValue::try_from(zbus::zvariant::Value::from(page_setup)).expect("no fds")),
258     ]);
259     if let Some(t) = token {
260         r.insert("token".to_string(), OwnedValue::from(t));
261     }
262     r
263 }
264 
265 #[interface(name = "org.freedesktop.impl.portal.Print")]
266 impl PrintPortal {
267     #[zbus(property, name = "version")]
268     fn version(&self) -> u32 {
269         2
270     }
271 
272     #[allow(clippy::too_many_arguments)]
273     async fn prepare_print(
274         &self,
275         _handle: zbus::zvariant::ObjectPath<'_>,
276         app_id: String,
277         _parent_window: String,
278         title: String,
279         _settings: HashMap<String, OwnedValue>,
280         page_setup: HashMap<String, OwnedValue>,
281         _options: HashMap<String, OwnedValue>,
282     ) -> (u32, HashMap<String, OwnedValue>) {
283         log::info!("PreparePrint {title:?} for {app_id:?}");
284         let Some(choice) = choose(&title).await else {
285             log::info!("PreparePrint {title:?}: dismissed");
286             return (RESPONSE_CANCELLED, HashMap::new());
287         };
288         let token = {
289             let mut p = self.prepared.lock().await;
290             p.0 = p.0.wrapping_add(1).max(1);
291             let t = p.0;
292             p.1.insert(t, choice.clone());
293             t
294         };
295         log::info!("PreparePrint {title:?}: {choice:?} as token {token}");
296         (RESPONSE_OK, results(&choice, page_setup, Some(token)))
297     }
298 
299     #[allow(clippy::too_many_arguments)]
300     async fn print(
301         &self,
302         _handle: zbus::zvariant::ObjectPath<'_>,
303         app_id: String,
304         _parent_window: String,
305         title: String,
306         fd: OwnedFd,
307         options: HashMap<String, OwnedValue>,
308     ) -> (u32, HashMap<String, OwnedValue>) {
309         let token = options.get("token").and_then(|v| u32::try_from(v).ok());
310         let prepared = match token {
311             Some(t) => self.prepared.lock().await.1.remove(&t),
312             None => None,
313         };
314         let choice = match prepared {
315             Some(c) => c,
316             None => match choose(&title).await {
317                 Some(c) => c,
318                 None => return (RESPONSE_CANCELLED, HashMap::new()),
319             },
320         };
321         log::info!("Print {title:?} for {app_id:?}: {choice:?}");
322         match send(&choice, &title, fd).await {
323             Ok(()) => (RESPONSE_OK, HashMap::new()),
324             Err(e) => {
325                 log::error!("Print {title:?}: {e}");
326                 (RESPONSE_OTHER, HashMap::new())
327             }
328         }
329     }
330 }
331 
332 /// Copy the rendered document out of `fd` and deliver it.
333 async fn send(choice: &Choice, title: &str, fd: OwnedFd) -> Result<(), String> {
334     use std::io::Read;
335     let mut data = Vec::new();
336     std::fs::File::from(std::os::fd::OwnedFd::from(fd))
337         .read_to_end(&mut data)
338         .map_err(|e| format!("reading the document: {e}"))?;
339     if let Some(path) = &choice.output {
340         std::fs::write(path, &data).map_err(|e| format!("writing {path}: {e}"))?;
341         log::info!("saved {} bytes to {path}", data.len());
342         return Ok(());
343     }
344     let dir = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").ok_or("no XDG_RUNTIME_DIR")?).join("cce-desktop-portal");
345     std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
346     let ext = if data.starts_with(b"%!PS") { "ps" } else { "pdf" };
347     static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
348     let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
349     let spool = dir.join(format!("print-{}-{n}.{ext}", std::process::id()));
350     std::fs::write(&spool, &data).map_err(|e| e.to_string())?;
351     let args = choice.lp_args(title, &spool.to_string_lossy());
352     if std::env::var_os("CCE_PRINT_DRY_RUN").is_some() {
353         log::info!("dry run: lp {}", args.join(" "));
354         let _ = std::fs::remove_file(&spool);
355         return Ok(());
356     }
357     let out = tokio::process::Command::new("lp").args(&args).output().await.map_err(|e| format!("lp: {e}"))?;
358     // lp has the job once it answers; the spool copy is ours to drop.
359     let _ = std::fs::remove_file(&spool);
360     if out.status.success() {
361         log::info!("{}", String::from_utf8_lossy(&out.stdout).trim());
362         Ok(())
363     } else {
364         Err(format!("lp: {}", String::from_utf8_lossy(&out.stderr).trim()))
365     }
366 }
367 
368 #[cfg(test)]
369 mod tests {
370     use super::*;
371 
372     fn dests() -> Vec<Destination> {
373         vec![
374             Destination { name: TO_FILE.into(), label: "Save as PDF".into(), duplex: false },
375             Destination { name: "laser".into(), label: "laser".into(), duplex: true },
376         ]
377     }
378 
379     #[test]
380     fn default_queue_first() {
381         assert_eq!(order_queues("inkjet\nlaser\n", "system default destination: laser"), ["laser", "inkjet"]);
382         assert_eq!(order_queues("a\nb\n", "no system default destination"), ["a", "b"]);
383         assert!(order_queues("", "").is_empty());
384     }
385 
386     #[test]
387     fn the_print_button_names_its_destination_and_controls() {
388         let out = r#"{"button":"print:1","checkboxes":{"0.all":true,"1.all":false,"1.duplex":true},"spinboxes":{"1.copies":2,"1.from":5,"1.to":3},"colors":{},"sliders":{}}"#;
389         let c = parse_answer(out, &dests()).unwrap();
390         assert_eq!(c, Choice { printer: "laser".into(), copies: 2, range: Some((3, 5)), duplex: true, output: None });
391         assert_eq!(c.lp_args("Doc", "/run/x.pdf"), ["-d", "laser", "-n", "2", "-t", "Doc", "-o", "sides=two-sided-long-edge", "--", "/run/x.pdf"]);
392         let s = c.settings();
393         let get = |k: &str| match s.get(k).map(|v| &**v) {
394             Some(zbus::zvariant::Value::Str(v)) => v.to_string(),
395             _ => panic!("{k} missing"),
396         };
397         assert_eq!(get("page-ranges"), "2-4", "GtkPrintSettings ranges are 0-based");
398         assert_eq!(get("print-pages"), "ranges");
399         assert_eq!(get("n-copies"), "2");
400     }
401 
402     #[test]
403     fn dismissal_and_page_turns_are_not_answers() {
404         assert_eq!(parse_answer("\n", &dests()), None);
405         assert_eq!(parse_answer(r#"{"button":"go:1"}"#, &dests()), None);
406         assert_eq!(parse_answer(r#"{"button":"print:9"}"#, &dests()), None);
407     }
408 
409     #[test]
410     fn layout_skips_the_list_for_one_destination() {
411         let one = dialog_layout("Doc", &dests()[..1]);
412         assert_eq!(one["pages"].as_array().unwrap().len(), 1);
413         let two = dialog_layout("Doc", &dests());
414         let pages = two["pages"].as_array().unwrap();
415         assert_eq!(pages.len(), 3);
416         assert_eq!(pages[0]["widgets"][2]["target_page"], 2);
417         let ids: Vec<&str> = pages[2]["widgets"].as_array().unwrap().iter().filter_map(|w| w["id"].as_str()).collect();
418         assert!(ids.contains(&"1.duplex") && ids.contains(&"print:1"));
419         let pdf_ids: Vec<&str> = pages[1]["widgets"].as_array().unwrap().iter().filter_map(|w| w["id"].as_str()).collect();
420         assert!(!pdf_ids.contains(&"0.copies"), "a file has no copies");
421     }
422 
423     #[test]
424     fn save_paths_end_in_pdf() {
425         assert_eq!(pdf_path("/home/u/a"), "/home/u/a.pdf");
426         assert_eq!(pdf_path("/home/u/a.PDF"), "/home/u/a.PDF");
427     }
428 }