use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rune::runtime::{RuntimeContext, Unit, budget};
use rune::termcolor::{ColorChoice, StandardStream};
use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
use crate::core::command::{Command, OpenTarget};
use crate::core::msg::Msg;
use crate::core::runtime::Mailbox;
const BUDGET: usize = 1_000_000;
type Hooks = Arc<Mutex<HashMap<String, Vec<String>>>>;
struct Bridge {
mailbox: Mailbox,
pending: Mutex<HashMap<u64, async_channel::Sender<String>>>,
next_id: AtomicU64,
}
impl Bridge {
fn next_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
}
struct LoadedPlugin {
name: String,
runtime: Arc<RuntimeContext>,
unit: Arc<Unit>,
hooks: Hooks,
}
pub struct PluginRuntime {
dir: PathBuf,
bridge: Arc<Bridge>,
plugins: Vec<LoadedPlugin>,
}
impl PluginRuntime {
pub fn new(dir: PathBuf, mailbox: Mailbox) -> Self {
let bridge = Arc::new(Bridge {
mailbox,
pending: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
});
let mut runtime = Self {
dir,
bridge,
plugins: Vec::new(),
};
runtime.load_all();
runtime
}
pub fn count(&self) -> usize {
self.plugins.len()
}
pub fn reload(&mut self) {
self.plugins.clear();
self.load_all();
}
pub fn fire(&self, event: &str, arg: &str) {
for plugin in &self.plugins {
let handlers = plugin
.hooks
.lock()
.map(|h| h.get(event).cloned().unwrap_or_default())
.unwrap_or_default();
for handler in handlers {
spawn_hook(
plugin.name.clone(),
plugin.runtime.clone(),
plugin.unit.clone(),
handler,
arg.to_string(),
);
}
}
}
pub fn resolve(&self, id: u64, result: String) {
if let Ok(mut pending) = self.bridge.pending.lock()
&& let Some(tx) = pending.remove(&id)
{
let _ = tx.try_send(result);
}
}
fn load_all(&mut self) {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "rn") {
match load_one(&path, &self.bridge) {
Ok(plugin) => {
eprintln!("[qbrsh] loaded plugin: {}", plugin.name);
self.plugins.push(plugin);
}
Err(e) => eprintln!("[qbrsh] plugin {} failed to load: {e}", path.display()),
}
}
}
}
}
fn spawn_hook(name: String, runtime: Arc<RuntimeContext>, unit: Arc<Unit>, handler: String, arg: String) {
glib::MainContext::default().spawn_local(async move {
let mut vm = Vm::new(runtime, unit);
let outcome = match vm.execute([handler.as_str()], (arg,)) {
Ok(mut execution) => budget::with(BUDGET, execution.async_complete())
.await
.into_result()
.map(|_| ())
.map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
};
if let Err(e) = outcome {
eprintln!("[qbrsh] plugin '{name}' hook '{handler}' error: {e}");
}
});
}
fn build_module(bridge: Arc<Bridge>, hooks: Hooks) -> Result<Module, rune::ContextError> {
let mut module = Module::with_crate("qbrsh")?;
let b = bridge.clone();
module
.function("command", move |s: String| {
if let Ok(cmd) = Command::parse(&s) {
b.mailbox.send(Msg::Command(cmd));
}
})
.build()?;
let b = bridge.clone();
module
.function("open", move |url: String| {
if crate::core::command::is_safe_external_target(&url) {
b.mailbox.send(Msg::Command(Command::Open {
target: OpenTarget::Current,
input: url,
}));
}
})
.build()?;
let b = bridge.clone();
module
.function("message", move |text: String| {
b.mailbox.send(Msg::PluginMessage(text));
})
.build()?;
let b = bridge.clone();
module
.function("eval_js", move |script: String| {
let bridge = b.clone();
async move {
let id = bridge.next_id();
let (tx, rx) = async_channel::bounded::<String>(1);
if let Ok(mut pending) = bridge.pending.lock() {
pending.insert(id, tx);
}
bridge.mailbox.send(Msg::PluginEvalRequest { id, script });
rx.recv().await.unwrap_or_default()
}
})
.build()?;
module
.function("on", move |event: String, handler: String| {
if let Ok(mut h) = hooks.lock() {
h.entry(event).or_default().push(handler);
}
})
.build()?;
Ok(module)
}
fn load_one(path: &Path, bridge: &Arc<Bridge>) -> Result<LoadedPlugin, String> {
let hooks: Hooks = Arc::new(Mutex::new(HashMap::new()));
let module = build_module(bridge.clone(), hooks.clone()).map_err(|e| e.to_string())?;
let mut context = Context::with_default_modules().map_err(|e| e.to_string())?;
context.install(&module).map_err(|e| e.to_string())?;
let runtime = Arc::new(context.runtime().map_err(|e| e.to_string())?);
let mut sources = Sources::new();
let source = Source::from_path(path).map_err(|e| e.to_string())?;
sources.insert(source).map_err(|e| e.to_string())?;
let mut diagnostics = Diagnostics::new();
let result = rune::prepare(&mut sources)
.with_context(&context)
.with_diagnostics(&mut diagnostics)
.build();
if !diagnostics.is_empty() {
let mut writer = StandardStream::stderr(ColorChoice::Auto);
let _ = diagnostics.emit(&mut writer, &sources);
}
let unit = Arc::new(result.map_err(|e| e.to_string())?);
let mut vm = Vm::new(runtime.clone(), unit.clone());
let _ = budget::with(BUDGET, || vm.call(["main"], ())).call();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("plugin")
.to_string();
Ok(LoadedPlugin {
name,
runtime,
unit,
hooks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::runtime::Mailbox;
fn bridge() -> Arc<Bridge> {
let (mailbox, _rx) = Mailbox::channel();
Arc::new(Bridge {
mailbox,
pending: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
})
}
fn load_source(src: &str, bridge: &Arc<Bridge>) -> LoadedPlugin {
let hooks: Hooks = Arc::new(Mutex::new(HashMap::new()));
let module = build_module(bridge.clone(), hooks.clone()).unwrap();
let mut context = Context::with_default_modules().unwrap();
context.install(&module).unwrap();
let runtime = Arc::new(context.runtime().unwrap());
let mut sources = Sources::new();
sources.insert(Source::memory(src).unwrap()).unwrap();
let unit = Arc::new(
rune::prepare(&mut sources)
.with_context(&context)
.build()
.unwrap(),
);
let mut vm = Vm::new(runtime.clone(), unit.clone());
let _ = budget::with(BUDGET, || vm.call(["main"], ())).call();
LoadedPlugin {
name: "test".to_string(),
runtime,
unit,
hooks,
}
}
#[test]
fn main_registers_hooks() {
let plugin = load_source(
r#"pub fn main() { qbrsh::on("page_load", "on_load"); }
pub fn on_load(url) { qbrsh::message(url); }"#,
&bridge(),
);
let hooks = plugin.hooks.lock().unwrap();
assert_eq!(hooks.get("page_load").map(|v| v.len()), Some(1));
}
#[test]
fn runaway_main_is_budget_aborted() {
let _plugin = load_source(
r#"pub fn main() { let n = 0; while true { n += 1; } }"#,
&bridge(),
);
}
}