Some checks failed
dev release / build (push) Failing after 7s
- io.rs: factor the pending-RPC-replies map into a PendingReplies type alias (type_complexity) - lua_env.rs: drop a redundant i64->i64 cast and an unneeded borrow before create_string (unnecessary_cast, needless_borrows_for_generic_args) - state_engine.rs: iterate watches.values() instead of discarding the key from watches.iter() (for_kv_map) - module_host_sandbox.rs: collapse nested if into a single condition (collapsible_if) (cherry picked from commit 49063cb98ddfeae35d842a6cf439123cfda763d9)
1262 lines
43 KiB
Rust
1262 lines
43 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use bread_shared::{AdapterSource, BreadEvent};
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
|
use tracing::warn;
|
|
|
|
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
|
use crate::core::types::{
|
|
Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState,
|
|
};
|
|
use crate::lua::LuaMessage;
|
|
|
|
#[derive(Clone)]
|
|
pub struct StateHandle {
|
|
state: Arc<RwLock<RuntimeState>>,
|
|
command_tx: mpsc::UnboundedSender<StateCommand>,
|
|
}
|
|
|
|
pub enum StateCommand {
|
|
RegisterSubscription {
|
|
id: SubscriptionId,
|
|
pattern: String,
|
|
once: bool,
|
|
},
|
|
RemoveSubscription {
|
|
id: SubscriptionId,
|
|
},
|
|
RegisterWatch {
|
|
id: SubscriptionId,
|
|
path: String,
|
|
},
|
|
RemoveWatch {
|
|
id: SubscriptionId,
|
|
},
|
|
ClearSubscriptions,
|
|
ClearModules,
|
|
ClearWidgets,
|
|
SetModuleStatus {
|
|
name: String,
|
|
status: ModuleLoadState,
|
|
last_error: Option<String>,
|
|
builtin: bool,
|
|
ungated: bool,
|
|
},
|
|
SetProfile {
|
|
name: String,
|
|
},
|
|
SetDeviceRules(Vec<DeviceRule>),
|
|
}
|
|
|
|
impl StateHandle {
|
|
pub fn new(
|
|
state: Arc<RwLock<RuntimeState>>,
|
|
command_tx: mpsc::UnboundedSender<StateCommand>,
|
|
) -> Self {
|
|
Self { state, command_tx }
|
|
}
|
|
|
|
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
|
|
self.state.clone()
|
|
}
|
|
|
|
pub async fn state_get(&self, path: &str) -> Option<Value> {
|
|
let state = self.state.read().await;
|
|
let full = serde_json::to_value(&*state).ok()?;
|
|
|
|
if path.is_empty() {
|
|
return Some(full);
|
|
}
|
|
|
|
let mut current = &full;
|
|
for part in path.split('.') {
|
|
current = current.get(part)?;
|
|
}
|
|
Some(current.clone())
|
|
}
|
|
|
|
pub async fn state_dump(&self) -> Value {
|
|
let state = self.state.read().await;
|
|
serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({}))
|
|
}
|
|
|
|
pub fn register_subscription(
|
|
&self,
|
|
id: SubscriptionId,
|
|
pattern: String,
|
|
once: bool,
|
|
) -> Result<()> {
|
|
self.command_tx
|
|
.send(StateCommand::RegisterSubscription { id, pattern, once })
|
|
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
|
|
}
|
|
|
|
pub fn remove_subscription(&self, id: SubscriptionId) {
|
|
let _ = self
|
|
.command_tx
|
|
.send(StateCommand::RemoveSubscription { id });
|
|
}
|
|
|
|
pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> {
|
|
self.command_tx
|
|
.send(StateCommand::RegisterWatch { id, path })
|
|
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
|
|
}
|
|
|
|
pub fn remove_watch(&self, id: SubscriptionId) {
|
|
let _ = self.command_tx.send(StateCommand::RemoveWatch { id });
|
|
}
|
|
|
|
pub fn clear_subscriptions(&self) {
|
|
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
|
|
}
|
|
|
|
pub fn clear_modules(&self) {
|
|
let _ = self.command_tx.send(StateCommand::ClearModules);
|
|
}
|
|
|
|
pub fn clear_widgets(&self) {
|
|
let _ = self.command_tx.send(StateCommand::ClearWidgets);
|
|
}
|
|
|
|
pub fn set_module_status(
|
|
&self,
|
|
name: String,
|
|
status: ModuleLoadState,
|
|
last_error: Option<String>,
|
|
builtin: bool,
|
|
) {
|
|
self.set_module_status_ex(name, status, last_error, builtin, false);
|
|
}
|
|
|
|
/// Same as [`set_module_status`](Self::set_module_status) but also
|
|
/// records whether the module is running with full, ungated `bread.*`
|
|
/// access (no `permissions` declared in its manifest). Kept as a
|
|
/// separate method rather than changing `set_module_status`'s signature
|
|
/// everywhere so call sites that don't yet know the answer (load
|
|
/// errors, disabled modules, etc.) don't have to thread a meaningless
|
|
/// value through.
|
|
pub fn set_module_status_ex(
|
|
&self,
|
|
name: String,
|
|
status: ModuleLoadState,
|
|
last_error: Option<String>,
|
|
builtin: bool,
|
|
ungated: bool,
|
|
) {
|
|
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
|
|
name,
|
|
status,
|
|
last_error,
|
|
builtin,
|
|
ungated,
|
|
});
|
|
}
|
|
|
|
pub fn set_profile(&self, name: String) {
|
|
let _ = self.command_tx.send(StateCommand::SetProfile { name });
|
|
}
|
|
|
|
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
|
|
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
|
|
}
|
|
}
|
|
|
|
pub async fn run_state_engine(
|
|
mut event_rx: mpsc::UnboundedReceiver<BreadEvent>,
|
|
mut command_rx: mpsc::UnboundedReceiver<StateCommand>,
|
|
state: Arc<RwLock<RuntimeState>>,
|
|
lua_tx: mpsc::UnboundedSender<LuaMessage>,
|
|
event_stream_tx: broadcast::Sender<BreadEvent>,
|
|
subscription_count: Arc<AtomicU64>,
|
|
mut shutdown_rx: watch::Receiver<bool>,
|
|
) {
|
|
let mut subscriptions = SubscriptionTable::default();
|
|
let mut watches: HashMap<SubscriptionId, String> = HashMap::new();
|
|
let mut device_rules: Vec<DeviceRule> = Vec::new();
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = shutdown_rx.changed() => {
|
|
if *shutdown_rx.borrow() {
|
|
break;
|
|
}
|
|
}
|
|
maybe_cmd = command_rx.recv() => {
|
|
let Some(cmd) = maybe_cmd else {
|
|
break;
|
|
};
|
|
if let StateCommand::SetDeviceRules(rules) = cmd {
|
|
device_rules = rules;
|
|
} else {
|
|
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
|
|
}
|
|
}
|
|
maybe_event = event_rx.recv() => {
|
|
let Some(mut event) = maybe_event else {
|
|
break;
|
|
};
|
|
|
|
// Resolve device name from user rules and patch the event data before
|
|
// any subscriber sees it, then emit the named companion event.
|
|
let device_event = if event.event == "bread.device.connected"
|
|
|| event.event == "bread.device.disconnected"
|
|
{
|
|
let is_disconnect = event.event == "bread.device.disconnected";
|
|
let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string();
|
|
|
|
// On disconnect, udev strips vendor/product identifiers from the event.
|
|
// Look up the device by id in the current state (it's still present
|
|
// because apply_event_to_state hasn't run yet) and reuse the stored name.
|
|
let device = if is_disconnect {
|
|
state.read().await
|
|
.devices.connected.iter()
|
|
.find(|d| d.id == id)
|
|
.map(|d| d.device.clone())
|
|
.unwrap_or_else(|| resolve_device(&device_rules, &event.data))
|
|
} else {
|
|
resolve_device(&device_rules, &event.data)
|
|
};
|
|
|
|
if let Some(data) = event.data.as_object_mut() {
|
|
data.insert("device".to_string(), Value::String(device.clone()));
|
|
}
|
|
let verb = if is_disconnect { "disconnected" } else { "connected" };
|
|
Some(BreadEvent::new(
|
|
format!("bread.device.{}.{}", device, verb),
|
|
AdapterSource::Udev,
|
|
json!({ "id": id, "device": device }),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (before_snapshot, after_snapshot) = if watches.is_empty() {
|
|
(None, None)
|
|
} else {
|
|
let mut guard = state.write().await;
|
|
let before = serde_json::to_value(&*guard).ok();
|
|
apply_event_to_state(&mut guard, &event);
|
|
let after = serde_json::to_value(&*guard).ok();
|
|
(before, after)
|
|
};
|
|
|
|
if watches.is_empty() {
|
|
let mut guard = state.write().await;
|
|
apply_event_to_state(&mut guard, &event);
|
|
}
|
|
|
|
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
|
|
|
if let Some(dev_ev) = device_event {
|
|
let mut guard = state.write().await;
|
|
apply_event_to_state(&mut guard, &dev_ev);
|
|
drop(guard);
|
|
dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
|
}
|
|
|
|
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
|
for path in watches.values() {
|
|
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
|
let new_val = value_at_path(&after, path).unwrap_or(Value::Null);
|
|
if old_val != new_val {
|
|
let synthetic = BreadEvent::new(
|
|
format!("bread.state.changed.{path}"),
|
|
AdapterSource::System,
|
|
json!({
|
|
"path": path,
|
|
"new": new_val,
|
|
"old": old_val,
|
|
}),
|
|
);
|
|
dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
warn!("state engine loop exited");
|
|
}
|
|
|
|
async fn handle_command(
|
|
cmd: StateCommand,
|
|
state: &Arc<RwLock<RuntimeState>>,
|
|
subscriptions: &mut SubscriptionTable,
|
|
watches: &mut HashMap<SubscriptionId, String>,
|
|
subscription_count: &Arc<AtomicU64>,
|
|
) {
|
|
match cmd {
|
|
StateCommand::RegisterSubscription { id, pattern, once } => {
|
|
subscriptions.add_with_id(id, pattern, once);
|
|
subscription_count.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
StateCommand::RemoveSubscription { id } => {
|
|
if subscriptions.remove(id) {
|
|
subscription_count.fetch_sub(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
StateCommand::RegisterWatch { id, path } => {
|
|
watches.insert(id, path);
|
|
}
|
|
StateCommand::RemoveWatch { id } => {
|
|
watches.remove(&id);
|
|
}
|
|
StateCommand::ClearSubscriptions => {
|
|
subscriptions.clear();
|
|
watches.clear();
|
|
subscription_count.store(0, Ordering::Relaxed);
|
|
}
|
|
StateCommand::ClearModules => {
|
|
state.write().await.modules.clear();
|
|
}
|
|
StateCommand::ClearWidgets => {
|
|
state.write().await.widgets.clear();
|
|
}
|
|
StateCommand::SetModuleStatus {
|
|
name,
|
|
status,
|
|
last_error,
|
|
builtin,
|
|
ungated,
|
|
} => {
|
|
let mut guard = state.write().await;
|
|
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
|
|
existing.status = status;
|
|
existing.last_error = last_error;
|
|
existing.builtin = builtin;
|
|
existing.ungated = ungated;
|
|
} else {
|
|
guard.modules.push(crate::core::types::ModuleStatus {
|
|
name,
|
|
status,
|
|
last_error,
|
|
builtin,
|
|
ungated,
|
|
store: HashMap::new(),
|
|
});
|
|
}
|
|
}
|
|
StateCommand::SetProfile { name } => {
|
|
let mut guard = state.write().await;
|
|
if guard.profile.active != name {
|
|
let previous = guard.profile.active.clone();
|
|
if guard.profile.history.len() >= 50 {
|
|
guard.profile.history.remove(0);
|
|
}
|
|
guard.profile.history.push(previous);
|
|
guard.profile.active = name;
|
|
}
|
|
}
|
|
StateCommand::SetDeviceRules(_) => {
|
|
// Handled directly in run_state_engine before this function is called.
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dispatch_event(
|
|
event: &BreadEvent,
|
|
subscriptions: &mut SubscriptionTable,
|
|
lua_tx: &mpsc::UnboundedSender<LuaMessage>,
|
|
event_stream_tx: &broadcast::Sender<BreadEvent>,
|
|
subscription_count: &Arc<AtomicU64>,
|
|
) {
|
|
let _ = event_stream_tx.send(event.clone());
|
|
|
|
let matches = subscriptions.match_event(&event.event);
|
|
for sub in &matches {
|
|
let _ = lua_tx.send(LuaMessage::Event {
|
|
subscription_id: sub.id,
|
|
event: event.clone(),
|
|
});
|
|
}
|
|
|
|
for sub in matches.into_iter().filter(|s| s.once) {
|
|
if subscriptions.remove(sub.id) {
|
|
subscription_count.fetch_sub(1, Ordering::Relaxed);
|
|
}
|
|
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
|
|
}
|
|
}
|
|
|
|
fn value_at_path(value: &Value, path: &str) -> Option<Value> {
|
|
if path.is_empty() {
|
|
return Some(value.clone());
|
|
}
|
|
let mut current = value;
|
|
for part in path.split('.') {
|
|
current = current.get(part)?;
|
|
}
|
|
Some(current.clone())
|
|
}
|
|
|
|
/// Logical Hyprland state key: both the legacy flat name and its
|
|
/// `bread.hyprland.*` sibling collapse to the same suffix
|
|
/// (`monitor.connected`, `workspace.changed`, …).
|
|
fn hyprland_state_key(event: &str) -> Option<&str> {
|
|
if let Some(rest) = event.strip_prefix("bread.hyprland.") {
|
|
return Some(rest);
|
|
}
|
|
match event {
|
|
"bread.monitor.connected" => Some("monitor.connected"),
|
|
"bread.monitor.disconnected" => Some("monitor.disconnected"),
|
|
"bread.workspace.changed" => Some("workspace.changed"),
|
|
"bread.workspace.created" => Some("workspace.created"),
|
|
"bread.workspace.destroyed" => Some("workspace.destroyed"),
|
|
"bread.window.focus.changed" => Some("window.focus.changed"),
|
|
"bread.window.focused" => Some("window.focused"),
|
|
"bread.window.opened" => Some("window.opened"),
|
|
"bread.window.closed" => Some("window.closed"),
|
|
"bread.window.moved" => Some("window.moved"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn json_stringish(value: Option<&Value>) -> Option<String> {
|
|
let value = value?;
|
|
if let Some(s) = value.as_str() {
|
|
return Some(s.to_string());
|
|
}
|
|
value.as_i64().map(|n| n.to_string())
|
|
}
|
|
|
|
fn workspace_id_from_data(data: &Value) -> Option<String> {
|
|
json_stringish(data.get("workspace"))
|
|
.or_else(|| json_stringish(data.get("id")))
|
|
.or_else(|| json_stringish(data.get("name")))
|
|
.or_else(|| {
|
|
data.get("data")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
})
|
|
}
|
|
|
|
fn active_window_from_data(data: &Value) -> Option<String> {
|
|
json_stringish(data.get("window"))
|
|
.or_else(|| json_stringish(data.get("class")))
|
|
.or_else(|| json_stringish(data.get("address")))
|
|
.or_else(|| {
|
|
data.get("data")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
})
|
|
}
|
|
|
|
fn upsert_monitor(state: &mut RuntimeState, data: &Value) {
|
|
let Some(name) = data.get("name").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
|
m.connected = true;
|
|
if let Some(res) = data.get("resolution").and_then(Value::as_str) {
|
|
m.resolution = Some(res.to_string());
|
|
}
|
|
if let Some(pos) = data.get("position").and_then(Value::as_str) {
|
|
m.position = Some(pos.to_string());
|
|
}
|
|
} else {
|
|
state.monitors.push(crate::core::types::Monitor {
|
|
name: name.to_string(),
|
|
connected: true,
|
|
resolution: data
|
|
.get("resolution")
|
|
.and_then(Value::as_str)
|
|
.map(ToString::to_string),
|
|
position: data
|
|
.get("position")
|
|
.and_then(Value::as_str)
|
|
.map(ToString::to_string),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn apply_hyprland_snapshot(state: &mut RuntimeState, data: &Value) {
|
|
if let Some(arr) = data.get("monitors").and_then(Value::as_array) {
|
|
state.monitors = arr
|
|
.iter()
|
|
.filter_map(|m| {
|
|
let name = m.get("name").and_then(Value::as_str)?;
|
|
let width = m.get("width").and_then(Value::as_u64);
|
|
let height = m.get("height").and_then(Value::as_u64);
|
|
let x = m.get("x").and_then(Value::as_i64);
|
|
let y = m.get("y").and_then(Value::as_i64);
|
|
let disabled = m.get("disabled").and_then(Value::as_bool).unwrap_or(false);
|
|
Some(crate::core::types::Monitor {
|
|
name: name.to_string(),
|
|
connected: !disabled,
|
|
resolution: match (width, height) {
|
|
(Some(w), Some(h)) => Some(format!("{w}x{h}")),
|
|
_ => None,
|
|
},
|
|
position: match (x, y) {
|
|
(Some(x), Some(y)) => Some(format!("{x}x{y}")),
|
|
_ => None,
|
|
},
|
|
})
|
|
})
|
|
.collect();
|
|
}
|
|
if let Some(arr) = data.get("workspaces").and_then(Value::as_array) {
|
|
state.workspaces = arr
|
|
.iter()
|
|
.filter_map(|ws| {
|
|
let id = json_stringish(ws.get("id")).or_else(|| json_stringish(ws.get("name")))?;
|
|
Some(crate::core::types::Workspace {
|
|
id,
|
|
monitor: json_stringish(ws.get("monitor")),
|
|
})
|
|
})
|
|
.collect();
|
|
}
|
|
if let Some(aw) = data.get("active_workspace") {
|
|
state.active_workspace =
|
|
json_stringish(aw.get("name")).or_else(|| json_stringish(aw.get("id")));
|
|
}
|
|
if let Some(win) = data.get("active_window") {
|
|
state.active_window = json_stringish(win.get("address"))
|
|
.or_else(|| json_stringish(win.get("class")))
|
|
.or_else(|| json_stringish(win.get("title")));
|
|
}
|
|
}
|
|
|
|
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
|
if event.event == "bread.hyprland.snapshot" {
|
|
apply_hyprland_snapshot(state, &event.data);
|
|
return;
|
|
}
|
|
|
|
if let Some(key) = hyprland_state_key(event.event.as_str()) {
|
|
match key {
|
|
"monitor.connected" => upsert_monitor(state, &event.data),
|
|
"monitor.disconnected" => {
|
|
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
|
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
|
m.connected = false;
|
|
}
|
|
}
|
|
}
|
|
"workspace.changed" => {
|
|
state.active_workspace = workspace_id_from_data(&event.data);
|
|
}
|
|
"workspace.created" => {
|
|
if let Some(id) = workspace_id_from_data(&event.data) {
|
|
if !state.workspaces.iter().any(|w| w.id == id) {
|
|
state.workspaces.push(crate::core::types::Workspace {
|
|
id,
|
|
monitor: json_stringish(event.data.get("monitor")),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
"workspace.destroyed" => {
|
|
if let Some(id) = workspace_id_from_data(&event.data) {
|
|
state.workspaces.retain(|w| w.id != id);
|
|
}
|
|
}
|
|
"window.focus.changed" | "window.focused" => {
|
|
state.active_window = active_window_from_data(&event.data);
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match event.event.as_str() {
|
|
"bread.device.connected" => {
|
|
apply_device_change(state, &event.data, true);
|
|
}
|
|
"bread.device.disconnected" => {
|
|
apply_device_change(state, &event.data, false);
|
|
}
|
|
"bread.network.connected" | "bread.network.disconnected" => {
|
|
if let Some(online) = event.data.get("online").and_then(Value::as_bool) {
|
|
state.network.online = online;
|
|
}
|
|
if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) {
|
|
state.network.interfaces.clear();
|
|
for (name, meta) in ifaces {
|
|
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
|
|
state
|
|
.network
|
|
.interfaces
|
|
.insert(name.clone(), InterfaceState { up });
|
|
}
|
|
}
|
|
}
|
|
"bread.power.changed"
|
|
| "bread.power.ac.connected"
|
|
| "bread.power.ac.disconnected"
|
|
| "bread.power.battery.low"
|
|
| "bread.power.battery.very_low"
|
|
| "bread.power.battery.critical"
|
|
| "bread.power.battery.full" => {
|
|
if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) {
|
|
state.power.ac_connected = ac;
|
|
}
|
|
if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) {
|
|
state.power.battery_percent = Some(battery.min(100) as u8);
|
|
state.power.battery_low = battery <= 20;
|
|
}
|
|
}
|
|
"bread.profile.activated" => {
|
|
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
|
if state.profile.active != name {
|
|
let previous = state.profile.active.clone();
|
|
if state.profile.history.len() >= 50 {
|
|
state.profile.history.remove(0);
|
|
}
|
|
state.profile.history.push(previous);
|
|
state.profile.active = name.to_string();
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
|
|
for rule in rules {
|
|
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data))
|
|
{
|
|
return rule.device.clone();
|
|
}
|
|
}
|
|
"unknown".to_string()
|
|
}
|
|
|
|
fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
|
if let Some(ref expected) = cond.vendor_id {
|
|
let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or("");
|
|
if actual.to_lowercase() != expected.to_lowercase() {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref expected) = cond.product_id {
|
|
let actual = data.get("product_id").and_then(Value::as_str).unwrap_or("");
|
|
if actual.to_lowercase() != expected.to_lowercase() {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref expected) = cond.name {
|
|
let actual = data
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if actual != expected.to_lowercase() {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref expected) = cond.vendor {
|
|
let actual = data
|
|
.get("vendor")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if actual != expected.to_lowercase() {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref contains) = cond.name_contains {
|
|
let name = data
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let vendor = data
|
|
.get("vendor")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let combined = format!("{name} {vendor}");
|
|
if !combined.contains(contains.to_lowercase().as_str()) {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(expected) = cond.id_input_keyboard {
|
|
if data
|
|
.get("id_input_keyboard")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
!= expected
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(expected) = cond.id_input_mouse {
|
|
if data
|
|
.get("id_input_mouse")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
!= expected
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(expected) = cond.id_input_tablet {
|
|
if data
|
|
.get("id_input_tablet")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
!= expected
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
if cond.usb_hub == Some(true) {
|
|
let ifaces = data
|
|
.get("id_usb_interfaces")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902");
|
|
let has_secondary = ifaces.contains(":0e")
|
|
|| ifaces.contains(":0200")
|
|
|| ifaces.contains(":0100")
|
|
|| ifaces.contains(":0801");
|
|
if !(has_hub && has_secondary) {
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref expected) = cond.id_usb_class {
|
|
let actual = data
|
|
.get("id_usb_class")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
if actual.to_lowercase() != expected.to_lowercase()
|
|
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(ref expected) = cond.subsystem {
|
|
let actual = data
|
|
.get("subsystem")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if actual != expected.to_lowercase() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
|
|
let id = data
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
if connected {
|
|
if state.devices.connected.iter().any(|d| d.id == id) {
|
|
return;
|
|
}
|
|
|
|
let device = data
|
|
.get("device")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
state.devices.connected.push(Device {
|
|
id,
|
|
name: data
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
device,
|
|
subsystem: data
|
|
.get("subsystem")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
vendor_id: data
|
|
.get("vendor_id")
|
|
.and_then(Value::as_str)
|
|
.map(ToString::to_string),
|
|
product_id: data
|
|
.get("product_id")
|
|
.and_then(Value::as_str)
|
|
.map(ToString::to_string),
|
|
});
|
|
} else {
|
|
state.devices.connected.retain(|d| d.id != id);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ev(name: &str, data: Value) -> BreadEvent {
|
|
BreadEvent {
|
|
event: name.to_string(),
|
|
timestamp: 0,
|
|
source: AdapterSource::System,
|
|
data,
|
|
id: bread_shared::new_event_id(),
|
|
caused_by: None,
|
|
}
|
|
}
|
|
|
|
// ─── value_at_path ────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn value_at_path_returns_root_for_empty_path() {
|
|
let v = json!({"a": 1});
|
|
assert_eq!(value_at_path(&v, ""), Some(json!({"a": 1})));
|
|
}
|
|
|
|
#[test]
|
|
fn value_at_path_navigates_nested_keys() {
|
|
let v = json!({"a": {"b": {"c": 42}}});
|
|
assert_eq!(value_at_path(&v, "a.b.c"), Some(json!(42)));
|
|
}
|
|
|
|
#[test]
|
|
fn value_at_path_returns_none_on_missing_key() {
|
|
let v = json!({"a": 1});
|
|
assert!(value_at_path(&v, "missing").is_none());
|
|
assert!(value_at_path(&v, "a.b.c").is_none());
|
|
}
|
|
|
|
// ─── apply_event_to_state: monitors ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn monitor_connect_adds_new_monitor() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.monitor.connected",
|
|
json!({"name": "DP-1", "resolution": "1920x1080", "position": "0x0"}),
|
|
),
|
|
);
|
|
assert_eq!(state.monitors.len(), 1);
|
|
assert_eq!(state.monitors[0].name, "DP-1");
|
|
assert!(state.monitors[0].connected);
|
|
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1080"));
|
|
assert_eq!(state.monitors[0].position.as_deref(), Some("0x0"));
|
|
}
|
|
|
|
#[test]
|
|
fn monitor_reconnect_does_not_duplicate() {
|
|
let mut state = RuntimeState::default();
|
|
let mk = || ev("bread.monitor.connected", json!({"name": "DP-1"}));
|
|
apply_event_to_state(&mut state, &mk());
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.monitor.disconnected", json!({"name": "DP-1"})),
|
|
);
|
|
apply_event_to_state(&mut state, &mk());
|
|
assert_eq!(state.monitors.len(), 1);
|
|
assert!(state.monitors[0].connected);
|
|
}
|
|
|
|
#[test]
|
|
fn monitor_disconnect_keeps_record_but_flips_connected_flag() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.monitor.connected", json!({"name": "DP-1"})),
|
|
);
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.monitor.disconnected", json!({"name": "DP-1"})),
|
|
);
|
|
assert_eq!(state.monitors.len(), 1);
|
|
assert!(!state.monitors[0].connected);
|
|
}
|
|
|
|
// ─── apply_event_to_state: workspace + window ─────────────────────────
|
|
|
|
#[test]
|
|
fn workspace_changed_updates_active_workspace() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.workspace.changed", json!({"workspace": "3"})),
|
|
);
|
|
assert_eq!(state.active_workspace.as_deref(), Some("3"));
|
|
// Falls back to `id` when `workspace` is absent.
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.workspace.changed", json!({"id": "5"})),
|
|
);
|
|
assert_eq!(state.active_workspace.as_deref(), Some("5"));
|
|
}
|
|
|
|
#[test]
|
|
fn window_focus_change_updates_active_window() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.window.focus.changed", json!({"window": "firefox"})),
|
|
);
|
|
assert_eq!(state.active_window.as_deref(), Some("firefox"));
|
|
// Falls back to `class`, then `address`.
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.window.focused", json!({"address": "0xdeadbeef"})),
|
|
);
|
|
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
|
|
}
|
|
|
|
#[test]
|
|
fn namespaced_hyprland_events_update_the_same_state() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.hyprland.monitor.connected",
|
|
json!({"name": "HDMI-A-1"}),
|
|
),
|
|
);
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.hyprland.workspace.changed", json!({"id": 4})),
|
|
);
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.hyprland.window.focus.changed",
|
|
json!({"kind": "activewindow", "data": "kitty,foo"}),
|
|
),
|
|
);
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.hyprland.workspace.created", json!({"workspace": "9"})),
|
|
);
|
|
assert_eq!(state.monitors.len(), 1);
|
|
assert_eq!(state.monitors[0].name, "HDMI-A-1");
|
|
assert_eq!(state.active_workspace.as_deref(), Some("4"));
|
|
assert_eq!(state.active_window.as_deref(), Some("kitty"));
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].id, "9");
|
|
}
|
|
|
|
#[test]
|
|
fn hyprland_snapshot_replaces_topology() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.monitor.connected", json!({"name": "stale"})),
|
|
);
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.hyprland.snapshot",
|
|
json!({
|
|
"monitors": [{"name": "eDP-1", "width": 1920, "height": 1200, "x": 0, "y": 0}],
|
|
"workspaces": [{"id": 1, "name": "1", "monitor": "eDP-1"}],
|
|
"active_workspace": {"id": 1, "name": "1"},
|
|
"active_window": {"address": "0xabc", "class": "kitty"}
|
|
}),
|
|
),
|
|
);
|
|
assert_eq!(state.monitors.len(), 1);
|
|
assert_eq!(state.monitors[0].name, "eDP-1");
|
|
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1200"));
|
|
assert_eq!(state.workspaces.len(), 1);
|
|
assert_eq!(state.workspaces[0].id, "1");
|
|
assert_eq!(state.active_workspace.as_deref(), Some("1"));
|
|
assert_eq!(state.active_window.as_deref(), Some("0xabc"));
|
|
}
|
|
|
|
// ─── apply_device_change ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn device_connect_adds_device_with_all_fields() {
|
|
let mut state = RuntimeState::default();
|
|
apply_device_change(
|
|
&mut state,
|
|
&json!({
|
|
"id": "1-1.4",
|
|
"name": "Logitech Mouse",
|
|
"device": "mouse",
|
|
"subsystem": "usb",
|
|
"vendor_id": "046d",
|
|
"product_id": "c52b",
|
|
}),
|
|
true,
|
|
);
|
|
assert_eq!(state.devices.connected.len(), 1);
|
|
let d = &state.devices.connected[0];
|
|
assert_eq!(d.id, "1-1.4");
|
|
assert_eq!(d.name, "Logitech Mouse");
|
|
assert_eq!(d.device, "mouse");
|
|
assert_eq!(d.subsystem, "usb");
|
|
assert_eq!(d.vendor_id.as_deref(), Some("046d"));
|
|
assert_eq!(d.product_id.as_deref(), Some("c52b"));
|
|
}
|
|
|
|
#[test]
|
|
fn device_connect_is_idempotent_for_same_id() {
|
|
let mut state = RuntimeState::default();
|
|
let data = json!({"id": "x", "device": "dock", "name": "Dock"});
|
|
apply_device_change(&mut state, &data, true);
|
|
apply_device_change(&mut state, &data, true);
|
|
assert_eq!(state.devices.connected.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn device_disconnect_removes_matching_id() {
|
|
let mut state = RuntimeState::default();
|
|
apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true);
|
|
apply_device_change(&mut state, &json!({"id": "b", "device": "y"}), true);
|
|
assert_eq!(state.devices.connected.len(), 2);
|
|
|
|
apply_device_change(&mut state, &json!({"id": "a"}), false);
|
|
assert_eq!(state.devices.connected.len(), 1);
|
|
assert_eq!(state.devices.connected[0].id, "b");
|
|
}
|
|
|
|
#[test]
|
|
fn device_disconnect_of_unknown_id_is_noop() {
|
|
let mut state = RuntimeState::default();
|
|
apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true);
|
|
apply_device_change(&mut state, &json!({"id": "ghost"}), false);
|
|
assert_eq!(state.devices.connected.len(), 1);
|
|
}
|
|
|
|
// ─── apply_event_to_state: power ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn power_event_updates_ac_and_battery_low_flag() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.power.battery.low",
|
|
json!({"ac_connected": false, "battery_percent": 18}),
|
|
),
|
|
);
|
|
assert!(!state.power.ac_connected);
|
|
assert_eq!(state.power.battery_percent, Some(18));
|
|
assert!(state.power.battery_low);
|
|
|
|
// 25% is no longer "low"
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.power.changed", json!({"battery_percent": 25})),
|
|
);
|
|
assert!(!state.power.battery_low);
|
|
}
|
|
|
|
#[test]
|
|
fn power_clamps_battery_percent_to_100() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.power.changed", json!({"battery_percent": 250u64})),
|
|
);
|
|
assert_eq!(state.power.battery_percent, Some(100));
|
|
}
|
|
|
|
// ─── apply_event_to_state: network ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn network_event_updates_online_flag_and_interfaces() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev(
|
|
"bread.network.connected",
|
|
json!({
|
|
"online": true,
|
|
"interfaces": {
|
|
"wlan0": {"up": true},
|
|
"eth0": {"up": false},
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
assert!(state.network.online);
|
|
assert_eq!(state.network.interfaces.len(), 2);
|
|
assert!(state.network.interfaces["wlan0"].up);
|
|
assert!(!state.network.interfaces["eth0"].up);
|
|
}
|
|
|
|
// ─── apply_event_to_state: profile ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn profile_activated_pushes_previous_to_history() {
|
|
let mut state = RuntimeState::default();
|
|
// Initial active is "default".
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.profile.activated", json!({"name": "battery"})),
|
|
);
|
|
assert_eq!(state.profile.active, "battery");
|
|
assert_eq!(state.profile.history, vec!["default"]);
|
|
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.profile.activated", json!({"name": "ac"})),
|
|
);
|
|
assert_eq!(state.profile.active, "ac");
|
|
assert_eq!(state.profile.history, vec!["default", "battery"]);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_activated_to_same_name_is_noop() {
|
|
let mut state = RuntimeState::default();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.profile.activated", json!({"name": "default"})),
|
|
);
|
|
assert_eq!(state.profile.active, "default");
|
|
assert!(state.profile.history.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_event_does_not_mutate_state() {
|
|
let mut state = RuntimeState::default();
|
|
let before = serde_json::to_value(&state).unwrap();
|
|
apply_event_to_state(
|
|
&mut state,
|
|
&ev("bread.unknown.event", json!({"foo": "bar"})),
|
|
);
|
|
let after = serde_json::to_value(&state).unwrap();
|
|
assert_eq!(before, after);
|
|
}
|
|
|
|
// ─── condition_matches ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn condition_vendor_id_matches_case_insensitively() {
|
|
let cond = MatchCondition {
|
|
vendor_id: Some("046D".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(condition_matches(&cond, &json!({"vendor_id": "046d"})));
|
|
assert!(!condition_matches(&cond, &json!({"vendor_id": "1234"})));
|
|
}
|
|
|
|
#[test]
|
|
fn condition_name_contains_searches_name_and_vendor() {
|
|
let cond = MatchCondition {
|
|
name_contains: Some("logi".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(condition_matches(&cond, &json!({"name": "Logitech MX"})));
|
|
assert!(condition_matches(&cond, &json!({"vendor": "Logitech Inc"})));
|
|
assert!(!condition_matches(&cond, &json!({"name": "Apple"})));
|
|
}
|
|
|
|
#[test]
|
|
fn condition_input_flags_match_booleans() {
|
|
let cond = MatchCondition {
|
|
id_input_keyboard: Some(true),
|
|
..Default::default()
|
|
};
|
|
assert!(condition_matches(
|
|
&cond,
|
|
&json!({"id_input_keyboard": true})
|
|
));
|
|
assert!(!condition_matches(
|
|
&cond,
|
|
&json!({"id_input_keyboard": false})
|
|
));
|
|
// Missing field defaults to false.
|
|
assert!(!condition_matches(&cond, &json!({})));
|
|
}
|
|
|
|
#[test]
|
|
fn condition_usb_hub_requires_hub_and_secondary_class() {
|
|
let cond = MatchCondition {
|
|
usb_hub: Some(true),
|
|
..Default::default()
|
|
};
|
|
assert!(condition_matches(
|
|
&cond,
|
|
&json!({"id_usb_interfaces": ":0900:0e00:"})
|
|
));
|
|
// Hub alone is not enough.
|
|
assert!(!condition_matches(
|
|
&cond,
|
|
&json!({"id_usb_interfaces": ":0900:"})
|
|
));
|
|
// Secondary alone is not enough.
|
|
assert!(!condition_matches(
|
|
&cond,
|
|
&json!({"id_usb_interfaces": ":0e00:"})
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn condition_id_usb_class_accepts_with_or_without_0x_prefix() {
|
|
let cond = MatchCondition {
|
|
id_usb_class: Some("0e".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(condition_matches(&cond, &json!({"id_usb_class": "0e"})));
|
|
assert!(condition_matches(&cond, &json!({"id_usb_class": "0x0e"})));
|
|
assert!(!condition_matches(&cond, &json!({"id_usb_class": "ff"})));
|
|
}
|
|
|
|
#[test]
|
|
fn condition_empty_matches_anything() {
|
|
let cond = MatchCondition::default();
|
|
assert!(condition_matches(&cond, &json!({})));
|
|
assert!(condition_matches(&cond, &json!({"vendor_id": "anything"})));
|
|
}
|
|
|
|
// ─── resolve_device ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn resolve_device_returns_first_matching_rule() {
|
|
let rules = vec![
|
|
DeviceRule {
|
|
device: "mouse".to_string(),
|
|
conditions: vec![MatchCondition {
|
|
vendor_id: Some("046d".to_string()),
|
|
..Default::default()
|
|
}],
|
|
},
|
|
DeviceRule {
|
|
device: "dock".to_string(),
|
|
conditions: vec![MatchCondition {
|
|
vendor_id: Some("17ef".to_string()),
|
|
..Default::default()
|
|
}],
|
|
},
|
|
];
|
|
assert_eq!(
|
|
resolve_device(&rules, &json!({"vendor_id": "046d"})),
|
|
"mouse"
|
|
);
|
|
assert_eq!(
|
|
resolve_device(&rules, &json!({"vendor_id": "17ef"})),
|
|
"dock"
|
|
);
|
|
assert_eq!(
|
|
resolve_device(&rules, &json!({"vendor_id": "0000"})),
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_device_skips_rules_with_no_conditions() {
|
|
let rules = vec![DeviceRule {
|
|
device: "wildcard".to_string(),
|
|
conditions: vec![],
|
|
}];
|
|
assert_eq!(resolve_device(&rules, &json!({})), "unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_device_with_empty_ruleset_returns_unknown() {
|
|
assert_eq!(resolve_device(&[], &json!({"vendor_id": "x"})), "unknown");
|
|
}
|
|
}
|