Allow well-formed bread.command events on the emit bus

Docs already said any module or app could publish bread.command.<app>.<verb>,
but command is reserved so both unsourced bread-emit and sourced App emit
rejected the whole namespace. Keep command unclaimable as an app id; accept
bread.command.<known-app>.<verb> (and let an app command another known app).
Also ship bread-emit and bread-module-host, and give udev enumerate the same
classification fields as a live add so boot-time devices are not all unknown.
This commit is contained in:
Breadway 2026-08-15 21:41:40 +08:00
parent a6973360bd
commit d3517d1433
52 changed files with 26405 additions and 6811 deletions

View file

@ -2,7 +2,7 @@ use std::os::unix::io::AsRawFd;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tracing::debug;
@ -19,20 +19,13 @@ impl UdevAdapter {
}
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let devices = enumerate_with_udev(&self.subsystems)?;
for device in devices {
tx.send(RawEvent {
source: AdapterSource::Udev,
kind: "udev.enumerate".to_string(),
payload: json!({
"action": "add",
"id": device.id,
"name": device.name,
"subsystem": device.subsystem,
}),
timestamp: now_unix_ms(),
})
.await?;
let mut enumerator = udev::Enumerator::new()?;
for subsystem in &self.subsystems {
enumerator.match_subsystem(subsystem)?;
}
for device in enumerator.scan_devices()? {
tx.send(build_device_event(&device, "add", "udev.enumerate"))
.await?;
}
Ok(())
}
@ -50,12 +43,6 @@ impl Adapter for UdevAdapter {
}
}
struct ScannedDevice {
id: String,
name: String,
subsystem: String,
}
// 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)
@ -110,81 +97,157 @@ fn build_event(event: &udev::Event) -> RawEvent {
.action()
.map(|a| a.to_string_lossy().to_string())
.unwrap_or_else(|| "change".to_string());
let subsystem = event
build_device_event(event, &action, "udev.change")
}
/// Shared live/enumerate payload. `udev::Event` deref's to `Device`, so
/// boot-time enumerate of an already-plugged device is equivalent to an
/// `add` of that same device (same identity + classification fields
/// `resolve_device` needs).
fn build_device_event(device: &udev::Device, action: &str, kind: &str) -> RawEvent {
let subsystem = device
.subsystem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let name = event
let name = device
.property_value("ID_MODEL")
.or_else(|| event.property_value("NAME"))
.or_else(|| device.property_value("NAME"))
.map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string()))
.or_else(|| device.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let id = event.syspath().to_string_lossy().to_string();
let id = device.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"),
}),
kind: kind.to_string(),
payload: udev_event_payload(
action,
&id,
&name,
&subsystem,
UdevClassification::from_device(device),
),
timestamp: now_unix_ms(),
}
}
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut enumerator = udev::Enumerator::new()?;
for subsystem in subsystems {
enumerator.match_subsystem(subsystem)?;
}
let mut out = Vec::new();
for dev in enumerator.scan_devices()? {
let subsystem = dev
.subsystem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let name = dev
.property_value("ID_MODEL")
.or_else(|| dev.property_value("NAME"))
.map(|v| v.to_string_lossy().to_string())
.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();
out.push(ScannedDevice {
id,
name,
subsystem,
});
}
Ok(out)
/// Classification / identity fields copied onto every udev payload so
/// `resolve_device` can name a device after boot the same way it names a
/// live plug-in.
struct UdevClassification {
id_input_keyboard: bool,
id_input_mouse: bool,
id_input_joystick: bool,
id_input_touchpad: bool,
id_input_tablet: bool,
id_usb_class: Option<String>,
id_usb_interfaces: Option<String>,
id_vendor: Option<String>,
id_model: Option<String>,
vendor_id: Option<String>,
product_id: Option<String>,
}
fn prop_bool(event: &udev::Event, key: &str) -> bool {
event
impl UdevClassification {
fn from_device(device: &udev::Device) -> Self {
Self {
id_input_keyboard: prop_bool(device, "ID_INPUT_KEYBOARD"),
id_input_mouse: prop_bool(device, "ID_INPUT_MOUSE"),
id_input_joystick: prop_bool(device, "ID_INPUT_JOYSTICK"),
id_input_touchpad: prop_bool(device, "ID_INPUT_TOUCHPAD"),
id_input_tablet: prop_bool(device, "ID_INPUT_TABLET"),
id_usb_class: prop_str(device, "ID_USB_CLASS"),
id_usb_interfaces: prop_str(device, "ID_USB_INTERFACES"),
id_vendor: prop_str(device, "ID_VENDOR"),
id_model: prop_str(device, "ID_MODEL"),
vendor_id: prop_str(device, "ID_VENDOR_ID"),
product_id: prop_str(device, "ID_MODEL_ID"),
}
}
}
fn udev_event_payload(
action: &str,
id: &str,
name: &str,
subsystem: &str,
class: UdevClassification,
) -> Value {
json!({
"action": action,
"id": id,
"name": name,
"subsystem": subsystem,
"id_input_keyboard": class.id_input_keyboard,
"id_input_mouse": class.id_input_mouse,
"id_input_joystick": class.id_input_joystick,
"id_input_touchpad": class.id_input_touchpad,
"id_input_tablet": class.id_input_tablet,
"id_usb_class": class.id_usb_class,
"id_usb_interfaces": class.id_usb_interfaces,
"id_vendor": class.id_vendor,
"id_model": class.id_model,
"vendor_id": class.vendor_id,
"product_id": class.product_id,
})
}
fn prop_bool(device: &udev::Device, key: &str) -> bool {
device
.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
fn prop_str(device: &udev::Device, key: &str) -> Option<String> {
device
.property_value(key)
.map(|v| v.to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enumerate_payload_includes_classification_fields() {
// Boot-time enumerate used to send only {action,id,name,subsystem},
// so resolve_device could never match vendor/product/input rules
// and bread.state.devices stayed "unknown" until the next unplug.
let payload = udev_event_payload(
"add",
"/sys/devices/pci0000:00/usb1/1-3",
"Keychron K2",
"usb",
UdevClassification {
id_input_keyboard: true,
id_input_mouse: false,
id_input_joystick: false,
id_input_touchpad: false,
id_input_tablet: false,
id_usb_class: None,
id_usb_interfaces: None,
id_vendor: Some("Keychron".into()),
id_model: Some("Keychron K2".into()),
vendor_id: Some("3434".into()),
product_id: Some("d030".into()),
},
);
assert_eq!(payload["action"], "add");
assert_eq!(payload["id"], "/sys/devices/pci0000:00/usb1/1-3");
assert_eq!(payload["name"], "Keychron K2");
assert_eq!(payload["subsystem"], "usb");
assert_eq!(payload["vendor_id"], "3434");
assert_eq!(payload["product_id"], "d030");
assert_eq!(payload["id_vendor"], "Keychron");
assert_eq!(payload["id_model"], "Keychron K2");
assert_eq!(payload["id_input_keyboard"], true);
assert_eq!(payload["id_input_mouse"], false);
assert_eq!(payload["id_input_joystick"], false);
assert_eq!(payload["id_input_touchpad"], false);
assert_eq!(payload["id_input_tablet"], false);
assert!(payload["id_usb_class"].is_null());
assert!(payload["id_usb_interfaces"].is_null());
}
}