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 }