From d44ece3649d1d27972432f2f28b091022232b92b Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 21:27:07 +0800 Subject: [PATCH] feat: enhance device normalization and classification - Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device. - Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts. - Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions. - Updated the state engine to handle device rules and resolve device names before dispatching events. - Modified the installation script to set up default configuration files for the daemon and Lua modules. - Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory. --- .gitignore | 32 ++++ Documentation.md | 116 +++++++++++-- README.md | 9 +- bread-cli/src/main.rs | 11 +- breadd/Cargo.toml | 2 +- breadd/src/adapters/hyprland.rs | 35 +++- breadd/src/adapters/udev.rs | 294 +++++++++----------------------- breadd/src/core/normalizer.rs | 190 +++++++-------------- breadd/src/core/state_engine.rs | 160 ++++++++++++++++- breadd/src/core/types.rs | 37 ++-- breadd/src/lua/mod.rs | 217 ++++++++++++++++------- scripts/install.sh | 92 ++++++++-- 12 files changed, 719 insertions(+), 476 deletions(-) diff --git a/.gitignore b/.gitignore index a253843..a92804c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,36 @@ +# Rust build artifacts target/ + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# Environment and secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ + +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid + +# Internal project docs and spec files kept out of public history Overview.md DAEMON.md LUA_RUNTIME.md diff --git a/Documentation.md b/Documentation.md index fe9ec60..f1a0aca 100644 --- a/Documentation.md +++ b/Documentation.md @@ -471,30 +471,110 @@ monitors.on({ ### `bread.devices` -Device connection rules with class-based matching. +Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals. + +Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers. ```lua local devices = require("bread.devices") --- Register a name pattern → class mapping -devices.register("CalDigit", "dock") -devices.register("Keychron", "keyboard") +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) devices.on({ - when = "connected", - class = "keyboard", - run = function(event) + when = "connected", + device = "dock", + run = "~/.config/bread/scripts/dock-connected.sh" +}) + +devices.on({ + when = "disconnected", + name = "CalDigit", -- pattern-matched against event.data.name + run = function(event) + bread.log("Dock disconnected: " .. event.data.name) + end, +}) +``` + +#### Functions + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. See options below. | + +#### Device rule options + +```lua +devices.on({ + when = "connected", -- required: "connected" or "disconnected" + device = "keyboard", -- optional: device name from devices.lua + name = "Keychron", -- optional: substring matched against device name + run = function(event) ... end -- required: function or shell string +}) +``` + +- `when` (required): One of `connected` or `disconnected`. +- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name. +- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match). +- `run` (required): Function or shell string to run when the rule matches. + +The callback receives the full device event: +```lua +{ + event = "bread.device.dock.connected", + data = { + id = "/sys/...", + device = "dock", -- name resolved from devices.lua + name = "CalDigit TS4", -- raw device name from udev + subsystem = "usb", + vendor_id = "0x35f5", + product_id = "0x0104", + raw = { ... } -- full udev properties + } +} +``` + +#### Example: Keyboard configuration on connect + +```lua +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.log("Keyboard connected: " .. event.data.name) bread.exec("xset r rate 200 40") end, }) ``` -| Function | Description | -|----------|-------------| -| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` | -| `M.register(pattern, class)` | Map a device name pattern to a class string | +#### Example: Dock-specific setup -`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +```lua +-- devices.lua defines: { device = "dock", vendor_id = "35f5" } + +devices.on({ + when = "connected", + device = "dock", + run = function(event) + bread.log("Dock connected") + bread.exec("~/.config/bread/scripts/dock-connected.sh") + end, +}) + +devices.on({ + when = "disconnected", + device = "dock", + run = function(event) + bread.log("Dock disconnected") + bread.exec("~/.config/bread/scripts/dock-disconnected.sh") + end, +}) +``` ### `bread.workspaces` @@ -570,12 +650,12 @@ Events are delivered as a `BreadEvent`: | Event | Data | |-------|------| -| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device..connected` | same | -| `bread.device..disconnected` | same | +| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` | +| `bread.device.disconnected` | same | +| `bread.device..connected` | `{ id, device }` | +| `bread.device..disconnected` | `{ id, device }` | -`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`. #### Hyprland @@ -641,7 +721,7 @@ Events are delivered as a `BreadEvent`: { "id": "/sys/...", "name": "CalDigit TS4", - "class": "dock", + "device": "dock", "subsystem": "usb", "vendor_id": "0x35f5", "product_id": "0x0104" diff --git a/README.md b/README.md index adbff67..cc1e32c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ bread reload --watch # Watch config dir and reload on changes # State and events bread state # Dump full runtime state as JSON bread events # Stream live normalized events -bread events --filter bread.device.* # Stream filtered events +bread events bread.device.* # Stream filtered events bread events --since 60 # Replay events from the last 60 seconds bread emit # Manually fire an event (for testing) @@ -319,9 +319,8 @@ Events follow the namespace convention `bread...`. | `bread.system.startup` | Daemon fully initialized | | `bread.device.connected` | Any device attached | | `bread.device.disconnected` | Any device removed | -| `bread.device.dock.connected` | Dock attached | -| `bread.device.dock.disconnected` | Dock removed | -| `bread.device.keyboard.connected` | Keyboard attached | +| `bread.device..connected` | Named device attached (name from `devices.lua`) | +| `bread.device..disconnected` | Named device removed | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | @@ -380,7 +379,7 @@ end) -- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" + return event.data.device == "keyboard" end, function(event) bread.exec("xset r rate 200 40") end) diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index eadd679..ae100c3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -42,8 +42,8 @@ enum Commands { }, /// Stream live normalized events Events { - #[arg(long)] - filter: Option, + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, /// Output raw JSON #[arg(long)] json: bool, @@ -169,12 +169,12 @@ async fn main() -> Result<()> { } } Commands::Events { - filter, + pattern, json, fields, since, } => { - stream_events(&socket, filter, json, fields, since).await?; + stream_events(&socket, pattern, json, fields, since).await?; } Commands::Modules { subcommand } => { handle_modules_cmd(subcommand, &socket).await?; @@ -769,8 +769,7 @@ fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> } "pip" => { let mut cmd = std::process::Command::new("pip"); - cmd.args(["install", "--user", "-r"]) - .arg(file.to_str().unwrap_or("")); + cmd.args(["install", "--user", "-r"]).arg(&file); let _ = cmd.status(); } "npm" => { diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 4b949be..9b968d9 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -14,7 +14,7 @@ tracing-subscriber.workspace = true mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] } async-trait = "0.1" toml = "0.8" -udev = "0.9" +udev = { version = "0.9", features = ["send"] } rtnetlink = "0.9" zbus = { version = "3.13", features = ["tokio"] } hex = "0.4" diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2ef3731..c032612 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -48,13 +48,36 @@ impl Adapter for HyprlandAdapter { } fn hyprland_event_socket() -> Result { - 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")) + + // If the env var is set, use it directly. + if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket2.sock")); + } + + // Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance. + // Hyprland creates a per-instance directory there containing .socket2.sock. + // This handles the case where breadd starts as a systemd user service before + // Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment. + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket2.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 1 => Ok(sockets.remove(0)), + n => { + warn!("found {n} Hyprland instances, using first"); + Ok(sockets.remove(0)) + } + } } fn parse_hyprland_line(line: &str) -> (String, String) { diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index c3aba56..5af66bc 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -1,12 +1,9 @@ -use std::collections::HashMap; -use std::fs; -use std::path::Path; +use std::os::unix::io::AsRawFd; 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; @@ -22,10 +19,7 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { - scan_devices(&self.subsystems).unwrap_or_default() - }); - + let devices = enumerate_with_udev(&self.subsystems)?; for device in devices { tx.send(RawEvent { source: AdapterSource::Udev, @@ -52,122 +46,106 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - Ok(()) => return Ok(()), - Err(err) => { - tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); - } - } - - // Fallback: poll sysfs every 2 seconds for environments where the - // netlink socket is unavailable (missing plugdev membership, containers, etc). - let mut known: HashMap = scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - loop { - let current = scan_devices(&self.subsystems).unwrap_or_default(); - let current_map: HashMap = current - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - for (id, dev) in ¤t_map { - if !known.contains_key(id) { - if tx.send(raw_change_event("add", dev)).await.is_err() { - return Ok(()); - } - } - } - - for (id, dev) in &known { - if !current_map.contains_key(id) { - if tx.send(raw_change_event("remove", dev)).await.is_err() { - return Ok(()); - } - } - } - - known = current_map; - sleep(Duration::from_secs(2)).await; - } + run_udev_monitor(self.subsystems.clone(), tx).await } } -#[derive(Clone, Debug)] struct ScannedDevice { id: String, name: String, subsystem: String, - vendor_id: Option, - product_id: Option, } +// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without +// first polling the fd returns None immediately and exits the loop — which is +// why the old code silently fell back to sysfs on every start. We use poll(2) +// inside spawn_blocking so the thread truly blocks until events are available. async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> 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()?; + let socket = builder.listen()?; + let fd = socket.as_raw_fd(); - 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, - "id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"), - "id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"), - "id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"), - "id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"), - "id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"), - "id_usb_class": prop_str(&event, "ID_USB_CLASS"), - "id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"), - "id_vendor": prop_str(&event, "ID_VENDOR"), - "id_model": prop_str(&event, "ID_MODEL"), - "vendor_id": prop_str(&event, "ID_VENDOR_ID"), - "product_id": prop_str(&event, "ID_MODEL_ID"), - }), - timestamp: now_unix_ms(), + loop { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, }; - if tx.blocking_send(msg).is_err() { - break; + let ret = unsafe { libc::poll(&mut pfd, 1, 1000) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err.into()); + } + if ret == 0 { + // Timeout: bail if the downstream channel has been dropped. + if tx.is_closed() { + return Ok(()); + } + continue; + } + if pfd.revents & libc::POLLIN != 0 { + while let Some(event) = socket.iter().next() { + if tx.blocking_send(build_event(&event)).is_err() { + return Ok(()); + } + } } } - - Ok(()) }) .await??; Ok(()) } +fn build_event(event: &udev::Event) -> RawEvent { + 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(); + + RawEvent { + source: AdapterSource::Udev, + kind: "udev.change".to_string(), + payload: json!({ + "action": action, + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"), + "id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"), + "id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"), + "id_usb_class": prop_str(event, "ID_USB_CLASS"), + "id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"), + "id_vendor": prop_str(event, "ID_VENDOR"), + "id_model": prop_str(event, "ID_MODEL"), + "vendor_id": prop_str(event, "ID_VENDOR_ID"), + "product_id": prop_str(event, "ID_MODEL_ID"), + }), + timestamp: now_unix_ms(), + } +} + fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { @@ -187,125 +165,7 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .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(); - let vendor_id = dev - .property_value("ID_VENDOR_ID") - .map(|v| v.to_string_lossy().to_string()); - let product_id = dev - .property_value("ID_MODEL_ID") - .map(|v| v.to_string_lossy().to_string()); - - out.push(ScannedDevice { - id, - name, - subsystem, - vendor_id, - product_id, - }); - } - - 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, - "vendor_id": dev.vendor_id, - "product_id": dev.product_id, - }), - timestamp: now_unix_ms(), - } -} - -fn scan_devices(subsystems: &[String]) -> Result> { - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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()) { - let syspath = entry.path(); - let vendor_id = fs::read_to_string(syspath.join("idVendor")) - .ok() - .map(|s| s.trim().to_string()); - let product_id = fs::read_to_string(syspath.join("idProduct")) - .ok() - .map(|s| s.trim().to_string()); - out.push(ScannedDevice { - id: format!("usb:{name}"), - name, - subsystem: "usb".to_string(), - vendor_id, - product_id, - }); - } - } - } + out.push(ScannedDevice { id, name, subsystem }); } Ok(out) diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 3eaef88..9c31d41 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -4,14 +4,16 @@ use std::sync::RwLock; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; use serde_json::{json, Value}; -use crate::core::types::DeviceClass; - /// How many multiples of `dedup_window_ms` an entry must be idle before eviction. const EVICT_MULTIPLIER: u64 = 60; pub struct EventNormalizer { dedup_window_ms: u64, recent: RwLock>, + /// Tracks the first time a physical device (keyed by verb+vendor_id+product_id) + /// fired within the current window, so subsequent child-node events from the + /// same plug-in are suppressed at the normalizer level. + seen_devices: RwLock>, } impl EventNormalizer { @@ -19,6 +21,7 @@ impl EventNormalizer { Self { dedup_window_ms, recent: RwLock::new(HashMap::new()), + seen_devices: RwLock::new(HashMap::new()), } } @@ -42,40 +45,75 @@ impl EventNormalizer { fn normalize_udev(&self, raw: &RawEvent) -> Vec { 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('"', ""); + // "bind" is the kernel attaching a driver to an interface — not a meaningful + // device state change for automation purposes. + if action == "bind" { + return vec![]; + } + + let name = raw.payload.get("name").and_then(Value::as_str).unwrap_or("unknown"); + let vendor = raw.payload.get("id_vendor").and_then(Value::as_str).unwrap_or_default(); + let vendor_id = raw.payload.get("vendor_id").and_then(Value::as_str).unwrap_or_default(); + let product_id = raw.payload.get("product_id").and_then(Value::as_str).unwrap_or_default(); + let subsystem = raw.payload.get("subsystem").and_then(Value::as_str).unwrap_or_default(); + + // Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry + // no identity information — they are USB protocol artefacts, not devices. + if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() { + return vec![]; + } + + // For connected/disconnected, suppress duplicate events from child nodes of + // the same physical device (e.g. input66, mouse0, event17 all from one plug-in). + // Key by verb+vendor_id+product_id so a second distinct device of the same + // model plugged in after the window still fires correctly. let verb = match action { "add" => "connected", "remove" => "disconnected", _ => "changed", }; - let mut events = vec![BreadEvent { + if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() { + let device_key = format!("{}:{}:{}", verb, vendor_id, product_id); + let now = raw.timestamp; + let already_seen = { + let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner()); + seen.get(&device_key) + .map(|&last| now.saturating_sub(last) < self.dedup_window_ms) + .unwrap_or(false) + }; + if already_seen { + return vec![]; + } + let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner()); + seen.insert(device_key, now); + // Evict stale entries + let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); + if evict_before > 0 { + seen.retain(|_, &mut last| last >= evict_before); + } + } + + let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown"); + + // Device name is always "unknown" here; the state engine applies user-defined + // classification rules from devices.lua before dispatching to subscribers. + vec![BreadEvent { event: format!("bread.device.{}", verb), timestamp: raw.timestamp, source: AdapterSource::Udev, data: json!({ "id": id, - "class": class, + "device": "unknown", + "name": name, + "vendor": vendor, + "vendor_id": vendor_id, + "product_id": product_id, + "subsystem": subsystem, "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 { @@ -109,13 +147,13 @@ impl EventNormalizer { event: "bread.monitor.connected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "monitorremoved" => vec![BreadEvent { event: "bread.monitor.disconnected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "activewindow" => vec![BreadEvent { event: "bread.window.focus.changed".to_string(), @@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { data.split(">>").collect() } -fn classify_device(payload: &Value) -> DeviceClass { - let subsystem = payload - .get("subsystem") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - // --- Property-based classification (reliable, hardware-agnostic) --- - - // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device. - if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Keyboard; - } - - // ID_INPUT_MOUSE=1 covers mice and trackballs. - if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Mouse; - } - - // ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc). - if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Tablet; - } - - // USB class 0x09 = Hub. Docks expose a hub interface; they also typically - // expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces. - // We check for hub + at least one of those secondary interfaces. - if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) { - let ifaces_lc = ifaces.to_lowercase(); - let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902"); - let has_secondary = ifaces_lc.contains(":0e") // video - || ifaces_lc.contains(":0200") // CDC ethernet - || ifaces_lc.contains(":0100") // audio - || ifaces_lc.contains(":0801"); // mass storage - if has_hub && has_secondary { - return DeviceClass::Dock; - } - } - - // USB class 0x01 = Audio. - if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) { - if cls == "01" || cls.to_lowercase() == "0x01" { - return DeviceClass::Audio; - } - // USB class 0x08 = Mass Storage. - if cls == "08" || cls.to_lowercase() == "0x08" { - return DeviceClass::Storage; - } - } - - // DRM subsystem = display connector. - if subsystem == "drm" { - return DeviceClass::Display; - } - - // Block devices = storage. - if subsystem == "block" { - return DeviceClass::Storage; - } - - // Sound subsystem = audio. - if subsystem == "sound" { - return DeviceClass::Audio; - } - - // --- Name-based fallback (catches user-registered patterns and obvious names) --- - // This runs last so the property-based rules above always win. - - let name = payload - .get("name") - .and_then(Value::as_str) - .or_else(|| payload.get("id_model").and_then(Value::as_str)) - .unwrap_or_default() - .to_lowercase(); - - let vendor = payload - .get("id_vendor") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - let combined = format!("{name} {vendor}"); - - if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") { - return DeviceClass::Dock; - } - if combined.contains("keyboard") || combined.contains("kbd") { - return DeviceClass::Keyboard; - } - if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") { - return DeviceClass::Mouse; - } - if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") { - return DeviceClass::Tablet; - } - if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") { - return DeviceClass::Audio; - } - if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") { - return DeviceClass::Storage; - } - - DeviceClass::Unknown -} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 6e69e6a..784a0e9 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -9,7 +9,7 @@ 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::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState}; use crate::lua::LuaMessage; #[derive(Clone)] @@ -46,6 +46,7 @@ pub enum StateCommand { SetProfile { name: String, }, + SetDeviceRules(Vec), } impl StateHandle { @@ -136,6 +137,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::SetProfile { name }); } + pub fn set_device_rules(&self, rules: Vec) { + let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); + } + pub fn subscription_count(&self) -> Arc { self.subscription_count.clone() } @@ -152,6 +157,7 @@ pub async fn run_state_engine( ) { let mut subscriptions = SubscriptionTable::default(); let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); loop { tokio::select! { @@ -164,13 +170,51 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; + if let StateCommand::SetDeviceRules(rules) = cmd { + device_rules = rules; + } else { + handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; + } } maybe_event = event_rx.recv() => { - let Some(event) = maybe_event else { + let Some(mut event) = maybe_event else { break; }; + // Resolve device name from user rules and patch the event data before + // any subscriber sees it, then emit the named companion event. + let device_event = if event.event == "bread.device.connected" + || event.event == "bread.device.disconnected" + { + let is_disconnect = event.event == "bread.device.disconnected"; + let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string(); + + // On disconnect, udev strips vendor/product identifiers from the event. + // Look up the device by id in the current state (it's still present + // because apply_event_to_state hasn't run yet) and reuse the stored name. + let device = if is_disconnect { + state.read().await + .devices.connected.iter() + .find(|d| d.id == id) + .map(|d| d.device.clone()) + .unwrap_or_else(|| resolve_device(&device_rules, &event.data)) + } else { + resolve_device(&device_rules, &event.data) + }; + + if let Some(data) = event.data.as_object_mut() { + data.insert("device".to_string(), Value::String(device.clone())); + } + let verb = if is_disconnect { "disconnected" } else { "connected" }; + Some(BreadEvent::new( + format!("bread.device.{}.{}", device, verb), + AdapterSource::Udev, + json!({ "id": id, "device": device }), + )) + } else { + None + }; + let (before_snapshot, after_snapshot) = if watches.is_empty() { (None, None) } else { @@ -188,6 +232,13 @@ pub async fn run_state_engine( dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + if let Some(dev_ev) = device_event { + let mut guard = state.write().await; + apply_event_to_state(&mut guard, &dev_ev); + drop(guard); + dispatch_event(&dev_ev, &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() { let old_val = value_at_path(&before, path).unwrap_or(Value::Null); @@ -273,6 +324,9 @@ async fn handle_command( guard.profile.active = name; } } + StateCommand::SetDeviceRules(_) => { + // Handled directly in run_state_engine before this function is called. + } } } @@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { } } +fn resolve_device(rules: &[DeviceRule], data: &Value) -> String { + for rule in rules { + if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) { + return rule.device.clone(); + } + } + "unknown".to_string() +} + +fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { + if let Some(ref expected) = cond.vendor_id { + let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.product_id { + let actual = data.get("product_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.name { + let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.vendor { + let actual = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref contains) = cond.name_contains { + let name = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase(); + let vendor = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase(); + let combined = format!("{name} {vendor}"); + if !combined.contains(contains.to_lowercase().as_str()) { + return false; + } + } + if let Some(expected) = cond.id_input_keyboard { + if data.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) != expected { + return false; + } + } + if let Some(expected) = cond.id_input_mouse { + if data.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) != expected { + return false; + } + } + if let Some(expected) = cond.id_input_tablet { + if data.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) != expected { + return false; + } + } + if cond.usb_hub == Some(true) { + let ifaces = data + .get("id_usb_interfaces") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902"); + let has_secondary = ifaces.contains(":0e") + || ifaces.contains(":0200") + || ifaces.contains(":0100") + || ifaces.contains(":0801"); + if !(has_hub && has_secondary) { + return false; + } + } + if let Some(ref expected) = cond.id_usb_class { + let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() + && actual.to_lowercase() != format!("0x{}", expected.to_lowercase()) + { + return false; + } + } + if let Some(ref expected) = cond.subsystem { + let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + true +} + fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) { let id = data .get("id") @@ -411,10 +554,11 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) return; } - let class = data - .get("class") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or(DeviceClass::Unknown); + let device = data + .get("device") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); state.devices.connected.push(Device { id, @@ -423,7 +567,7 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), - class, + device, subsystem: data .get("subsystem") .and_then(Value::as_str) diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 119b7af..38c7f81 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -55,7 +55,7 @@ pub struct DeviceTopology { pub struct Device { pub id: String, pub name: String, - pub class: DeviceClass, + pub device: String, pub subsystem: String, #[serde(skip_serializing_if = "Option::is_none")] pub vendor_id: Option, @@ -63,17 +63,30 @@ pub struct Device { pub product_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum DeviceClass { - Dock, - Keyboard, - Mouse, - Tablet, - Display, - Storage, - Audio, - Unknown, +/// One set of match conditions. All provided fields must match. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MatchCondition { + pub vendor_id: Option, + pub product_id: Option, + pub name: Option, + pub vendor: Option, + pub name_contains: Option, + pub id_input_keyboard: Option, + pub id_input_mouse: Option, + pub id_input_tablet: Option, + /// True triggers the compound USB hub + secondary-interface check. + pub usb_hub: Option, + pub id_usb_class: Option, + pub subsystem: Option, +} + +/// A device rule from `devices.lua`. The device name is assigned if ANY +/// condition in `conditions` matches (OR semantics across conditions, +/// AND semantics within a condition). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceRule { + pub device: String, + pub conditions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 7caa9c6..7744fdf 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -21,7 +21,7 @@ use tracing::{error, info, warn}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::{ModuleLoadState, RuntimeState}; +use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; use bread_shared::now_unix_ms; pub enum LuaMessage { @@ -275,6 +275,8 @@ impl LuaEngine { .clear(); self.install_api()?; + self.load_device_rules()?; + self.load_profiles()?; self.load_init_and_modules()?; self.run_on_reload(); info!("lua runtime reloaded"); @@ -515,8 +517,14 @@ impl LuaEngine { let profile_tbl = self.lua.create_table()?; let state_handle = self.state_handle.clone(); + let emit_tx = self.emit_tx.clone(); let activate_fn = self.lua.create_function(move |_lua, name: String| { state_handle.set_profile(name.clone()); + let _ = emit_tx.send(BreadEvent::new( + "bread.profile.activated", + AdapterSource::System, + serde_json::json!({ "name": name }), + )); Ok(()) })?; profile_tbl.set("activate", activate_fn)?; @@ -700,6 +708,13 @@ impl LuaEngine { })?; hyprland_tbl.set("keyword", keyword_fn)?; + let eval_fn = self.lua.create_function(move |_lua, expr: String| { + let resp = hyprland_request(&format!("eval {expr}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("eval", eval_fn)?; + let active_window_fn = self.lua.create_function(move |lua, ()| { let resp = hyprland_request("j/activewindow") .map_err(|e| LuaError::external(e.to_string()))?; @@ -835,6 +850,11 @@ impl LuaEngine { ModuleInfo { table_key: key }, ); + // Register in package.loaded so require("bread.devices") etc. works + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set(decl.name.clone(), module_tbl.clone())?; + Ok(module_tbl) })?; bread.set("module", module_fn)?; @@ -907,6 +927,98 @@ impl LuaEngine { Ok(()) } + fn load_device_rules(&self) -> Result<()> { + let devices_path = self + .entry_point + .parent() + .map(|p| p.join("devices.lua")) + .unwrap_or_else(|| std::path::PathBuf::from("devices.lua")); + + if !devices_path.exists() { + return Ok(()); + } + + let source = fs::read_to_string(&devices_path) + .map_err(|e| anyhow!("failed to read devices.lua: {e}"))?; + + let rules_value: mlua::Value = self + .lua + .load(&source) + .set_name("devices.lua") + .eval() + .map_err(|e| anyhow!("devices.lua error: {e}"))?; + + let mlua::Value::Table(tbl) = rules_value else { + return Err(anyhow!("devices.lua must return a table of rules")); + }; + + let mut rules: Vec = Vec::new(); + for pair in tbl.sequence_values::() { + let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?; + let device: String = entry.get("device").unwrap_or_default(); + if device.is_empty() { + continue; + } + + // If the rule has a `match` key, each entry in it is a separate condition (OR logic). + // Otherwise the rule table itself is the single condition. + let conditions: Vec = + if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") { + match_tbl + .sequence_values::() + .filter_map(|r| r.ok()) + .map(|t| parse_match_condition(&t)) + .collect() + } else { + vec![parse_match_condition(&entry)] + }; + + if !conditions.is_empty() { + rules.push(DeviceRule { device, conditions }); + } + } + + self.state_handle.set_device_rules(rules); + Ok(()) + } + + fn load_profiles(&self) -> Result<()> { + let profiles_path = self + .entry_point + .parent() + .map(|p| p.join("profiles.lua")) + .unwrap_or_else(|| PathBuf::from("profiles.lua")); + + if !profiles_path.exists() { + return Ok(()); + } + + let path_str = profiles_path.to_string_lossy().to_string(); + self.lua.globals().set("__profiles_path", path_str)?; + self.lua + .load( + r#" + local ok, result = pcall(loadfile, __profiles_path) + __profiles_path = nil + if ok and type(result) == "function" then + ok, result = pcall(result) + end + if ok and type(result) == "table" then + bread.on("bread.profile.activated", function(event) + local name = event.data and event.data.name + local fn = name and result[name] + if type(fn) == "function" then + fn(event) + end + end) + end + "#, + ) + .set_name("profiles.lua") + .exec() + .map_err(|e| anyhow!("profiles.lua error: {e}")) + } + fn load_init_and_modules(&self) -> Result<()> { self.load_lua_file(&self.entry_point, "init", false)?; @@ -1796,24 +1908,18 @@ const BUILTIN_DEVICES: &str = r#" local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local rules = {} -local user_patterns = {} -- { { pattern = "...", class = "..." }, ... } 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 + if when == "connected" and not event.event:match("%.connected$") then + return false + elseif when == "disconnected" and not event.event:match("%.disconnected$") then + return false end - if class and data.class ~= class then + if rule.device and data.device ~= rule.device then return false end @@ -1832,55 +1938,15 @@ local function run_rule(rule, event) end end --- Reclassify an event's data.class based on user-registered name patterns. --- Called before rule matching so that user-registered patterns take effect --- even for devices that the daemon classified as Unknown. -local function apply_user_patterns(event) - if not event.data then return event end - local name = tostring(event.data.name or ""):lower() - local vendor = tostring(event.data.vendor or ""):lower() - local combined = name .. " " .. vendor - for _, p in ipairs(user_patterns) do - if combined:find(p.pattern, 1, true) then - -- Return a shallow copy with the class overridden so we don't - -- mutate the original event that other handlers may receive. - local patched = {} - for k, v in pairs(event) do patched[k] = v end - patched.data = {} - for k, v in pairs(event.data) do patched.data[k] = v end - patched.data.class = p.class - return patched - end - end - return event -end - function M.on(opts) table.insert(rules, opts) end --- Register a user-defined device pattern so the daemon can correctly classify --- hardware that the automatic classifier doesn't recognise. --- --- Usage: --- local devices = require("bread.devices") --- devices.register("CalDigit", "dock") --- devices.register("Keychron", "keyboard") --- devices.register("MX Master", "mouse") --- --- The pattern is matched case-insensitively against the device name and vendor --- combined. The class must be one of: dock, keyboard, mouse, tablet, display, --- storage, audio, unknown. -function M.register(pattern, class) - table.insert(user_patterns, { pattern = pattern:lower(), class = class }) -end - function M.on_load() bread.on("bread.device.**", function(event) - local patched = apply_user_patterns(event) for _, rule in ipairs(rules) do - if matches_rule(rule, patched) then - run_rule(rule, patched) + if matches_rule(rule, event) then + run_rule(rule, event) end end end) @@ -2018,13 +2084,28 @@ fn builtin_module_decls(disabled: &HashSet) -> Vec { } fn hyprland_request_socket() -> Result { - let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") - .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); - Ok(PathBuf::from(runtime) - .join("hypr") - .join(instance) - .join(".socket.sock")) + + if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")); + } + + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 1 => Ok(sockets.remove(0)), + _ => Ok(sockets.remove(0)), + } } fn hyprland_request(request: &str) -> Result { @@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result { Ok(buffer) } +fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition { + MatchCondition { + vendor_id: tbl.get("vendor_id").ok(), + product_id: tbl.get("product_id").ok(), + name: tbl.get("name").ok(), + vendor: tbl.get("vendor").ok(), + name_contains: tbl.get("name_contains").ok(), + id_input_keyboard: tbl.get("id_input_keyboard").ok(), + id_input_mouse: tbl.get("id_input_mouse").ok(), + id_input_tablet: tbl.get("id_input_tablet").ok(), + usb_hub: tbl.get("usb_hub").ok(), + id_usb_class: tbl.get("id_usb_class").ok(), + subsystem: tbl.get("subsystem").ok(), + } +} + fn list_lua_files(root: &Path) -> Result> { let mut out = Vec::new(); if !root.exists() { diff --git a/scripts/install.sh b/scripts/install.sh index 961f792..5440530 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,35 +2,97 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" SERVICE_DIR="${HOME}/.config/systemd/user" +CONFIG_DIR="${HOME}/.config/bread" +MODULES_DIR="${CONFIG_DIR}/modules" # ── build ────────────────────────────────────────────────────────────────────── echo "building bread (release)..." cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" +echo "" -# ── install binaries ─────────────────────────────────────────────────────────── -echo "installing binaries to $INSTALL_PREFIX (requires sudo)..." -sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd" -sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread" -echo " installed $INSTALL_PREFIX/breadd" -echo " installed $INSTALL_PREFIX/bread" +# ── symlinks ─────────────────────────────────────────────────────────────────── +echo "symlinking binaries into $BIN_DIR..." +mkdir -p "$BIN_DIR" +ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd" +ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread" +echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd" +echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread" + +if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + echo "" + echo " note: $BIN_DIR is not in PATH — add to your shell profile:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi +echo "" + +# ── config ───────────────────────────────────────────────────────────────────── +echo "setting up config..." +mkdir -p "$CONFIG_DIR" "$MODULES_DIR" + +if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then + cat > "$CONFIG_DIR/breadd.toml" << 'EOF' +[daemon] +log_level = "info" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = true + +[adapters.udev] +enabled = true + +[adapters.power] +enabled = true + +[adapters.network] +enabled = true +EOF + echo " created $CONFIG_DIR/breadd.toml" +else + echo " $CONFIG_DIR/breadd.toml already exists, skipping" +fi + +if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then + cat > "$CONFIG_DIR/init.lua" << 'EOF' +-- bread init.lua — loaded before modules, use for global setup +bread.log("bread started") +EOF + echo " created $CONFIG_DIR/init.lua" +else + echo " $CONFIG_DIR/init.lua already exists, skipping" +fi +echo "" # ── systemd user service ─────────────────────────────────────────────────────── echo "installing systemd user service..." mkdir -p "$SERVICE_DIR" -install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service" -echo " installed $SERVICE_DIR/breadd.service" +# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin. +sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \ + "$REPO_ROOT/packaging/systemd/breadd.service" \ + > "$SERVICE_DIR/breadd.service" +echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)" systemctl --user daemon-reload -systemctl --user enable --now breadd -echo " breadd enabled and started" + +if systemctl --user is-active --quiet breadd 2>/dev/null; then + systemctl --user restart breadd + echo " breadd restarted" +else + systemctl --user enable --now breadd + echo " breadd enabled and started" +fi +echo "" # ── verify ───────────────────────────────────────────────────────────────────── sleep 0.5 -if bread ping &>/dev/null; then - echo "" - bread doctor +if "$BIN_DIR/bread" ping &>/dev/null; then + "$BIN_DIR/bread" doctor else - echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" + echo "warning: daemon did not respond to ping" + echo " check: journalctl --user -u breadd -n 20" fi