1 // Copyright (C) 2021  Tassilo Horn <tsdh@gnu.org>
2 //
3 // This program is free software: you can redistribute it and/or modify it
4 // under the terms of the GNU General Public License as published by the Free
5 // Software Foundation, either version 3 of the License, or (at your option)
6 // any later version.
7 //
8 // This program is distributed in the hope that it will be useful, but WITHOUT
9 // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
10 // FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
11 // more details.
12 //
13 // You should have received a copy of the GNU General Public License along with
14 // this program.  If not, see <https://www.gnu.org/licenses/>.
15 
16 //! Utility functions including selection between choices using a menu program.
17 
18 use crate::config as cfg;
19 use lazy_static::lazy_static;
20 use std::collections::HashMap;
21 use std::io::{BufRead, Write};
22 use std::path as p;
23 use std::process as proc;
24 
get_swayr_socket_path() -> String25 pub fn get_swayr_socket_path() -> String {
26     // We prefer checking the env variable instead of
27     // directories::BaseDirs::new().unwrap().runtime_dir().unwrap() because
28     // directories errors if the XDG_RUNTIME_DIR isn't set or set to a relative
29     // path which actually works fine for sway & swayr.
30     let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR");
31     let wayland_display = std::env::var("WAYLAND_DISPLAY");
32     format!(
33         "{}/swayr-{}.sock",
34         match xdg_runtime_dir {
35             Ok(val) => val,
36             Err(_e) => {
37                 eprintln!("Couldn't get XDG_RUNTIME_DIR!");
38                 String::from("/tmp")
39             }
40         },
41         match wayland_display {
42             Ok(val) => val,
43             Err(_e) => {
44                 eprintln!("Couldn't get WAYLAND_DISPLAY!");
45                 String::from("unknown")
46             }
47         }
48     )
49 }
50 
desktop_entry_folders() -> Vec<Box<p::Path>>51 fn desktop_entry_folders() -> Vec<Box<p::Path>> {
52     let mut dirs: Vec<Box<p::Path>> = vec![];
53 
54     // XDG_DATA_HOME/applications
55     if let Some(dd) = directories::BaseDirs::new() {
56         dirs.push(dd.data_local_dir().to_path_buf().into_boxed_path());
57     }
58 
59     let default_dirs =
60         ["/usr/local/share/applications/", "/usr/local/share/applications/"];
61     for dir in default_dirs {
62         dirs.push(p::Path::new(dir).to_path_buf().into_boxed_path());
63     }
64 
65     if let Ok(xdg_data_dirs) = std::env::var("XDG_DATA_DIRS") {
66         for mut dir in std::env::split_paths(&xdg_data_dirs) {
67             dir.push("applications/");
68             dirs.push(dir.into_boxed_path());
69         }
70     }
71 
72     dirs.sort();
73     dirs.dedup();
74 
75     dirs
76 }
77 
desktop_entries() -> Vec<Box<p::Path>>78 fn desktop_entries() -> Vec<Box<p::Path>> {
79     let mut entries = vec![];
80     for dir in desktop_entry_folders() {
81         if let Ok(readdir) = dir.read_dir() {
82             for entry in readdir.flatten() {
83                 let path = entry.path();
84                 if path.is_file()
85                     && path.extension().map(|ext| ext == "desktop")
86                         == Some(true)
87                 {
88                     entries.push(path.to_path_buf().into_boxed_path());
89                 }
90             }
91         }
92     }
93     entries
94 }
95 
find_icon(icon_name: &str, icon_dirs: &[String]) -> Option<Box<p::Path>>96 fn find_icon(icon_name: &str, icon_dirs: &[String]) -> Option<Box<p::Path>> {
97     let p = p::Path::new(icon_name);
98     if p.is_file() {
99         println!("(1) Icon name '{}' -> {}", icon_name, p.display());
100         return Some(p.to_path_buf().into_boxed_path());
101     }
102 
103     for dir in icon_dirs {
104         for ext in &["png", "svg"] {
105             let mut pb = p::PathBuf::from(dir);
106             pb.push(icon_name.to_owned() + "." + ext);
107             let icon_file = pb.as_path();
108             if icon_file.is_file() {
109                 println!(
110                     "(2) Icon name '{}' -> {}",
111                     icon_name,
112                     icon_file.display()
113                 );
114                 return Some(icon_file.to_path_buf().into_boxed_path());
115             }
116         }
117     }
118 
119     println!("(3) No icon for name {}", icon_name);
120     None
121 }
122 
123 lazy_static! {
124     static ref WM_CLASS_OR_ICON_RX: regex::Regex =
125         regex::Regex::new(r"(StartupWMClass|Icon)=(.+)").unwrap();
126     static ref REV_DOMAIN_NAME_RX: regex::Regex =
127         regex::Regex::new(r"^(?:[a-zA-Z0-9-]+\.)+([a-zA-Z0-9-]+)$").unwrap();
128 }
129 
get_app_id_to_icon_map( icon_dirs: &[String], ) -> HashMap<String, Box<p::Path>>130 fn get_app_id_to_icon_map(
131     icon_dirs: &[String],
132 ) -> HashMap<String, Box<p::Path>> {
133     let mut map: HashMap<String, Box<p::Path>> = HashMap::new();
134 
135     for e in desktop_entries() {
136         if let Ok(f) = std::fs::File::open(&e) {
137             let buf = std::io::BufReader::new(f);
138             let mut wm_class: Option<String> = None;
139             let mut icon: Option<Box<p::Path>> = None;
140 
141             // Get App-Id and Icon from desktop file.
142             for line in buf.lines() {
143                 if wm_class.is_some() && icon.is_some() {
144                     break;
145                 }
146                 if let Ok(line) = line {
147                     if let Some(cap) = WM_CLASS_OR_ICON_RX.captures(&line) {
148                         if "StartupWMClass" == cap.get(1).unwrap().as_str() {
149                             wm_class.replace(
150                                 cap.get(2).unwrap().as_str().to_string(),
151                             );
152                         } else if let Some(icon_file) =
153                             find_icon(cap.get(2).unwrap().as_str(), icon_dirs)
154                         {
155                             icon.replace(icon_file);
156                         }
157                     }
158                 }
159             }
160 
161             if let Some(icon) = icon {
162                 // Sometimes the StartupWMClass is the app_id, e.g. FF Dev
163                 // Edition has StartupWMClass firefoxdeveloperedition although
164                 // the desktop file is named firefox-developer-edition.
165                 if let Some(wm_class) = wm_class {
166                     map.insert(wm_class, icon.clone());
167                 }
168 
169                 // Some apps have a reverse domain name desktop file, e.g.,
170                 // org.gnome.eog.desktop but reports as just eog.
171                 let desktop_file_name = String::from(
172                     e.with_extension("").file_name().unwrap().to_string_lossy(),
173                 );
174                 if let Some(caps) =
175                     REV_DOMAIN_NAME_RX.captures(&desktop_file_name)
176                 {
177                     map.insert(
178                         caps.get(1).unwrap().as_str().to_string(),
179                         icon.clone(),
180                     );
181                 }
182 
183                 // The usual case is that the app with foo.desktop also has the
184                 // app_id foo.
185                 map.insert(desktop_file_name.clone(), icon);
186             }
187         }
188     }
189 
190     println!(
191         "Desktop entries to icon files ({} entries):\n{:#?}",
192         map.len(),
193         map
194     );
195     map
196 }
197 
198 lazy_static! {
199     static ref APP_ID_TO_ICON_MAP: std::sync::Mutex<Option<HashMap<String, Box<p::Path>>>> =
200         std::sync::Mutex::new(None);
201 }
202 
get_icon(app_id: &str, icon_dirs: &[String]) -> Option<Box<p::Path>>203 pub fn get_icon(app_id: &str, icon_dirs: &[String]) -> Option<Box<p::Path>> {
204     let mut opt = APP_ID_TO_ICON_MAP.lock().unwrap();
205 
206     if opt.is_none() {
207         opt.replace(get_app_id_to_icon_map(icon_dirs));
208     }
209 
210     opt.as_ref().unwrap().get(app_id).map(|i| i.to_owned())
211 }
212 
213 #[test]
test_icon_stuff()214 fn test_icon_stuff() {
215     let icon_dirs = vec![
216         String::from("/usr/local/share/icons/hicolor/scalable/apps"),
217         String::from("/usr/local/share/icons/hicolor/48x48/apps"),
218         String::from("/usr/local/share/icons/Adwaita/48x48/apps"),
219         String::from("/usr/local/share/pixmaps"),
220     ];
221     let m = get_app_id_to_icon_map(&icon_dirs);
222     println!("Found {} icon entries:\n{:#?}", m.len(), m);
223 
224     let apps = vec!["Emacs", "Alacritty", "firefoxdeveloperedition", "gimp"];
225     for app in apps {
226         println!("Icon for {}: {:?}", app, get_icon(app, &icon_dirs))
227     }
228 }
229 
230 pub trait DisplayFormat {
format_for_display(&self, config: &cfg::Config) -> String231     fn format_for_display(&self, config: &cfg::Config) -> String;
get_indent_level(&self) -> usize232     fn get_indent_level(&self) -> usize;
233 }
234 
select_from_menu<'a, 'b, TS>( prompt: &'a str, choices: &'b [TS], ) -> Result<&'b TS, String> where TS: DisplayFormat + Sized,235 pub fn select_from_menu<'a, 'b, TS>(
236     prompt: &'a str,
237     choices: &'b [TS],
238 ) -> Result<&'b TS, String>
239 where
240     TS: DisplayFormat + Sized,
241 {
242     let mut map: HashMap<String, &TS> = HashMap::new();
243     let mut strs: Vec<String> = vec![];
244     let cfg = cfg::load_config();
245     for c in choices {
246         let s = c.format_for_display(&cfg);
247         strs.push(s.clone());
248 
249         // Workaround: rofi has "\u0000icon\u001f/path/to/icon.png" as image
250         // escape sequence which comes after the actual text but returns only
251         // the text, not the escape sequence.
252         if s.contains('\0') {
253             if let Some(prefix) = s.split('\0').next() {
254                 map.insert(prefix.to_string(), c);
255             }
256         }
257 
258         map.insert(s, c);
259     }
260 
261     let menu_exec = cfg.get_menu_executable();
262     let args: Vec<String> = cfg
263         .get_menu_args()
264         .iter()
265         .map(|a| a.replace("{prompt}", prompt))
266         .collect();
267 
268     let mut menu = proc::Command::new(&menu_exec)
269         .args(args)
270         .stdin(proc::Stdio::piped())
271         .stdout(proc::Stdio::piped())
272         .spawn()
273         .expect(&("Error running ".to_owned() + &menu_exec));
274 
275     {
276         let stdin = menu
277             .stdin
278             .as_mut()
279             .expect("Failed to open the menu program's stdin");
280         let input = strs.join("\n");
281         //println!("Menu program {} input:\n{}", menu_exec, input);
282         stdin
283             .write_all(input.as_bytes())
284             .expect("Failed to write to the menu program's stdin");
285     }
286 
287     let output = menu.wait_with_output().expect("Failed to read stdout");
288     let choice = String::from_utf8_lossy(&output.stdout);
289     let mut choice = String::from(choice);
290     choice.pop(); // Remove trailing \n from choice.
291     map.get(&choice).copied().ok_or(choice)
292 }
293