onagre_launcher_plugins/scripts/
mod.rs1use 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 let mut parts = interpreter.split_ascii_whitespace();
67
68 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 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}