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