onagre_launcher_plugins/scripts/
mod.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright © 2021 System76
3
4use crate::*;
5use onagre_launcher::*;
6
7use flume::Sender;
8use futures::StreamExt;
9use regex::Regex;
10use std::collections::VecDeque;
11use std::path::{Path, PathBuf};
12use std::process::Stdio;
13use tokio::io::AsyncBufReadExt;
14use tokio::process::Command;
15
16const LOCAL_PATH: &str = ".local/share/pop-launcher/scripts";
17const SYSTEM_ADMIN_PATH: &str = "/etc/pop-launcher/scripts";
18const DISTRIBUTION_PATH: &str = "/usr/lib/pop-launcher/scripts";
19
20pub async fn main() {
21    let mut requests = json_input_stream(async_stdin());
22
23    let mut app = App::new();
24
25    app.reload().await;
26
27    while let Some(result) = requests.next().await {
28        match result {
29            Ok(request) => match request {
30                Request::Activate(id) => app.activate(id).await,
31                Request::Search(query) => app.search(&query).await,
32                Request::Exit => break,
33                _ => (),
34            },
35
36            Err(why) => {
37                tracing::error!("malformed JSON input: {}", why);
38            }
39        }
40    }
41}
42
43pub struct App {
44    scripts: Vec<ScriptInfo>,
45    out: tokio::io::Stdout,
46}
47
48impl App {
49    fn new() -> Self {
50        App {
51            scripts: Vec::with_capacity(16),
52            out: async_stdout(),
53        }
54    }
55
56    async fn activate(&mut self, id: u32) {
57        if let Some(script) = self.scripts.get(id as usize) {
58            let mut shell: String = Default::default();
59            let mut args: Vec<&OsStr> = Vec::new();
60
61            let program = script
62                .interpreter
63                .as_deref()
64                .and_then(|interpreter| {
65                    // split the shebang into parts, e.g. ["/bin/bash"], or a more complex ["/usr/bin/env", "bash"]
66                    let mut parts = interpreter.split_ascii_whitespace();
67
68                    // first part must be the command to run, e.g. "/usr/bin/env"
69                    let command = parts.next()?;
70
71                    for arg in parts {
72                        args.push(arg.as_ref());
73                    }
74
75                    Some(command)
76                })
77                .or_else(|| {
78                    if let Ok(string) = std::env::var("SHELL") {
79                        shell = string;
80                        return Some(&shell);
81                    }
82
83                    None
84                })
85                .unwrap_or("sh");
86
87            // add the script file itself as a final arg for the interpreter
88            args.push(script.path.as_ref());
89
90            send(&mut self.out, PluginResponse::Close).await;
91
92            let _ = Command::new(program)
93                .args(args)
94                .stdin(Stdio::null())
95                .stdout(Stdio::null())
96                .stderr(Stdio::null())
97                .spawn();
98        }
99    }
100
101    async fn reload(&mut self) {
102        let (tx, rx) = flume::bounded::<ScriptInfo>(8);
103
104        let mut queue = VecDeque::new();
105
106        queue.push_back(
107            dirs::home_dir()
108                .expect("user does not have home dir")
109                .join(LOCAL_PATH),
110        );
111        queue.push_back(Path::new(SYSTEM_ADMIN_PATH).to_owned());
112        queue.push_back(Path::new(DISTRIBUTION_PATH).to_owned());
113
114        let script_sender = async move {
115            while let Some(path) = queue.pop_front() {
116                load_from(&path, &mut queue, tx.clone()).await;
117            }
118        };
119
120        let script_receiver = async {
121            'outer: while let Ok(script) = rx.recv_async().await {
122                tracing::debug!("appending script: {:?}", script);
123                for cached_script in &self.scripts {
124                    if cached_script.name == script.name {
125                        continue 'outer;
126                    }
127                }
128                self.scripts.push(script);
129            }
130        };
131
132        futures::future::join(script_sender, script_receiver).await;
133    }
134
135    async fn search(&mut self, query: &str) {
136        let &mut Self {
137            ref scripts,
138            ref mut out,
139            ..
140        } = self;
141        for (id, script) in scripts.iter().enumerate() {
142            let should_include = script.name.to_ascii_lowercase().contains(query)
143                || script.description.to_ascii_lowercase().contains(query)
144                || script.keywords.iter().any(|k| k.contains(query));
145
146            if should_include {
147                send(
148                    out,
149                    PluginResponse::Append(PluginSearchResult {
150                        id: id as u32,
151                        name: script.name.clone(),
152                        description: script.description.clone(),
153                        icon: script
154                            .icon
155                            .as_ref()
156                            .map(|icon| IconSource::Name(icon.clone().into())),
157                        keywords: Some(script.keywords.clone()),
158                        ..Default::default()
159                    }),
160                )
161                .await;
162            }
163        }
164
165        send(out, PluginResponse::Finished).await;
166    }
167}
168
169#[derive(Debug, Default)]
170struct ScriptInfo {
171    interpreter: Option<String>,
172    name: String,
173    icon: Option<String>,
174    path: PathBuf,
175    keywords: Vec<String>,
176    description: String,
177}
178
179async fn load_from(path: &Path, paths: &mut VecDeque<PathBuf>, tx: Sender<ScriptInfo>) {
180    if let Ok(directory) = path.read_dir() {
181        for entry in directory.filter_map(Result::ok) {
182            let tx = tx.clone();
183            let path = entry.path();
184
185            if path.is_dir() {
186                paths.push_back(path);
187                continue;
188            }
189
190            tokio::spawn(async move {
191                let shebang_re = Regex::new(r"^!\s*").unwrap();
192
193                let mut file = match tokio::fs::File::open(&path).await {
194                    Ok(file) => tokio::io::BufReader::new(file).lines(),
195                    Err(why) => {
196                        tracing::error!("cannot open script at {}: {}", path.display(), why);
197                        return;
198                    }
199                };
200
201                let mut info = ScriptInfo {
202                    path,
203                    ..Default::default()
204                };
205
206                let mut first = true;
207
208                while let Ok(Some(line)) = file.next_line().await {
209                    if !line.starts_with('#') {
210                        break;
211                    }
212
213                    let line = line[1..].trim();
214
215                    if first {
216                        first = false;
217                        if shebang_re.is_match(line) {
218                            info.interpreter = Some(shebang_re.replace(line, "").to_string());
219                            continue;
220                        }
221                    }
222
223                    if let Some(stripped) = line.strip_prefix("name:") {
224                        info.name = stripped.trim_start().to_owned();
225                    } else if let Some(stripped) = line.strip_prefix("description:") {
226                        info.description = stripped.trim_start().to_owned();
227                    } else if let Some(stripped) = line.strip_prefix("icon:") {
228                        info.icon = Some(stripped.trim_start().to_owned());
229                    } else if let Some(stripped) = line.strip_prefix("keywords:") {
230                        info.keywords =
231                            stripped.trim_start().split(' ').map(String::from).collect();
232                    }
233                }
234
235                let _ = tx.send_async(info).await;
236            });
237        }
238    }
239}