Enhance installation process, update service paths, and improve device classification
This commit is contained in:
parent
e339660084
commit
f0ef411697
12 changed files with 323 additions and 70 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
target/
|
target/
|
||||||
Overview.md
|
Overview.md
|
||||||
DAEMON.md
|
DAEMON.md
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
.github/
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -289,6 +289,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"clap",
|
"clap",
|
||||||
|
"libc",
|
||||||
"notify",
|
"notify",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
84
README.md
84
README.md
|
|
@ -72,16 +72,20 @@ Optional but preferred:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Breadway/bread.git
|
git clone https://github.com/Breadway/bread.git
|
||||||
cd bread
|
cd bread
|
||||||
cargo build --release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Binaries will be at `target/release/breadd` and `target/release/bread`.
|
Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon:
|
||||||
|
|
||||||
Install them:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo install -Dm755 target/release/breadd /usr/local/bin/breadd
|
bash scripts/install.sh
|
||||||
sudo install -Dm755 target/release/bread /usr/local/bin/bread
|
```
|
||||||
|
|
||||||
|
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)
|
### Arch Linux (PKGBUILD)
|
||||||
|
|
@ -130,6 +134,15 @@ enabled = true
|
||||||
|
|
||||||
[events]
|
[events]
|
||||||
dedup_window_ms = 100
|
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`:
|
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
|
```bash
|
||||||
bread reload # Hot-reload all Lua modules
|
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 state # Dump full runtime state as JSON
|
||||||
bread events # Stream live normalized events
|
bread events # Stream live normalized events
|
||||||
bread events --filter bread.device.* # Stream filtered 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 modules # List loaded modules and status
|
||||||
bread profile-list # List defined profiles
|
bread profile-list # List defined profiles
|
||||||
bread profile-activate <name> # Activate a named profile
|
bread profile-activate <name> # Activate a named profile
|
||||||
bread emit <event> --data '{}' # Manually fire an event (for testing)
|
bread emit <event> --data '{}' # Manually fire an event (for testing)
|
||||||
bread ping # Check daemon connectivity
|
bread ping # Check daemon connectivity
|
||||||
bread health # Daemon version, uptime, PID
|
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
|
### Events
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
-- Subscribe to an event
|
-- Subscribe to an event; returns a numeric ID
|
||||||
bread.on("bread.monitor.connected", function(event)
|
local id = bread.on("bread.monitor.connected", function(event)
|
||||||
print(event.data.name)
|
print(event.data.name)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- Unsubscribe by ID
|
||||||
|
bread.off(id)
|
||||||
|
|
||||||
-- Subscribe once, then auto-unsubscribe
|
-- Subscribe once, then auto-unsubscribe
|
||||||
bread.once("bread.system.startup", function(event)
|
bread.once("bread.system.startup", function(event)
|
||||||
-- runs exactly once
|
-- runs exactly once
|
||||||
end)
|
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)
|
-- Emit a custom event (for cross-module communication)
|
||||||
bread.emit("mymodule.something", { key = "value" })
|
bread.emit("mymodule.something", { key = "value" })
|
||||||
```
|
```
|
||||||
|
|
@ -222,6 +248,12 @@ bread.emit("mymodule.something", { key = "value" })
|
||||||
local monitors = bread.state.get("monitors")
|
local monitors = bread.state.get("monitors")
|
||||||
local workspace = bread.state.get("active_workspace")
|
local workspace = bread.state.get("active_workspace")
|
||||||
local power = bread.state.get("power")
|
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
|
### Profiles
|
||||||
|
|
@ -231,12 +263,42 @@ bread.profile.activate("desk")
|
||||||
bread.profile.activate("default")
|
bread.profile.activate("default")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Execution
|
### Execution and notifications
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
-- Fire-and-forget: returns immediately, process runs in background
|
-- Fire-and-forget: returns immediately, process runs in background
|
||||||
bread.exec("kitty")
|
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 } ] }
|
{ "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.
|
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ name = "bread-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "bread"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-shared = { path = "../bread-shared" }
|
bread-shared = { path = "../bread-shared" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tokio::sync::mpsc;
|
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<()> {
|
async fn watch_reload(socket: &Path) -> Result<()> {
|
||||||
let config_dir = config_directory();
|
let config_dir = config_directory();
|
||||||
println!("watching {} for changes...", config_dir.display());
|
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.
|
// Debounce: drain any follow-up events that arrive within 150ms.
|
||||||
// A single file save typically generates 2-3 fs events in rapid succession.
|
// 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() {}
|
while rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
let response = send_request(socket, "modules.reload", json!({})).await?;
|
let response = send_request(socket, "modules.reload", json!({})).await?;
|
||||||
|
|
@ -370,9 +358,6 @@ async fn watch_reload(socket: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn print_doctor(socket: &Path) -> Result<()> {
|
async fn print_doctor(socket: &Path) -> Result<()> {
|
||||||
let stream = match UnixStream::connect(socket).await {
|
let stream = match UnixStream::connect(socket).await {
|
||||||
Ok(stream) => stream,
|
Ok(stream) => stream,
|
||||||
|
|
|
||||||
|
|
@ -52,18 +52,23 @@ impl Adapter for UdevAdapter {
|
||||||
|
|
||||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
debug!("udev adapter started");
|
debug!("udev adapter started");
|
||||||
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
|
match run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
|
||||||
return Ok(());
|
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.
|
// Fallback: poll sysfs every 2 seconds for environments where the
|
||||||
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
|
// netlink socket is unavailable (missing plugdev membership, containers, etc).
|
||||||
|
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)
|
||||||
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| (d.id.clone(), d))
|
.map(|d| (d.id.clone(), d))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let current = scan_devices(&self.subsystems)?;
|
let current = scan_devices(&self.subsystems).unwrap_or_default();
|
||||||
let current_map: HashMap<String, ScannedDevice> = current
|
let current_map: HashMap<String, ScannedDevice> = current
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| (d.id.clone(), d))
|
.map(|d| (d.id.clone(), d))
|
||||||
|
|
@ -71,13 +76,17 @@ impl Adapter for UdevAdapter {
|
||||||
|
|
||||||
for (id, dev) in ¤t_map {
|
for (id, dev) in ¤t_map {
|
||||||
if !known.contains_key(id) {
|
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 {
|
for (id, dev) in &known {
|
||||||
if !current_map.contains_key(id) {
|
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,
|
"id": id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"subsystem": subsystem,
|
"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(),
|
timestamp: now_unix_ms(),
|
||||||
};
|
};
|
||||||
|
|
@ -263,3 +281,17 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
|
|
||||||
Ok(out)
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,33 +289,104 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_device(payload: &Value) -> DeviceClass {
|
fn classify_device(payload: &Value) -> DeviceClass {
|
||||||
let name = payload
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase();
|
|
||||||
let subsystem = payload
|
let subsystem = payload
|
||||||
.get("subsystem")
|
.get("subsystem")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if name.contains("dock") {
|
// --- Property-based classification (reliable, hardware-agnostic) ---
|
||||||
return DeviceClass::Dock;
|
|
||||||
}
|
// udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device.
|
||||||
if subsystem == "input" && name.contains("keyboard") {
|
if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
return DeviceClass::Keyboard;
|
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;
|
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" {
|
if subsystem == "drm" {
|
||||||
return DeviceClass::Display;
|
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;
|
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;
|
return DeviceClass::Storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,14 +270,16 @@ impl Server {
|
||||||
"events.replay" => {
|
"events.replay" => {
|
||||||
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
|
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 cutoff = now_unix_ms().saturating_sub(since_ms);
|
||||||
let mut replay = Vec::new();
|
let replay: Vec<BreadEvent> = self
|
||||||
if let Ok(buf) = self.event_buffer.lock() {
|
.event_buffer
|
||||||
for event in buf.iter() {
|
.lock()
|
||||||
if event.timestamp >= cutoff {
|
.map(|buf| {
|
||||||
replay.push(event);
|
buf.iter()
|
||||||
}
|
.filter(|e| e.timestamp >= cutoff)
|
||||||
}
|
.cloned()
|
||||||
}
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
|
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
|
||||||
}
|
}
|
||||||
_ => Err("unknown method".to_string()),
|
_ => Err("unknown method".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -1499,7 +1499,15 @@ fn state_value_to_lua<'lua>(
|
||||||
state_arc: &Arc<RwLock<RuntimeState>>,
|
state_arc: &Arc<RwLock<RuntimeState>>,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> mlua::Result<Value<'lua>> {
|
) -> 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)
|
let mut value = serde_json::to_value(&*snapshot)
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
if path.is_empty() {
|
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> {
|
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)?;
|
let entry = guard.modules.iter().find(|m| m.name == module)?;
|
||||||
entry.store.get(key).cloned()
|
entry.store.get(key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: String, value: JsonValue) {
|
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) {
|
if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) {
|
||||||
entry.store.insert(key, value);
|
entry.store.insert(key, value);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1616,6 +1634,7 @@ const BUILTIN_DEVICES: &str = r#"
|
||||||
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
|
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
|
||||||
|
|
||||||
local rules = {}
|
local rules = {}
|
||||||
|
local user_patterns = {} -- { { pattern = "...", class = "..." }, ... }
|
||||||
|
|
||||||
local function matches_rule(rule, event)
|
local function matches_rule(rule, event)
|
||||||
local class = rule.class
|
local class = rule.class
|
||||||
|
|
@ -1651,15 +1670,55 @@ local function run_rule(rule, event)
|
||||||
end
|
end
|
||||||
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)
|
function M.on(opts)
|
||||||
table.insert(rules, opts)
|
table.insert(rules, opts)
|
||||||
end
|
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()
|
function M.on_load()
|
||||||
bread.on("bread.device.**", function(event)
|
bread.on("bread.device.**", function(event)
|
||||||
|
local patched = apply_user_patterns(event)
|
||||||
for _, rule in ipairs(rules) do
|
for _, rule in ipairs(rules) do
|
||||||
if matches_rule(rule, event) then
|
if matches_rule(rule, patched) then
|
||||||
run_rule(rule, event)
|
run_rule(rule, patched)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
@ -1811,13 +1870,11 @@ fn hyprland_request(request: &str) -> Result<String> {
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
let socket = hyprland_request_socket()?;
|
let socket = hyprland_request_socket()?;
|
||||||
tokio::task::block_in_place(|| {
|
let mut stream = UnixStream::connect(&socket)?;
|
||||||
let mut stream = UnixStream::connect(&socket)?;
|
stream.write_all(request.as_bytes())?;
|
||||||
stream.write_all(request.as_bytes())?;
|
let mut buffer = String::new();
|
||||||
let mut buffer = String::new();
|
stream.read_to_string(&mut buffer)?;
|
||||||
stream.read_to_string(&mut buffer)?;
|
Ok(buffer)
|
||||||
Ok(buffer)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,6 @@ build() {
|
||||||
package() {
|
package() {
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
|
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"
|
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Wants=graphical-session.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=%h/.cargo/bin/breadd
|
ExecStart=/usr/bin/breadd
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
|
|
||||||
36
scripts/install.sh
Executable file
36
scripts/install.sh
Executable 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue