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::widget::{WidgetNode, WidgetPlacement, WidgetSpec}; use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind}; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::{Deserialize, 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::rules::{self, RuleAction, RulesLoadOutcome}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; use crate::core::types::{ DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus, }; use crate::module_host::{self, ModuleHostOutcome, ModuleHostRegistry}; 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>, }, Shutdown, } #[derive(Debug, Clone, Serialize)] pub struct ErrorEntry { pub timestamp: u64, pub module: Option, pub message: String, } #[derive(Clone)] pub struct RuntimeHandle { tx: mpsc::UnboundedSender, recent_errors: Arc>>, } impl RuntimeHandle { pub fn sender(&self) -> mpsc::UnboundedSender { 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 { 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, module_host_registry: ModuleHostRegistry, ) -> Result { 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, module_host_registry, ) { 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, module: Option, raw_kind: Option, kind: HandlerKind, } #[derive(Clone, Copy, Eq, PartialEq)] enum HandlerKind { Event, StateWatch, } struct TimerEntry { callback: RegistryKey, repeating: bool, cancel_tx: watch::Sender, /// The module active when `bread.after`/`bread.every` registered this /// timer, so `handle_timer` can restore module context for the callback /// (needed by module-scoped APIs like `bread.widget.*`) — same /// `current_module` capture used for `bread.on`'s `HandlerEntry.module`. module: Option, } #[derive(Clone)] struct ModuleDecl { name: String, version: Option, after: Vec, path: PathBuf, source: Option<&'static str>, builtin: bool, /// Declared `[[permissions]]` from this module's `bread.module.toml`, /// read from disk alongside `path` for non-builtin modules (`None` for /// builtins, which never go through manifest-based scoping at all). /// /// `None` here also covers the third-party, no-manifest-at-all and /// manifest-with-no-permissions-key cases — both mean "not declared", /// which `load_scoped_lua_file` treats as full, ungated access for /// backward compatibility (see `Documentation.md`). `Some(vec![])` is a /// deliberate "baseline only" declaration and is scoped down for real. permissions: Option>, } struct ModuleInfo { table_key: RegistryKey, } struct LuaEngine { lua: Lua, handlers: Arc>>, watch_ids: Arc>>, timers: Arc>>, next_sub_id: Arc, next_timer_id: Arc, current_module: Arc>>, /// The `id` of the `BreadEvent` whose subscriber callback is currently /// executing, if any. Set immediately before invoking a handler in /// `handle_event` and restored (not merely cleared) immediately after, /// so nested/reentrant dispatch and a single event fan-out to multiple /// subscriptions both see the correct parent id. Read synchronously by /// `bread.emit()`'s binding to populate the outgoing event's /// `caused_by`. The Lua engine runs single-threaded/cooperatively (one /// engine instance processes `LuaMessage`s serially on a dedicated /// thread), so a callback invocation and any `bread.emit()` inside it /// happen synchronously within one `handle_event` call — no additional /// synchronization beyond the existing `Mutex` (mirroring /// `current_module` above) is needed. current_dispatch_id: Arc>>, modules: Arc>>, module_decls: Arc>>, module_order: Arc>>, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender, lua_tx: mpsc::UnboundedSender, entry_point: PathBuf, module_path: PathBuf, modules_config: ModulesConfig, notifications_config: NotificationsConfig, recent_errors: Arc>>, /// Workstream G: spawn/token bookkeeping for out-of-process module /// hosts, shared with `ipc::Server` (which authenticates the spawned /// children and serves their RPC calls). See `crate::module_host`. module_host_registry: ModuleHostRegistry, socket_path: PathBuf, } impl LuaEngine { fn new( config: Config, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender, lua_tx: mpsc::UnboundedSender, recent_errors: Arc>>, module_host_registry: ModuleHostRegistry, ) -> Result { let socket_path = config.socket_path(); 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)), current_dispatch_id: 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, module_host_registry, socket_path, 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.state_handle.clear_widgets(); self.lua = Lua::new(); self.handlers .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); self.watch_ids .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); self.modules .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); self.module_decls .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); self.module_order .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); self.install_api()?; self.load_device_rules()?; self.load_profiles()?; self.load_rules_toml()?; self.load_init_and_modules()?; self.run_on_reload(); // clear_widgets() above is silent (no event) since it's just a state // wipe ahead of modules re-registering. That's a problem when a // module goes from "registered some widgets" to "disabled and // skipped" across this reload: nothing re-registers, so no // bread.widget.registered fires, and a renderer that only refetches // on bread.widget.* events (see breadbar's widgets::client) never // learns the registry emptied out. One definitive signal per reload, // regardless of whether anything actually changed, closes that gap. let _ = self.emit_tx.send(BreadEvent::new( "bread.widget.cleared", AdapterSource::System, serde_json::json!({}), )); 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)| { 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 current_dispatch_id = self.current_dispatch_id.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::(other) .unwrap_or_else(|_| serde_json::json!({})), }; // Daemon-internal emit — same trusted path as adapter/IPC // construction, tagged System. If this runs synchronously // inside a subscriber callback (see `handle_event`), thread // the currently-dispatching event's id through as // `caused_by` so the causality chain can be reconstructed. let caused_by = current_dispatch_id .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); let mut event = BreadEvent::new(event_name, AdapterSource::System, data); event.caused_by = caused_by; emit_tx .send(event) .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("-c") .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)?; // `bread.exec` is deliberately fire-and-forget (spawn_blocking, no // result). This is the capturing counterpart for the common "run a // fast local command and read its stdout back into Lua" case (e.g. // `git -C rev-parse --abbrev-ref HEAD`). It blocks the calling // Lua callback for real, so it's only appropriate for quick // commands — hence the timeout. The subprocess itself runs on a // plain std::thread (not spawn_blocking) so the Lua thread can wait // on a channel with a deadline; `Command::output()` drains stdout // internally as it reads, so a chatty command can't deadlock this by // filling a pipe buffer while nobody's reading it. On timeout the // spawned thread and its child are left to finish/exit on their own // rather than force-killed — acceptable for the fast-command case // this exists for, not worth the extra complexity for a rare hang. let exec_capture_fn = self.lua .create_function(|_lua, (cmd, opts): (String, Option
)| { let timeout_ms: u64 = opts .as_ref() .and_then(|o| o.get("timeout_ms").ok()) .unwrap_or(2000); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = std::process::Command::new("sh").arg("-c").arg(&cmd).output(); let _ = tx.send(result); }); match rx.recv_timeout(std::time::Duration::from_millis(timeout_ms)) { Ok(Ok(output)) => Ok(( output.status.success(), String::from_utf8_lossy(&output.stdout).to_string(), )), Ok(Err(_)) | Err(_) => Ok((false, String::new())), } })?; bread.set("exec_capture", exec_capture_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
)| { 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 = 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 current_module = self.current_module.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); let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone(); timers .lock() .map_err(|_| LuaError::external("timer lock poisoned"))? .insert( id, TimerEntry { callback: key, repeating: false, cancel_tx, module, }, ); 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 current_module = self.current_module.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); let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone(); timers .lock() .map_err(|_| LuaError::external("timer lock poisoned"))? .insert( id, TimerEntry { callback: key, repeating: true, cancel_tx, module, }, ); 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()))?; json_to_lua(lua, &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()))?; json_to_lua(lua, &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()))?; json_to_lua(lua, &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()))?; json_to_lua(lua, &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 json_to_lua(lua, &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::(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.widget — declarative, live-updating widgets rendered by // sibling bread* apps (breadbar) in their bar/popover free space. // Follows the same Rust-backed-registry pattern as bread.workflow // (see install_workflow_helpers / workflow_register below): // mutate RuntimeState.widgets via the try_write spin-lock, then // emit a bread.widget.* event so subscribers (and, indirectly, // breadbar's events.subscribe stream) observe the change. let widget_tbl = self.lua.create_table()?; let state_arc = self.state_handle.state_arc(); let current_module = self.current_module.clone(); let emit_tx = self.emit_tx.clone(); let widget_register_fn = self.lua.create_function( move |lua, spec_table: Table| -> mlua::Result<(bool, Option)> { let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone() .ok_or_else(|| { LuaError::external("bread.widget.register must be called from within a module") })?; let args: WidgetRegisterArgs = match lua.from_value(Value::Table(spec_table)) { Ok(a) => a, Err(e) => return Ok((false, Some(e.to_string()))), }; Ok(match widget_register(&state_arc, &module, args) { Ok(spec) => { let data = serde_json::to_value(&spec).unwrap_or_default(); let _ = emit_tx.send(BreadEvent::new( "bread.widget.registered", AdapterSource::System, data, )); (true, None) } Err(e) => (false, Some(e.to_string())), }) }, )?; widget_tbl.set("register", widget_register_fn)?; let state_arc = self.state_handle.state_arc(); let current_module = self.current_module.clone(); let emit_tx = self.emit_tx.clone(); let widget_update_fn = self.lua.create_function( move |lua, (local_id, patch): (String, Table)| -> mlua::Result<(bool, Option)> { let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone() .ok_or_else(|| { LuaError::external("bread.widget.update must be called from within a module") })?; let args: WidgetUpdateArgs = match lua.from_value(Value::Table(patch)) { Ok(a) => a, Err(e) => return Ok((false, Some(e.to_string()))), }; Ok(match widget_update(&state_arc, &module, &local_id, args) { Ok(Some(spec)) => { let data = serde_json::to_value(&spec).unwrap_or_default(); let _ = emit_tx.send(BreadEvent::new( "bread.widget.updated", AdapterSource::System, data, )); (true, None) } Ok(None) => (false, Some("no such widget".to_string())), Err(e) => (false, Some(e.to_string())), }) }, )?; widget_tbl.set("update", widget_update_fn)?; let state_arc = self.state_handle.state_arc(); let current_module = self.current_module.clone(); let emit_tx = self.emit_tx.clone(); let widget_remove_fn = self.lua.create_function(move |_lua, local_id: String| { let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone() .ok_or_else(|| { LuaError::external("bread.widget.remove must be called from within a module") })?; let full_id = format!("{module}.{local_id}"); let removed = widget_remove(&state_arc, &module, &local_id); if removed { let _ = emit_tx.send(BreadEvent::new( "bread.widget.removed", AdapterSource::System, serde_json::json!({ "id": full_id }), )); } Ok(removed) })?; widget_tbl.set("remove", widget_remove_fn)?; let state_arc = self.state_handle.state_arc(); let current_module = self.current_module.clone(); let widget_list_fn = self.lua.create_function(move |lua, ()| { let module = current_module .lock() .map_err(|_| LuaError::external("module context lock poisoned"))? .clone() .ok_or_else(|| { LuaError::external("bread.widget.list must be called from within a module") })?; let json = widget_list_json(&state_arc, &module); json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; widget_tbl.set("list", widget_list_fn)?; bread.set("widget", widget_tbl)?; // bread.machine — hostname/tags; reads an optional, externally-managed // ~/.config/bread/sync.toml if present (bread does not create it) 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)?; // Distinct from `read`: `/proc//cwd` and friends are symlinks // whose *target path* is the payload, not a file to open and read — // `std::fs::read_to_string` on one of those fails with "Is a // directory" (or reads the wrong thing for a symlink-to-file). let readlink_fn = self.lua.create_function(|_lua, path: String| { let expanded = lua_expand_path(&path); match std::fs::read_link(&expanded) { Ok(target) => Ok(Some(target.to_string_lossy().to_string())), Err(_) => Ok(None), } })?; fs_tbl.set("readlink", readlink_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.json — for parsing output from things like `kitty @ ls` or // any other JSON-emitting CLI invoked via bread.exec_capture. Uses // the same null-handling as every other JSON entry point into Lua // (json_to_lua, not a bare to_value) so `nil` behaves as Lua nil, // not a sentinel. let json_tbl = self.lua.create_table()?; let decode_fn = self.lua.create_function(|lua, s: String| { match serde_json::from_str::(&s) { Ok(v) => json_to_lua(lua, &v).map(Some).or(Ok(None)), Err(_) => Ok(None), } })?; json_tbl.set("decode", decode_fn)?; bread.set("json", json_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_workflow_helpers()?; 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 = Vec::new(); for pair in tbl.sequence_values::() { 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 = if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") { match_tbl .sequence_values::() .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}")) } /// Reads and validates `rules.toml` (if present) and stashes the result /// in Lua globals for `bread.rules`'s `on_load()` to pick up once /// `load_init_and_modules()` loads that built-in module. See the /// `BUILTIN_RULES` doc comment for why globals rather than generating /// Lua source text: the rule data is dynamic (comes from a config file /// parsed fresh each reload) while `ModuleDecl::source` is a /// `&'static str`, so passing it as real Lua values via `mlua`'s Table /// API sidesteps ever having to hand-escape a path or shell command /// into a Lua string literal. fn load_rules_toml(&self) -> Result<()> { let path = rules::rules_path(); match rules::load_rules(&path) { RulesLoadOutcome::Absent => Ok(()), RulesLoadOutcome::Fatal(msg) => { self.lua.globals().set("__rules_fatal", msg)?; Ok(()) } RulesLoadOutcome::Loaded { rules, issues } => { let data = self.lua.create_table()?; for (i, rule) in rules.iter().enumerate() { let tbl = self.lua.create_table()?; tbl.set("on", rule.on.clone())?; match &rule.action { // `run` names exactly one program: tilde-expand it // the same way `bread.fs.*`/`bread.exec` callers // would, then shell-quote the whole thing so a path // containing spaces still runs as one file rather // than being word-split by the `sh -c` that // `bread.exec()` runs it through. RuleAction::Run(path) => { let expanded = lua_expand_path(path); let quoted = shell_quote(&expanded.to_string_lossy()); tbl.set("run", quoted)?; } // `exec` is a full shell command line — pass it // through to `bread.exec()` verbatim, exactly like // hand-written Lua calling `bread.exec()` directly. RuleAction::Exec(cmd) => { tbl.set("exec", cmd.clone())?; } RuleAction::Notify(message) => { tbl.set("notify", message.clone())?; } } data.set(i + 1, tbl)?; } self.lua.globals().set("__rules_data", data)?; if !issues.is_empty() { let combined = issues .iter() .map(|issue| issue.to_string()) .collect::>() .join("; "); self.lua .globals() .set("__rules_warning", format!("rules.toml: {combined}"))?; } Ok(()) } } } 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 = 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)) { let name = module_name_from_path(&self.module_path, &path); // bos-settings' module picker writes filenames (e.g. // "widget.lua") into `disable`, not the bare module name a file // registers under via bread.module({name=...}) — match either // form so both conventions work rather than requiring the UI // and the daemon to agree on one exact string. let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); if disabled.contains(&name) || disabled.contains(filename) { self.state_handle .set_module_status(name, ModuleLoadState::Disabled, None, false); continue; } match self.scan_module_decl(&path) { Ok(mut decl) => { // Manifest lives beside the module's entry file // (bread-cli's install_from_local always writes // //{bread.module.toml,init.lua} — // see bread-cli/src/modules_mgmt.rs). A hand-authored // flat file with no sibling manifest (e.g. the // "Your first module" walkthrough's modules/hello.lua) // has no manifest to find at all, which read_module_permissions // reports the same way as an existing-but-permissions-less // one: None, i.e. full ungated backward-compat access. decl.permissions = read_module_permissions(&path); decls.push(decl); } Err(err) => { 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().unwrap_or_else(|e| e.into_inner()); 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()); // Static per-decl (not per-status-transition) property: whether // this module is running with full, ungated bread.* access. Only // ever true for a non-builtin module with no declared // permissions — see ModuleDecl::permissions' doc comment. let ungated = !decl.builtin && decl.permissions.is_none(); match self.load_module(&decl) { Ok(()) => { self.state_handle.set_module_status_ex( decl.name.clone(), ModuleLoadState::Loaded, None, decl.builtin, ungated, ); } Err(err) => { self.state_handle.set_module_status_ex( decl.name.clone(), ModuleLoadState::LoadError, Some(err.to_string()), decl.builtin, ungated, ); } } } *self.module_order.lock().unwrap_or_else(|e| e.into_inner()) = load_order; Ok(()) } fn load_module(&self, decl: &ModuleDecl) -> Result<()> { // Workstream G branch point: a third-party module that declared // `[[permissions]]` (opted into the D capability-manifest system — // `decl.permissions.is_some()`, including `Some(&[])`) gets spawned // as a separate, OS-sandboxed `bread-module-host` process instead of // being loaded into this Lua VM at all. Its Lua state, `bread.on` // handlers, timers, etc. all live in that other process from here // on — none of this engine's module-table/on_load bookkeeping below // applies to it, hence the early return. // // `decl.permissions.is_none()` (no manifest, or a manifest with no // `permissions` key) falls through to the unchanged in-process, // unscoped path for backward compatibility — see // `ModuleDecl::permissions`'s doc comment and `Documentation.md`'s // "Workstream G" section for why this is a deliberate scope // decision rather than an oversight: Landlock needs concrete rules // to build from, and "no manifest at all" carries none. if decl.source.is_none() { if let Some(permissions) = decl.permissions.as_ref() { return self.load_out_of_process_module(decl, permissions); } } self.set_current_module(Some(decl.name.clone())); let result = if let Some(source) = decl.source { // Builtins (bread.monitors/devices/workspaces/binds) — embedded // source, always the full ambient bread table, never scoped. self.load_lua_source(source, &decl.name) } else { // Third-party, on-disk, no-manifest module: today's original // behavior, unchanged (full ungated in-process access). self.load_scoped_lua_file(&decl.path, &decl.name, None) }; 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) } /// Spawn (or respawn, on `bread reload`) a sandboxed `bread-module-host` /// child for `decl` and block until it reports ready or fails — see /// `crate::module_host::spawn_module_host`. Blocking here (rather than /// making `load_module` async) keeps `load_module`'s existing /// synchronous "a module either loaded or it didn't" contract intact /// for callers like `load_init_and_modules` and the `modules.reload` /// IPC method, which both expect to know Loaded-vs-LoadError before /// they return. fn load_out_of_process_module( &self, decl: &ModuleDecl, permissions: &[ModulePermission], ) -> Result<()> { let outcome = module_host::spawn_module_host( &self.module_host_registry, &decl.name, &decl.path, permissions, &self.socket_path, &self.emit_tx, )?; match outcome { ModuleHostOutcome::Ready => Ok(()), ModuleHostOutcome::LoadError(err) => Err(anyhow!(err)), } } /// Load `init.lua` (the trusted entry point) or any other file that /// should see the real, unscoped `bread` global exactly like today. /// Not used for third-party modules — see [`load_scoped_lua_file`]. 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(()) } /// Load a third-party module's `.lua` file, giving its chunk a /// capability-scoped `_ENV` instead of the real shared globals. /// /// `permissions: None` means the module's manifest declared no /// `permissions` at all (no manifest on disk, or a manifest predating /// this field) — backward compat: full, ungated access, identical to /// `load_lua_file`. `Some(perms)` (including `Some(&[])`) builds a fresh /// `bread` table containing only the baseline bindings plus whatever /// `perms` grants, and sets it as the chunk's environment. See /// `build_scoped_env` for how `require`/stdlib stay reachable. fn load_scoped_lua_file( &self, path: &Path, module_name: &str, permissions: Option<&[ModulePermission]>, ) -> 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, false, ); return Ok(()); } let src = fs::read_to_string(path)?; let chunk = self.lua.load(&src).set_name(path.to_string_lossy().as_ref()); match permissions { None => { // No manifest / no permissions declared: today's behavior, // unchanged. Do NOT call set_environment here at all (rather // than passing globals() explicitly) so this stays // byte-for-byte the same code path load_lua_file already // uses and has always used. chunk.exec()?; } Some(perms) => { let env = self.build_scoped_env(perms)?; chunk.set_environment(env).exec()?; } } Ok(()) } /// Build a fresh `_ENV` table for a capability-scoped module chunk. /// /// Shape: a table whose own `bread` key is a *new* table containing only /// the baseline bindings (event subscription, timers, json, module/ /// store, logging, and the pure-Lua sugar built on top of those — /// debounce/spawn/wait*/workflow) plus whichever gated namespaces /// `perms` grants (fs, exec, notify, machine, hyprland, widget, /// bluetooth, state, profile — split at the granularity the manifest /// schema exposes, e.g. `fs.read` without `fs.write` yields a `bread.fs` /// table with `.read`/`.exists`/`.readlink`/`.expand` but no `.write`). /// A permission that isn't granted means the corresponding key is /// genuinely absent (`bread.fs == nil`), not present-but-erroring. /// /// Everything else — `pairs`, `string`, `table`, `math`, `pcall`, /// `coroutine`, `require`, `package`, ... — is reached through a /// metatable `__index` that falls back to the real global table, so /// `require("bread.devices")` still works: `require` is the real global /// function operating on the real global `package.loaded`, which /// already contains `bread.devices`'s module table by the time any /// third-party module loads (builtins load first). That module table's /// own methods (`devices.on()` etc.) were themselves defined while /// `bread.devices` was loaded unscoped, so they close over the *real* /// `bread` table as an upvalue — Lua closures capture their defining /// environment lexically, not the caller's — which is exactly why /// `require("bread.devices")` keeps working from inside a scoped module /// with no special-casing needed here. /// /// One deliberate hardening step beyond that: `_G` is explicitly /// rebound to point at this same scoped table (self-referentially, the /// same way stock Lua's base library self-references the real global /// table under `_G`). Without that, `local G = _G; G.bread.fs...` would /// walk straight past the whole mechanism, since `_G` is just an /// ordinary global (not magic in Lua 5.2+) and would otherwise resolve /// through the `__index` fallback to the *real* globals table. /// /// What this does **not** do: strip `os`/`io`/`debug` from the /// fallback. Those remain reachable from a scoped module exactly as /// they are from an unscoped one — `os.execute`/`io.open` bypass /// `bread.exec`/`bread.fs` gating entirely if a module chooses to use /// them directly. This mechanism gates the documented `bread.*` API /// surface (so a well-behaved module naturally degrades, and an /// accidental over-reach is caught), it is not a hard security boundary /// against a deliberately malicious script — that's what the /// out-of-process module sandboxing workstream this manifest schema /// exists for is for. fn build_scoped_env(&self, perms: &[ModulePermission]) -> Result> { let globals = self.lua.globals(); let real_bread: Table = globals.get("bread")?; let granted: HashSet = perms.iter().map(|p| p.kind).collect(); let scoped_bread = self.lua.create_table()?; // Baseline — always available, no manifest entry required. const BASELINE_KEYS: &[&str] = &[ "on", "once", "filter", "off", "emit", "after", "every", "cancel", "json", "module", "log", "warn", "error", "debounce", "spawn", "wait", "wait_any", "wait_all", "workflow", ]; for key in BASELINE_KEYS { let v: Value = real_bread.get(*key)?; if !matches!(v, Value::Nil) { scoped_bread.set(*key, v)?; } } if granted.contains(&PermissionKind::StateRead) || granted.contains(&PermissionKind::StateWatch) { let real_state: Table = real_bread.get("state")?; let scoped_state = self.lua.create_table()?; if granted.contains(&PermissionKind::StateRead) { for key in [ "get", "monitors", "active_workspace", "active_window", "devices", "power", "network", "profile", ] { let v: Value = real_state.get(key)?; scoped_state.set(key, v)?; } } if granted.contains(&PermissionKind::StateWatch) { let v: Value = real_state.get("watch")?; scoped_state.set("watch", v)?; } scoped_bread.set("state", scoped_state)?; } if granted.contains(&PermissionKind::ProfileActivate) { let v: Value = real_bread.get("profile")?; scoped_bread.set("profile", v)?; } if granted.contains(&PermissionKind::Exec) { let exec: Value = real_bread.get("exec")?; scoped_bread.set("exec", exec)?; let exec_capture: Value = real_bread.get("exec_capture")?; scoped_bread.set("exec_capture", exec_capture)?; } if granted.contains(&PermissionKind::Notify) { let v: Value = real_bread.get("notify")?; scoped_bread.set("notify", v)?; } if granted.contains(&PermissionKind::Machine) { let v: Value = real_bread.get("machine")?; scoped_bread.set("machine", v)?; } if granted.contains(&PermissionKind::Hyprland) { let v: Value = real_bread.get("hyprland")?; scoped_bread.set("hyprland", v)?; } if granted.contains(&PermissionKind::Widget) { let v: Value = real_bread.get("widget")?; scoped_bread.set("widget", v)?; } if granted.contains(&PermissionKind::Bluetooth) { let v: Value = real_bread.get("bluetooth")?; scoped_bread.set("bluetooth", v)?; } if granted.contains(&PermissionKind::FsRead) || granted.contains(&PermissionKind::FsWrite) { let real_fs: Table = real_bread.get("fs")?; let scoped_fs = self.lua.create_table()?; if granted.contains(&PermissionKind::FsRead) { for key in ["read", "exists", "readlink", "expand"] { let v: Value = real_fs.get(key)?; scoped_fs.set(key, v)?; } } if granted.contains(&PermissionKind::FsWrite) { let v: Value = real_fs.get("write")?; scoped_fs.set("write", v)?; } scoped_bread.set("fs", scoped_fs)?; } let env = self.lua.create_table()?; let mt = self.lua.create_table()?; mt.set("__index", self.lua.globals())?; env.set_metatable(Some(mt)); env.set("bread", scoped_bread)?; env.set("_G", env.clone())?; Ok(env) } 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().unwrap_or_else(|e| e.into_inner()); 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::(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 = json_to_lua(&self.lua, &event)?; let allowed = filter.call::<_, bool>(event_value).unwrap_or(false); if !allowed { return Ok(()); } } self.set_current_module(module.clone()); // Every subscriber invocation triggered by this event should see the // same parent id, and a `bread.emit()` call made synchronously // inside the callback should attribute its `caused_by` to *this* // event, not whatever was dispatching (if anything) before it. let previous_dispatch_id = self.set_current_dispatch_id(Some(event.id.clone())); let result = match kind { HandlerKind::Event => { let event_value = json_to_lua(&self.lua, &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 = json_to_lua(&self.lua, &new_val)?; let old_lua = json_to_lua(&self.lua, &old_val)?; callback.call::<_, ()>((new_lua, old_lua)) } }; // Restore rather than clear, so this correctly unwinds if dispatch // is ever reentrant (e.g. a callback that pumps messages itself). self.set_current_dispatch_id(previous_dispatch_id); self.set_current_module(None); 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, module) = { let timers = self.timers.lock().unwrap_or_else(|e| e.into_inner()); let Some(entry) = timers.get(&id) else { return Ok(()); }; let callback: Function = self.lua.registry_value(&entry.callback)?; (callback, entry.repeating, entry.module.clone()) }; self.set_current_module(module); let result = callback.call::<_, ()>(()); self.set_current_module(None); if let Err(err) = result { 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) -> Result<()> { if let Some(hook) = self.get_module_hook(name, "on_load") { self.set_current_module(Some(name.to_string())); let result = hook.call::<_, ()>(()); self.set_current_module(None); if let Err(err) = result { error!(module = %name, error = %err, "module on_load failed"); // Propagate rather than setting LoadError here directly: the // caller (load_module, via load_init_and_modules) is the // single place that decides Loaded vs LoadError for a // module, so this failure isn't immediately clobbered back // to Loaded by that outer Ok(()) branch — which is exactly // what used to happen when this function swallowed the // error and always returned successfully. return Err(anyhow!(err.to_string())); } } Ok(()) } fn run_on_reload(&self) { let order = self .module_order .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); for name in order { if let Some(hook) = self.get_module_hook(&name, "on_reload") { self.set_current_module(Some(name.clone())); let result = hook.call::<_, ()>(()); self.set_current_module(None); if let Err(err) = result { error!(module = %name, error = %err, "module on_reload failed"); let builtin = self.module_is_builtin(&name); self.state_handle.set_module_status_ex( name.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, self.module_ungated(&name), ); } } } } fn run_on_unload(&self) { let order = self .module_order .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); for name in order.into_iter().rev() { if let Some(hook) = self.get_module_hook(&name, "on_unload") { self.set_current_module(Some(name.clone())); let result = hook.call::<_, ()>(()); self.set_current_module(None); if let Err(err) = result { error!(module = %name, error = %err, "module on_unload failed"); let builtin = self.module_is_builtin(&name); self.state_handle.set_module_status_ex( name.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, self.module_ungated(&name), ); } } } } 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_ex( module.to_string(), ModuleLoadState::Degraded, Some(err.to_string()), builtin, self.module_ungated(module), ); 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> { 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) } /// Whether `name` is a third-party module running with full, ungated /// `bread.*` access (no `permissions` declared). See /// `ModuleDecl::permissions`'s doc comment for exactly what "declared" /// means. Always `false` for builtins and for unknown module names. fn module_ungated(&self, name: &str) -> bool { self.module_decls .lock() .ok() .and_then(|map| { map.get(name) .map(|d| !d.builtin && d.permissions.is_none()) }) .unwrap_or(false) } fn set_current_module(&self, name: Option) { if let Ok(mut guard) = self.current_module.lock() { *guard = name; } } /// Set the "currently dispatching" event id, returning whatever id was /// there before. Callers restore the previous value (rather than /// clearing to `None`) when the handler invocation finishes, so nested/ /// reentrant dispatch unwinds correctly — see `current_dispatch_id`'s /// doc comment on the struct definition. fn set_current_dispatch_id(&self, id: Option) -> Option { match self.current_dispatch_id.lock() { Ok(mut guard) => std::mem::replace(&mut *guard, id), Err(poisoned) => std::mem::replace(&mut *poisoned.into_inner(), id), } } 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 { const MODULE_DECL_ABORT: &str = "__bread_module_decl__"; let lua = Lua::new(); let decl_cell: Rc>> = 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 = table.get("version").ok(); let after: Vec = table.get("after").unwrap_or_default(); *decl_cell_cloned.borrow_mut() = Some(ModuleDecl { name, version, after, path: module_path.clone(), source: None, builtin: false, // Populated afterwards by the caller (load_init_and_modules), // which reads bread.module.toml from disk — scan_module_decl // only cares about the bread.module({...}) declaration itself. permissions: None, }); 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(()) } /// `bread.workflow` (define/start/step/status/list) and the multi-condition /// wait helpers `bread.wait_any`/`bread.wait_all`. Composition (spawning, /// yielding, timeouts) is plain Lua built on the existing `bread.spawn`/ /// `bread.on`/`bread.once`/`bread.after`/`bread.cancel`/`bread.off` /// primitives from [`install_wait_helper`](Self::install_wait_helper) — /// mirroring how that method itself works. Only the *introspectable /// status* piece needs a Rust host bridge (the `__workflow_*` functions /// below), since `workflows.list` is served over IPC from the async side /// while the workflow body runs as a Lua coroutine on this dedicated Lua /// thread; both sides read/write the same `Arc>` /// that `module_store_get`/`module_store_set` already use for exactly /// this kind of cross-thread bridging. fn install_workflow_helpers(&self) -> Result<()> { let globals = self.lua.globals(); let bread: Table = globals.get("bread")?; let state_arc = self.state_handle.state_arc(); let register_fn = self.lua.create_function(move |_lua, name: String| { workflow_register(&state_arc, &name); Ok(()) })?; bread.set("__workflow_register", register_fn)?; let state_arc = self.state_handle.state_arc(); let step_fn = self .lua .create_function(move |_lua, (name, label): (String, String)| { workflow_step(&state_arc, &name, &label); Ok(()) })?; bread.set("__workflow_step", step_fn)?; let state_arc = self.state_handle.state_arc(); let finish_fn = self.lua.create_function(move |_lua, name: String| { workflow_finish(&state_arc, &name); Ok(()) })?; bread.set("__workflow_finish", finish_fn)?; let state_arc = self.state_handle.state_arc(); let fail_fn = self .lua .create_function(move |_lua, (name, error): (String, String)| { workflow_fail(&state_arc, &name, &error); Ok(()) })?; bread.set("__workflow_fail", fail_fn)?; let state_arc = self.state_handle.state_arc(); let timeout_fn = self.lua.create_function(move |_lua, name: String| { workflow_timeout(&state_arc, &name); Ok(()) })?; bread.set("__workflow_timeout", timeout_fn)?; let state_arc = self.state_handle.state_arc(); let status_fn = self.lua.create_function(move |lua, name: String| { match workflow_status_json(&state_arc, &name) { Some(json) => json_to_lua(lua, &json).map_err(|e| LuaError::external(e.to_string())), None => Ok(Value::Nil), } })?; bread.set("__workflow_status", status_fn)?; let state_arc = self.state_handle.state_arc(); let list_fn = self.lua.create_function(move |lua, ()| { let json = workflow_list_json(&state_arc); json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; bread.set("__workflow_list", list_fn)?; self.lua .load( r#" bread.wait_any = function(patterns, opts) if type(patterns) ~= "table" then error("bread.wait_any requires a table of patterns") end opts = opts or {} local co = coroutine.running() if not co then error("bread.wait_any must be called inside a coroutine") end local ids = {} local timer local resumed = false local function cleanup() for _, id in ipairs(ids) do bread.off(id) end if timer then bread.cancel(timer) end end for _, pattern in ipairs(patterns) do local id = bread.once(pattern, function(event) if resumed then return end resumed = true cleanup() coroutine.resume(co, event, pattern) end) table.insert(ids, id) end if opts.timeout then timer = bread.after(opts.timeout, function() if resumed then return end resumed = true cleanup() coroutine.resume(co, nil, nil) end) end return coroutine.yield() end bread.wait_all = function(patterns, opts) if type(patterns) ~= "table" then error("bread.wait_all requires a table of patterns") end opts = opts or {} local co = coroutine.running() if not co then error("bread.wait_all must be called inside a coroutine") end local remaining = {} local count = 0 for _, p in ipairs(patterns) do if remaining[p] == nil then remaining[p] = true count = count + 1 end end local results = {} local got = 0 local timer local resumed = false local ids = {} local function finish(timed_out) if resumed then return end resumed = true for _, id in ipairs(ids) do bread.off(id) end if timer then bread.cancel(timer) end if timed_out then results.timed_out = true end coroutine.resume(co, results) end for _, pattern in ipairs(patterns) do local id = bread.once(pattern, function(event) if remaining[pattern] then remaining[pattern] = nil results[pattern] = event got = got + 1 if got >= count then finish(false) end end end) table.insert(ids, id) end if opts.timeout then timer = bread.after(opts.timeout, function() finish(true) end) end return coroutine.yield() end bread.workflow = {} local __workflow_bodies = {} local __co_to_workflow = setmetatable({}, { __mode = "k" }) bread.workflow.define = function(name, fn) if type(name) ~= "string" then error("bread.workflow.define requires a name string") end __workflow_bodies[name] = fn end bread.workflow.start = function(name, opts) local fn = __workflow_bodies[name] if not fn then error("bread.workflow.start: no workflow defined with name '" .. tostring(name) .. "'") end opts = opts or {} bread.__workflow_register(name) local deadline_timer if opts.deadline then deadline_timer = bread.after(opts.deadline, function() bread.__workflow_timeout(name) end) end local co = coroutine.create(function() local ok, err = pcall(fn, opts.args) if deadline_timer then bread.cancel(deadline_timer) end if ok then bread.__workflow_finish(name) else bread.__workflow_fail(name, tostring(err)) end end) __co_to_workflow[co] = name local ok, err = coroutine.resume(co) if not ok then bread.__workflow_fail(name, tostring(err)) end end bread.workflow.step = function(label) local co = coroutine.running() local name = co and __co_to_workflow[co] if not name then error("bread.workflow.step must be called inside a running workflow body") end bread.__workflow_step(name, label) end bread.workflow.status = function(name) return bread.__workflow_status(name) end bread.workflow.list = function() return bread.__workflow_list() end "#, ) .exec()?; Ok(()) } } fn workflow_register(state_arc: &Arc>, name: &str) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let now = now_unix_ms(); if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { entry.state = WorkflowState::Running; entry.step = None; entry.started_at = now; entry.updated_at = now; entry.error = None; } else { guard.workflows.push(WorkflowStatus { name: name.to_string(), state: WorkflowState::Running, step: None, started_at: now, updated_at: now, error: None, }); } } fn workflow_step(state_arc: &Arc>, name: &str, label: &str) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { entry.step = Some(label.to_string()); entry.updated_at = now_unix_ms(); } } fn workflow_finish(state_arc: &Arc>, name: &str) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { entry.state = WorkflowState::Done; entry.updated_at = now_unix_ms(); } } fn workflow_fail(state_arc: &Arc>, name: &str, error: &str) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { entry.state = WorkflowState::Failed; entry.error = Some(error.to_string()); entry.updated_at = now_unix_ms(); } } fn workflow_timeout(state_arc: &Arc>, name: &str) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) { // Don't clobber a workflow that already reached a terminal state // between the deadline firing and this callback running. if entry.state == WorkflowState::Running { entry.state = WorkflowState::TimedOut; entry.updated_at = now_unix_ms(); } } } fn workflow_status_json(state_arc: &Arc>, name: &str) -> Option { let guard = loop { if let Ok(g) = state_arc.try_read() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let entry = guard.workflows.iter().find(|w| w.name == name)?; serde_json::to_value(entry).ok() } fn workflow_list_json(state_arc: &Arc>) -> JsonValue { let guard = loop { if let Ok(g) = state_arc.try_read() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; serde_json::to_value(&guard.workflows).unwrap_or_else(|_| JsonValue::Array(vec![])) } /// Table shape accepted by `bread.widget.register`. Parsed directly from the /// Lua table via mlua's serde bridge, so `root`'s nested `children` tables /// deserialize straight into a `WidgetNode` tree without a manual walker. #[derive(Debug, Deserialize)] struct WidgetRegisterArgs { id: String, placement: WidgetPlacement, #[serde(default)] order: i32, #[serde(default = "default_widget_visible")] visible: bool, #[serde(default)] tooltip: Option, root: WidgetNode, } /// Table shape accepted by `bread.widget.update` — every field optional, so /// a caller can patch just what changed (typically just `root` on a timer). #[derive(Debug, Default, Deserialize)] struct WidgetUpdateArgs { #[serde(default)] root: Option, #[serde(default)] tooltip: Option, #[serde(default)] visible: Option, #[serde(default)] order: Option, } fn default_widget_visible() -> bool { true } fn widget_register( state_arc: &Arc>, module: &str, args: WidgetRegisterArgs, ) -> std::result::Result { args.root.validate()?; let spec = WidgetSpec { id: format!("{module}.{}", args.id), module: module.to_string(), placement: args.placement, order: args.order, visible: args.visible, tooltip: args.tooltip, root: args.root, updated_at: now_unix_ms(), }; let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; if let Some(existing) = guard.widgets.iter_mut().find(|w| w.id == spec.id) { *existing = spec.clone(); } else { guard.widgets.push(spec.clone()); } Ok(spec) } fn widget_update( state_arc: &Arc>, module: &str, local_id: &str, args: WidgetUpdateArgs, ) -> std::result::Result, bread_shared::widget::WidgetValidationError> { if let Some(root) = &args.root { root.validate()?; } let full_id = format!("{module}.{local_id}"); let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let Some(entry) = guard.widgets.iter_mut().find(|w| w.id == full_id) else { return Ok(None); }; if let Some(root) = args.root { entry.root = root; } if let Some(tooltip) = args.tooltip { entry.tooltip = Some(tooltip); } if let Some(visible) = args.visible { entry.visible = visible; } if let Some(order) = args.order { entry.order = order; } entry.updated_at = now_unix_ms(); Ok(Some(entry.clone())) } fn widget_remove(state_arc: &Arc>, module: &str, local_id: &str) -> bool { let full_id = format!("{module}.{local_id}"); let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let before = guard.widgets.len(); guard.widgets.retain(|w| w.id != full_id); before != guard.widgets.len() } fn widget_list_json(state_arc: &Arc>, module: &str) -> JsonValue { let guard = loop { if let Ok(g) = state_arc.try_read() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let mine: Vec<&WidgetSpec> = guard.widgets.iter().filter(|w| w.module == module).collect(); serde_json::to_value(&mine).unwrap_or_else(|_| JsonValue::Array(vec![])) } fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, String)>) { let mut errors = Vec::new(); let mut map: HashMap = 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> = HashMap::new(); let mut reverse: HashMap> = HashMap::new(); let mut invalid: HashSet = 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 = 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) } /// Read the `permissions` declared in the `bread.module.toml` manifest /// sibling to a third-party module's entry file, if any. /// /// `bread-cli`'s `install_from_local` always lays a module out as /// `//{bread.module.toml,init.lua,...}` (see /// `bread-cli/src/modules_mgmt.rs`), so the manifest is always the entry /// file's parent directory + `bread.module.toml`. Returns `None` — meaning /// "not declared", handled as full ungated backward-compat access by /// `load_scoped_lua_file` — whenever: the module has no directory-level /// manifest at all (a hand-authored flat file, e.g. the "Your first /// module" walkthrough's `modules/hello.lua`); the manifest exists but has /// no `permissions` key; or the manifest fails to parse (logged, not /// treated as a load error — a broken manifest shouldn't also break the /// module load path it's unrelated to). fn read_module_permissions(module_file: &Path) -> Option> { #[derive(serde::Deserialize)] struct PermissionsOnly { #[serde(default)] permissions: Option>, } let manifest_path = module_file.parent()?.join("bread.module.toml"); if !manifest_path.exists() { return None; } let raw = match fs::read_to_string(&manifest_path) { Ok(raw) => raw, Err(err) => { warn!(path = %manifest_path.display(), error = %err, "failed to read bread.module.toml"); return None; } }; match toml::from_str::(&raw) { Ok(parsed) => parsed.permissions, Err(err) => { warn!(path = %manifest_path.display(), error = %err, "failed to parse bread.module.toml; treating as no permissions declared"); None } } } /// `lua.to_value()`'s default `Options` map JSON null / Rust `Option::None` /// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to /// preserve JSON round-trip fidelity — but bread never round-trips a Lua /// value back into JSON through mlua, so that distinction buys nothing here /// and only sets a trap for the ordinary Lua idiom `if not value then ...`, /// which silently doesn't catch the sentinel (found via a module crashing /// on `#value` when `active_window` was null: `not ` is `false`, /// same as any other non-nil value). Every JSON/state value handed to Lua /// goes through this instead of a bare `to_value` call. fn json_to_lua<'lua, T>(lua: &'lua Lua, value: &T) -> mlua::Result> where T: Serialize + ?Sized, { lua.to_value_with( value, mlua::SerializeOptions::new() .serialize_none_to_null(false) .serialize_unit_to_null(false), ) } fn state_value_to_lua<'lua>( lua: &'lua Lua, state_arc: &Arc>, path: &str, ) -> mlua::Result> { // 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(); std::thread::yield_now(); }; let mut value = serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?; if path.is_empty() { return json_to_lua(lua, &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"))?; } json_to_lua(lua, &value).map_err(|e| LuaError::external(e.to_string())) } fn module_store_get( state_arc: &Arc>, module: &str, key: &str, ) -> Option { let guard = loop { if let Ok(g) = state_arc.try_read() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; let entry = guard.modules.iter().find(|m| m.name == module)?; entry.store.get(key).cloned() } fn module_store_set( state_arc: &Arc>, module: &str, key: String, value: JsonValue, ) { let mut guard = loop { if let Ok(g) = state_arc.try_write() { break g; } std::hint::spin_loop(); std::thread::yield_now(); }; 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, // Placeholder until the real load-time status (with the correct // ungated value) lands via set_module_status_ex; this fallback only // fires if a module's own store is written before that happens. ungated: 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) } /// POSIX single-quotes `s` for safe embedding as one token in a `sh -c` /// command line — used for `rules.toml`'s `run = "