diff --git a/.gitignore b/.gitignore index fdd4532..9472698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ target/ Overview.md -DAEMON.md \ No newline at end of file +DAEMON.md +.claude +CLAUDE.md +.github/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d987076..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "anyhow", "bread-shared", "clap", + "libc", "notify", "serde", "serde_json", diff --git a/README.md b/README.md index 8da6518..73512df 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,20 @@ Optional but preferred: ```bash git clone https://github.com/Breadway/bread.git cd bread -cargo build --release ``` -Binaries will be at `target/release/breadd` and `target/release/bread`. - -Install them: +Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon: ```bash -sudo install -Dm755 target/release/breadd /usr/local/bin/breadd -sudo install -Dm755 target/release/bread /usr/local/bin/bread +bash scripts/install.sh +``` + +Or do it step by step: + +```bash +cargo build --release +sudo install -Dm755 target/release/breadd /usr/bin/breadd +sudo install -Dm755 target/release/bread /usr/bin/bread ``` ### Arch Linux (PKGBUILD) @@ -130,6 +134,15 @@ enabled = true [events] dedup_window_ms = 100 + +[notifications] +default_timeout_ms = 5000 +default_urgency = "normal" +notify_send_path = "notify-send" + +[modules] +builtin = true # load built-in modules (monitors, devices, etc.) +disable = [] # list of built-in module names to disable ``` Your automation lives in `~/.config/bread/init.lua`: @@ -153,15 +166,18 @@ All commands communicate with the running daemon over a Unix socket at `$XDG_RUN ```bash bread reload # Hot-reload all Lua modules +bread reload --watch # Watch config dir and reload on changes bread state # Dump full runtime state as JSON bread events # Stream live normalized events bread events --filter bread.device.* # Stream filtered events +bread events --since 60 # Replay events from the last 60 seconds bread modules # List loaded modules and status bread profile-list # List defined profiles bread profile-activate # Activate a named profile bread emit --data '{}' # Manually fire an event (for testing) bread ping # Check daemon connectivity bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health ``` --- @@ -201,16 +217,26 @@ Events follow the namespace convention `bread...`. ### Events ```lua --- Subscribe to an event -bread.on("bread.monitor.connected", function(event) +-- Subscribe to an event; returns a numeric ID +local id = bread.on("bread.monitor.connected", function(event) print(event.data.name) end) +-- Unsubscribe by ID +bread.off(id) + -- Subscribe once, then auto-unsubscribe bread.once("bread.system.startup", function(event) -- runs exactly once end) +-- Subscribe with a predicate filter +bread.filter("bread.device.connected", function(event) + return event.data.class == "keyboard" +end, function(event) + bread.exec("xset r rate 200 40") +end) + -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) ``` @@ -222,6 +248,12 @@ bread.emit("mymodule.something", { key = "value" }) local monitors = bread.state.get("monitors") local workspace = bread.state.get("active_workspace") local power = bread.state.get("power") +local devices = bread.state.get("devices") + +-- Watch a state key and fire on changes +bread.state.watch("active_workspace", function(new, old) + print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +end) ``` ### Profiles @@ -231,12 +263,42 @@ bread.profile.activate("desk") bread.profile.activate("default") ``` -### Execution +### Execution and notifications ```lua -- Fire-and-forget: returns immediately, process runs in background bread.exec("kitty") -bread.exec("notify-send 'Dock connected'") + +-- Desktop notification +bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +``` + +### Timers + +```lua +-- Run once after a delay (ms) +bread.after(500, function() + bread.exec("some-delayed-command") +end) + +-- Run on a repeating interval (ms); returns a timer ID +local id = bread.every(60000, function() + bread.log("tick") +end) +bread.cancel(id) + +-- Debounce a rapidly-firing handler +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +``` + +### Logging + +```lua +bread.log("Module loaded") +bread.warn("Unexpected state") +bread.error("Something failed") ``` --- @@ -255,7 +317,7 @@ Response: { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } ``` -Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `emit`. +Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`. `events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 5f18678..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -3,6 +3,10 @@ name = "bread-cli" version = "0.1.0" edition = "2021" +[[bin]] +name = "bread" +path = "src/main.rs" + [dependencies] bread-shared = { path = "../bread-shared" } serde.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 9382494..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use std::env; use std::io; use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; @@ -331,18 +331,6 @@ fn print_reload(value: &Value) { } } -async fn watch_reload(socket: &Path) -> Result<()> { - let config_dir = config_directory(); - println!("watching {} for changes...", config_dir.display()); - - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| { - let _ = tx.send(res); - })?; - watcher.watch(&config_dir, RecursiveMode::Recursive)?; - - use tokio::time::{sleep, Duration}; - async fn watch_reload(socket: &Path) -> Result<()> { let config_dir = config_directory(); println!("watching {} for changes...", config_dir.display()); @@ -360,7 +348,7 @@ async fn watch_reload(socket: &Path) -> Result<()> { // Debounce: drain any follow-up events that arrive within 150ms. // A single file save typically generates 2-3 fs events in rapid succession. - sleep(Duration::from_millis(150)).await; + tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} let response = send_request(socket, "modules.reload", json!({})).await?; @@ -370,9 +358,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { Ok(()) } - Ok(()) -} - async fn print_doctor(socket: &Path) -> Result<()> { let stream = match UnixStream::connect(socket).await { Ok(stream) => stream, diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ffe4d15..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,18 +52,23 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - return Ok(()); + 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 for environments where monitor sockets are unavailable. - let mut known: HashMap = scan_devices(&self.subsystems)? + // 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)?; + let current = scan_devices(&self.subsystems).unwrap_or_default(); let current_map: HashMap = current .into_iter() .map(|d| (d.id.clone(), d)) @@ -71,13 +76,17 @@ impl Adapter for UdevAdapter { for (id, dev) in ¤t_map { if !known.contains_key(id) { - tx.send(raw_change_event("add", dev)).await?; + if tx.send(raw_change_event("add", dev)).await.is_err() { + return Ok(()); + } } } for (id, dev) in &known { if !current_map.contains_key(id) { - tx.send(raw_change_event("remove", dev)).await?; + if tx.send(raw_change_event("remove", dev)).await.is_err() { + return Ok(()); + } } } @@ -130,6 +139,15 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "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"), }), timestamp: now_unix_ms(), }; @@ -263,3 +281,17 @@ fn scan_devices(subsystems: &[String]) -> Result> { Ok(out) } + +fn prop_bool(event: &udev::Event, key: &str) -> bool { + event + .property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn prop_str(event: &udev::Event, key: &str) -> Option { + event + .property_value(key) + .map(|v| v.to_string_lossy().to_string()) +} diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index b424e1c..3eaef88 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -289,33 +289,104 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { } 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") { + // --- 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; } - if subsystem == "input" && name.contains("mouse") { + + // 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; } - if subsystem == "sound" || name.contains("audio") { + + // Block devices = storage. + if subsystem == "block" { + return DeviceClass::Storage; + } + + // Sound subsystem = audio. + if subsystem == "sound" { return DeviceClass::Audio; } - if subsystem == "block" || name.contains("storage") { + + // --- 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; } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index f99ea74..fff3368 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -270,14 +270,16 @@ impl Server { "events.replay" => { let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); let cutoff = now_unix_ms().saturating_sub(since_ms); - let mut replay = Vec::new(); - if let Ok(buf) = self.event_buffer.lock() { - for event in buf.iter() { - if event.timestamp >= cutoff { - replay.push(event); - } - } - } + let replay: Vec = self + .event_buffer + .lock() + .map(|buf| { + buf.iter() + .filter(|e| e.timestamp >= cutoff) + .cloned() + .collect() + }) + .unwrap_or_default(); Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) } _ => Err("unknown method".to_string()), diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 9acc814..228ac61 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1499,7 +1499,15 @@ fn state_value_to_lua<'lua>( state_arc: &Arc>, path: &str, ) -> mlua::Result> { - let snapshot = state_arc.blocking_read(); + // The Lua thread runs a current_thread runtime. blocking_read and block_in_place + // both require the multi-thread runtime and panic here. try_read succeeds + // immediately in the common case; the write lock is held for microseconds. + let snapshot = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; let mut value = serde_json::to_value(&*snapshot) .map_err(|e| LuaError::external(e.to_string()))?; if path.is_empty() { @@ -1518,13 +1526,23 @@ fn state_value_to_lua<'lua>( } fn module_store_get(state_arc: &Arc>, module: &str, key: &str) -> Option { - let guard = state_arc.blocking_read(); + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; let entry = guard.modules.iter().find(|m| m.name == module)?; entry.store.get(key).cloned() } fn module_store_set(state_arc: &Arc>, module: &str, key: String, value: JsonValue) { - let mut guard = state_arc.blocking_write(); + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + }; if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) { entry.store.insert(key, value); return; @@ -1616,6 +1634,7 @@ 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 @@ -1651,15 +1670,55 @@ 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, event) then - run_rule(rule, event) + if matches_rule(rule, patched) then + run_rule(rule, patched) end end end) @@ -1811,13 +1870,11 @@ fn hyprland_request(request: &str) -> Result { use std::os::unix::net::UnixStream; let socket = hyprland_request_socket()?; - tokio::task::block_in_place(|| { - let mut stream = UnixStream::connect(&socket)?; - stream.write_all(request.as_bytes())?; - let mut buffer = String::new(); - stream.read_to_string(&mut buffer)?; - Ok(buffer) - }) + let mut stream = UnixStream::connect(&socket)?; + stream.write_all(request.as_bytes())?; + let mut buffer = String::new(); + stream.read_to_string(&mut buffer)?; + Ok(buffer) } fn list_lua_files(root: &Path) -> Result> { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 1a36db0..8ce69ee 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -20,6 +20,6 @@ build() { package() { cd "${srcdir}/${pkgname}-${pkgver}" install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" - install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli" + install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread" install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" } diff --git a/packaging/systemd/breadd.service b/packaging/systemd/breadd.service index 9f36697..95f0942 100644 --- a/packaging/systemd/breadd.service +++ b/packaging/systemd/breadd.service @@ -5,7 +5,7 @@ Wants=graphical-session.target [Service] Type=simple -ExecStart=%h/.cargo/bin/breadd +ExecStart=/usr/bin/breadd Restart=on-failure RestartSec=2 UMask=0077 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..961f792 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +SERVICE_DIR="${HOME}/.config/systemd/user" + +# ── build ────────────────────────────────────────────────────────────────────── +echo "building bread (release)..." +cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" + +# ── 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" + +# ── 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" + +systemctl --user daemon-reload +systemctl --user enable --now breadd +echo " breadd enabled and started" + +# ── verify ───────────────────────────────────────────────────────────────────── +sleep 0.5 +if bread ping &>/dev/null; then + echo "" + bread doctor +else + echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" +fi