git.lucas.co / cce-desktop-portal
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 }