bread/breadd/src/lua/mod.rs
2026-05-16 19:44:19 +08:00

2459 lines
83 KiB
Rust

use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
use serde::Serialize;
use serde_json::Value as JsonValue;
use tokio::sync::{mpsc, oneshot, watch, RwLock};
use tokio::task;
use tokio::time::{interval_at, sleep, Instant};
use tracing::{error, info, warn};
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
Event {
subscription_id: SubscriptionId,
event: BreadEvent,
},
SubscriptionCancelled {
id: SubscriptionId,
},
TimerFired {
id: TimerId,
},
Reload {
reply: oneshot::Sender<std::result::Result<(), String>>,
},
Shutdown,
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorEntry {
pub timestamp: u64,
pub module: Option<String>,
pub message: String,
}
#[derive(Clone)]
pub struct RuntimeHandle {
tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
}
impl RuntimeHandle {
pub fn sender(&self) -> mpsc::UnboundedSender<LuaMessage> {
self.tx.clone()
}
pub async fn reload(&self) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(LuaMessage::Reload { reply: tx })
.map_err(|_| anyhow!("lua runtime channel closed"))?;
match rx.await {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(anyhow!(err)),
Err(_) => Err(anyhow!("lua runtime dropped reload response")),
}
}
pub fn shutdown(&self) {
let _ = self.tx.send(LuaMessage::Shutdown);
}
pub fn recent_errors(&self) -> Vec<ErrorEntry> {
self.recent_errors
.lock()
.map(|buf| buf.iter().cloned().collect())
.unwrap_or_default()
}
}
pub fn spawn_runtime(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
let handle = RuntimeHandle {
tx,
recent_errors: recent_errors.clone(),
};
let thread_tx = handle.tx.clone();
std::thread::Builder::new()
.name("breadd-lua".to_string())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create lua runtime thread");
rt.block_on(async move {
let mut engine = match LuaEngine::new(
config,
state_handle,
emit_tx,
thread_tx.clone(),
recent_errors,
) {
Ok(engine) => engine,
Err(err) => {
error!(error = %err, "failed to initialize lua engine");
return;
}
};
if let Err(err) = engine.reload_internal() {
error!(error = %err, "initial lua load failed");
}
while let Some(msg) = rx.recv().await {
match msg {
LuaMessage::Event {
subscription_id,
event,
} => {
if let Err(err) = engine.handle_event(subscription_id, event) {
error!(error = %err, "lua event handler failed");
}
}
LuaMessage::SubscriptionCancelled { id } => {
engine.remove_handler(id);
}
LuaMessage::TimerFired { id } => {
if let Err(err) = engine.handle_timer(id) {
error!(error = %err, "lua timer handler failed");
}
}
LuaMessage::Reload { reply } => {
let result = engine.reload_internal().map_err(|e| e.to_string());
let _ = reply.send(result);
}
LuaMessage::Shutdown => {
break;
}
}
}
info!("lua runtime thread exiting");
});
})?;
let _ = thread_tx;
Ok(handle)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub(crate) struct TimerId(u64);
struct HandlerEntry {
callback: RegistryKey,
filter: Option<RegistryKey>,
module: Option<String>,
raw_kind: Option<String>,
kind: HandlerKind,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum HandlerKind {
Event,
StateWatch,
}
struct TimerEntry {
callback: RegistryKey,
repeating: bool,
cancel_tx: watch::Sender<bool>,
}
#[derive(Clone)]
struct ModuleDecl {
name: String,
version: Option<String>,
after: Vec<String>,
path: PathBuf,
source: Option<&'static str>,
builtin: bool,
}
struct ModuleInfo {
table_key: RegistryKey,
}
struct LuaEngine {
lua: Lua,
handlers: Arc<Mutex<HashMap<SubscriptionId, HandlerEntry>>>,
watch_ids: Arc<Mutex<HashSet<SubscriptionId>>>,
timers: Arc<Mutex<HashMap<TimerId, TimerEntry>>>,
next_sub_id: Arc<AtomicU64>,
next_timer_id: Arc<AtomicU64>,
current_module: Arc<Mutex<Option<String>>>,
modules: Arc<Mutex<HashMap<String, ModuleInfo>>>,
module_decls: Arc<Mutex<HashMap<String, ModuleDecl>>>,
module_order: Arc<Mutex<Vec<String>>>,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
entry_point: PathBuf,
module_path: PathBuf,
modules_config: ModulesConfig,
notifications_config: NotificationsConfig,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
}
impl LuaEngine {
fn new(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
) -> Result<Self> {
Ok(Self {
lua: Lua::new(),
handlers: Arc::new(Mutex::new(HashMap::new())),
watch_ids: Arc::new(Mutex::new(HashSet::new())),
timers: Arc::new(Mutex::new(HashMap::new())),
next_sub_id: Arc::new(AtomicU64::new(1)),
next_timer_id: Arc::new(AtomicU64::new(1)),
current_module: Arc::new(Mutex::new(None)),
modules: Arc::new(Mutex::new(HashMap::new())),
module_decls: Arc::new(Mutex::new(HashMap::new())),
module_order: Arc::new(Mutex::new(Vec::new())),
state_handle,
emit_tx,
lua_tx,
entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(),
modules_config: config.modules.clone(),
notifications_config: config.notifications.clone(),
recent_errors,
})
}
fn reload_internal(&mut self) -> Result<()> {
self.run_on_unload();
self.cancel_all_timers();
self.state_handle.clear_subscriptions();
self.state_handle.clear_modules();
self.lua = Lua::new();
self.handlers
.lock()
.expect("lua handlers mutex poisoned")
.clear();
self.watch_ids
.lock()
.expect("lua watch ids mutex poisoned")
.clear();
self.modules
.lock()
.expect("lua modules mutex poisoned")
.clear();
self.module_decls
.lock()
.expect("lua module decls mutex poisoned")
.clear();
self.module_order
.lock()
.expect("lua module order mutex poisoned")
.clear();
self.install_api()?;
self.load_device_rules()?;
self.load_profiles()?;
self.load_init_and_modules()?;
self.run_on_reload();
info!("lua runtime reloaded");
Ok(())
}
fn install_api(&self) -> Result<()> {
let globals = self.lua.globals();
let bread = self.lua.create_table()?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let current_module = self.current_module.clone();
let on_fn =
self.lua
.create_function(move |lua, (pattern, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let module = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
handlers
.lock()
.map_err(|_| LuaError::external("handler lock poisoned"))?
.insert(
id,
HandlerEntry {
callback: key,
filter: None,
module,
raw_kind: None,
kind: HandlerKind::Event,
},
);
state_handle
.register_subscription(id, pattern, false)
.map_err(LuaError::external)?;
Ok(id.0)
})?;
bread.set("on", on_fn)?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let current_module = self.current_module.clone();
let once_fn =
self.lua
.create_function(move |lua, (pattern, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let module = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
handlers
.lock()
.map_err(|_| LuaError::external("handler lock poisoned"))?
.insert(
id,
HandlerEntry {
callback: key,
filter: None,
module,
raw_kind: None,
kind: HandlerKind::Event,
},
);
state_handle
.register_subscription(id, pattern, true)
.map_err(LuaError::external)?;
Ok(id.0)
})?;
bread.set("once", once_fn)?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let current_module = self.current_module.clone();
let filter_fn = self
.lua
.create_function(move |lua, (pattern, callback, opts): (String, Function, Option<Table>)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let filter = if let Some(opts) = opts {
let filter_fn: Function = opts
.get("filter")
.map_err(|_| LuaError::external("missing filter function"))?;
Some(lua.create_registry_value(filter_fn)?)
} else {
return Err(LuaError::external(
"bread.filter requires an opts table with a 'filter' function: bread.filter(pattern, fn, { filter = fn })",
));
};
let module = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
handlers
.lock()
.map_err(|_| LuaError::external("handler lock poisoned"))?
.insert(
id,
HandlerEntry {
callback: key,
filter,
module,
raw_kind: None,
kind: HandlerKind::Event,
},
);
state_handle
.register_subscription(id, pattern, false)
.map_err(LuaError::external)?;
Ok(id.0)
})?;
bread.set("filter", filter_fn)?;
let handlers = self.handlers.clone();
let watch_ids = self.watch_ids.clone();
let state_handle = self.state_handle.clone();
let off_fn = self.lua.create_function(move |_lua, id: u64| {
let sub_id = SubscriptionId(id);
if let Ok(mut map) = handlers.lock() {
map.remove(&sub_id);
}
state_handle.remove_subscription(sub_id);
if let Ok(mut set) = watch_ids.lock() {
if set.remove(&sub_id) {
state_handle.remove_watch(sub_id);
}
}
Ok(())
})?;
bread.set("off", off_fn)?;
let emit_tx = self.emit_tx.clone();
let emit_fn =
self.lua
.create_function(move |lua, (event_name, payload): (String, Value)| {
let data = match payload {
Value::Nil => serde_json::json!({}),
other => lua
.from_value::<serde_json::Value>(other)
.unwrap_or_else(|_| serde_json::json!({})),
};
emit_tx
.send(BreadEvent::new(event_name, AdapterSource::System, data))
.map_err(|_| LuaError::external("event channel closed"))?;
Ok(())
})?;
bread.set("emit", emit_fn)?;
let state_arc = self.state_handle.state_arc();
let state_tbl = self.lua.create_table()?;
let get_fn = self
.lua
.create_function(move |lua, path: String| state_value_to_lua(lua, &state_arc, &path))?;
state_tbl.set("get", get_fn)?;
let state_arc = self.state_handle.state_arc();
let monitors_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "monitors"))?;
state_tbl.set("monitors", monitors_fn)?;
let state_arc = self.state_handle.state_arc();
let active_ws_fn = self.lua.create_function(move |lua, ()| {
state_value_to_lua(lua, &state_arc, "active_workspace")
})?;
state_tbl.set("active_workspace", active_ws_fn)?;
let state_arc = self.state_handle.state_arc();
let active_win_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "active_window"))?;
state_tbl.set("active_window", active_win_fn)?;
let state_arc = self.state_handle.state_arc();
let devices_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "devices"))?;
state_tbl.set("devices", devices_fn)?;
let state_arc = self.state_handle.state_arc();
let power_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "power"))?;
state_tbl.set("power", power_fn)?;
let state_arc = self.state_handle.state_arc();
let network_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "network"))?;
state_tbl.set("network", network_fn)?;
let state_arc = self.state_handle.state_arc();
let profile_state_fn = self
.lua
.create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "profile"))?;
state_tbl.set("profile", profile_state_fn)?;
let handlers = self.handlers.clone();
let watch_ids = self.watch_ids.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let current_module = self.current_module.clone();
let watch_fn =
self.lua
.create_function(move |lua, (path, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let module = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
handlers
.lock()
.map_err(|_| LuaError::external("handler lock poisoned"))?
.insert(
id,
HandlerEntry {
callback: key,
filter: None,
module,
raw_kind: None,
kind: HandlerKind::StateWatch,
},
);
watch_ids
.lock()
.map_err(|_| LuaError::external("watch id lock poisoned"))?
.insert(id);
state_handle
.register_watch(id, path.clone())
.map_err(LuaError::external)?;
state_handle
.register_subscription(id, format!("bread.state.changed.{path}"), false)
.map_err(LuaError::external)?;
Ok(id.0)
})?;
state_tbl.set("watch", watch_fn)?;
bread.set("state", state_tbl)?;
let profile_tbl = self.lua.create_table()?;
let state_handle = self.state_handle.clone();
let emit_tx = self.emit_tx.clone();
let activate_fn = self.lua.create_function(move |_lua, name: String| {
state_handle.set_profile(name.clone());
let _ = emit_tx.send(BreadEvent::new(
"bread.profile.activated",
AdapterSource::System,
serde_json::json!({ "name": name }),
));
Ok(())
})?;
profile_tbl.set("activate", activate_fn)?;
bread.set("profile", profile_tbl)?;
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
task::spawn_blocking(move || {
match std::process::Command::new("sh")
.arg("-lc")
.arg(&cmd)
.status()
{
Ok(status) => {
if !status.success() {
tracing::warn!(cmd = %cmd, code = ?status.code(), "bread.exec exited non-zero");
}
}
Err(err) => {
tracing::error!(cmd = %cmd, error = %err, "bread.exec failed to spawn");
}
}
});
Ok(())
})?;
bread.set("exec", exec_fn)?;
let notify_path = self.notifications_config.notify_send_path.clone();
let default_urgency = self.notifications_config.default_urgency.clone();
let default_timeout = self.notifications_config.default_timeout_ms;
let emit_tx = self.emit_tx.clone();
let notify_fn =
self.lua
.create_function(move |_lua, (message, opts): (String, Option<Table>)| {
let title: String = opts
.as_ref()
.and_then(|o| o.get("title").ok())
.unwrap_or_else(|| "bread".to_string());
let urgency: String = opts
.as_ref()
.and_then(|o| o.get("urgency").ok())
.unwrap_or_else(|| default_urgency.clone());
let timeout: i64 = opts
.as_ref()
.and_then(|o| o.get("timeout").ok())
.unwrap_or(default_timeout);
let icon: Option<String> = opts.as_ref().and_then(|o| o.get("icon").ok());
let cmd_path = notify_path.clone();
let title_clone = title.clone();
let message_clone = message.clone();
let urgency_clone = urgency.clone();
task::spawn_blocking(move || {
let mut cmd = std::process::Command::new(cmd_path);
cmd.args([
"--app-name",
"bread",
"--urgency",
&urgency_clone,
"--expire-time",
&timeout.to_string(),
]);
if let Some(icon) = icon {
cmd.args(["--icon", &icon]);
}
let _ = cmd.args([&title_clone, &message_clone]).status();
});
let _ = emit_tx.send(BreadEvent::new(
"bread.notify.sent",
AdapterSource::System,
serde_json::json!({
"title": title,
"message": message,
"urgency": urgency,
}),
));
Ok(())
})?;
bread.set("notify", notify_fn)?;
let timers = self.timers.clone();
let next_timer_id = self.next_timer_id.clone();
let lua_tx = self.lua_tx.clone();
let after_fn =
self.lua
.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let (cancel_tx, mut cancel_rx) = watch::channel(false);
timers
.lock()
.map_err(|_| LuaError::external("timer lock poisoned"))?
.insert(
id,
TimerEntry {
callback: key,
repeating: false,
cancel_tx,
},
);
let lua_tx = lua_tx.clone();
task::spawn(async move {
tokio::select! {
_ = sleep(Duration::from_millis(delay_ms)) => {
if !*cancel_rx.borrow() {
let _ = lua_tx.send(LuaMessage::TimerFired { id });
}
}
_ = cancel_rx.changed() => {}
}
});
Ok(id.0)
})?;
bread.set("after", after_fn)?;
let timers = self.timers.clone();
let next_timer_id = self.next_timer_id.clone();
let lua_tx = self.lua_tx.clone();
let every_fn =
self.lua
.create_function(move |lua, (interval_ms, callback): (u64, Function)| {
let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let (cancel_tx, mut cancel_rx) = watch::channel(false);
timers
.lock()
.map_err(|_| LuaError::external("timer lock poisoned"))?
.insert(
id,
TimerEntry {
callback: key,
repeating: true,
cancel_tx,
},
);
let lua_tx = lua_tx.clone();
task::spawn(async move {
let start = Instant::now() + Duration::from_millis(interval_ms);
let mut ticker = interval_at(start, Duration::from_millis(interval_ms));
loop {
tokio::select! {
_ = ticker.tick() => {
if *cancel_rx.borrow() {
break;
}
let _ = lua_tx.send(LuaMessage::TimerFired { id });
}
_ = cancel_rx.changed() => {
if *cancel_rx.borrow() {
break;
}
}
}
}
});
Ok(id.0)
})?;
bread.set("every", every_fn)?;
let timers = self.timers.clone();
let cancel_fn = self.lua.create_function(move |_lua, id: u64| {
let timer_id = TimerId(id);
if let Ok(mut map) = timers.lock() {
if let Some(entry) = map.remove(&timer_id) {
let _ = entry.cancel_tx.send(true);
}
}
Ok(())
})?;
bread.set("cancel", cancel_fn)?;
let hyprland_tbl = self.lua.create_table()?;
let dispatch_fn =
self.lua
.create_function(move |_lua, (cmd, args): (String, String)| {
let resp = hyprland_request(&format!("dispatch {cmd} {args}"))
.map_err(|e| LuaError::external(e.to_string()))?;
Ok(resp)
})?;
hyprland_tbl.set("dispatch", dispatch_fn)?;
let keyword_fn =
self.lua
.create_function(move |_lua, (key, value): (String, String)| {
let resp = hyprland_request(&format!("keyword {key} {value}"))
.map_err(|e| LuaError::external(e.to_string()))?;
Ok(resp)
})?;
hyprland_tbl.set("keyword", keyword_fn)?;
let eval_fn = self.lua.create_function(move |_lua, expr: String| {
let resp = hyprland_request(&format!("eval {expr}"))
.map_err(|e| LuaError::external(e.to_string()))?;
Ok(resp)
})?;
hyprland_tbl.set("eval", eval_fn)?;
let active_window_fn = self.lua.create_function(move |lua, ()| {
let resp = hyprland_request("j/activewindow")
.map_err(|e| LuaError::external(e.to_string()))?;
let json: JsonValue =
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
lua.to_value(&json)
.map_err(|e| LuaError::external(e.to_string()))
})?;
hyprland_tbl.set("active_window", active_window_fn)?;
let monitors_fn = self.lua.create_function(move |lua, ()| {
let resp =
hyprland_request("j/monitors").map_err(|e| LuaError::external(e.to_string()))?;
let json: JsonValue =
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
lua.to_value(&json)
.map_err(|e| LuaError::external(e.to_string()))
})?;
hyprland_tbl.set("monitors", monitors_fn)?;
let workspaces_fn = self.lua.create_function(move |lua, ()| {
let resp =
hyprland_request("j/workspaces").map_err(|e| LuaError::external(e.to_string()))?;
let json: JsonValue =
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
lua.to_value(&json)
.map_err(|e| LuaError::external(e.to_string()))
})?;
hyprland_tbl.set("workspaces", workspaces_fn)?;
let clients_fn = self.lua.create_function(move |lua, ()| {
let resp =
hyprland_request("j/clients").map_err(|e| LuaError::external(e.to_string()))?;
let json: JsonValue =
serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?;
lua.to_value(&json)
.map_err(|e| LuaError::external(e.to_string()))
})?;
hyprland_tbl.set("clients", clients_fn)?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let current_module = self.current_module.clone();
let on_raw_fn =
self.lua
.create_function(move |lua, (event, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
let module = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
handlers
.lock()
.map_err(|_| LuaError::external("handler lock poisoned"))?
.insert(
id,
HandlerEntry {
callback: key,
filter: None,
module,
raw_kind: Some(event),
kind: HandlerKind::Event,
},
);
state_handle
.register_subscription(id, "bread.hyprland.event".to_string(), false)
.map_err(LuaError::external)?;
Ok(id.0)
})?;
hyprland_tbl.set("on_raw", on_raw_fn)?;
bread.set("hyprland", hyprland_tbl)?;
let modules = self.modules.clone();
let module_decls = self.module_decls.clone();
let current_module = self.current_module.clone();
let state_arc = self.state_handle.state_arc();
let module_fn = self.lua.create_function(move |lua, decl: Table| {
let name: String = decl.get("name")?;
let expected = current_module
.lock()
.map_err(|_| LuaError::external("module context lock poisoned"))?
.clone();
if expected.as_deref() != Some(&name) {
return Err(LuaError::external(
"module name does not match current load",
));
}
let decl = module_decls
.lock()
.map_err(|_| LuaError::external("module decls lock poisoned"))?
.get(&name)
.cloned()
.ok_or_else(|| LuaError::external("module declaration not found"))?;
let module_tbl = lua.create_table()?;
module_tbl.set("name", decl.name.clone())?;
if let Some(version) = decl.version.clone() {
module_tbl.set("version", version)?;
}
let store_tbl = lua.create_table()?;
let module_name = decl.name.clone();
let state_arc_get = state_arc.clone();
let get_fn = lua.create_function(move |lua, key: String| {
if let Some(value) = module_store_get(&state_arc_get, &module_name, &key) {
return lua
.to_value(&value)
.map_err(|e| LuaError::external(e.to_string()));
}
Ok(Value::Nil)
})?;
store_tbl.set("get", get_fn)?;
let module_name = decl.name.clone();
let state_arc_set = state_arc.clone();
let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| {
let json = lua
.from_value::<JsonValue>(value)
.unwrap_or(JsonValue::Null);
module_store_set(&state_arc_set, &module_name, key, json);
Ok(())
})?;
store_tbl.set("set", set_fn)?;
module_tbl.set("store", store_tbl)?;
let key = lua.create_registry_value(module_tbl.clone())?;
modules
.lock()
.map_err(|_| LuaError::external("module registry lock poisoned"))?
.insert(decl.name.clone(), ModuleInfo { table_key: key });
// Register in package.loaded so require("bread.devices") etc. works
let package: Table = lua.globals().get("package")?;
let loaded: Table = package.get("loaded")?;
loaded.set(decl.name.clone(), module_tbl.clone())?;
Ok(module_tbl)
})?;
bread.set("module", module_fn)?;
// bread.machine — machine name and tags from sync.toml
let machine_tbl = self.lua.create_table()?;
let name_fn = self
.lua
.create_function(|_lua, ()| Ok(lua_machine_name()))?;
machine_tbl.set("name", name_fn)?;
let tags_fn = self.lua.create_function(|lua, ()| {
let tags = lua_machine_tags();
let tbl = lua.create_table()?;
for (i, tag) in tags.iter().enumerate() {
tbl.set(i + 1, tag.clone())?;
}
Ok(tbl)
})?;
machine_tbl.set("tags", tags_fn)?;
let has_tag_fn = self
.lua
.create_function(|_lua, tag: String| Ok(lua_machine_tags().contains(&tag)))?;
machine_tbl.set("has_tag", has_tag_fn)?;
bread.set("machine", machine_tbl)?;
// bread.fs — file system helpers
let fs_tbl = self.lua.create_table()?;
let write_fn = self
.lua
.create_function(|_lua, (path, content): (String, String)| {
let expanded = lua_expand_path(&path);
if let Some(parent) = expanded.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LuaError::external(e.to_string()))?;
}
std::fs::write(&expanded, content).map_err(|e| LuaError::external(e.to_string()))
})?;
fs_tbl.set("write", write_fn)?;
let read_fn = self.lua.create_function(|_lua, path: String| {
let expanded = lua_expand_path(&path);
match std::fs::read_to_string(&expanded) {
Ok(s) => Ok(Some(s)),
Err(_) => Ok(None),
}
})?;
fs_tbl.set("read", read_fn)?;
let exists_fn = self
.lua
.create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?;
fs_tbl.set("exists", exists_fn)?;
let expand_fn = self.lua.create_function(|_lua, path: String| {
Ok(lua_expand_path(&path).to_string_lossy().to_string())
})?;
fs_tbl.set("expand", expand_fn)?;
bread.set("fs", fs_tbl)?;
// bread.bluetooth — BlueZ control
let bluetooth_tbl = self.lua.create_table()?;
let power_fn = self.lua.create_function(move |_lua, enabled: bool| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_set_powered(enabled).await {
tracing::warn!("bread.bluetooth.power failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("power", power_fn)?;
let powered_fn = self.lua.create_function(move |_lua, ()| {
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
})?;
bluetooth_tbl.set("powered", powered_fn)?;
let connect_fn = self.lua.create_function(move |_lua, address: String| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_connect(address).await {
tracing::warn!("bread.bluetooth.connect failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("connect", connect_fn)?;
let disconnect_fn = self.lua.create_function(move |_lua, address: String| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_disconnect(address).await {
tracing::warn!("bread.bluetooth.disconnect failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("disconnect", disconnect_fn)?;
let scan_fn = self.lua.create_function(move |_lua, enabled: bool| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_set_scanning(enabled).await {
tracing::warn!("bread.bluetooth.scan failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("scan", scan_fn)?;
let devices_fn = self.lua.create_function(move |lua, ()| {
let devs = match bluetooth_query(|| bluetooth_list_devices()) {
Ok(d) => d,
Err(_) => return Ok(Value::Nil),
};
let tbl = lua.create_table()?;
for (i, dev) in devs.iter().enumerate() {
let dt = lua.create_table()?;
dt.set("address", dev.address.clone())?;
dt.set("name", dev.name.clone())?;
dt.set("connected", dev.connected)?;
dt.set("paired", dev.paired)?;
tbl.set(i + 1, dt)?;
}
Ok(Value::Table(tbl))
})?;
bluetooth_tbl.set("devices", devices_fn)?;
bread.set("bluetooth", bluetooth_tbl)?;
globals.set("bread", bread)?;
self.install_require_loader()?;
self.install_wait_helper()?;
self.install_log_helpers()?;
self.install_debounce()?;
Ok(())
}
fn load_device_rules(&self) -> Result<()> {
let devices_path = self
.entry_point
.parent()
.map(|p| p.join("devices.lua"))
.unwrap_or_else(|| std::path::PathBuf::from("devices.lua"));
if !devices_path.exists() {
return Ok(());
}
let source = fs::read_to_string(&devices_path)
.map_err(|e| anyhow!("failed to read devices.lua: {e}"))?;
let rules_value: mlua::Value = self
.lua
.load(&source)
.set_name("devices.lua")
.eval()
.map_err(|e| anyhow!("devices.lua error: {e}"))?;
let mlua::Value::Table(tbl) = rules_value else {
return Err(anyhow!("devices.lua must return a table of rules"));
};
let mut rules: Vec<DeviceRule> = Vec::new();
for pair in tbl.sequence_values::<mlua::Table>() {
let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?;
let device: String = entry.get("device").unwrap_or_default();
if device.is_empty() {
continue;
}
// If the rule has a `match` key, each entry in it is a separate condition (OR logic).
// Otherwise the rule table itself is the single condition.
let conditions: Vec<MatchCondition> =
if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") {
match_tbl
.sequence_values::<mlua::Table>()
.filter_map(|r| r.ok())
.map(|t| parse_match_condition(&t))
.collect()
} else {
vec![parse_match_condition(&entry)]
};
if !conditions.is_empty() {
rules.push(DeviceRule { device, conditions });
}
}
self.state_handle.set_device_rules(rules);
Ok(())
}
fn load_profiles(&self) -> Result<()> {
let profiles_path = self
.entry_point
.parent()
.map(|p| p.join("profiles.lua"))
.unwrap_or_else(|| PathBuf::from("profiles.lua"));
if !profiles_path.exists() {
return Ok(());
}
let path_str = profiles_path.to_string_lossy().to_string();
self.lua.globals().set("__profiles_path", path_str)?;
self.lua
.load(
r#"
local ok, result = pcall(loadfile, __profiles_path)
__profiles_path = nil
if ok and type(result) == "function" then
ok, result = pcall(result)
end
if ok and type(result) == "table" then
bread.on("bread.profile.activated", function(event)
local name = event.data and event.data.name
local fn = name and result[name]
if type(fn) == "function" then
fn(event)
end
end)
end
"#,
)
.set_name("profiles.lua")
.exec()
.map_err(|e| anyhow!("profiles.lua error: {e}"))
}
fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init", false)?;
let mut files = list_lua_files(&self.module_path)?;
files.sort();
let disabled: HashSet<String> = self.modules_config.disable.iter().cloned().collect();
let mut decls = Vec::new();
if self.modules_config.builtin {
decls.extend(builtin_module_decls(&disabled));
}
for path in files
.into_iter()
.filter(|p| !is_lib_path(&self.module_path, p))
{
match self.scan_module_decl(&path) {
Ok(decl) => decls.push(decl),
Err(err) => {
let name = module_name_from_path(&self.module_path, &path);
self.state_handle.set_module_status(
name,
ModuleLoadState::LoadError,
Some(err.to_string()),
false,
);
}
}
}
let (ordered, dep_errors) = order_module_decls(decls);
let mut decl_map = self
.module_decls
.lock()
.expect("module decls mutex poisoned");
decl_map.clear();
for decl in &ordered {
decl_map.insert(decl.name.clone(), decl.clone());
}
drop(decl_map);
for (name, err) in dep_errors {
self.state_handle
.set_module_status(name, ModuleLoadState::LoadError, Some(err), false);
}
let mut load_order = Vec::new();
for decl in ordered {
load_order.push(decl.name.clone());
match self.load_module(&decl) {
Ok(()) => {
self.state_handle.set_module_status(
decl.name.clone(),
ModuleLoadState::Loaded,
None,
decl.builtin,
);
}
Err(err) => {
self.state_handle.set_module_status(
decl.name.clone(),
ModuleLoadState::LoadError,
Some(err.to_string()),
decl.builtin,
);
}
}
}
*self
.module_order
.lock()
.expect("module order mutex poisoned") = load_order;
Ok(())
}
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source {
self.load_lua_source(source, &decl.name)
} else {
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
};
self.set_current_module(None);
result?;
if !self.module_is_registered(&decl.name) {
return Err(anyhow!("module did not call bread.module"));
}
self.run_on_load(&decl.name);
Ok(())
}
fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> {
if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping");
self.state_handle.set_module_status(
module_name.to_string(),
ModuleLoadState::NotFound,
None,
builtin,
);
return Ok(());
}
let src = fs::read_to_string(path)?;
self.lua
.load(&src)
.set_name(path.to_string_lossy().as_ref())
.exec()?;
Ok(())
}
fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> {
self.lua
.load(source)
.set_name(module_name)
.exec()
.map_err(|e| anyhow!(e.to_string()))
}
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
let (callback, filter, raw_kind, kind, module) = {
let handlers = self.handlers.lock().expect("lua handlers mutex poisoned");
let Some(entry) = handlers.get(&id) else {
return Ok(());
};
let callback: Function = self.lua.registry_value(&entry.callback)?;
let filter = match entry.filter.as_ref() {
Some(key) => Some(self.lua.registry_value::<Function>(key)?),
None => None,
};
(
callback,
filter,
entry.raw_kind.clone(),
entry.kind,
entry.module.clone(),
)
};
if let Some(kind) = raw_kind.as_deref() {
let matches = event
.data
.get("kind")
.and_then(JsonValue::as_str)
.map(|k| k == kind)
.unwrap_or(false);
if !matches {
return Ok(());
}
}
if let Some(filter) = filter {
let event_value = self.lua.to_value(&event)?;
let allowed = filter.call::<_, bool>(event_value).unwrap_or(false);
if !allowed {
return Ok(());
}
}
let result = match kind {
HandlerKind::Event => {
let event_value = self.lua.to_value(&event)?;
callback.call::<_, ()>(event_value)
}
HandlerKind::StateWatch => {
let new_val = event.data.get("new").cloned().unwrap_or(JsonValue::Null);
let old_val = event.data.get("old").cloned().unwrap_or(JsonValue::Null);
let new_lua = self.lua.to_value(&new_val)?;
let old_lua = self.lua.to_value(&old_val)?;
callback.call::<_, ()>((new_lua, old_lua))
}
};
if let Err(err) = result {
error!(subscription = id.0, error = %err, "lua callback failed");
self.handle_callback_error(module.as_deref(), id, err);
}
Ok(())
}
fn handle_timer(&self, id: TimerId) -> Result<()> {
let (callback, repeating) = {
let timers = self.timers.lock().expect("lua timers mutex poisoned");
let Some(entry) = timers.get(&id) else {
return Ok(());
};
let callback: Function = self.lua.registry_value(&entry.callback)?;
(callback, entry.repeating)
};
if let Err(err) = callback.call::<_, ()>(()) {
error!(timer = id.0, error = %err, "lua timer callback failed");
}
if !repeating {
if let Ok(mut map) = self.timers.lock() {
map.remove(&id);
}
}
Ok(())
}
fn remove_handler(&self, id: SubscriptionId) {
if let Ok(mut map) = self.handlers.lock() {
map.remove(&id);
}
}
fn run_on_load(&self, name: &str) {
if let Some(hook) = self.get_module_hook(name, "on_load") {
if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_load failed");
let builtin = self.module_is_builtin(name);
self.state_handle.set_module_status(
name.to_string(),
ModuleLoadState::LoadError,
Some(err.to_string()),
builtin,
);
}
}
}
fn run_on_reload(&self) {
let order = self
.module_order
.lock()
.expect("module order mutex poisoned")
.clone();
for name in order {
if let Some(hook) = self.get_module_hook(&name, "on_reload") {
if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_reload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status(
name.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
);
}
}
}
}
fn run_on_unload(&self) {
let order = self
.module_order
.lock()
.expect("module order mutex poisoned")
.clone();
for name in order.into_iter().rev() {
if let Some(hook) = self.get_module_hook(&name, "on_unload") {
if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_unload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status(
name.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
);
}
}
}
}
fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) {
if let Some(module) = module {
let builtin = self.module_is_builtin(module);
if let Ok(mut buf) = self.recent_errors.lock() {
if buf.len() >= 50 {
buf.pop_front();
}
buf.push_back(ErrorEntry {
timestamp: now_unix_ms(),
module: Some(module.to_string()),
message: err.to_string(),
});
}
self.state_handle.set_module_status(
module.to_string(),
ModuleLoadState::Degraded,
Some(err.to_string()),
builtin,
);
if let Some(hook) = self.get_module_hook(module, "on_error") {
match hook.call::<_, bool>(err.to_string()) {
Ok(keep) => {
if !keep {
self.remove_handler(id);
self.state_handle.remove_subscription(id);
self.state_handle.remove_watch(id);
}
}
Err(hook_err) => {
error!(module = %module, error = %hook_err, "module on_error failed");
}
}
}
}
}
fn get_module_hook(&self, name: &str, hook: &str) -> Option<Function<'_>> {
let modules = self.modules.lock().ok()?;
let info = modules.get(name)?;
let table: Table = self.lua.registry_value(&info.table_key).ok()?;
match table.get::<_, Value>(hook).ok()? {
Value::Function(func) => Some(func),
_ => None,
}
}
fn module_is_registered(&self, name: &str) -> bool {
self.modules
.lock()
.map(|map| map.contains_key(name))
.unwrap_or(false)
}
fn module_is_builtin(&self, name: &str) -> bool {
self.module_decls
.lock()
.ok()
.and_then(|map| map.get(name).map(|d| d.builtin))
.unwrap_or(false)
}
fn set_current_module(&self, name: Option<String>) {
if let Ok(mut guard) = self.current_module.lock() {
*guard = name;
}
}
fn cancel_all_timers(&self) {
if let Ok(mut map) = self.timers.lock() {
for (_, entry) in map.drain() {
let _ = entry.cancel_tx.send(true);
}
}
}
fn install_log_helpers(&self) -> Result<()> {
// bread.log(msg) → tracing::info
// bread.warn(msg) → tracing::warn
// bread.error(msg) → tracing::error
//
// Each accepts any Lua value and coerces it to a string via tostring()
// so callers can do bread.log(some_table) without a crash.
self.lua
.load(
r#"
local _bread = bread
local function stringify(v)
if type(v) == "string" then
return v
end
return tostring(v)
end
function _bread.log(msg)
_bread.__log_info(stringify(msg))
end
function _bread.warn(msg)
_bread.__log_warn(stringify(msg))
end
function _bread.error(msg)
_bread.__log_error(stringify(msg))
end
"#,
)
.exec()?;
// Register the raw Rust-backed log functions that the Lua wrappers call.
let globals = self.lua.globals();
let bread: mlua::Table = globals.get("bread")?;
let info_fn = self.lua.create_function(|_, msg: String| {
tracing::info!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_info", info_fn)?;
let warn_fn = self.lua.create_function(|_, msg: String| {
tracing::warn!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_warn", warn_fn)?;
let error_fn = self.lua.create_function(|_, msg: String| {
tracing::error!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_error", error_fn)?;
Ok(())
}
fn install_debounce(&self) -> Result<()> {
// bread.debounce(delay_ms, fn) → wrapped_fn
//
// Returns a new function. When that function is called, it resets a
// timer. The original function is only called once the timer expires
// without being reset. Useful for rapid hardware events (e.g. monitor
// topology changes that fire multiple events in quick succession).
//
// Because the Lua runtime is single-threaded, we implement this in
// pure Lua using bread.cancel / bread.after.
self.lua
.load(
r#"
function bread.debounce(delay_ms, fn)
local timer_id = nil
return function(...)
local args = { ... }
if timer_id then
bread.cancel(timer_id)
timer_id = nil
end
timer_id = bread.after(delay_ms, function()
timer_id = nil
fn(table.unpack(args))
end)
end
end
"#,
)
.exec()?;
Ok(())
}
fn scan_module_decl(&self, path: &Path) -> Result<ModuleDecl> {
const MODULE_DECL_ABORT: &str = "__bread_module_decl__";
let lua = Lua::new();
let decl_cell: Rc<RefCell<Option<ModuleDecl>>> = Rc::new(RefCell::new(None));
let decl_cell_cloned = decl_cell.clone();
let module_path = path.to_path_buf();
let module_fn = lua.create_function(move |_lua, table: Table| -> mlua::Result<()> {
let name: String = table.get("name")?;
let version: Option<String> = table.get("version").ok();
let after: Vec<String> = table.get("after").unwrap_or_default();
*decl_cell_cloned.borrow_mut() = Some(ModuleDecl {
name,
version,
after,
path: module_path.clone(),
source: None,
builtin: false,
});
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?;
// Build a minimal bread stub: bread.module() captures the decl and aborts;
// all other bread.* accesses return a no-op callable so modules that call
// bread.log() or bread.fs.exists() before bread.module() don't crash during scanning.
let bread = lua.create_table()?;
bread.set("module", module_fn)?;
lua.globals().set("bread", bread)?;
lua.load(
r#"
local _noop = function(...) end
local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop }
local _noop_tbl = setmetatable({}, _noop_tbl_mt)
setmetatable(bread, {
__index = function(_, k)
if k == "module" then return rawget(bread, "module") end
return _noop_tbl
end
})
"#,
)
.exec()?;
let src = fs::read_to_string(path)?;
let result = lua
.load(&src)
.set_name(path.to_string_lossy().as_ref())
.exec();
// bread.module() throws MODULE_DECL_ABORT to abort scanning early.
// mlua may wrap the error in CallbackError, so match on string content.
if let Err(err) = result {
if !err.to_string().contains(MODULE_DECL_ABORT) {
return Err(anyhow!(err.to_string()));
}
}
let decl = decl_cell.borrow().clone();
decl.ok_or_else(|| anyhow!("module missing bread.module declaration"))
}
fn install_require_loader(&self) -> Result<()> {
let module_path = self.module_path.clone();
let loader = self.lua.create_function(move |lua, name: String| {
if !name.starts_with("bread.") {
return Ok(Value::Nil);
}
let rel = name.trim_start_matches("bread.").replace('.', "/");
let path = module_path.join(format!("{rel}.lua"));
if !path.exists() {
return Ok(Value::Nil);
}
let src = fs::read_to_string(&path).map_err(|e| LuaError::external(e.to_string()))?;
let func = lua
.load(&src)
.set_name(path.to_string_lossy().as_ref())
.into_function()
.map_err(|e| LuaError::external(e.to_string()))?;
Ok(Value::Function(func))
})?;
let globals = self.lua.globals();
let bread: Table = globals.get("bread")?;
bread.set("__require_loader", loader)?;
self.lua
.load(
r#"
local searchers = package.searchers or package.loaders
if searchers then
table.insert(searchers, 1, function(name)
return bread.__require_loader(name)
end)
end
"#,
)
.exec()?;
Ok(())
}
fn install_wait_helper(&self) -> Result<()> {
self.lua
.load(
r#"
bread.spawn = function(fn)
local co = coroutine.create(fn)
local ok, err = coroutine.resume(co)
if not ok then
error(err)
end
end
bread.wait = function(pattern, opts)
if type(pattern) ~= "string" then
error("bread.wait requires a pattern string")
end
opts = opts or {}
local co = coroutine.running()
if not co then
error("bread.wait must be called inside a coroutine")
end
local id
local timer
id = bread.once(pattern, function(event)
if timer then
bread.cancel(timer)
end
coroutine.resume(co, event)
end)
if opts.timeout then
timer = bread.after(opts.timeout, function()
bread.off(id)
coroutine.resume(co, nil)
end)
end
return coroutine.yield()
end
"#,
)
.exec()?;
Ok(())
}
}
fn order_module_decls(decls: Vec<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String, String)>) {
let mut errors = Vec::new();
let mut map: HashMap<String, ModuleDecl> = HashMap::new();
for decl in decls {
if map.contains_key(&decl.name) {
errors.push((decl.name.clone(), "duplicate module name".to_string()));
continue;
}
map.insert(decl.name.clone(), decl);
}
let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
let mut invalid: HashSet<String> = HashSet::new();
for (name, decl) in map.iter() {
let mut missing = Vec::new();
for dep in &decl.after {
if map.contains_key(dep) {
deps.entry(name.clone()).or_default().insert(dep.clone());
reverse.entry(dep.clone()).or_default().insert(name.clone());
} else {
missing.push(dep.clone());
}
}
if !missing.is_empty() {
errors.push((
name.clone(),
format!("missing dependency: {}", missing.join(", ")),
));
invalid.insert(name.clone());
}
}
let mut ready: Vec<String> = map
.keys()
.filter(|name| !deps.contains_key(*name) && !invalid.contains(*name))
.cloned()
.collect();
ready.sort();
let mut ordered = Vec::new();
let mut deps = deps;
while let Some(name) = ready.pop() {
if let Some(decl) = map.get(&name) {
ordered.push(decl.clone());
}
if let Some(children) = reverse.remove(&name) {
for child in children {
if invalid.contains(&child) {
continue;
}
if let Some(entry) = deps.get_mut(&child) {
entry.remove(&name);
if entry.is_empty() {
deps.remove(&child);
ready.push(child);
ready.sort();
}
}
}
}
}
for (name, _) in deps {
if !invalid.contains(&name) {
errors.push((name, "circular dependency".to_string()));
}
}
(ordered, errors)
}
fn module_name_from_path(module_root: &Path, path: &Path) -> String {
let rel = path.strip_prefix(module_root).unwrap_or(path);
let mut name = rel.with_extension("").to_string_lossy().replace('/', ".");
if name.starts_with('.') {
name.remove(0);
}
name
}
fn is_lib_path(module_root: &Path, path: &Path) -> bool {
let rel = path.strip_prefix(module_root).unwrap_or(path);
rel.components()
.next()
.and_then(|c| c.as_os_str().to_str())
.map(|c| c == "lib")
.unwrap_or(false)
}
fn state_value_to_lua<'lua>(
lua: &'lua Lua,
state_arc: &Arc<RwLock<RuntimeState>>,
path: &str,
) -> mlua::Result<Value<'lua>> {
// The Lua thread runs a current_thread runtime. blocking_read and block_in_place
// both require the multi-thread runtime and panic here. try_read succeeds
// immediately in the common case; the write lock is held for microseconds.
let snapshot = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
};
let mut value =
serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?;
if path.is_empty() {
return lua
.to_value(&value)
.map_err(|e| LuaError::external(e.to_string()));
}
for part in path.split('.') {
value = value
.get(part)
.cloned()
.ok_or_else(|| LuaError::external("state path not found"))?;
}
lua.to_value(&value)
.map_err(|e| LuaError::external(e.to_string()))
}
fn module_store_get(
state_arc: &Arc<RwLock<RuntimeState>>,
module: &str,
key: &str,
) -> Option<JsonValue> {
let guard = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
};
let entry = guard.modules.iter().find(|m| m.name == module)?;
entry.store.get(key).cloned()
}
fn module_store_set(
state_arc: &Arc<RwLock<RuntimeState>>,
module: &str,
key: String,
value: JsonValue,
) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
};
if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) {
entry.store.insert(key, value);
return;
}
let mut store = HashMap::new();
store.insert(key, value);
guard.modules.push(crate::core::types::ModuleStatus {
name: module.to_string(),
status: ModuleLoadState::Loaded,
last_error: None,
builtin: false,
store,
});
}
fn lua_expand_path(path: &str) -> std::path::PathBuf {
if path == "~" {
if let Some(home) = dirs_home() {
return home;
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs_home() {
return home.join(rest);
}
}
std::path::PathBuf::from(path)
}
fn dirs_home() -> Option<std::path::PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Some(std::path::PathBuf::from(home));
}
None
}
fn lua_machine_name() -> String {
if let Ok(sync_toml) = read_sync_toml() {
if let Some(name) = sync_toml
.get("machine")
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
{
return name.to_string();
}
}
lua_hostname()
}
fn lua_hostname() -> String {
// Try gethostname via libc
let mut buf = [0u8; 256];
unsafe {
if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 {
if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() {
if !s.is_empty() {
return s.to_string();
}
}
}
}
// Fall back to /etc/hostname
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn lua_machine_tags() -> Vec<String> {
if let Ok(sync_toml) = read_sync_toml() {
if let Some(tags) = sync_toml
.get("machine")
.and_then(|m| m.get("tags"))
.and_then(|v| v.as_array())
{
return tags
.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect();
}
}
vec![]
}
fn read_sync_toml() -> anyhow::Result<toml::Value> {
let config_dir = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|_| std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
let path = config_dir.join("bread").join("sync.toml");
let raw = std::fs::read_to_string(path)?;
Ok(raw.parse::<toml::Value>()?)
}
const BUILTIN_MONITORS: &str = r#"
local M = bread.module({ name = "bread.monitors", version = "1.0.0" })
local workflows = {}
local layouts = {}
local function matches_when(event_name, when)
if when == "connected" then
return event_name == "bread.monitor.connected"
elseif when == "disconnected" then
return event_name == "bread.monitor.disconnected"
elseif when == "changed" then
return event_name == "bread.monitor.changed"
end
return false
end
local function matches_monitors(list, event)
if not list or #list == 0 then
return true
end
local name = event.data and event.data.name
if not name then
return false
end
for _, monitor in ipairs(list) do
if monitor == name then
return true
end
end
return false
end
local function run_workflow(wf, event)
if type(wf.run) == "function" then
wf.run(event)
elseif type(wf.run) == "string" then
bread.exec(wf.run)
end
end
function M.on(opts)
table.insert(workflows, opts)
end
function M.layout(name, fn)
layouts[name] = fn
end
function M.apply(name)
return function()
local fn = layouts[name]
if fn then
fn()
end
end
end
function M.on_load()
bread.on("bread.monitor.**", function(event)
for _, wf in ipairs(workflows) do
if matches_when(event.event, wf.when) and matches_monitors(wf.monitors, event) then
run_workflow(wf, event)
end
end
end)
end
return M
"#;
const BUILTIN_DEVICES: &str = r#"
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
local rules = {}
local function matches_rule(rule, event)
local when = rule.when
local data = event.data or {}
if when == "connected" and not event.event:match("%.connected$") then
return false
elseif when == "disconnected" and not event.event:match("%.disconnected$") then
return false
end
if rule.device and data.device ~= rule.device then
return false
end
if rule.name and data.name and not tostring(data.name):match(rule.name) then
return false
end
return true
end
local function run_rule(rule, event)
if type(rule.run) == "function" then
rule.run(event)
elseif type(rule.run) == "string" then
bread.exec(rule.run)
end
end
function M.on(opts)
table.insert(rules, opts)
end
function M.on_load()
bread.on("bread.device.**", function(event)
for _, rule in ipairs(rules) do
if matches_rule(rule, event) then
run_rule(rule, event)
end
end
end)
end
return M
"#;
const BUILTIN_WORKSPACES: &str = r#"
local M = bread.module({ name = "bread.workspaces", version = "1.0.0", after = { "bread.monitors" } })
local assignments = {}
local rules = {}
function M.assign(workspace, monitor)
table.insert(assignments, { workspace = workspace, monitor = monitor })
end
function M.pin(opts)
table.insert(rules, opts)
end
function M.apply_assignments()
local monitors = bread.state.monitors()
local active = {}
for _, m in ipairs(monitors) do
if m.connected then
active[m.name] = true
end
end
for _, a in ipairs(assignments) do
if active[a.monitor] then
bread.hyprland.dispatch("moveworkspacetomonitor", a.workspace .. " " .. a.monitor)
end
end
end
function M.on_load()
bread.on("bread.monitor.**", function()
M.apply_assignments()
end)
bread.on("bread.window.opened", function(event)
for _, rule in ipairs(rules) do
if event.data and event.data.class and event.data.class:match(rule.app) then
local address = event.data.address or ""
bread.hyprland.dispatch("movetoworkspacesilent", rule.workspace .. ",address:" .. address)
end
end
end)
bread.once("bread.system.startup", function()
M.apply_assignments()
end)
end
return M
"#;
const BUILTIN_BINDS: &str = r#"
local M = bread.module({ name = "bread.binds", version = "1.0.0" })
local active = {}
local function bind_string(opts)
local mods = table.concat(opts.mods or {}, " ")
local args = opts.args or ""
if mods ~= "" then
return mods .. ", " .. opts.key .. ", " .. opts.dispatch .. ", " .. args
end
return opts.key .. ", " .. opts.dispatch .. ", " .. args
end
function M.add(opts)
local bind = bind_string(opts)
bread.hyprland.keyword("bind", bind)
active[opts.key] = opts
return opts.key
end
function M.remove(key)
local bind = active[key]
if not bind then
return
end
bread.hyprland.keyword("unbind", bind_string(bind))
active[key] = nil
end
function M.replace(key, opts)
M.remove(key)
return M.add(opts)
end
function M.on_unload()
for key, _ in pairs(active) do
M.remove(key)
end
end
return M
"#;
fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
let mut out = Vec::new();
let entries = vec![
("bread.monitors", "1.0.0", Vec::new(), BUILTIN_MONITORS),
("bread.devices", "1.0.0", Vec::new(), BUILTIN_DEVICES),
(
"bread.workspaces",
"1.0.0",
vec!["bread.monitors".to_string()],
BUILTIN_WORKSPACES,
),
("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS),
];
for (name, version, after, source) in entries {
if disabled.contains(name) {
continue;
}
out.push(ModuleDecl {
name: name.to_string(),
version: Some(version.to_string()),
after,
path: PathBuf::from(format!("<builtin:{name}>")),
source: Some(source),
builtin: true,
});
}
out
}
fn hyprland_request_socket() -> Result<PathBuf> {
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket.sock"));
}
let hypr_dir = PathBuf::from(&runtime).join("hypr");
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
.flatten()
.map(|e| e.path().join(".socket.sock"))
.filter(|p| p.exists())
.collect();
match sockets.len() {
0 => Err(anyhow!(
"no Hyprland instance found in {}",
hypr_dir.display()
)),
1 => Ok(sockets.remove(0)),
_ => Ok(sockets.remove(0)),
}
}
fn hyprland_request(request: &str) -> Result<String> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let socket = hyprland_request_socket()?;
let mut stream = UnixStream::connect(&socket)?;
stream.write_all(request.as_bytes())?;
let mut buffer = String::new();
stream.read_to_string(&mut buffer)?;
Ok(buffer)
}
fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition {
MatchCondition {
vendor_id: tbl.get("vendor_id").ok(),
product_id: tbl.get("product_id").ok(),
name: tbl.get("name").ok(),
vendor: tbl.get("vendor").ok(),
name_contains: tbl.get("name_contains").ok(),
id_input_keyboard: tbl.get("id_input_keyboard").ok(),
id_input_mouse: tbl.get("id_input_mouse").ok(),
id_input_tablet: tbl.get("id_input_tablet").ok(),
usb_hub: tbl.get("usb_hub").ok(),
id_usb_class: tbl.get("id_usb_class").ok(),
subsystem: tbl.get("subsystem").ok(),
}
}
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
if !root.exists() {
return Ok(out);
}
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some("lua") {
out.push(path);
}
}
}
Ok(out)
}
// ─── Bluetooth helpers ────────────────────────────────────────────────────────
/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget
/// async Bluetooth operation. Needed because the Lua thread runs inside
/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden.
fn bluetooth_spawn<F, Fut>(factory: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()>,
{
std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bluetooth action thread")
.block_on(factory());
});
}
/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua
/// gets a return value.
fn bluetooth_query<F, Fut, T>(factory: F) -> anyhow::Result<T>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = anyhow::Result<T>>,
T: Send + 'static,
{
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bluetooth query thread")
.block_on(factory());
let _ = tx.send(result);
});
rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
}
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
let msg = conn
.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
)
.await?;
let objects: std::collections::HashMap<
OwnedObjectPath,
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
> = msg.body()?;
for (path, interfaces) in &objects {
if interfaces.contains_key("org.bluez.Adapter1") {
return Ok(path.as_str().to_string());
}
}
Err(anyhow::anyhow!("no Bluetooth adapter found"))
}
async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
conn.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.freedesktop.DBus.Properties"),
"Set",
&(
"org.bluez.Adapter1",
"Powered",
zbus::zvariant::Value::from(enabled),
),
)
.await?;
Ok(())
}
async fn bluetooth_get_powered() -> anyhow::Result<bool> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let msg = conn
.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.bluez.Adapter1", "Powered"),
)
.await?;
let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?;
let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false));
Ok(json.as_bool().unwrap_or(false))
}
async fn bluetooth_connect(address: String) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
conn.call_method(
Some("org.bluez"),
dev_path.as_str(),
Some("org.bluez.Device1"),
"Connect",
&(),
)
.await?;
Ok(())
}
async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
conn.call_method(
Some("org.bluez"),
dev_path.as_str(),
Some("org.bluez.Device1"),
"Disconnect",
&(),
)
.await?;
Ok(())
}
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
conn.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.bluez.Adapter1"),
method,
&(),
)
.await?;
Ok(())
}
struct BluetoothDevice {
address: String,
name: String,
connected: bool,
paired: bool,
}
async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
let conn = zbus::Connection::system().await?;
let msg = conn
.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
)
.await?;
let objects: std::collections::HashMap<
OwnedObjectPath,
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
> = msg.body()?;
let mut devices = Vec::new();
for (_, interfaces) in &objects {
if let Some(props) = interfaces.get("org.bluez.Device1") {
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
devices.push(BluetoothDevice {
address: json
.get("Address")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
name: json
.get("Name")
.or_else(|| json.get("Alias"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
connected: json
.get("Connected")
.and_then(|v| v.as_bool())
.unwrap_or(false),
paired: json
.get("Paired")
.and_then(|v| v.as_bool())
.unwrap_or(false),
});
}
}
Ok(devices)
}