This commit is contained in:
Breadway 2026-05-11 16:30:05 +08:00
parent 0e3233009b
commit 1a00daf6a8
11 changed files with 1192 additions and 67 deletions

130
Cargo.lock generated
View file

@ -289,6 +289,7 @@ dependencies = [
"anyhow",
"bread-shared",
"clap",
"notify",
"serde",
"serde_json",
"tokio",
@ -429,6 +430,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -579,6 +589,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -591,6 +611,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures"
version = "0.3.32"
@ -798,6 +827,26 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
@ -830,6 +879,26 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64"
dependencies = [
"bitflags 2.11.1",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -952,6 +1021,18 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.0"
@ -1083,6 +1164,25 @@ dependencies = [
"memoffset 0.7.1",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -1402,6 +1502,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -1639,7 +1748,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.2.0",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@ -1844,6 +1953,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -1930,6 +2049,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View file

@ -10,3 +10,4 @@ serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
clap = { version = "4.5", features = ["derive"] }
notify = "6.1"

View file

@ -1,10 +1,14 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
use std::env;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
#[derive(Parser, Debug)]
#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")]
@ -16,13 +20,32 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// Hot-reload all Lua modules
Reload,
Reload {
/// Watch config directory and reload on changes
#[arg(long)]
watch: bool,
},
/// Dump current runtime state
State,
State {
/// Optional dotted path into RuntimeState
path: Option<String>,
/// Output raw JSON
#[arg(long)]
json: bool,
},
/// Stream live normalized events
Events {
#[arg(long)]
filter: Option<String>,
/// Output raw JSON
#[arg(long)]
json: bool,
/// Comma-separated fields to display
#[arg(long)]
fields: Option<String>,
/// Replay events from the last N seconds
#[arg(long)]
since: Option<u64>,
},
/// List loaded modules and status
Modules,
@ -40,6 +63,12 @@ enum Commands {
Ping,
/// Fetch daemon health details
Health,
/// Diagnose daemon and module health
Doctor {
/// Output raw JSON
#[arg(long)]
json: bool,
},
}
#[tokio::main]
@ -48,16 +77,38 @@ async fn main() -> Result<()> {
let socket = daemon_socket_path();
match &cli.command {
Commands::Reload => {
let response = send_request(&socket, "modules.reload", json!({})).await?;
print_json(&response)?;
Commands::Reload { watch } => {
if *watch {
watch_reload(&socket).await?;
} else {
let response = send_request(&socket, "modules.reload", json!({})).await?;
print_reload(&response);
}
}
Commands::State => {
let response = send_request(&socket, "state.dump", json!({})).await?;
print_json(&response)?;
Commands::State { path, json } => {
if *json {
let response = if let Some(path) = path {
send_request(&socket, "state.get", json!({ "key": path })).await?
} else {
send_request(&socket, "state.dump", json!({})).await?
};
print_json(&response)?;
} else {
let response = if let Some(path) = path {
send_request(&socket, "state.get", json!({ "key": path })).await?
} else {
send_request(&socket, "state.dump", json!({})).await?
};
print_state_formatted(path.as_deref(), &response);
}
}
Commands::Events { filter } => {
stream_events(&socket, filter.clone()).await?;
Commands::Events {
filter,
json,
fields,
since,
} => {
stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?;
}
Commands::Modules => {
let response = send_request(&socket, "modules.list", json!({})).await?;
@ -92,6 +143,14 @@ async fn main() -> Result<()> {
let response = send_request(&socket, "health", json!({})).await?;
print_json(&response)?;
}
Commands::Doctor { json } => {
if *json {
let response = send_request(&socket, "health", json!({})).await?;
print_json(&response)?;
} else {
print_doctor(&socket).await?;
}
}
}
Ok(())
@ -128,7 +187,26 @@ async fn send_request(socket: &Path, method: &str, params: Value) -> Result<Valu
Ok(response.get("result").cloned().unwrap_or_else(|| json!({})))
}
async fn stream_events(socket: &Path, filter: Option<String>) -> Result<()> {
async fn stream_events(
socket: &Path,
filter: Option<String>,
raw_json: bool,
fields: Option<String>,
since: Option<u64>,
) -> Result<()> {
if let Some(seconds) = since {
let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?;
if let Some(list) = replay.as_array() {
for item in list {
if raw_json {
println!("{}", serde_json::to_string_pretty(item)?);
} else {
print_event(item, fields.as_deref());
}
}
}
}
let stream = UnixStream::connect(socket).await?;
let (read_half, mut write_half) = stream.into_split();
let request = json!({
@ -146,7 +224,11 @@ async fn stream_events(socket: &Path, filter: Option<String>) -> Result<()> {
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let value: Value = serde_json::from_str(&line)?;
println!("{}", serde_json::to_string_pretty(&value)?);
if raw_json {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
print_event(&value, fields.as_deref());
}
}
Ok(())
@ -156,3 +238,214 @@ fn print_json(value: &Value) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_state_formatted(path: Option<&str>, value: &Value) {
if let Some(path) = path {
println!("{path}");
}
print_value(value, 0);
}
fn print_value(value: &Value, indent: usize) {
let pad = " ".repeat(indent);
match value {
Value::Object(map) => {
for (key, val) in map {
println!("{pad}{key}");
print_value(val, indent + 2);
}
}
Value::Array(list) => {
for (idx, val) in list.iter().enumerate() {
println!("{pad}[{idx}]");
print_value(val, indent + 2);
}
}
other => {
println!("{pad}{}", other);
}
}
}
fn print_event(event: &Value, fields: Option<&str>) {
if let Some(fields) = fields {
let mut out = serde_json::Map::new();
for field in fields.split(',') {
let field = field.trim();
if field.is_empty() {
continue;
}
if let Some(val) = event.get(field) {
out.insert(field.to_string(), val.clone());
}
}
println!("{}", Value::Object(out));
return;
}
let ts = event.get("timestamp").and_then(Value::as_u64).unwrap_or(0);
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("?");
let source = event.get("source").and_then(Value::as_str).unwrap_or("?");
let time = format_timestamp(ts);
println!("{time} {event_name} source={source}");
if let Some(data) = event.get("data") {
println!(" data: {}", data);
}
}
fn format_timestamp(ms: u64) -> String {
let secs = ms / 1000;
let millis = ms % 1000;
let time = UNIX_EPOCH + Duration::from_secs(secs);
let datetime = time.duration_since(UNIX_EPOCH).unwrap_or_default();
let seconds = datetime.as_secs() % 60;
let minutes = (datetime.as_secs() / 60) % 60;
let hours = (datetime.as_secs() / 3600) % 24;
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
}
fn print_reload(value: &Value) {
println!("reloading lua runtime...");
if let Some(mods) = value.get("modules").and_then(Value::as_array) {
for module in mods {
let name = module.get("name").and_then(Value::as_str).unwrap_or("?");
let status = module.get("status").and_then(Value::as_str).unwrap_or("?");
let error = module.get("last_error").and_then(Value::as_str);
if let Some(error) = error {
println!("{name} {status}");
println!(" {error}");
} else {
println!("{name} {status}");
}
}
}
}
async fn watch_reload(socket: &Path) -> Result<()> {
let config_dir = config_directory();
println!("watching {} for changes...", config_dir.display());
let (tx, mut rx) = mpsc::unbounded_channel();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})?;
watcher.watch(&config_dir, RecursiveMode::Recursive)?;
while let Some(msg) = rx.recv().await {
if msg.is_ok() {
let response = send_request(socket, "modules.reload", json!({})).await?;
print_reload(&response);
}
}
Ok(())
}
async fn print_doctor(socket: &Path) -> Result<()> {
let stream = match UnixStream::connect(socket).await {
Ok(stream) => stream,
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
println!("bread doctor");
println!(" daemon ✗ not running");
println!(" socket {} (not found)", socket.display());
println!();
println!(" start the daemon: systemctl --user start breadd");
println!(" view logs: journalctl --user -u breadd -f");
return Ok(());
}
return Err(err.into());
}
};
let response = send_request_with_stream(stream, "health", json!({})).await?;
render_doctor(&response);
Ok(())
}
fn render_doctor(health: &Value) {
println!("bread doctor");
let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false);
let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0);
let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown");
let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0);
let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?");
println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid);
println!(" version {version}");
println!(" uptime {}s", uptime_ms / 1000);
println!(" socket {socket}");
if let Some(adapters) = health.get("adapters").and_then(Value::as_object) {
println!();
println!("adapters");
for (name, status) in adapters {
println!(" {:20} {}", name, status);
}
}
if let Some(modules) = health.get("modules").and_then(Value::as_array) {
println!();
println!("modules");
for module in modules {
let name = module.get("name").and_then(Value::as_str).unwrap_or("?");
let status = module.get("status").and_then(Value::as_str).unwrap_or("?");
let error = module.get("last_error").and_then(Value::as_str);
println!(" {:30} {}", name, status);
if let Some(error) = error {
println!("{error}");
}
}
}
if let Some(count) = health.get("subscriptions").and_then(Value::as_u64) {
println!();
println!("subscriptions {count}");
}
if let Some(errors) = health.get("recent_errors").and_then(Value::as_array) {
if !errors.is_empty() {
println!();
println!("recent errors ({} total)", errors.len());
for entry in errors.iter().take(5) {
println!(" {entry}");
}
}
}
}
async fn send_request_with_stream(
stream: UnixStream,
method: &str,
params: Value,
) -> Result<Value> {
let (read_half, mut write_half) = stream.into_split();
let request = json!({
"id": "1",
"method": method,
"params": params,
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let Some(line) = lines.next_line().await? else {
anyhow::bail!("daemon closed connection without response");
};
let response: Value = serde_json::from_str(&line)?;
if let Some(error) = response.get("error").and_then(Value::as_str) {
anyhow::bail!(error.to_string());
}
Ok(response.get("result").cloned().unwrap_or_else(|| json!({})))
}
fn config_directory() -> PathBuf {
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Path::new(&xdg).join("bread");
}
if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(".config/bread");
}
PathBuf::from(".config/bread")
}

View file

@ -1,8 +1,11 @@
use anyhow::Result;
use async_trait::async_trait;
use bread_shared::RawEvent;
use tokio::sync::{mpsc, watch};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::info;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised;
@ -14,6 +17,13 @@ pub mod udev;
pub mod network_rtnetlink;
pub mod power_upower;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterStatus {
Connected,
Disconnected,
}
#[async_trait]
pub trait Adapter: Send + Sync {
fn name(&self) -> &'static str;
@ -30,6 +40,7 @@ pub struct Manager {
raw_tx: mpsc::Sender<RawEvent>,
config: Config,
shutdown_rx: watch::Receiver<bool>,
status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
}
impl Manager {
@ -42,9 +53,14 @@ impl Manager {
raw_tx,
config,
shutdown_rx,
status: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn status_handle(&self) -> Arc<RwLock<HashMap<String, AdapterStatus>>> {
self.status.clone()
}
pub async fn start_all(&self) -> Result<()> {
info!("starting adapters");
@ -91,17 +107,27 @@ impl Manager {
let tx = self.raw_tx.clone();
let shutdown_rx = self.shutdown_rx.clone();
let shutdown_for_task = shutdown_rx.clone();
let status = self.status.clone();
spawn_supervised(name, shutdown_rx, move || {
let adapter = adapter.clone();
let tx = tx.clone();
let mut shutdown_rx = shutdown_for_task.clone();
let status = status.clone();
async move {
adapter.on_connect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Connected);
}
let result = tokio::select! {
result = adapter.run(tx) => result,
_ = shutdown_rx.changed() => Ok(()),
};
adapter.on_disconnect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected);
}
result
}
});

View file

@ -12,8 +12,12 @@ pub struct Config {
#[serde(default)]
pub lua: LuaConfig,
#[serde(default)]
pub modules: ModulesConfig,
#[serde(default)]
pub adapters: AdaptersConfig,
#[serde(default)]
pub notifications: NotificationsConfig,
#[serde(default)]
pub events: EventsConfig,
}
@ -33,6 +37,14 @@ pub struct LuaConfig {
pub module_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModulesConfig {
#[serde(default = "default_true")]
pub builtin: bool,
#[serde(default)]
pub disable: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdaptersConfig {
#[serde(default)]
@ -73,12 +85,24 @@ pub struct EventsConfig {
pub dedup_window_ms: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NotificationsConfig {
#[serde(default = "default_notify_timeout")]
pub default_timeout_ms: i64,
#[serde(default = "default_notify_urgency")]
pub default_urgency: String,
#[serde(default = "default_notify_path")]
pub notify_send_path: String,
}
impl Default for Config {
fn default() -> Self {
Self {
daemon: DaemonConfig::default(),
lua: LuaConfig::default(),
modules: ModulesConfig::default(),
adapters: AdaptersConfig::default(),
notifications: NotificationsConfig::default(),
events: EventsConfig::default(),
}
}
@ -102,6 +126,15 @@ impl Default for LuaConfig {
}
}
impl Default for ModulesConfig {
fn default() -> Self {
Self {
builtin: default_true(),
disable: Vec::new(),
}
}
}
impl Default for AdaptersConfig {
fn default() -> Self {
Self {
@ -147,6 +180,16 @@ impl Default for EventsConfig {
}
}
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
default_timeout_ms: default_notify_timeout(),
default_urgency: default_notify_urgency(),
notify_send_path: default_notify_path(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path();
@ -218,6 +261,18 @@ fn default_dedup_window() -> u64 {
100
}
fn default_notify_timeout() -> i64 {
3000
}
fn default_notify_urgency() -> String {
"normal".to_string()
}
fn default_notify_path() -> String {
"notify-send".to_string()
}
fn default_udev_subsystems() -> Vec<String> {
vec![
"usb".to_string(),

View file

@ -80,22 +80,102 @@ impl EventNormalizer {
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
let mapped = match kind {
"workspace" | "workspacev2" => "bread.workspace.changed",
"monitoradded" => "bread.monitor.connected",
"monitorremoved" => "bread.monitor.disconnected",
"activewindow" | "activewindowv2" => "bread.window.focus.changed",
"openwindow" => "bread.window.opened",
"closewindow" => "bread.window.closed",
_ => "bread.hyprland.event",
};
let data = raw
.payload
.get("data")
.and_then(Value::as_str)
.unwrap_or("");
vec![BreadEvent {
event: mapped.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}]
match kind {
"workspace" | "workspacev2" => vec![BreadEvent {
event: "bread.workspace.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"createworkspace" => vec![BreadEvent {
event: "bread.workspace.created".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"destroyworkspace" => vec![BreadEvent {
event: "bread.workspace.destroyed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"monitoradded" => vec![BreadEvent {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"activewindowv2" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.focused".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&"")
}),
}]
}
"openwindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.opened".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
"class": fields.get(2).unwrap_or(&""),
"title": fields.get(3).unwrap_or(&""),
}),
}]
}
"closewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.closed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "address": fields.get(0).unwrap_or(&"") }),
}]
}
"movewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.moved".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
}),
}]
}
_ => vec![BreadEvent {
event: "bread.hyprland.event".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
}
}
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
@ -201,6 +281,13 @@ impl EventNormalizer {
}
}
fn split_hyprland_fields(data: &str) -> Vec<&str> {
if data.is_empty() {
return Vec::new();
}
data.split(">>").collect()
}
fn classify_device(payload: &Value) -> DeviceClass {
let name = payload
.get("name")

View file

@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent};
@ -15,6 +16,7 @@ use crate::lua::LuaMessage;
pub struct StateHandle {
state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>,
subscription_count: Arc<AtomicU64>,
}
pub enum StateCommand {
@ -38,6 +40,7 @@ pub enum StateCommand {
name: String,
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
},
SetProfile {
name: String,
@ -45,8 +48,16 @@ pub enum StateCommand {
}
impl StateHandle {
pub fn new(state: Arc<RwLock<RuntimeState>>, command_tx: mpsc::UnboundedSender<StateCommand>) -> Self {
Self { state, command_tx }
pub fn new(
state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>,
subscription_count: Arc<AtomicU64>,
) -> Self {
Self {
state,
command_tx,
subscription_count,
}
}
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
@ -101,17 +112,28 @@ impl StateHandle {
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
}
pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option<String>) {
pub fn set_module_status(
&self,
name: String,
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name,
status,
last_error,
builtin,
});
}
pub fn set_profile(&self, name: String) {
let _ = self.command_tx.send(StateCommand::SetProfile { name });
}
pub fn subscription_count(&self) -> Arc<AtomicU64> {
self.subscription_count.clone()
}
}
pub async fn run_state_engine(
@ -120,6 +142,7 @@ pub async fn run_state_engine(
state: Arc<RwLock<RuntimeState>>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: broadcast::Sender<BreadEvent>,
subscription_count: Arc<AtomicU64>,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut subscriptions = SubscriptionTable::default();
@ -136,7 +159,7 @@ pub async fn run_state_engine(
let Some(cmd) = maybe_cmd else {
break;
};
handle_command(cmd, &state, &mut subscriptions, &mut watches).await;
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
}
maybe_event = event_rx.recv() => {
let Some(event) = maybe_event else {
@ -158,7 +181,7 @@ pub async fn run_state_engine(
apply_event_to_state(&mut guard, &event);
}
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx);
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
for (_id, path) in watches.iter() {
@ -174,7 +197,7 @@ pub async fn run_state_engine(
"old": old_val,
}),
);
dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx);
dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
}
}
}
@ -190,13 +213,17 @@ async fn handle_command(
state: &Arc<RwLock<RuntimeState>>,
subscriptions: &mut SubscriptionTable,
watches: &mut HashMap<SubscriptionId, String>,
subscription_count: &Arc<AtomicU64>,
) {
match cmd {
StateCommand::RegisterSubscription { id, pattern, once } => {
subscriptions.add_with_id(id, pattern, once);
subscription_count.fetch_add(1, Ordering::Relaxed);
}
StateCommand::RemoveSubscription { id } => {
subscriptions.remove(id);
if subscriptions.remove(id) {
subscription_count.fetch_sub(1, Ordering::Relaxed);
}
}
StateCommand::RegisterWatch { id, path } => {
watches.insert(id, path);
@ -207,21 +234,25 @@ async fn handle_command(
StateCommand::ClearSubscriptions => {
subscriptions.clear();
watches.clear();
subscription_count.store(0, Ordering::Relaxed);
}
StateCommand::SetModuleStatus {
name,
status,
last_error,
builtin,
} => {
let mut guard = state.write().await;
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
existing.status = status;
existing.last_error = last_error;
existing.builtin = builtin;
} else {
guard.modules.push(crate::core::types::ModuleStatus {
name,
status,
last_error,
builtin,
store: HashMap::new(),
});
}
@ -242,6 +273,7 @@ fn dispatch_event(
subscriptions: &mut SubscriptionTable,
lua_tx: &mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: &broadcast::Sender<BreadEvent>,
subscription_count: &Arc<AtomicU64>,
) {
let _ = event_stream_tx.send(event.clone());
@ -254,7 +286,9 @@ fn dispatch_event(
}
for sub in matches.into_iter().filter(|s| s.once) {
subscriptions.remove(sub.id);
if subscriptions.remove(sub.id) {
subscription_count.fetch_sub(1, Ordering::Relaxed);
}
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
}
}
@ -302,11 +336,12 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
.map(ToString::to_string);
state.active_workspace = ws;
}
"bread.window.focus.changed" => {
"bread.window.focus.changed" | "bread.window.focused" => {
state.active_window = event
.data
.get("window")
.or_else(|| event.data.get("class"))
.or_else(|| event.data.get("address"))
.and_then(Value::as_str)
.map(ToString::to_string);
}

View file

@ -123,6 +123,8 @@ pub struct ModuleStatus {
pub status: ModuleLoadState,
pub last_error: Option<String>,
#[serde(default)]
pub builtin: bool,
#[serde(default)]
pub store: HashMap<String, Value>,
}

View file

@ -1,18 +1,22 @@
use std::collections::{HashMap, VecDeque};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process;
use std::time::Instant;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{error, info, warn};
use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
@ -23,6 +27,9 @@ pub struct Server {
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
started_at: Instant,
pid: u32,
}
@ -51,6 +58,9 @@ impl Server {
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
) -> Self {
Self {
socket_path,
@ -58,6 +68,9 @@ impl Server {
event_tx,
lua_runtime,
emit_tx,
adapter_status,
subscription_count,
event_buffer,
started_at: Instant::now(),
pid: process::id(),
}
@ -166,12 +179,25 @@ impl Server {
let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
}
"modules.reload" => self
.lua_runtime
.reload()
.await
.map(|_| json!({ "reloaded": true }))
.map_err(|e| e.to_string()),
"modules.reload" => {
let started = Instant::now();
if let Err(err) = self.lua_runtime.reload().await {
return Err((id, err.to_string()));
}
let duration_ms = started.elapsed().as_millis();
let modules = self
.state_handle
.state_dump()
.await
.get("modules")
.cloned()
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"modules": modules,
}))
}
"profile.list" => {
let full = self.state_handle.state_dump().await;
let profiles = full
@ -224,13 +250,36 @@ impl Server {
}
"health" => {
let uptime_ms = self.started_at.elapsed().as_millis();
let state = self.state_handle.state_dump().await;
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
let adapters = self.adapter_status.read().await.clone();
let subscription_count = self.subscription_count.load(std::sync::atomic::Ordering::Relaxed);
let recent_errors = self.lua_runtime.recent_errors();
Ok(json!({
"ok": true,
"pid": self.pid,
"version": env!("CARGO_PKG_VERSION"),
"uptime_ms": uptime_ms,
"socket": self.socket_path.to_string_lossy(),
"adapters": adapters,
"modules": modules,
"subscriptions": subscription_count,
"recent_errors": recent_errors,
}))
}
"events.replay" => {
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
let cutoff = now_unix_ms().saturating_sub(since_ms);
let mut replay = Vec::new();
if let Ok(buf) = self.event_buffer.lock() {
for event in buf.iter() {
if event.timestamp >= cutoff {
replay.push(event);
}
}
}
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
}
_ => Err("unknown method".to_string()),
};

View file

@ -1,5 +1,5 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::rc::Rc;
@ -10,16 +10,18 @@ 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, sleep};
use tokio::time::{interval_at, sleep, Instant};
use tracing::{error, info, warn};
use crate::core::config::Config;
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{ModuleLoadState, RuntimeState};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
Event {
@ -38,9 +40,17 @@ pub enum LuaMessage {
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 {
@ -63,6 +73,13 @@ impl RuntimeHandle {
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(
@ -71,7 +88,11 @@ pub fn spawn_runtime(
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = RuntimeHandle { tx };
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()
@ -83,7 +104,13 @@ pub fn spawn_runtime(
.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()) {
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");
@ -160,6 +187,8 @@ struct ModuleDecl {
version: Option<String>,
after: Vec<String>,
path: PathBuf,
source: Option<&'static str>,
builtin: bool,
}
struct ModuleInfo {
@ -182,6 +211,9 @@ struct LuaEngine {
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 {
@ -190,6 +222,7 @@ impl LuaEngine {
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(),
@ -207,6 +240,9 @@ impl LuaEngine {
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,
})
}
@ -324,7 +360,9 @@ impl LuaEngine {
.map_err(|_| LuaError::external("missing filter function"))?;
Some(lua.create_registry_value(filter_fn)?)
} else {
return Err(LuaError::external("missing filter options"));
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()
@ -503,6 +541,61 @@ impl LuaEngine {
})?;
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();
@ -556,7 +649,8 @@ impl LuaEngine {
);
let lua_tx = lua_tx.clone();
task::spawn(async move {
let mut ticker = interval(Duration::from_millis(interval_ms));
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() => {
@ -750,12 +844,22 @@ impl LuaEngine {
}
fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init")?;
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),
@ -765,6 +869,7 @@ impl LuaEngine {
name,
ModuleLoadState::LoadError,
Some(err.to_string()),
false,
);
}
}
@ -784,7 +889,7 @@ impl LuaEngine {
for (name, err) in dep_errors {
self.state_handle
.set_module_status(name, ModuleLoadState::LoadError, Some(err));
.set_module_status(name, ModuleLoadState::LoadError, Some(err), false);
}
let mut load_order = Vec::new();
@ -792,14 +897,19 @@ impl LuaEngine {
load_order.push(decl.name.clone());
match self.load_module(&decl) {
Ok(()) => {
self.state_handle
.set_module_status(decl.name.clone(), ModuleLoadState::Loaded, None);
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,
);
}
}
@ -815,7 +925,11 @@ impl LuaEngine {
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone()));
let result = self.load_lua_file(&decl.path, &decl.name);
let result = if let Some(source) = decl.source.as_deref() {
self.load_lua_source(source, &decl.name)
} else {
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
};
self.set_current_module(None);
result?;
@ -827,13 +941,14 @@ impl LuaEngine {
Ok(())
}
fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> {
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(());
}
@ -843,6 +958,14 @@ impl LuaEngine {
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");
@ -935,8 +1058,13 @@ impl LuaEngine {
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");
self.state_handle
.set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string()));
let builtin = self.module_is_builtin(name);
self.state_handle.set_module_status(
name.to_string(),
ModuleLoadState::LoadError,
Some(err.to_string()),
builtin,
);
}
}
}
@ -951,10 +1079,12 @@ impl LuaEngine {
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,
);
}
}
@ -971,10 +1101,12 @@ impl LuaEngine {
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,
);
}
}
@ -983,10 +1115,22 @@ impl LuaEngine {
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()) {
@ -1022,6 +1166,14 @@ impl LuaEngine {
.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;
@ -1052,6 +1204,8 @@ impl LuaEngine {
version,
after,
path: module_path.clone(),
source: None,
builtin: false,
});
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?;
@ -1119,6 +1273,14 @@ impl LuaEngine {
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")
@ -1288,10 +1450,266 @@ fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: St
name: module.to_string(),
status: ModuleLoadState::Loaded,
last_error: None,
builtin: false,
store,
});
}
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 class = rule.class
local when = rule.when
local data = event.data or {}
if when == "connected" and event.event ~= "bread.device.connected" then
if not event.event:match("%.connected$") then
return false
end
elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then
if not event.event:match("%.disconnected$") then
return false
end
end
if class and data.class ~= class 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 instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
@ -1307,11 +1725,13 @@ fn hyprland_request(request: &str) -> Result<String> {
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)
tokio::task::block_in_place(|| {
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 list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {

View file

@ -3,7 +3,9 @@ mod core;
mod ipc;
mod lua;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
@ -33,7 +35,8 @@ async fn main() -> Result<()> {
let (event_stream_tx, _) = broadcast::channel(2048);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
let subscription_count = Arc::new(AtomicU64::new(0));
let state_handle = StateHandle::new(state.clone(), state_cmd_tx, subscription_count.clone());
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_tx = lua_runtime.sender();
@ -44,6 +47,7 @@ async fn main() -> Result<()> {
state.clone(),
lua_tx,
event_stream_tx.clone(),
subscription_count.clone(),
shutdown_rx.clone(),
));
@ -78,6 +82,28 @@ async fn main() -> Result<()> {
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?;
let adapter_status = adapter_manager.status_handle();
let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000)));
{
let mut rx = event_stream_tx.subscribe();
let event_buffer = event_buffer.clone();
tokio::spawn(async move {
loop {
let evt = match rx.recv().await {
Ok(evt) => evt,
Err(_) => break,
};
if let Ok(mut buf) = event_buffer.lock() {
if buf.len() >= 1000 {
buf.pop_front();
}
buf.push_back(evt);
}
}
});
}
let _ = normalized_tx.send(BreadEvent::new(
"bread.system.startup",
AdapterSource::System,
@ -90,6 +116,9 @@ async fn main() -> Result<()> {
event_stream_tx,
lua_runtime.clone(),
normalized_tx,
adapter_status,
subscription_count,
event_buffer,
);
info!("breadd fully started");