Release 1.0
This commit is contained in:
parent
009ea6da0e
commit
730a8b61d7
32 changed files with 6629 additions and 0 deletions
228
breadd/src/core/config.rs
Normal file
228
breadd/src/core/config.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfig,
|
||||
#[serde(default)]
|
||||
pub lua: LuaConfig,
|
||||
#[serde(default)]
|
||||
pub adapters: AdaptersConfig,
|
||||
#[serde(default)]
|
||||
pub events: EventsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DaemonConfig {
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
#[serde(default)]
|
||||
pub socket_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LuaConfig {
|
||||
#[serde(default = "default_lua_entry")]
|
||||
pub entry_point: String,
|
||||
#[serde(default = "default_lua_modules")]
|
||||
pub module_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AdaptersConfig {
|
||||
#[serde(default)]
|
||||
pub hyprland: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub udev: UdevConfig,
|
||||
#[serde(default)]
|
||||
pub power: PowerConfig,
|
||||
#[serde(default)]
|
||||
pub network: AdapterToggle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AdapterToggle {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UdevConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_udev_subsystems")]
|
||||
pub subsystems: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PowerConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_poll_interval")]
|
||||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct EventsConfig {
|
||||
#[serde(default = "default_dedup_window")]
|
||||
pub dedup_window_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daemon: DaemonConfig::default(),
|
||||
lua: LuaConfig::default(),
|
||||
adapters: AdaptersConfig::default(),
|
||||
events: EventsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_level: default_log_level(),
|
||||
socket_path: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LuaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entry_point: default_lua_entry(),
|
||||
module_path: default_lua_modules(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdaptersConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hyprland: AdapterToggle::default(),
|
||||
udev: UdevConfig::default(),
|
||||
power: PowerConfig::default(),
|
||||
network: AdapterToggle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdapterToggle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UdevConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
subsystems: default_udev_subsystems(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PowerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
poll_interval_secs: default_poll_interval(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dedup_window_ms: default_dedup_window(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&path)?;
|
||||
let cfg: Config = toml::from_str(&raw)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> PathBuf {
|
||||
if !self.daemon.socket_path.is_empty() {
|
||||
return expand_home(&self.daemon.socket_path);
|
||||
}
|
||||
|
||||
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
Path::new(&runtime_dir).join("bread").join("breadd.sock")
|
||||
}
|
||||
|
||||
pub fn lua_entry_point(&self) -> PathBuf {
|
||||
expand_home(&self.lua.entry_point)
|
||||
}
|
||||
|
||||
pub fn lua_module_path(&self) -> PathBuf {
|
||||
expand_home(&self.lua.module_path)
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg).join("bread").join("breadd.toml");
|
||||
}
|
||||
|
||||
expand_home("~/.config/bread/breadd.toml")
|
||||
}
|
||||
|
||||
fn expand_home(input: &str) -> PathBuf {
|
||||
if let Some(stripped) = input.strip_prefix("~/") {
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return Path::new(&home).join(stripped);
|
||||
}
|
||||
}
|
||||
PathBuf::from(input)
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn default_lua_entry() -> String {
|
||||
"~/.config/bread/init.lua".to_string()
|
||||
}
|
||||
|
||||
fn default_lua_modules() -> String {
|
||||
"~/.config/bread/modules".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_poll_interval() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_dedup_window() -> u64 {
|
||||
100
|
||||
}
|
||||
|
||||
fn default_udev_subsystems() -> Vec<String> {
|
||||
vec![
|
||||
"usb".to_string(),
|
||||
"input".to_string(),
|
||||
"drm".to_string(),
|
||||
"power_supply".to_string(),
|
||||
]
|
||||
}
|
||||
6
breadd/src/core/mod.rs
Normal file
6
breadd/src/core/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod normalizer;
|
||||
pub mod state_engine;
|
||||
pub mod subscriptions;
|
||||
pub mod supervisor;
|
||||
pub mod types;
|
||||
213
breadd/src/core/normalizer.rs
Normal file
213
breadd/src/core/normalizer.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::core::types::DeviceClass;
|
||||
|
||||
pub struct EventNormalizer {
|
||||
dedup_window_ms: u64,
|
||||
recent: Mutex<HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
impl EventNormalizer {
|
||||
pub fn new(dedup_window_ms: u64) -> Self {
|
||||
Self {
|
||||
dedup_window_ms,
|
||||
recent: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut out = match raw.source {
|
||||
AdapterSource::Udev => self.normalize_udev(raw),
|
||||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||
AdapterSource::Power => self.normalize_power(raw),
|
||||
AdapterSource::Network => self.normalize_network(raw),
|
||||
AdapterSource::System => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
};
|
||||
|
||||
out.retain(|ev| self.accept(ev));
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
|
||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let class = classify_device(&raw.payload);
|
||||
let class_str = serde_json::to_string(&class)
|
||||
.unwrap_or_else(|_| "\"unknown\"".to_string())
|
||||
.replace('"', "");
|
||||
|
||||
let verb = match action {
|
||||
"add" => "connected",
|
||||
"remove" => "disconnected",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
let mut events = vec![BreadEvent {
|
||||
event: format!("bread.device.{}", verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}];
|
||||
|
||||
events.push(BreadEvent {
|
||||
event: format!("bread.device.{}.{}", class_str, verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
}),
|
||||
});
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let mapped = match kind {
|
||||
"workspace" | "workspacev2" => "bread.workspace.changed",
|
||||
"monitoradded" => "bread.monitor.connected",
|
||||
"monitorremoved" => "bread.monitor.disconnected",
|
||||
"activewindow" | "activewindowv2" => "bread.window.focus.changed",
|
||||
"openwindow" => "bread.window.opened",
|
||||
"closewindow" => "bread.window.closed",
|
||||
_ => "bread.hyprland.event",
|
||||
};
|
||||
|
||||
vec![BreadEvent {
|
||||
event: mapped.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) {
|
||||
events.push(BreadEvent {
|
||||
event: if ac {
|
||||
"bread.power.ac.connected".to_string()
|
||||
} else {
|
||||
"bread.power.ac.disconnected".to_string()
|
||||
},
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) {
|
||||
let battery_event = if level <= 5 {
|
||||
Some("bread.power.battery.critical")
|
||||
} else if level <= 10 {
|
||||
Some("bread.power.battery.very_low")
|
||||
} else if level <= 20 {
|
||||
Some("bread.power.battery.low")
|
||||
} else if level >= 100 {
|
||||
Some("bread.power.battery.full")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(event) = battery_event {
|
||||
events.push(BreadEvent {
|
||||
event: event.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
events.push(BreadEvent {
|
||||
event: "bread.power.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
|
||||
let name = if online {
|
||||
"bread.network.connected"
|
||||
} else {
|
||||
"bread.network.disconnected"
|
||||
};
|
||||
|
||||
vec![BreadEvent {
|
||||
event: name.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Network,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn accept(&self, event: &BreadEvent) -> bool {
|
||||
let key = format!("{}:{}", event.event, event.data);
|
||||
let mut recent = self.recent.lock().expect("normalizer dedup mutex poisoned");
|
||||
let now = event.timestamp;
|
||||
|
||||
if let Some(last) = recent.get(&key) {
|
||||
if now.saturating_sub(*last) < self.dedup_window_ms {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
recent.insert(key, now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
return DeviceClass::Keyboard;
|
||||
}
|
||||
if subsystem == "input" && name.contains("mouse") {
|
||||
return DeviceClass::Mouse;
|
||||
}
|
||||
if subsystem == "drm" {
|
||||
return DeviceClass::Display;
|
||||
}
|
||||
if subsystem == "sound" || name.contains("audio") {
|
||||
return DeviceClass::Audio;
|
||||
}
|
||||
if subsystem == "block" || name.contains("storage") {
|
||||
return DeviceClass::Storage;
|
||||
}
|
||||
|
||||
DeviceClass::Unknown
|
||||
}
|
||||
304
breadd/src/core/state_engine.rs
Normal file
304
breadd/src/core/state_engine.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use bread_shared::BreadEvent;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
||||
use crate::core::types::{Device, DeviceClass, InterfaceState, 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,
|
||||
},
|
||||
ClearSubscriptions,
|
||||
SetModuleStatus {
|
||||
name: String,
|
||||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
},
|
||||
SetProfile {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
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 clear_subscriptions(&self) {
|
||||
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
|
||||
}
|
||||
|
||||
pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option<String>) {
|
||||
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_profile(&self, name: String) {
|
||||
let _ = self.command_tx.send(StateCommand::SetProfile { name });
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut subscriptions = SubscriptionTable::default();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
maybe_cmd = command_rx.recv() => {
|
||||
let Some(cmd) = maybe_cmd else {
|
||||
break;
|
||||
};
|
||||
handle_command(cmd, &state, &mut subscriptions).await;
|
||||
}
|
||||
maybe_event = event_rx.recv() => {
|
||||
let Some(event) = maybe_event else {
|
||||
break;
|
||||
};
|
||||
|
||||
apply_event_to_state(&state, &event).await;
|
||||
|
||||
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) {
|
||||
subscriptions.remove(sub.id);
|
||||
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("state engine loop exited");
|
||||
}
|
||||
|
||||
async fn handle_command(
|
||||
cmd: StateCommand,
|
||||
state: &Arc<RwLock<RuntimeState>>,
|
||||
subscriptions: &mut SubscriptionTable,
|
||||
) {
|
||||
match cmd {
|
||||
StateCommand::RegisterSubscription { id, pattern, once } => {
|
||||
subscriptions.add_with_id(id, pattern, once);
|
||||
}
|
||||
StateCommand::ClearSubscriptions => {
|
||||
subscriptions.clear();
|
||||
}
|
||||
StateCommand::SetModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
} => {
|
||||
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;
|
||||
} else {
|
||||
guard.modules.push(crate::core::types::ModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
StateCommand::SetProfile { name } => {
|
||||
let mut guard = state.write().await;
|
||||
if guard.profile.active != name {
|
||||
let previous = guard.profile.active.clone();
|
||||
guard.profile.history.push(previous);
|
||||
guard.profile.active = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_event_to_state(state: &Arc<RwLock<RuntimeState>>, event: &BreadEvent) {
|
||||
let mut guard = state.write().await;
|
||||
match event.event.as_str() {
|
||||
"bread.monitor.connected" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = true;
|
||||
} else {
|
||||
guard.monitors.push(crate::core::types::Monitor {
|
||||
name: name.to_string(),
|
||||
connected: true,
|
||||
resolution: event.data.get("resolution").and_then(Value::as_str).map(ToString::to_string),
|
||||
position: event.data.get("position").and_then(Value::as_str).map(ToString::to_string),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"bread.monitor.disconnected" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bread.workspace.changed" => {
|
||||
let ws = event
|
||||
.data
|
||||
.get("workspace")
|
||||
.or_else(|| event.data.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
guard.active_workspace = ws;
|
||||
}
|
||||
"bread.window.focus.changed" => {
|
||||
guard.active_window = event
|
||||
.data
|
||||
.get("window")
|
||||
.or_else(|| event.data.get("class"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
}
|
||||
"bread.device.connected" => {
|
||||
apply_device_change(&mut guard, &event.data, true);
|
||||
}
|
||||
"bread.device.disconnected" => {
|
||||
apply_device_change(&mut guard, &event.data, false);
|
||||
}
|
||||
"bread.network.connected" | "bread.network.disconnected" => {
|
||||
if let Some(online) = event.data.get("online").and_then(Value::as_bool) {
|
||||
guard.network.online = online;
|
||||
}
|
||||
if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) {
|
||||
guard.network.interfaces.clear();
|
||||
for (name, meta) in ifaces {
|
||||
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
|
||||
guard.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) {
|
||||
guard.power.ac_connected = ac;
|
||||
}
|
||||
if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) {
|
||||
guard.power.battery_percent = Some(battery.min(100) as u8);
|
||||
guard.power.battery_low = battery <= 20;
|
||||
}
|
||||
}
|
||||
"bread.profile.activated" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if guard.profile.active != name {
|
||||
let previous = guard.profile.active.clone();
|
||||
guard.profile.history.push(previous);
|
||||
guard.profile.active = name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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 class = data
|
||||
.get("class")
|
||||
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
|
||||
.unwrap_or(DeviceClass::Unknown);
|
||||
|
||||
state.devices.connected.push(Device {
|
||||
id,
|
||||
name: data
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
class,
|
||||
subsystem: data
|
||||
.get("subsystem")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
});
|
||||
} else {
|
||||
state.devices.connected.retain(|d| d.id != id);
|
||||
}
|
||||
}
|
||||
63
breadd/src/core/subscriptions.rs
Normal file
63
breadd/src/core/subscriptions.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
||||
pub struct SubscriptionId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Subscription {
|
||||
pub id: SubscriptionId,
|
||||
pub pattern: String,
|
||||
pub once: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct SubscriptionTable {
|
||||
entries: Vec<Subscription>,
|
||||
by_id: HashMap<SubscriptionId, usize>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl SubscriptionTable {
|
||||
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId {
|
||||
self.next_id = self.next_id.max(id.0.saturating_add(1));
|
||||
|
||||
let sub = Subscription { id, pattern, once };
|
||||
self.entries.push(sub);
|
||||
self.by_id.insert(id, self.entries.len() - 1);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: SubscriptionId) -> bool {
|
||||
let Some(idx) = self.by_id.remove(&id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.entries.swap_remove(idx);
|
||||
if let Some(swapped) = self.entries.get(idx) {
|
||||
self.by_id.insert(swapped.id, idx);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.by_id.clear();
|
||||
}
|
||||
|
||||
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|sub| matches_pattern(&sub.pattern, event_name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_pattern(pattern: &str, event_name: &str) -> bool {
|
||||
if pattern.ends_with(".*") {
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
return event_name.starts_with(prefix);
|
||||
}
|
||||
|
||||
pattern == event_name
|
||||
}
|
||||
65
breadd/src/core/supervisor.rs
Normal file
65
breadd/src/core/supervisor.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use std::future::Future;
|
||||
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub fn spawn_supervised<F, Fut>(
|
||||
name: &'static str,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
mut task_factory: F,
|
||||
)
|
||||
where
|
||||
F: FnMut() -> Fut + Send + 'static,
|
||||
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!(adapter = name, "shutdown requested");
|
||||
break;
|
||||
}
|
||||
|
||||
let result = tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!(adapter = name, "shutdown requested");
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result = task_factory() => result,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!(adapter = name, "adapter task exited cleanly");
|
||||
attempt = 0;
|
||||
}
|
||||
Err(err) => {
|
||||
error!(adapter = name, error = %err, "adapter task failed");
|
||||
attempt = attempt.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
if *shutdown_rx.borrow() {
|
||||
info!(adapter = name, "shutdown requested");
|
||||
break;
|
||||
}
|
||||
|
||||
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
|
||||
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure");
|
||||
tokio::select! {
|
||||
_ = sleep(Duration::from_millis(wait_ms)) => {},
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!(adapter = name, "shutdown requested");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
132
breadd/src/core/types.rs
Normal file
132
breadd/src/core/types.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeState {
|
||||
pub monitors: Vec<Monitor>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub active_workspace: Option<String>,
|
||||
pub active_window: Option<String>,
|
||||
pub devices: DeviceTopology,
|
||||
pub network: NetworkState,
|
||||
pub power: PowerState,
|
||||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitors: Vec::new(),
|
||||
workspaces: Vec::new(),
|
||||
active_workspace: None,
|
||||
active_window: None,
|
||||
devices: DeviceTopology::default(),
|
||||
network: NetworkState::default(),
|
||||
power: PowerState::default(),
|
||||
profile: ProfileState::default(),
|
||||
modules: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
pub connected: bool,
|
||||
pub resolution: Option<String>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Workspace {
|
||||
pub id: String,
|
||||
pub monitor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DeviceTopology {
|
||||
pub connected: Vec<Device>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub class: DeviceClass,
|
||||
pub subsystem: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceClass {
|
||||
Dock,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
Tablet,
|
||||
Display,
|
||||
Storage,
|
||||
Audio,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct NetworkState {
|
||||
pub interfaces: HashMap<String, InterfaceState>,
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterfaceState {
|
||||
pub up: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PowerState {
|
||||
pub ac_connected: bool,
|
||||
pub battery_percent: Option<u8>,
|
||||
pub battery_low: bool,
|
||||
}
|
||||
|
||||
impl Default for PowerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ac_connected: false,
|
||||
battery_percent: None,
|
||||
battery_low: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileState {
|
||||
pub active: String,
|
||||
pub history: Vec<String>,
|
||||
pub profiles: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ProfileState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: "default".to_string(),
|
||||
history: Vec::new(),
|
||||
profiles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleStatus {
|
||||
pub name: String,
|
||||
pub status: ModuleLoadState,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModuleLoadState {
|
||||
Loaded,
|
||||
LoadError,
|
||||
NotFound,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue