Release 1.0

This commit is contained in:
Breadway 2026-05-11 11:56:03 +08:00
parent 009ea6da0e
commit 730a8b61d7
32 changed files with 6629 additions and 0 deletions

27
breadd/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "breadd"
version = "0.1.0"
edition = "2021"
[dependencies]
bread-shared = { path = "../bread-shared" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
async-trait = "0.1"
toml = "0.8"
udev = "0.9"
rtnetlink = "0.9"
zbus = { version = "3.13", features = ["tokio"] }
hex = "0.4"
futures-util = "0.3"
netlink-packet-route = "0.11"
netlink-packet-core = "0.4"
libc = "0.2"
[dev-dependencies]
tempfile = "3.13"

View file

@ -0,0 +1,66 @@
use std::env;
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::adapters::Adapter;
#[derive(Clone, Default)]
pub struct HyprlandAdapter;
#[async_trait::async_trait]
impl Adapter for HyprlandAdapter {
fn name(&self) -> &'static str {
"hyprland"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("hyprland adapter started");
let socket = hyprland_event_socket()?;
let stream = UnixStream::connect(&socket).await?;
let reader = BufReader::new(stream);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
let (kind, data) = parse_hyprland_line(&line);
tx.send(RawEvent {
source: AdapterSource::Hyprland,
kind: "hyprland.event".to_string(),
payload: json!({
"kind": kind,
"raw": line,
"data": data,
}),
timestamp: now_unix_ms(),
})
.await?;
}
warn!("hyprland socket closed");
Err(anyhow!("hyprland socket closed"))
}
}
fn hyprland_event_socket() -> Result<PathBuf> {
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket2.sock"))
}
fn parse_hyprland_line(line: &str) -> (String, String) {
if let Some((kind, data)) = line.split_once(">>") {
return (kind.to_string(), data.to_string());
}
("unknown".to_string(), line.to_string())
}

109
breadd/src/adapters/mod.rs Normal file
View file

@ -0,0 +1,109 @@
use anyhow::Result;
use async_trait::async_trait;
use bread_shared::RawEvent;
use tokio::sync::{mpsc, watch};
use tracing::info;
use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised;
pub mod hyprland;
pub mod network;
pub mod power;
pub mod udev;
pub mod network_rtnetlink;
pub mod power_upower;
#[async_trait]
pub trait Adapter: Send + Sync {
fn name(&self) -> &'static str;
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()>;
async fn on_connect(&self) -> Result<()> {
Ok(())
}
async fn on_disconnect(&self) -> Result<()> {
Ok(())
}
}
pub struct Manager {
raw_tx: mpsc::Sender<RawEvent>,
config: Config,
shutdown_rx: watch::Receiver<bool>,
}
impl Manager {
pub fn new(
raw_tx: mpsc::Sender<RawEvent>,
config: Config,
shutdown_rx: watch::Receiver<bool>,
) -> Self {
Self {
raw_tx,
config,
shutdown_rx,
}
}
pub async fn start_all(&self) -> Result<()> {
info!("starting adapters");
if self.config.adapters.udev.enabled {
let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone());
adapter.enumerate_existing(&self.raw_tx).await?;
self.spawn_adapter(adapter);
}
if self.config.adapters.hyprland.enabled {
self.spawn_adapter(hyprland::HyprlandAdapter::default());
}
if self.config.adapters.power.enabled {
// Prefer UPower DBus adapter; fall back to sysfs poller
let upower = power_upower::UPowerAdapter::new();
if let Ok(adapter) = upower {
self.spawn_adapter(adapter);
} else {
self.spawn_adapter(power::PowerAdapter::new(
self.config.adapters.power.poll_interval_secs,
));
}
}
if self.config.adapters.network.enabled {
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
let rt = network_rtnetlink::RtnetlinkAdapter::new();
if let Ok(adapter) = rt {
self.spawn_adapter(adapter);
} else {
self.spawn_adapter(network::NetworkAdapter::default());
}
}
Ok(())
}
fn spawn_adapter<A>(&self, adapter: A)
where
A: Adapter + Clone + 'static,
{
let name = adapter.name();
let tx = self.raw_tx.clone();
let shutdown_rx = self.shutdown_rx.clone();
let shutdown_for_task = shutdown_rx.clone();
spawn_supervised(name, shutdown_rx, move || {
let adapter = adapter.clone();
let tx = tx.clone();
let mut shutdown_rx = shutdown_for_task.clone();
async move {
adapter.on_connect().await?;
let result = tokio::select! {
result = adapter.run(tx) => result,
_ = shutdown_rx.changed() => Ok(()),
};
adapter.on_disconnect().await?;
result
}
});
}
}

View file

@ -0,0 +1,93 @@
use std::collections::BTreeMap;
use std::fs;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone, Default)]
pub struct NetworkAdapter;
#[async_trait::async_trait]
impl Adapter for NetworkAdapter {
fn name(&self) -> &'static str {
"network"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("network adapter started");
let mut last = read_network_state();
tx.send(network_raw_event(&last)).await?;
loop {
sleep(Duration::from_secs(5)).await;
let now = read_network_state();
if now != last {
tx.send(network_raw_event(&now)).await?;
last = now;
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct NetworkSnapshot {
interfaces: BTreeMap<String, bool>,
online: bool,
}
fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent {
let interfaces = snapshot
.interfaces
.iter()
.map(|(name, up)| (name.clone(), json!({ "up": up })))
.collect::<serde_json::Map<String, serde_json::Value>>();
RawEvent {
source: AdapterSource::Network,
kind: "network.snapshot".to_string(),
payload: json!({
"online": snapshot.online,
"interfaces": interfaces,
}),
timestamp: now_unix_ms(),
}
}
fn read_network_state() -> NetworkSnapshot {
let mut interfaces = BTreeMap::new();
if let Ok(entries) = fs::read_dir("/sys/class/net") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "lo" {
continue;
}
let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default();
let up = oper.trim() == "up";
interfaces.insert(name, up);
}
}
let online = has_default_route();
NetworkSnapshot { interfaces, online }
}
fn has_default_route() -> bool {
if let Ok(routes) = fs::read_to_string("/proc/net/route") {
for line in routes.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() > 2 && cols[1] == "00000000" {
return true;
}
}
}
false
}

View file

@ -0,0 +1,151 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{AdapterSource, RawEvent};
use futures_util::StreamExt;
use netlink_packet_route::RtnlMessage;
use rtnetlink::new_connection;
use serde_json::json;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use tokio::sync::mpsc;
use tracing::{debug, info};
use super::Adapter;
#[derive(Clone, Debug)]
pub struct RtnetlinkAdapter;
impl RtnetlinkAdapter {
pub fn new() -> Result<Self> {
// Try to create a connection to validate presence of rtnetlink
let conn = new_connection();
match conn {
Ok((connection, _handle, _messages)) => {
// Spawn and immediately drop the connection task; we just validated
tokio::spawn(connection);
Ok(Self)
}
Err(e) => Err(anyhow!(e)),
}
}
}
#[async_trait]
impl Adapter for RtnetlinkAdapter {
fn name(&self) -> &'static str {
"rtnetlink-network"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("rtnetlink adapter starting");
let (connection, _handle, mut messages) = new_connection()?;
tokio::spawn(connection);
while let Some((message, _addr)) = messages.next().await {
match message.payload {
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
let ifname = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()),
_ => None,
});
let mtu = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu),
_ => None,
});
let netns_id = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id),
_ => None,
});
let netns_fd = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd),
_ => None,
});
let up = link.header.flags & (libc::IFF_UP as u32) != 0;
if let Some(name) = ifname {
let kind = if up { "link.up" } else { "link.down" };
let payload = json!({
"ifname": name,
"index": link.header.index,
"mtu": mtu,
"netns_id": netns_id,
"netns_fd": netns_fd
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
// Heuristic: if destination is default (empty), treat as default-route change
let is_default = route.header.destination_prefix_length == 0;
if is_default {
let gateway = route.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()),
_ => None,
});
let gateway_ip = gateway.as_deref().and_then(ip_from_bytes);
let payload = json!({
"gateway": gateway_ip,
"table": route.header.table
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => {
let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
_ => None,
});
let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
_ => None,
});
let ip = address.as_deref().and_then(ip_from_bytes);
let payload = json!({
"ifindex": addr.header.index,
"prefix_len": addr.header.prefix_len,
"family": addr.header.family,
"address": ip,
"label": label
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => {
let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
_ => None,
});
let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
_ => None,
});
let ip = address.as_deref().and_then(ip_from_bytes);
let payload = json!({
"ifindex": addr.header.index,
"prefix_len": addr.header.prefix_len,
"family": addr.header.family,
"address": ip,
"label": label
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
_ => {
debug!("unhandled netlink message");
}
}
}
Ok(())
}
}
fn ip_from_bytes(bytes: &[u8]) -> Option<String> {
match bytes.len() {
4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()),
16 => {
let octets: [u8; 16] = bytes.try_into().ok()?;
Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string())
}
_ => None,
}
}

View file

@ -0,0 +1,92 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone)]
pub struct PowerAdapter {
poll_interval_secs: u64,
}
impl PowerAdapter {
pub fn new(poll_interval_secs: u64) -> Self {
Self { poll_interval_secs }
}
}
#[async_trait::async_trait]
impl Adapter for PowerAdapter {
fn name(&self) -> &'static str {
"power"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("power adapter started");
let mut last = read_power_state();
tx.send(power_raw_event(&last)).await?;
loop {
sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await;
let now = read_power_state();
if now != last {
tx.send(power_raw_event(&now)).await?;
last = now;
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct PowerSnapshot {
ac_connected: bool,
battery_percent: Option<u8>,
}
fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent {
RawEvent {
source: AdapterSource::Power,
kind: "power.snapshot".to_string(),
payload: json!({
"ac_connected": snapshot.ac_connected,
"battery_percent": snapshot.battery_percent,
}),
timestamp: now_unix_ms(),
}
}
fn read_power_state() -> PowerSnapshot {
let power_dir = Path::new("/sys/class/power_supply");
let mut ac_connected = false;
let mut battery_percent = None;
if let Ok(entries) = fs::read_dir(power_dir) {
for entry in entries.flatten() {
let path = entry.path();
let typ = fs::read_to_string(path.join("type")).unwrap_or_default();
if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") {
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
if online.trim() == "1" {
ac_connected = true;
}
} else if typ.trim().eq_ignore_ascii_case("Battery") {
let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default();
if let Ok(parsed) = cap.trim().parse::<u8>() {
battery_percent = Some(parsed.min(100));
}
}
}
}
PowerSnapshot {
ac_connected,
battery_percent,
}
}

View file

@ -0,0 +1,147 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{AdapterSource, RawEvent};
use futures_util::StreamExt;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, info};
use zbus::{Message, MessageStream};
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use super::Adapter;
#[derive(Clone, Debug)]
pub struct UPowerAdapter;
impl UPowerAdapter {
pub fn new() -> Result<Self> {
// Attempt to connect to system bus to validate availability
// We don't actually open the connection here because zbus::Connection::system() is async.
Ok(Self)
}
}
#[async_trait]
impl Adapter for UPowerAdapter {
fn name(&self) -> &'static str {
"upower"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("UPower adapter starting (attempting DBus subscription)");
// Defer loading zbus until runtime to avoid build-time optional complexity
match zbus::Connection::system().await {
Ok(conn) => {
let payload = json!({"message": "upower:connected"});
let _ = tx
.send(RawEvent {
source: AdapterSource::Power,
kind: "power.upower.connected".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
let mut stream = MessageStream::from(&conn);
while let Some(result) = stream.next().await {
match result {
Ok(message) => match parse_upower_message(&message) {
Ok(event) => {
let _ = tx.send(event).await;
}
Err(err) => {
debug!("upower parse error: {err:?}");
}
},
Err(err) => {
debug!("upower stream error: {err:?}");
}
}
}
Ok(())
}
Err(e) => {
// If DBus connection fails, fall back to periodic polling handled elsewhere
Err(anyhow!(e))
}
}
}
}
fn parse_upower_message(message: &Message) -> Result<RawEvent> {
let header = message.header()?;
let interface = header.interface()?.map(|v| v.as_str()).unwrap_or("");
let member = header.member()?.map(|v| v.as_str()).unwrap_or("");
let path = header.path()?.map(|v| v.as_str()).unwrap_or("");
if interface == "org.freedesktop.UPower" {
match member {
"DeviceAdded" => {
let (device_path,): (OwnedObjectPath,) = message.body()?;
let payload = json!({"device_path": device_path.as_str()});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.added".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
"DeviceRemoved" => {
let (device_path,): (OwnedObjectPath,) = message.body()?;
let payload = json!({"device_path": device_path.as_str()});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.removed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
_ => {}
}
}
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
let (iface, changed, invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
message.body()?;
if iface == "org.freedesktop.UPower.Device" {
let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({}));
let normalized = json!({
"percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()),
"state": changed_json.get("State").and_then(|v| v.as_u64()),
"time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()),
"time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()),
"is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()),
"battery_type": changed_json.get("Type").and_then(|v| v.as_u64()),
"online": changed_json.get("Online").and_then(|v| v.as_bool()),
"native_path": changed_json.get("NativePath").and_then(|v| v.as_str()),
"model": changed_json.get("Model").and_then(|v| v.as_str()),
"vendor": changed_json.get("Vendor").and_then(|v| v.as_str()),
"serial": changed_json.get("Serial").and_then(|v| v.as_str()),
"update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()),
});
let payload = json!({
"path": path,
"properties": changed_json,
"invalidated": invalidated,
"normalized": normalized
});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.changed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
}
Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.upower.signal".to_string(),
payload: json!({"interface": interface, "member": member, "path": path}),
timestamp: bread_shared::now_unix_ms(),
})
}

265
breadd/src/adapters/udev.rs Normal file
View file

@ -0,0 +1,265 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone)]
pub struct UdevAdapter {
subsystems: Vec<String>,
}
impl UdevAdapter {
pub fn new(subsystems: Vec<String>) -> Self {
Self { subsystems }
}
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
scan_devices(&self.subsystems).unwrap_or_default()
});
for device in devices {
tx.send(RawEvent {
source: AdapterSource::Udev,
kind: "udev.enumerate".to_string(),
payload: json!({
"action": "add",
"id": device.id,
"name": device.name,
"subsystem": device.subsystem,
}),
timestamp: now_unix_ms(),
})
.await?;
}
Ok(())
}
}
#[async_trait::async_trait]
impl Adapter for UdevAdapter {
fn name(&self) -> &'static str {
"udev"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("udev adapter started");
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
return Ok(());
}
// Fallback for environments where monitor sockets are unavailable.
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
loop {
let current = scan_devices(&self.subsystems)?;
let current_map: HashMap<String, ScannedDevice> = current
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
for (id, dev) in &current_map {
if !known.contains_key(id) {
tx.send(raw_change_event("add", dev)).await?;
}
}
for (id, dev) in &known {
if !current_map.contains_key(id) {
tx.send(raw_change_event("remove", dev)).await?;
}
}
known = current_map;
sleep(Duration::from_secs(2)).await;
}
}
}
#[derive(Clone, Debug)]
struct ScannedDevice {
id: String,
name: String,
subsystem: String,
}
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> {
let mut builder = udev::MonitorBuilder::new()?;
for subsystem in &subsystems {
builder = builder.match_subsystem(subsystem)?;
}
let monitor = builder.listen()?;
for event in monitor.iter() {
let action = event
.action()
.map(|a| a.to_string_lossy().to_string())
.unwrap_or_else(|| "change".to_string());
let subsystem = event
.subsystem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let name = event
.property_value("ID_MODEL")
.or_else(|| event.property_value("NAME"))
.map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let id = event
.syspath()
.to_string_lossy()
.to_string();
let msg = RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
"action": action,
"id": id,
"name": name,
"subsystem": subsystem,
}),
timestamp: now_unix_ms(),
};
if tx.blocking_send(msg).is_err() {
break;
}
}
Ok(())
})
.await??;
Ok(())
}
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut enumerator = udev::Enumerator::new()?;
for subsystem in subsystems {
enumerator.match_subsystem(subsystem)?;
}
let mut out = Vec::new();
for dev in enumerator.scan_devices()? {
let subsystem = dev
.subsystem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let name = dev
.property_value("ID_MODEL")
.or_else(|| dev.property_value("NAME"))
.map(|v| v.to_string_lossy().to_string())
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string());
let id = dev.syspath().to_string_lossy().to_string();
out.push(ScannedDevice {
id,
name,
subsystem,
});
}
Ok(out)
}
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
"action": action,
"id": dev.id,
"name": dev.name,
"subsystem": dev.subsystem,
}),
timestamp: now_unix_ms(),
}
}
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut out = Vec::new();
if subsystems.iter().any(|s| s == "drm") {
let drm_dir = Path::new("/sys/class/drm");
if drm_dir.exists() {
for entry in fs::read_dir(drm_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains('-') {
continue;
}
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
if status.trim() == "connected" {
out.push(ScannedDevice {
id: format!("drm:{name}"),
name,
subsystem: "drm".to_string(),
});
}
}
}
}
if subsystems.iter().any(|s| s == "input") {
let input_dir = Path::new("/dev/input/by-id");
if input_dir.exists() {
for entry in fs::read_dir(input_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("input:{name}"),
name,
subsystem: "input".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "power_supply") {
let pwr_dir = Path::new("/sys/class/power_supply");
if pwr_dir.exists() {
for entry in fs::read_dir(pwr_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("power_supply:{name}"),
name,
subsystem: "power_supply".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "usb") {
let usb_dir = Path::new("/sys/bus/usb/devices");
if usb_dir.exists() {
for entry in fs::read_dir(usb_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
out.push(ScannedDevice {
id: format!("usb:{name}"),
name,
subsystem: "usb".to_string(),
});
}
}
}
}
Ok(out)
}

228
breadd/src/core/config.rs Normal file
View file

@ -0,0 +1,228 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub lua: LuaConfig,
#[serde(default)]
pub adapters: AdaptersConfig,
#[serde(default)]
pub events: EventsConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DaemonConfig {
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub socket_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LuaConfig {
#[serde(default = "default_lua_entry")]
pub entry_point: String,
#[serde(default = "default_lua_modules")]
pub module_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdaptersConfig {
#[serde(default)]
pub hyprland: AdapterToggle,
#[serde(default)]
pub udev: UdevConfig,
#[serde(default)]
pub power: PowerConfig,
#[serde(default)]
pub network: AdapterToggle,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdapterToggle {
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UdevConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_udev_subsystems")]
pub subsystems: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PowerConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_poll_interval")]
pub poll_interval_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EventsConfig {
#[serde(default = "default_dedup_window")]
pub dedup_window_ms: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
daemon: DaemonConfig::default(),
lua: LuaConfig::default(),
adapters: AdaptersConfig::default(),
events: EventsConfig::default(),
}
}
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
log_level: default_log_level(),
socket_path: String::new(),
}
}
}
impl Default for LuaConfig {
fn default() -> Self {
Self {
entry_point: default_lua_entry(),
module_path: default_lua_modules(),
}
}
}
impl Default for AdaptersConfig {
fn default() -> Self {
Self {
hyprland: AdapterToggle::default(),
udev: UdevConfig::default(),
power: PowerConfig::default(),
network: AdapterToggle::default(),
}
}
}
impl Default for AdapterToggle {
fn default() -> Self {
Self {
enabled: default_true(),
}
}
}
impl Default for UdevConfig {
fn default() -> Self {
Self {
enabled: default_true(),
subsystems: default_udev_subsystems(),
}
}
}
impl Default for PowerConfig {
fn default() -> Self {
Self {
enabled: default_true(),
poll_interval_secs: default_poll_interval(),
}
}
}
impl Default for EventsConfig {
fn default() -> Self {
Self {
dedup_window_ms: default_dedup_window(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path();
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&path)?;
let cfg: Config = toml::from_str(&raw)?;
Ok(cfg)
}
pub fn socket_path(&self) -> PathBuf {
if !self.daemon.socket_path.is_empty() {
return expand_home(&self.daemon.socket_path);
}
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Path::new(&runtime_dir).join("bread").join("breadd.sock")
}
pub fn lua_entry_point(&self) -> PathBuf {
expand_home(&self.lua.entry_point)
}
pub fn lua_module_path(&self) -> PathBuf {
expand_home(&self.lua.module_path)
}
}
fn config_path() -> PathBuf {
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Path::new(&xdg).join("bread").join("breadd.toml");
}
expand_home("~/.config/bread/breadd.toml")
}
fn expand_home(input: &str) -> PathBuf {
if let Some(stripped) = input.strip_prefix("~/") {
if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(stripped);
}
}
PathBuf::from(input)
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_lua_entry() -> String {
"~/.config/bread/init.lua".to_string()
}
fn default_lua_modules() -> String {
"~/.config/bread/modules".to_string()
}
fn default_true() -> bool {
true
}
fn default_poll_interval() -> u64 {
30
}
fn default_dedup_window() -> u64 {
100
}
fn default_udev_subsystems() -> Vec<String> {
vec![
"usb".to_string(),
"input".to_string(),
"drm".to_string(),
"power_supply".to_string(),
]
}

6
breadd/src/core/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod config;
pub mod normalizer;
pub mod state_engine;
pub mod subscriptions;
pub mod supervisor;
pub mod types;

View file

@ -0,0 +1,213 @@
use std::collections::HashMap;
use std::sync::Mutex;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use serde_json::{json, Value};
use crate::core::types::DeviceClass;
pub struct EventNormalizer {
dedup_window_ms: u64,
recent: Mutex<HashMap<String, u64>>,
}
impl EventNormalizer {
pub fn new(dedup_window_ms: u64) -> Self {
Self {
dedup_window_ms,
recent: Mutex::new(HashMap::new()),
}
}
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut out = match raw.source {
AdapterSource::Udev => self.normalize_udev(raw),
AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw),
AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source,
data: raw.payload.clone(),
}],
};
out.retain(|ev| self.accept(ev));
out
}
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
let class = classify_device(&raw.payload);
let class_str = serde_json::to_string(&class)
.unwrap_or_else(|_| "\"unknown\"".to_string())
.replace('"', "");
let verb = match action {
"add" => "connected",
"remove" => "disconnected",
_ => "changed",
};
let mut events = vec![BreadEvent {
event: format!("bread.device.{}", verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
"raw": raw.payload,
}),
}];
events.push(BreadEvent {
event: format!("bread.device.{}.{}", class_str, verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
}),
});
events
}
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",
};
vec![BreadEvent {
event: mapped.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}]
}
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut events = Vec::new();
if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) {
events.push(BreadEvent {
event: if ac {
"bread.power.ac.connected".to_string()
} else {
"bread.power.ac.disconnected".to_string()
},
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) {
let battery_event = if level <= 5 {
Some("bread.power.battery.critical")
} else if level <= 10 {
Some("bread.power.battery.very_low")
} else if level <= 20 {
Some("bread.power.battery.low")
} else if level >= 100 {
Some("bread.power.battery.full")
} else {
None
};
if let Some(event) = battery_event {
events.push(BreadEvent {
event: event.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
}
if events.is_empty() {
events.push(BreadEvent {
event: "bread.power.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
events
}
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
let name = if online {
"bread.network.connected"
} else {
"bread.network.disconnected"
};
vec![BreadEvent {
event: name.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Network,
data: raw.payload.clone(),
}]
}
fn accept(&self, event: &BreadEvent) -> bool {
let key = format!("{}:{}", event.event, event.data);
let mut recent = self.recent.lock().expect("normalizer dedup mutex poisoned");
let now = event.timestamp;
if let Some(last) = recent.get(&key) {
if now.saturating_sub(*last) < self.dedup_window_ms {
return false;
}
}
recent.insert(key, now);
true
}
}
fn classify_device(payload: &Value) -> DeviceClass {
let name = payload
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let subsystem = payload
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
if name.contains("dock") {
return DeviceClass::Dock;
}
if subsystem == "input" && name.contains("keyboard") {
return DeviceClass::Keyboard;
}
if subsystem == "input" && name.contains("mouse") {
return DeviceClass::Mouse;
}
if subsystem == "drm" {
return DeviceClass::Display;
}
if subsystem == "sound" || name.contains("audio") {
return DeviceClass::Audio;
}
if subsystem == "block" || name.contains("storage") {
return DeviceClass::Storage;
}
DeviceClass::Unknown
}

View file

@ -0,0 +1,304 @@
use std::sync::Arc;
use anyhow::Result;
use bread_shared::BreadEvent;
use serde_json::Value;
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::warn;
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState};
use crate::lua::LuaMessage;
#[derive(Clone)]
pub struct StateHandle {
state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>,
}
pub enum StateCommand {
RegisterSubscription {
id: SubscriptionId,
pattern: String,
once: bool,
},
ClearSubscriptions,
SetModuleStatus {
name: String,
status: ModuleLoadState,
last_error: Option<String>,
},
SetProfile {
name: String,
},
}
impl StateHandle {
pub fn new(state: Arc<RwLock<RuntimeState>>, command_tx: mpsc::UnboundedSender<StateCommand>) -> Self {
Self { state, command_tx }
}
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
self.state.clone()
}
pub async fn state_get(&self, path: &str) -> Option<Value> {
let state = self.state.read().await;
let full = serde_json::to_value(&*state).ok()?;
if path.is_empty() {
return Some(full);
}
let mut current = &full;
for part in path.split('.') {
current = current.get(part)?;
}
Some(current.clone())
}
pub async fn state_dump(&self) -> Value {
let state = self.state.read().await;
serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({}))
}
pub fn register_subscription(&self, id: SubscriptionId, pattern: String, once: bool) -> Result<()> {
self.command_tx
.send(StateCommand::RegisterSubscription {
id,
pattern,
once,
})
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
}
pub fn clear_subscriptions(&self) {
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
}
pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option<String>) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name,
status,
last_error,
});
}
pub fn set_profile(&self, name: String) {
let _ = self.command_tx.send(StateCommand::SetProfile { name });
}
}
pub async fn run_state_engine(
mut event_rx: mpsc::UnboundedReceiver<BreadEvent>,
mut command_rx: mpsc::UnboundedReceiver<StateCommand>,
state: Arc<RwLock<RuntimeState>>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: broadcast::Sender<BreadEvent>,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut subscriptions = SubscriptionTable::default();
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
maybe_cmd = command_rx.recv() => {
let Some(cmd) = maybe_cmd else {
break;
};
handle_command(cmd, &state, &mut subscriptions).await;
}
maybe_event = event_rx.recv() => {
let Some(event) = maybe_event else {
break;
};
apply_event_to_state(&state, &event).await;
let _ = event_stream_tx.send(event.clone());
let matches = subscriptions.match_event(&event.event);
for sub in &matches {
let _ = lua_tx.send(LuaMessage::Event {
subscription_id: sub.id,
event: event.clone(),
});
}
for sub in matches.into_iter().filter(|s| s.once) {
subscriptions.remove(sub.id);
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
}
}
}
}
warn!("state engine loop exited");
}
async fn handle_command(
cmd: StateCommand,
state: &Arc<RwLock<RuntimeState>>,
subscriptions: &mut SubscriptionTable,
) {
match cmd {
StateCommand::RegisterSubscription { id, pattern, once } => {
subscriptions.add_with_id(id, pattern, once);
}
StateCommand::ClearSubscriptions => {
subscriptions.clear();
}
StateCommand::SetModuleStatus {
name,
status,
last_error,
} => {
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;
} else {
guard.modules.push(crate::core::types::ModuleStatus {
name,
status,
last_error,
});
}
}
StateCommand::SetProfile { name } => {
let mut guard = state.write().await;
if guard.profile.active != name {
let previous = guard.profile.active.clone();
guard.profile.history.push(previous);
guard.profile.active = name;
}
}
}
}
async fn apply_event_to_state(state: &Arc<RwLock<RuntimeState>>, event: &BreadEvent) {
let mut guard = state.write().await;
match event.event.as_str() {
"bread.monitor.connected" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
m.connected = true;
} else {
guard.monitors.push(crate::core::types::Monitor {
name: name.to_string(),
connected: true,
resolution: event.data.get("resolution").and_then(Value::as_str).map(ToString::to_string),
position: event.data.get("position").and_then(Value::as_str).map(ToString::to_string),
});
}
}
}
"bread.monitor.disconnected" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
m.connected = false;
}
}
}
"bread.workspace.changed" => {
let ws = event
.data
.get("workspace")
.or_else(|| event.data.get("id"))
.and_then(Value::as_str)
.map(ToString::to_string);
guard.active_workspace = ws;
}
"bread.window.focus.changed" => {
guard.active_window = event
.data
.get("window")
.or_else(|| event.data.get("class"))
.and_then(Value::as_str)
.map(ToString::to_string);
}
"bread.device.connected" => {
apply_device_change(&mut guard, &event.data, true);
}
"bread.device.disconnected" => {
apply_device_change(&mut guard, &event.data, false);
}
"bread.network.connected" | "bread.network.disconnected" => {
if let Some(online) = event.data.get("online").and_then(Value::as_bool) {
guard.network.online = online;
}
if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) {
guard.network.interfaces.clear();
for (name, meta) in ifaces {
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
guard.network.interfaces.insert(name.clone(), InterfaceState { up });
}
}
}
"bread.power.changed"
| "bread.power.ac.connected"
| "bread.power.ac.disconnected"
| "bread.power.battery.low"
| "bread.power.battery.very_low"
| "bread.power.battery.critical"
| "bread.power.battery.full" => {
if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) {
guard.power.ac_connected = ac;
}
if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) {
guard.power.battery_percent = Some(battery.min(100) as u8);
guard.power.battery_low = battery <= 20;
}
}
"bread.profile.activated" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if guard.profile.active != name {
let previous = guard.profile.active.clone();
guard.profile.history.push(previous);
guard.profile.active = name.to_string();
}
}
}
_ => {}
}
}
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
let id = data
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
if connected {
if state.devices.connected.iter().any(|d| d.id == id) {
return;
}
let class = data
.get("class")
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
.unwrap_or(DeviceClass::Unknown);
state.devices.connected.push(Device {
id,
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
class,
subsystem: data
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
});
} else {
state.devices.connected.retain(|d| d.id != id);
}
}

View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct SubscriptionId(pub u64);
#[derive(Debug, Clone)]
pub struct Subscription {
pub id: SubscriptionId,
pub pattern: String,
pub once: bool,
}
#[derive(Default, Debug)]
pub struct SubscriptionTable {
entries: Vec<Subscription>,
by_id: HashMap<SubscriptionId, usize>,
next_id: u64,
}
impl SubscriptionTable {
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId {
self.next_id = self.next_id.max(id.0.saturating_add(1));
let sub = Subscription { id, pattern, once };
self.entries.push(sub);
self.by_id.insert(id, self.entries.len() - 1);
id
}
pub fn remove(&mut self, id: SubscriptionId) -> bool {
let Some(idx) = self.by_id.remove(&id) else {
return false;
};
self.entries.swap_remove(idx);
if let Some(swapped) = self.entries.get(idx) {
self.by_id.insert(swapped.id, idx);
}
true
}
pub fn clear(&mut self) {
self.entries.clear();
self.by_id.clear();
}
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
self.entries
.iter()
.filter(|sub| matches_pattern(&sub.pattern, event_name))
.cloned()
.collect()
}
}
fn matches_pattern(pattern: &str, event_name: &str) -> bool {
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix);
}
pattern == event_name
}

View file

@ -0,0 +1,65 @@
use std::future::Future;
use tokio::sync::watch;
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
pub fn spawn_supervised<F, Fut>(
name: &'static str,
mut shutdown_rx: watch::Receiver<bool>,
mut task_factory: F,
)
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
{
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
let result = tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
continue;
}
result = task_factory() => result,
};
match result {
Ok(()) => {
info!(adapter = name, "adapter task exited cleanly");
attempt = 0;
}
Err(err) => {
error!(adapter = name, error = %err, "adapter task failed");
attempt = attempt.saturating_add(1);
}
}
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure");
tokio::select! {
_ = sleep(Duration::from_millis(wait_ms)) => {},
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
}
}
}
});
}

132
breadd/src/core/types.rs Normal file
View file

@ -0,0 +1,132 @@
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeState {
pub monitors: Vec<Monitor>,
pub workspaces: Vec<Workspace>,
pub active_workspace: Option<String>,
pub active_window: Option<String>,
pub devices: DeviceTopology,
pub network: NetworkState,
pub power: PowerState,
pub profile: ProfileState,
pub modules: Vec<ModuleStatus>,
}
impl Default for RuntimeState {
fn default() -> Self {
Self {
monitors: Vec::new(),
workspaces: Vec::new(),
active_workspace: None,
active_window: None,
devices: DeviceTopology::default(),
network: NetworkState::default(),
power: PowerState::default(),
profile: ProfileState::default(),
modules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Monitor {
pub name: String,
pub connected: bool,
pub resolution: Option<String>,
pub position: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: String,
pub monitor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceTopology {
pub connected: Vec<Device>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: String,
pub name: String,
pub class: DeviceClass,
pub subsystem: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DeviceClass {
Dock,
Keyboard,
Mouse,
Tablet,
Display,
Storage,
Audio,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NetworkState {
pub interfaces: HashMap<String, InterfaceState>,
pub online: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterfaceState {
pub up: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerState {
pub ac_connected: bool,
pub battery_percent: Option<u8>,
pub battery_low: bool,
}
impl Default for PowerState {
fn default() -> Self {
Self {
ac_connected: false,
battery_percent: None,
battery_low: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileState {
pub active: String,
pub history: Vec<String>,
pub profiles: BTreeMap<String, String>,
}
impl Default for ProfileState {
fn default() -> Self {
Self {
active: "default".to_string(),
history: Vec::new(),
profiles: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleStatus {
pub name: String,
pub status: ModuleLoadState,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModuleLoadState {
Loaded,
LoadError,
NotFound,
}

272
breadd/src/ipc/mod.rs Normal file
View file

@ -0,0 +1,272 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process;
use std::time::Instant;
use anyhow::{anyhow, Result};
use bread_shared::{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 tracing::{error, info, warn};
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
#[derive(Clone)]
pub struct Server {
socket_path: PathBuf,
state_handle: StateHandle,
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
started_at: Instant,
pid: u32,
}
#[derive(Debug, Deserialize)]
struct IpcRequest {
id: String,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Serialize)]
struct IpcResponse {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl Server {
pub fn new(
socket_path: PathBuf,
state_handle: StateHandle,
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Self {
Self {
socket_path,
state_handle,
event_tx,
lua_runtime,
emit_tx,
started_at: Instant::now(),
pid: process::id(),
}
}
pub async fn serve(&self, mut shutdown_rx: watch::Receiver<bool>) -> Result<()> {
if let Some(parent) = self.socket_path.parent() {
fs::create_dir_all(parent)?;
}
if self.socket_path.exists() {
fs::remove_file(&self.socket_path)?;
}
let listener = UnixListener::bind(&self.socket_path)?;
fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?;
info!(socket = %self.socket_path.display(), "ipc server listening");
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
accept = listener.accept() => {
let (stream, _) = accept?;
let server = self.clone();
tokio::spawn(async move {
if let Err(err) = server.handle_connection(stream).await {
warn!(error = %err, "ipc connection failed");
}
});
}
}
}
Ok(())
}
async fn handle_connection(&self, stream: UnixStream) -> Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let req: IpcRequest = serde_json::from_str(&line)?;
if req.method == "events.subscribe" {
let filter = req
.params
.get("filter")
.and_then(Value::as_str)
.map(ToString::to_string);
let ok = IpcResponse {
id: req.id,
result: Some(json!({ "subscribed": true })),
error: None,
};
write_half
.write_all(format!("{}\n", serde_json::to_string(&ok)?).as_bytes())
.await?;
self.stream_events(&mut write_half, filter).await?;
return Ok(());
}
let response = match self.handle_request(req).await {
Ok(res) => IpcResponse {
id: res.0,
result: Some(res.1),
error: None,
},
Err((id, err)) => IpcResponse {
id,
result: None,
error: Some(err),
},
};
write_half
.write_all(format!("{}\n", serde_json::to_string(&response)?).as_bytes())
.await?;
}
Ok(())
}
async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> {
let id = req.id.clone();
let result = match req.method.as_str() {
"ping" => Ok(json!({ "ok": true })),
"state.get" => {
let key = req.params.get("key").and_then(Value::as_str).unwrap_or("");
let value = self
.state_handle
.state_get(key)
.await
.ok_or_else(|| anyhow!("state path not found"));
value.map_err(|e| e.to_string())
}
"state.dump" => Ok(self.state_handle.state_dump().await),
"modules.list" => {
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()),
"profile.list" => {
let full = self.state_handle.state_dump().await;
let profiles = full
.get("profile")
.and_then(|v| v.get("profiles"))
.cloned()
.unwrap_or_else(|| json!({}));
Ok(profiles)
}
"profile.activate" => {
let Some(name) = req
.params
.get("name")
.and_then(Value::as_str)
else {
return Err((id, "missing profile name".to_string()));
};
self.state_handle.set_profile(name.to_string());
if self
.emit_tx
.send(BreadEvent::new(
"bread.profile.activated",
AdapterSource::System,
json!({ "name": name }),
))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "active": name }))
}
"emit" => {
let Some(event) = req
.params
.get("event")
.and_then(Value::as_str)
else {
return Err((id, "missing event name".to_string()));
};
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::System, data))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "emitted": true }))
}
"health" => {
let uptime_ms = self.started_at.elapsed().as_millis();
Ok(json!({
"ok": true,
"pid": self.pid,
"version": env!("CARGO_PKG_VERSION"),
"uptime_ms": uptime_ms,
}))
}
_ => Err("unknown method".to_string()),
};
match result {
Ok(v) => Ok((id, v)),
Err(err) => Err((id, err)),
}
}
async fn stream_events(
&self,
writer: &mut tokio::net::unix::OwnedWriteHalf,
filter: Option<String>,
) -> Result<()> {
let mut rx = self.event_tx.subscribe();
loop {
let evt = rx.recv().await?;
if let Some(filter) = filter.as_deref() {
if !matches_filter(&evt.event, filter) {
continue;
}
}
let line = format!("{}\n", serde_json::to_string(&evt)?);
if let Err(err) = writer.write_all(line.as_bytes()).await {
error!(error = %err, "failed to write event stream line");
return Ok(());
}
}
}
}
fn matches_filter(event_name: &str, pattern: &str) -> bool {
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix);
}
event_name == pattern
}

340
breadd/src/lua/mod.rs Normal file
View file

@ -0,0 +1,340 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, Value};
use tokio::sync::{mpsc, oneshot};
use tracing::{error, info, warn};
use crate::core::config::Config;
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::ModuleLoadState;
pub enum LuaMessage {
Event {
subscription_id: SubscriptionId,
event: BreadEvent,
},
SubscriptionCancelled {
id: SubscriptionId,
},
Reload {
reply: oneshot::Sender<std::result::Result<(), String>>,
},
Shutdown,
}
#[derive(Clone)]
pub struct RuntimeHandle {
tx: mpsc::UnboundedSender<LuaMessage>,
}
impl RuntimeHandle {
pub fn sender(&self) -> mpsc::UnboundedSender<LuaMessage> {
self.tx.clone()
}
pub async fn reload(&self) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(LuaMessage::Reload { reply: tx })
.map_err(|_| anyhow!("lua runtime channel closed"))?;
match rx.await {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(anyhow!(err)),
Err(_) => Err(anyhow!("lua runtime dropped reload response")),
}
}
pub fn shutdown(&self) {
let _ = self.tx.send(LuaMessage::Shutdown);
}
}
pub fn spawn_runtime(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = RuntimeHandle { tx };
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) {
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::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)
}
struct LuaEngine {
lua: Lua,
handlers: Arc<Mutex<HashMap<SubscriptionId, RegistryKey>>>,
next_sub_id: Arc<AtomicU64>,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
entry_point: PathBuf,
module_path: PathBuf,
}
impl LuaEngine {
fn new(config: Config, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender<BreadEvent>) -> Result<Self> {
Ok(Self {
lua: Lua::new(),
handlers: Arc::new(Mutex::new(HashMap::new())),
next_sub_id: Arc::new(AtomicU64::new(1)),
state_handle,
emit_tx,
entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(),
})
}
fn reload_internal(&mut self) -> Result<()> {
self.state_handle.clear_subscriptions();
self.lua = Lua::new();
self.handlers
.lock()
.expect("lua handlers mutex poisoned")
.clear();
self.install_api()?;
self.load_init_and_modules()?;
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 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)?;
handlers
.lock()
.map_err(|_| mlua::Error::external("handler lock poisoned"))?
.insert(id, key);
state_handle
.register_subscription(id, pattern, false)
.map_err(mlua::Error::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 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)?;
handlers
.lock()
.map_err(|_| mlua::Error::external("handler lock poisoned"))?
.insert(id, key);
state_handle
.register_subscription(id, pattern, true)
.map_err(mlua::Error::external)?;
Ok(id.0)
})?;
bread.set("once", once_fn)?;
let emit_tx = self.emit_tx.clone();
let emit_fn = self.lua.create_function(move |lua, (event_name, payload): (String, Value)| {
let data = match payload {
Value::Nil => serde_json::json!({}),
other => lua
.from_value::<serde_json::Value>(other)
.unwrap_or_else(|_| serde_json::json!({})),
};
emit_tx
.send(BreadEvent::new(event_name, AdapterSource::System, data))
.map_err(|_| mlua::Error::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| {
let snapshot = state_arc.blocking_read();
let mut value = serde_json::to_value(&*snapshot)
.map_err(|e| mlua::Error::external(e.to_string()))?;
if path.is_empty() {
return lua
.to_value(&value)
.map_err(|e| mlua::Error::external(e.to_string()));
}
for part in path.split('.') {
value = value
.get(part)
.cloned()
.ok_or_else(|| mlua::Error::external("state path not found"))?;
}
lua.to_value(&value)
.map_err(|e| mlua::Error::external(e.to_string()))
})?;
state_tbl.set("get", get_fn)?;
bread.set("state", state_tbl)?;
let profile_tbl = self.lua.create_table()?;
let state_handle = self.state_handle.clone();
let activate_fn = self.lua.create_function(move |_lua, name: String| {
state_handle.set_profile(name.clone());
Ok(())
})?;
profile_tbl.set("activate", activate_fn)?;
bread.set("profile", profile_tbl)?;
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
let status = std::process::Command::new("sh")
.arg("-lc")
.arg(&cmd)
.status()
.map_err(mlua::Error::external)?;
Ok(status.code().unwrap_or_default())
})?;
bread.set("exec", exec_fn)?;
globals.set("bread", bread)?;
Ok(())
}
fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init")?;
let mut files = list_lua_files(&self.module_path)?;
files.sort();
for path in files {
let module_name = path
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("unknown")
.to_string();
match self.load_lua_file(&path, &module_name) {
Ok(()) => {
self.state_handle
.set_module_status(module_name, ModuleLoadState::Loaded, None);
}
Err(err) => {
self.state_handle.set_module_status(
module_name,
ModuleLoadState::LoadError,
Some(err.to_string()),
);
}
}
}
Ok(())
}
fn load_lua_file(&self, path: &Path, module_name: &str) -> 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,
);
return Ok(());
}
let src = fs::read_to_string(path)?;
self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
Ok(())
}
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
let handlers = self.handlers.lock().expect("lua handlers mutex poisoned");
let Some(reg) = handlers.get(&id) else {
return Ok(());
};
let callback: Function = self.lua.registry_value(reg)?;
let event_value = self.lua.to_value(&event)?;
if let Err(err) = callback.call::<_, ()>(event_value) {
error!(subscription = id.0, error = %err, "lua callback failed");
}
Ok(())
}
fn remove_handler(&self, id: SubscriptionId) {
if let Ok(mut map) = self.handlers.lock() {
map.remove(&id);
}
}
}
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
if !root.exists() {
return Ok(out);
}
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some("lua") {
out.push(path);
}
}
}
Ok(out)
}

128
breadd/src/main.rs Normal file
View file

@ -0,0 +1,128 @@
mod adapters;
mod core;
mod ipc;
mod lua;
use std::sync::Arc;
use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use crate::core::config::Config;
use crate::core::normalizer::EventNormalizer;
use crate::core::state_engine::{run_state_engine, StateHandle};
use crate::core::types::RuntimeState;
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::load()?;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(config.daemon.log_level.clone()))
.init();
info!("starting breadd");
let state = Arc::new(RwLock::new(RuntimeState::default()));
let (raw_tx, mut raw_rx) = mpsc::channel::<RawEvent>(2048);
let (normalized_tx, normalized_rx) = mpsc::unbounded_channel::<BreadEvent>();
let (state_cmd_tx, state_cmd_rx) = mpsc::unbounded_channel();
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 lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_tx = lua_runtime.sender();
tokio::spawn(run_state_engine(
normalized_rx,
state_cmd_rx,
state.clone(),
lua_tx,
event_stream_tx.clone(),
shutdown_rx.clone(),
));
let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms));
{
let normalizer = normalizer.clone();
let normalized_tx = normalized_tx.clone();
let mut shutdown_rx = shutdown_rx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
maybe_raw = raw_rx.recv() => {
let Some(raw) = maybe_raw else {
break;
};
for event in normalizer.normalize(&raw) {
if normalized_tx.send(event).is_err() {
break;
}
}
}
}
}
});
}
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?;
let _ = normalized_tx.send(BreadEvent::new(
"bread.system.startup",
AdapterSource::System,
serde_json::json!({}),
));
let ipc_server = ipc::Server::new(
config.socket_path(),
state_handle,
event_stream_tx,
lua_runtime.clone(),
normalized_tx,
);
info!("breadd fully started");
tokio::select! {
result = ipc_server.serve(shutdown_rx.clone()) => {
if let Err(err) = result {
error!(error = %err, "ipc server failed");
}
}
_ = wait_for_shutdown() => {
info!("shutdown signal received");
}
}
let _ = shutdown_tx.send(true);
lua_runtime.shutdown();
Ok(())
}
async fn wait_for_shutdown() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}

View file

@ -0,0 +1,210 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::sleep;
#[tokio::test]
async fn ping_and_state_dump_work() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let ping = harness.send_request("ping", json!({})).await?;
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
let health = harness.send_request("health", json!({})).await?;
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
assert!(health.get("version").and_then(Value::as_str).is_some());
assert!(health.get("uptime_ms").and_then(Value::as_u64).is_some());
let dump = harness.send_request("state.dump", json!({})).await?;
assert!(dump.get("devices").is_some());
assert!(dump.get("profile").is_some());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn events_stream_receives_emitted_events() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let stream = UnixStream::connect(harness.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-1",
"method": "events.subscribe",
"params": {
"filter": "bread.system.*"
}
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
let ack = reader
.next_line()
.await?
.ok_or_else(|| anyhow!("missing subscribe ack"))?;
let ack_json: Value = serde_json::from_str(&ack)?;
assert_eq!(
ack_json
.get("result")
.and_then(|v| v.get("subscribed"))
.and_then(Value::as_bool),
Some(true)
);
harness
.send_request(
"emit",
json!({
"event": "bread.system.test",
"data": { "ok": true }
}),
)
.await?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut got = false;
while Instant::now() < deadline {
let Some(line) = reader.next_line().await? else {
break;
};
let event: Value = serde_json::from_str(&line)?;
if event.get("event").and_then(Value::as_str) == Some("bread.system.test") {
got = true;
break;
}
}
assert!(got, "did not receive emitted event on stream");
harness.shutdown();
Ok(())
}
struct TestHarness {
_temp: TempDir,
child: Child,
socket_path: PathBuf,
}
impl TestHarness {
fn spawn() -> Result<Self> {
let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
let home = temp.path().join("home");
fs::create_dir_all(&runtime_dir)?;
fs::create_dir_all(&config_home)?;
fs::create_dir_all(&home)?;
let bread_cfg = config_home.join("bread");
fs::create_dir_all(bread_cfg.join("modules"))?;
fs::write(
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
fs::write(
bread_cfg.join("breadd.toml"),
r#"
[daemon]
log_level = "error"
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = false
[adapters.udev]
enabled = false
[adapters.power]
enabled = false
[adapters.network]
enabled = false
"#,
)?;
let socket_path = runtime_dir.join("bread").join("breadd.sock");
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
.env("XDG_RUNTIME_DIR", &runtime_dir)
.env("XDG_CONFIG_HOME", &config_home)
.env("HOME", &home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(Self {
_temp: temp,
child,
socket_path,
})
}
fn socket_path(&self) -> &Path {
&self.socket_path
}
async fn wait_until_ready(&self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
if self.socket_path.exists() {
let ping = self.send_request("ping", json!({})).await;
if ping.is_ok() {
return Ok(());
}
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("daemon did not become ready in time"))
}
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
let stream = UnixStream::connect(self.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let req = json!({
"id": "1",
"method": method,
"params": params,
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let line = lines
.next_line()
.await?
.ok_or_else(|| anyhow!("missing ipc response"))?;
let parsed: Value = serde_json::from_str(&line)?;
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
return Err(anyhow!(err.to_string()));
}
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
}
fn shutdown(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}