Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git
src/app_chooser.rs (8.3K)
1 //! `org.freedesktop.impl.portal.AppChooser`: the "Open with…" dialog, as a
2 //! cce popup instead of a GTK window.
3 //!
4 //! The frontend has already done the hard part — it hands over `choices`, the
5 //! desktop-file IDs that handle the content — and asks only which one. This
6 //! backend runs `cce-cloud --choose` (the launcher's chooser mode: rows are
7 //! each ID's name and icon, the answer is the ID) with the IDs on stdin, the
8 //! way the file chooser runs `cce-files --select`. The frontend launches the
9 //! app itself once it has the answer.
10 //!
11 //! The request object at `handle` lets the app (or the frontend, when the app
12 //! exits) take the question back: `Close` kills the cce-cloud client, and the
13 //! cce-cloud daemon closes a popup whose client hung up.
14
15 use std::collections::HashMap;
16 use std::process::Stdio;
17 use std::sync::Arc;
18
19 use tokio::io::AsyncWriteExt;
20 use tokio::sync::{oneshot, Mutex};
21 use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value};
22 use zbus::{interface, Connection, ObjectServer};
23
24 const RESPONSE_OK: u32 = 0;
25 const RESPONSE_CANCELLED: u32 = 1;
26 const RESPONSE_OTHER: u32 = 2;
27
28 /// Longest a path or URI may run in the prompt before it is shortened.
29 const PROMPT_SUBJECT_MAX: usize = 48;
30
31 type Pending = Arc<Mutex<HashMap<OwnedObjectPath, oneshot::Sender<()>>>>;
32
33 #[derive(Default)]
34 pub struct AppChooser {
35 pending: Pending,
36 }
37
38 fn str_opt(options: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
39 match options.get(key).map(|v| &**v) {
40 Some(Value::Str(s)) if !s.is_empty() => Some(s.to_string()),
41 _ => None,
42 }
43 }
44
45 /// The popup's prompt: what is being opened, as briefly as says it. cce-cloud
46 /// draws it straight before the query (or its placeholder), so it carries
47 /// its own separator.
48 pub fn prompt(filename: Option<&str>, uri: Option<&str>) -> String {
49 let subject = filename
50 .map(|f| f.rsplit('/').next().unwrap_or(f).to_string())
51 .or_else(|| uri.map(str::to_string));
52 match subject {
53 Some(s) if s.chars().count() > PROMPT_SUBJECT_MAX => {
54 let head: String = s.chars().take(PROMPT_SUBJECT_MAX - 1).collect();
55 format!("Open {head}… with: ")
56 }
57 Some(s) => format!("Open {s} with: "),
58 None => "Open with: ".to_string(),
59 }
60 }
61
62 /// The chooser's answer as the portal response: an ID it was offered, or
63 /// cancelled (Escape answers an empty line; anything else was not a choice).
64 pub fn response(stdout: &str, choices: &[String]) -> (u32, Option<String>) {
65 let pick = stdout.lines().next().unwrap_or("").trim();
66 if !pick.is_empty() && choices.iter().any(|c| c == pick) {
67 (RESPONSE_OK, Some(pick.to_string()))
68 } else {
69 (RESPONSE_CANCELLED, None)
70 }
71 }
72
73 /// cce-cloud from the install location: the bus activates this backend with
74 /// the session's environment, whose PATH need not include ~/.local/bin.
75 /// `CCE_CHOOSER_BIN` points one run at another build, for testing.
76 fn cce_cloud() -> std::path::PathBuf {
77 if let Some(bin) = std::env::var_os("CCE_CHOOSER_BIN").filter(|b| !b.is_empty()) {
78 return bin.into();
79 }
80 let installed = std::env::var_os("HOME")
81 .map(|h| std::path::PathBuf::from(h).join(".local/bin/cce-cloud"))
82 .filter(|p| p.is_file());
83 installed.unwrap_or_else(|| "cce-cloud".into())
84 }
85
86 #[interface(name = "org.freedesktop.impl.portal.AppChooser")]
87 impl AppChooser {
88 #[zbus(property, name = "version")]
89 fn version(&self) -> u32 {
90 2
91 }
92
93 async fn choose_application(
94 &self,
95 #[zbus(object_server)] server: &ObjectServer,
96 handle: ObjectPath<'_>,
97 app_id: String,
98 _parent_window: String,
99 choices: Vec<String>,
100 options: HashMap<String, OwnedValue>,
101 ) -> (u32, HashMap<String, OwnedValue>) {
102 let path: OwnedObjectPath = handle.clone().into();
103 let (filename, uri) = (str_opt(&options, "filename"), str_opt(&options, "uri"));
104 let last = str_opt(&options, "last_choice");
105 log::info!("ChooseApplication {path} for {app_id:?}: {} choices, last {last:?}", choices.len());
106 if choices.is_empty() {
107 return (RESPONSE_CANCELLED, HashMap::new());
108 }
109
110 let (cancel_tx, cancel_rx) = oneshot::channel();
111 self.pending.lock().await.insert(path.clone(), cancel_tx);
112 if let Err(e) = server.at(&path, ChooserRequest { path: path.clone(), pending: self.pending.clone() }).await {
113 log::warn!("exporting request {path}: {e}");
114 }
115
116 let mut cmd = tokio::process::Command::new(cce_cloud());
117 cmd.arg("--choose").arg("-p").arg(prompt(filename.as_deref(), uri.as_deref()));
118 if let Some(last) = last.as_ref().filter(|l| choices.contains(l)) {
119 cmd.arg("-s").arg(last);
120 }
121 // Killing the client on cancel is the whole cancel path: the daemon
122 // closes a popup whose client hung up.
123 cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).kill_on_drop(true);
124
125 let outcome = match cmd.spawn() {
126 Ok(mut child) => {
127 if let Some(mut stdin) = child.stdin.take() {
128 let feed = choices.join("\n") + "\n";
129 if let Err(e) = stdin.write_all(feed.as_bytes()).await {
130 log::warn!("feeding the chooser: {e}");
131 }
132 // Dropped here: EOF tells cce-cloud the list is complete.
133 }
134 tokio::select! {
135 out = child.wait_with_output() => Some(out),
136 _ = cancel_rx => None,
137 }
138 }
139 Err(e) => Some(Err(e)),
140 };
141
142 self.pending.lock().await.remove(&path);
143 let _ = server.remove::<ChooserRequest, _>(&path).await;
144
145 match outcome {
146 None => {
147 log::info!("{path}: closed by the caller");
148 (RESPONSE_CANCELLED, HashMap::new())
149 }
150 Some(Err(e)) => {
151 log::error!("running {}: {e}", cce_cloud().display());
152 (RESPONSE_OTHER, HashMap::new())
153 }
154 Some(Ok(out)) => match response(&String::from_utf8_lossy(&out.stdout), &choices) {
155 (code, Some(choice)) => {
156 log::info!("{path}: chose {choice}");
157 (code, HashMap::from([("choice".to_string(), OwnedValue::from(zbus::zvariant::Str::from(choice)))]))
158 }
159 (code, None) => {
160 log::info!("{path}: dismissed");
161 (code, HashMap::new())
162 }
163 },
164 }
165 }
166
167 /// The handler list changed while the popup is open (an app was
168 /// installed). The open popup keeps the list it was given; the next
169 /// request sees the new one.
170 async fn update_choices(&self, handle: ObjectPath<'_>, choices: Vec<String>) {
171 log::info!("UpdateChoices {handle}: {} choices (the open popup keeps its list)", choices.len());
172 }
173 }
174
175 /// `org.freedesktop.impl.portal.Request` at the handle the frontend chose.
176 struct ChooserRequest {
177 path: OwnedObjectPath,
178 pending: Pending,
179 }
180
181 #[interface(name = "org.freedesktop.impl.portal.Request")]
182 impl ChooserRequest {
183 async fn close(&self, #[zbus(connection)] _conn: &Connection) {
184 if let Some(cancel) = self.pending.lock().await.remove(&self.path) {
185 let _ = cancel.send(());
186 }
187 }
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193
194 #[test]
195 fn prompts() {
196 assert_eq!(prompt(Some("/home/u/report.pdf"), None), "Open report.pdf with: ");
197 assert_eq!(prompt(None, Some("https://example.org/a")), "Open https://example.org/a with: ");
198 assert_eq!(prompt(None, None), "Open with: ");
199 let long = format!("https://example.org/{}", "x".repeat(80));
200 let p = prompt(None, Some(&long));
201 assert!(p.ends_with("… with: ") && p.chars().count() < 70, "{p}");
202 }
203
204 #[test]
205 fn only_an_offered_id_is_a_choice() {
206 let choices = vec!["org.gnome.Evince".to_string(), "firefox".to_string()];
207 assert_eq!(response("firefox\n", &choices), (RESPONSE_OK, Some("firefox".to_string())));
208 assert_eq!(response("\n", &choices), (RESPONSE_CANCELLED, None), "Escape");
209 assert_eq!(response("", &choices), (RESPONSE_CANCELLED, None), "killed");
210 assert_eq!(response("something typed\n", &choices), (RESPONSE_CANCELLED, None));
211 }
212 }