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
|
|
@ -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 ¤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<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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue