Enhance installation process, update service paths, and improve device classification

This commit is contained in:
Breadway 2026-05-11 18:39:39 +08:00
parent edb2ba338a
commit 55d103b3cf
12 changed files with 323 additions and 70 deletions

5
.gitignore vendored
View file

@ -1,3 +1,6 @@
target/
Overview.md
DAEMON.md
DAEMON.md
.claude
CLAUDE.md
.github/

1
Cargo.lock generated
View file

@ -289,6 +289,7 @@ dependencies = [
"anyhow",
"bread-shared",
"clap",
"libc",
"notify",
"serde",
"serde_json",

View file

@ -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 <name> # Activate a named profile
bread emit <event> --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.<subsystem>.<noun>.<verb>`.
### 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.

View file

@ -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

View file

@ -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,

View file

@ -52,18 +52,23 @@ impl Adapter for UdevAdapter {
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(());
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<String, ScannedDevice> = 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<String, ScannedDevice> = 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<String, ScannedDevice> = current
.into_iter()
.map(|d| (d.id.clone(), d))
@ -71,13 +76,17 @@ impl Adapter for UdevAdapter {
for (id, dev) in &current_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<String>, tx: mpsc::Sender<RawEvent>) -
"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<Vec<ScannedDevice>> {
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<String> {
event
.property_value(key)
.map(|v| v.to_string_lossy().to_string())
}

View file

@ -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;
}

View file

@ -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<BreadEvent> = 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()),

View file

@ -1499,7 +1499,15 @@ fn state_value_to_lua<'lua>(
state_arc: &Arc<RwLock<RuntimeState>>,
path: &str,
) -> mlua::Result<Value<'lua>> {
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<RwLock<RuntimeState>>, module: &str, key: &str) -> Option<JsonValue> {
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<RwLock<RuntimeState>>, 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<String> {
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<Vec<PathBuf>> {

View file

@ -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"
}

View file

@ -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

36
scripts/install.sh Executable file
View file

@ -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