pop-launcher/plugins/src/scripts/mod.rs
Duane Johnson 1fa817f12f
Handle shebang more thoroughly in scripts plugin (#160)
* feat(scripts): allow whitespace after shebang `#!` characters

* feat(scripts): allow shebang line to have its own args

* fix(scripts): improve unwraps and extra allocations; use SHELL env variable
2023-01-20 15:15:28 -07:00

239 lines
7.5 KiB
Rust

// SPDX-License-Identifier: GPL-3.0-only
// Copyright © 2021 System76
use crate::*;
use pop_launcher::*;
use flume::Sender;
use futures::StreamExt;
use regex::Regex;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
const LOCAL_PATH: &str = ".local/share/pop-launcher/scripts";
const SYSTEM_ADMIN_PATH: &str = "/etc/pop-launcher/scripts";
const DISTRIBUTION_PATH: &str = "/usr/lib/pop-launcher/scripts";
pub async fn main() {
let mut requests = json_input_stream(async_stdin());
let mut app = App::new();
app.reload().await;
while let Some(result) = requests.next().await {
match result {
Ok(request) => match request {
Request::Activate(id) => app.activate(id).await,
Request::Search(query) => app.search(&query).await,
Request::Exit => break,
_ => (),
},
Err(why) => {
tracing::error!("malformed JSON input: {}", why);
}
}
}
}
pub struct App {
scripts: Vec<ScriptInfo>,
out: tokio::io::Stdout,
}
impl App {
fn new() -> Self {
App {
scripts: Vec::with_capacity(16),
out: async_stdout(),
}
}
async fn activate(&mut self, id: u32) {
if let Some(script) = self.scripts.get(id as usize) {
let mut shell: String = Default::default();
let mut args: Vec<&OsStr> = Vec::new();
let program = script
.interpreter
.as_deref()
.and_then(|interpreter| {
// split the shebang into parts, e.g. ["/bin/bash"], or a more complex ["/usr/bin/env", "bash"]
let mut parts = interpreter.split_ascii_whitespace();
// first part must be the command to run, e.g. "/usr/bin/env"
let command = parts.next()?;
for arg in parts {
args.push(arg.as_ref());
}
Some(command)
})
.or_else(|| {
if let Ok(string) = std::env::var("SHELL") {
shell = string;
return Some(&shell);
}
None
})
.unwrap_or("sh");
// add the script file itself as a final arg for the interpreter
args.push(script.path.as_ref());
send(&mut self.out, PluginResponse::Close).await;
let _ = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
async fn reload(&mut self) {
let (tx, rx) = flume::bounded::<ScriptInfo>(8);
let mut queue = VecDeque::new();
queue.push_back(
dirs::home_dir()
.expect("user does not have home dir")
.join(LOCAL_PATH),
);
queue.push_back(Path::new(SYSTEM_ADMIN_PATH).to_owned());
queue.push_back(Path::new(DISTRIBUTION_PATH).to_owned());
let script_sender = async move {
while let Some(path) = queue.pop_front() {
load_from(&path, &mut queue, tx.clone()).await;
}
};
let script_receiver = async {
'outer: while let Ok(script) = rx.recv_async().await {
tracing::debug!("appending script: {:?}", script);
for cached_script in &self.scripts {
if cached_script.name == script.name {
continue 'outer;
}
}
self.scripts.push(script);
}
};
futures::future::join(script_sender, script_receiver).await;
}
async fn search(&mut self, query: &str) {
let &mut Self {
ref scripts,
ref mut out,
..
} = self;
for (id, script) in scripts.iter().enumerate() {
let should_include = script.name.to_ascii_lowercase().contains(query)
|| script.description.to_ascii_lowercase().contains(query)
|| script.keywords.iter().any(|k| k.contains(query));
if should_include {
send(
out,
PluginResponse::Append(PluginSearchResult {
id: id as u32,
name: script.name.clone(),
description: script.description.clone(),
icon: script
.icon
.as_ref()
.map(|icon| IconSource::Name(icon.clone().into())),
keywords: Some(script.keywords.clone()),
..Default::default()
}),
)
.await;
}
}
send(out, PluginResponse::Finished).await;
}
}
#[derive(Debug, Default)]
struct ScriptInfo {
interpreter: Option<String>,
name: String,
icon: Option<String>,
path: PathBuf,
keywords: Vec<String>,
description: String,
}
async fn load_from(path: &Path, paths: &mut VecDeque<PathBuf>, tx: Sender<ScriptInfo>) {
if let Ok(directory) = path.read_dir() {
for entry in directory.filter_map(Result::ok) {
let tx = tx.clone();
let path = entry.path();
if path.is_dir() {
paths.push_back(path);
continue;
}
tokio::spawn(async move {
let shebang_re = Regex::new(r"^!\s*").unwrap();
let mut file = match tokio::fs::File::open(&path).await {
Ok(file) => tokio::io::BufReader::new(file).lines(),
Err(why) => {
tracing::error!("cannot open script at {}: {}", path.display(), why);
return;
}
};
let mut info = ScriptInfo {
path,
..Default::default()
};
let mut first = true;
while let Ok(Some(line)) = file.next_line().await {
if !line.starts_with('#') {
break;
}
let line = line[1..].trim();
if first {
first = false;
if shebang_re.is_match(line) {
info.interpreter = Some(shebang_re.replace(line, "").to_string());
continue;
}
}
if let Some(stripped) = line.strip_prefix("name:") {
info.name = stripped.trim_start().to_owned();
} else if let Some(stripped) = line.strip_prefix("description:") {
info.description = stripped.trim_start().to_owned();
} else if let Some(stripped) = line.strip_prefix("icon:") {
info.icon = Some(stripped.trim_start().to_owned());
} else if let Some(stripped) = line.strip_prefix("keywords:") {
info.keywords =
stripped.trim_start().split(' ').map(String::from).collect();
}
}
let _ = tx.send_async(info).await;
});
}
}
}