Release 1.0

This commit is contained in:
Breadway 2026-05-11 11:56:03 +08:00
parent 009ea6da0e
commit 730a8b61d7
32 changed files with 6629 additions and 0 deletions

42
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
rust: [stable]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ matrix.rust }}
- name: Cargo cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
- name: Build
run: cargo build --workspace --verbose
- name: Run tests
run: cargo test --workspace --verbose
- name: Build release
run: cargo build --workspace --release
- name: Package artifacts
run: |
mkdir -p dist
tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: bread-${{ matrix.os }}
path: dist/*.tgz

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

2876
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[workspace]
members = [
"bread-shared",
"breadd",
"bread-cli"
]
resolver = "2"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.40", features = ["full"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

503
DAEMON.md Normal file
View file

@ -0,0 +1,503 @@
# breadd — Daemon Architecture
### The Bread Runtime Daemon
---
## Overview
`breadd` is the long-running Rust daemon at the center of Bread. It is the canonical source of truth for all desktop runtime state: what hardware is connected, what the compositor is doing, what profile is active, and what events have occurred.
Everything else in Bread — Lua modules, the CLI, profile logic, automation behavior — exists as a consumer of what `breadd` tracks and exposes. The daemon is the foundation.
`breadd` does not implement automation. It makes automation possible.
---
## Responsibilities
At a high level, `breadd` is responsible for six things:
1. **Adapter management** — spawn and supervise connections to external systems (Hyprland IPC, udev, power, network)
2. **Event ingestion** — receive raw signals from adapters and push them into the pipeline
3. **Event normalization** — transform raw signals into stable, semantic Bread events
4. **State maintenance** — keep a live, structured model of the desktop
5. **Subscription dispatch** — deliver normalized events to Lua module subscribers
6. **IPC** — expose runtime state and control to the CLI and external consumers
The daemon does not decide what to do when events occur. That is Lua's job. The daemon decides what is true about the system, and tells Lua about it.
---
## Process Model
`breadd` is a single long-running process started at login (via systemd user service or similar). It runs for the duration of the session.
```
breadd (main process)
├── Adapter threads
│ ├── HyprlandAdapter (async task — IPC socket reader)
│ ├── UdevAdapter (async task — netlink listener)
│ ├── PowerAdapter (async task — sysfs / UPower watcher)
│ └── NetworkAdapter (async task — netlink / D-Bus watcher)
├── State Engine (async task — central coordinator)
├── Lua Runtime (dedicated thread — Lua is not Send)
├── IPC Server (async task — Unix socket listener)
└── Watcher (async task — config file watcher, optional)
```
The daemon uses Tokio as its async runtime. Most work is non-blocking and event-driven. The Lua runtime runs on a dedicated OS thread because Lua's C bindings are not `Send`-safe; it communicates with the async side through a bounded channel.
---
## Internal Event Pipeline
Every signal that enters `breadd` flows through the same pipeline before it reaches a Lua module:
```
External System
Adapter
(raw ingestion)
│ RawEvent
Normalizer
(semantic interpretation)
│ BreadEvent
State Engine
(state update + fan-out)
├──► State Store (updated)
└──► Subscription Dispatcher
│ BreadEvent (per subscriber)
Lua Runtime
(module handlers)
```
No step is skipped. Raw events never reach Lua directly. Lua never reads from sysfs or a compositor socket directly. The pipeline enforces clean separation between "what the system said" and "what it means."
---
## Core Data Structures
### RawEvent
A `RawEvent` is what an adapter produces. It is uninterpreted — it contains only what the external system reported.
```rust
pub struct RawEvent {
pub source: AdapterSource, // Hyprland | Udev | Power | Network
pub kind: String, // raw event type string from the source
pub payload: serde_json::Value, // raw data, source-specific shape
pub timestamp: u64, // unix milliseconds
}
```
### BreadEvent
A `BreadEvent` is what the normalizer produces. It is stable, versioned, and typed.
```rust
pub struct BreadEvent {
pub event: String, // "bread.device.dock.connected"
pub timestamp: u64,
pub source: AdapterSource,
pub data: serde_json::Value, // normalized, structured payload
}
```
The `event` field follows the namespace convention `bread.<subsystem>.<noun>.<verb>`. This string is stable across Bread versions; modules can rely on it without breaking.
### RuntimeState
The `RuntimeState` is the daemon's live model of the desktop. It is updated atomically as events arrive.
```rust
pub struct RuntimeState {
pub monitors: Vec<Monitor>,
pub workspaces: Vec<Workspace>,
pub active_workspace: Option<WorkspaceId>,
pub active_window: Option<WindowId>,
pub devices: DeviceTopology,
pub network: NetworkState,
pub power: PowerState,
pub profile: ProfileState,
pub modules: Vec<ModuleStatus>,
}
```
State is stored behind an `Arc<RwLock<RuntimeState>>`. Readers (IPC, Lua state queries) take a read lock briefly. The state engine holds the write lock only during update. Contention is minimal because updates are infrequent relative to query frequency.
---
## Adapters
Each adapter is an independent async task. It owns its connection to an external system and is responsible for reconnection if that connection is lost.
### Adapter Trait
```rust
#[async_trait]
pub trait Adapter: Send + Sync {
fn name(&self) -> &str;
async fn run(&self, tx: Sender<RawEvent>) -> Result<()>;
async fn on_connect(&self) {}
async fn on_disconnect(&self) {}
}
```
Each adapter runs its `run` loop indefinitely, pushing `RawEvent`s into the shared channel. Failures inside `run` trigger a reconnect cycle with exponential backoff — the adapter never terminates the daemon.
### HyprlandAdapter
Connects to Hyprland's event socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock`). Reads newline-delimited event strings and forwards them as `RawEvent`s.
Handles:
- `monitoradded` / `monitorremoved`
- `workspace` / `workspacev2`
- `activewindow` / `activewindowv2`
- `openwindow` / `closewindow`
- `focusedmon`
Reconnects if the socket disappears (compositor restart). Buffers events during reconnect to avoid losing the first few signals after the compositor comes back.
### UdevAdapter
Uses `tokio-udev` to listen on a netlink socket for kernel device events. Monitors all subsystems relevant to desktop hardware:
- `usb` — docks, peripherals, hubs
- `input` — keyboards, mice, tablets
- `drm` — display connectors
- `power_supply` — batteries, chargers
Unlike most adapters, `UdevAdapter` also performs an initial enumeration on startup so the state engine has a full picture of currently-connected hardware before any hotplug events arrive.
### PowerAdapter
Reads battery and AC state from `/sys/class/power_supply/`. Polls on a configurable interval (default: 30s) and emits events on meaningful state transitions:
- AC plugged / unplugged
- Battery level crossing thresholds (20%, 10%, 5%)
- Battery full
Also subscribes to UPower over D-Bus when available for faster event delivery.
### NetworkAdapter
Monitors network interface state via netlink. Emits events when interfaces transition between up and down states, and when the system gains or loses default-route connectivity.
---
## Normalizer
The normalizer sits between the adapter channel and the state engine. It is a pure function: given a `RawEvent`, produce zero or more `BreadEvent`s.
```rust
pub trait Normalizer: Send + Sync {
fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent>;
}
```
Each adapter source has its own normalizer implementation. Normalization is where domain knowledge lives: knowing that a udev `add` event on a `usb` device with certain vendor/product IDs means "dock connected" rather than "generic USB device."
### Device Classification
The `UdevNormalizer` maintains a device classifier that maps hardware identifiers to semantic device types:
```rust
pub enum DeviceClass {
Dock,
Keyboard,
Mouse,
Tablet,
Display,
Storage,
Audio,
Unknown,
}
```
Classification is based on udev properties (`ID_INPUT_KEYBOARD`, `ID_USB_CLASS`, subsystem, driver name). Unknown devices are classified as `Unknown` and still emit a generic `bread.device.connected` event — they are never silently dropped.
### Event Deduplication
The normalizer tracks recent events and suppresses duplicates within a configurable window (default: 100ms). This prevents rapid-fire hardware oscillation (e.g., a dock that briefly disconnects and reconnects during power negotiation) from flooding the event bus with spurious events.
---
## State Engine
The state engine is the coordinator. It receives `BreadEvent`s from the normalizer, updates `RuntimeState`, and dispatches events to subscribers.
```rust
pub struct StateEngine {
state: Arc<RwLock<RuntimeState>>,
subscriptions: Arc<RwLock<SubscriptionTable>>,
lua_tx: Sender<LuaMessage>,
}
```
On each event:
1. Acquire write lock on `RuntimeState`
2. Apply the state update corresponding to the event
3. Release write lock
4. Look up matching subscriptions in `SubscriptionTable`
5. For each match, send a `LuaMessage::Event` to the Lua runtime channel
State updates are synchronous and must be fast. No I/O, no blocking, no external calls inside the update path.
### Subscription Table
The `SubscriptionTable` maps event patterns to subscriber IDs. Patterns support exact matches and wildcard suffix matching (`bread.device.*`).
```rust
pub struct SubscriptionTable {
entries: Vec<Subscription>,
}
pub struct Subscription {
pub id: SubscriptionId,
pub pattern: EventPattern,
pub once: bool,
}
```
Matching is O(n) over the subscription list. For typical module counts (tens of subscriptions), this is negligible. If subscription counts grow into the thousands, an index structure would be warranted — but that is not a V1 concern.
---
## Lua Runtime
The Lua runtime runs on a dedicated OS thread. It owns the `mlua` `Lua` instance and processes messages from the async side through a `tokio::sync::mpsc` channel.
```rust
pub enum LuaMessage {
Event(BreadEvent),
Reload,
Exec(String),
StateQuery { key: String, reply: oneshot::Sender<serde_json::Value> },
Shutdown,
}
```
### Module Loading
On startup (and on reload), the Lua runtime:
1. Creates a fresh `Lua` instance (on reload, the old one is dropped)
2. Registers all built-in `bread.*` API functions
3. Evaluates `~/.config/bread/init.lua`
4. Resolves module dependency order
5. Loads each module in order, calling `on_load` if defined
6. Registers all `bread.on` subscriptions with the state engine
### Error Isolation
Lua errors during event handler execution are caught with `pcall`. The error message and stack trace are logged. The handler is removed from the subscription table if it is a `once` subscription; otherwise it remains registered and will be called again on the next matching event.
Errors during module load are fatal to that module but not to the daemon. The failed module is marked as `LoadError` in module state; remaining modules continue loading.
### Lua ↔ Rust Boundary
All calls across the Lua/Rust boundary go through `mlua`'s safe API. Rust functions registered as Lua globals return `mlua::Result` and handle their own error mapping. Panics inside registered functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread.
---
## IPC
`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON.
### Request / Response
```json
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
```
```json
{ "id": "1", "result": [ { "name": "HDMI-A-1", "resolution": "2560x1440" } ] }
```
### Methods
| Method | Description |
|--------|-------------|
| `state.get` | Read a value from RuntimeState by key path |
| `state.dump` | Return full RuntimeState as JSON |
| `events.subscribe` | Subscribe to a stream of BreadEvents (persistent connection) |
| `modules.list` | List loaded modules and their status |
| `modules.reload` | Trigger a hot reload |
| `profile.list` | List defined profiles |
| `profile.activate` | Activate a named profile |
| `emit` | Inject a synthetic BreadEvent |
| `ping` | Health check |
### Event Streaming
`events.subscribe` upgrades the connection to a streaming mode. The daemon pushes `BreadEvent` JSON objects line-by-line as they occur. The CLI's `bread events` command uses this to implement its live event stream. The connection remains open until the client disconnects.
### IPC Security
The socket is created with `0600` permissions, owned by the user. No authentication is performed — any process running as the same user can connect. This is intentional for V1 and consistent with how tools like Hyprland and sway handle their IPC sockets.
---
## Hot Reload
Hot reload is a first-class feature of `breadd`. The daemon persists; the Lua layer restarts.
Reload sequence:
```
bread reload (CLI)
IPC: modules.reload
StateEngine: pause event dispatch to Lua
LuaRuntime: receive Reload message
├── call on_unload() on each module (reverse dependency order)
├── cancel all active timers and intervals
├── send subscription cancellations to SubscriptionTable
├── drop Lua instance (all state cleared)
├── create new Lua instance
├── re-register built-in API
├── re-load init.lua and all modules
└── re-register subscriptions with SubscriptionTable
StateEngine: resume event dispatch
IPC: reload complete response
```
If any module fails to load during reload, the reload aborts. The previous Lua instance cannot be restored (it was dropped), so the daemon enters a degraded state: no Lua handlers active, but the daemon itself remains running and IPC-accessible. The CLI reports the error and the user can fix the Lua and reload again.
This tradeoff (no rollback on failed reload) is intentional for V1. Rollback would require snapshotting the previous Lua state before initiating reload, which adds complexity. The user experience is acceptable: a syntax error in a module gives a clear error message via `bread reload`, and the daemon stays alive.
---
## Startup Sequence
```
1. Parse config (breadd.toml or default)
2. Initialize logging (tracing subscriber)
3. Create RuntimeState (empty)
4. Create SubscriptionTable (empty)
5. Bind IPC socket
6. Spawn adapter tasks:
a. UdevAdapter (enumerate existing devices → populate initial state)
b. HyprlandAdapter (connect to compositor socket)
c. PowerAdapter (read initial battery state)
d. NetworkAdapter (read initial interface state)
7. Spawn StateEngine task
8. Spawn Lua runtime thread
9. Send Lua runtime: load init.lua
10. Lua loads modules, registers subscriptions
11. StateEngine fires bread.system.startup event
12. Daemon enters steady-state event loop
```
Step 6a (UdevAdapter enumeration) is synchronous before other adapters start. This ensures that when Lua modules first run, `bread.state.get("devices")` returns an accurate picture of what's already connected rather than an empty list.
---
## Configuration
`breadd` reads from `~/.config/bread/breadd.toml`. All values have defaults; the file is optional.
```toml
[daemon]
log_level = "info" # trace | debug | info | warn | error
socket_path = "" # default: $XDG_RUNTIME_DIR/bread/breadd.sock
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = true
reconnect_delay_ms = 500
reconnect_max_attempts = 10
[adapters.udev]
enabled = true
subsystems = ["usb", "input", "drm", "power_supply"]
[adapters.power]
enabled = true
poll_interval_secs = 30
[adapters.network]
enabled = true
[events]
dedup_window_ms = 100 # suppress duplicate events within this window
```
---
## Observability
### Logging
`breadd` uses `tracing` for structured logging. Log level is configurable. At `debug` level, every `RawEvent` and `BreadEvent` is logged with full payloads. At `info` level, only significant lifecycle events and errors are logged.
### `bread doctor`
The `bread doctor` command queries the daemon over IPC and produces a diagnostic report:
- Daemon version and uptime
- IPC socket status
- Adapter connection status (connected / disconnected / reconnecting)
- Module load status (loaded / error / not found)
- Active subscriptions count
- Recent errors (last 10 Lua errors with stack traces)
- RuntimeState summary
### `bread events`
Streams the live `BreadEvent` log to the terminal. Supports optional pattern filtering:
```bash
bread events # all events
bread events --filter "bread.device.*" # device events only
bread events --filter "bread.monitor.*" # monitor events only
```
---
## Failure Modes & Recovery
| Failure | Behavior |
|---------|----------|
| Hyprland socket unavailable | HyprlandAdapter retries with backoff; other adapters unaffected |
| Compositor restart | HyprlandAdapter detects disconnect, reconnects when socket reappears |
| Lua syntax error on reload | Reload aborts; daemon enters degraded mode; reports error via IPC |
| Lua runtime error in handler | Error caught and logged; handler remains registered; daemon continues |
| IPC client disconnect | Connection cleaned up; no effect on daemon |
| udev socket error | UdevAdapter logs error and retries; events may be missed during outage |
| Panic in Rust async task | Task restarts via supervisor; logged as critical error |
The daemon is designed to never require a full restart due to a recoverable failure. The only cases that warrant a daemon restart are: daemon binary update, unrecoverable OS-level error, or explicit user action.
---
## Summary
`breadd` is a narrow, focused daemon. It does not automate. It does not configure the compositor. It does not manage packages or provision machines.
It does one thing well: maintain a live, coherent model of the desktop runtime and deliver that model — as structured state and semantic events — to the Lua automation layer that acts on it.
Everything complex lives in Lua. Everything reliable lives in `breadd`.

12
bread-cli/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "bread-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
bread-shared = { path = "../bread-shared" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
clap = { version = "4.5", features = ["derive"] }

158
bread-cli/src/main.rs Normal file
View file

@ -0,0 +1,158 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::env;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
#[derive(Parser, Debug)]
#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Hot-reload all Lua modules
Reload,
/// Dump current runtime state
State,
/// Stream live normalized events
Events {
#[arg(long)]
filter: Option<String>,
},
/// List loaded modules and status
Modules,
/// List available profiles
ProfileList,
/// Activate a profile
ProfileActivate { name: String },
/// Manually emit an event
Emit {
event: String,
#[arg(short, long, default_value = "{}")]
data: String,
},
/// Health check daemon connectivity
Ping,
/// Fetch daemon health details
Health,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let socket = daemon_socket_path();
match &cli.command {
Commands::Reload => {
let response = send_request(&socket, "modules.reload", json!({})).await?;
print_json(&response)?;
}
Commands::State => {
let response = send_request(&socket, "state.dump", json!({})).await?;
print_json(&response)?;
}
Commands::Events { filter } => {
stream_events(&socket, filter.clone()).await?;
}
Commands::Modules => {
let response = send_request(&socket, "modules.list", json!({})).await?;
print_json(&response)?;
}
Commands::ProfileList => {
let response = send_request(&socket, "profile.list", json!({})).await?;
print_json(&response)?;
}
Commands::ProfileActivate { name } => {
let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?;
print_json(&response)?;
}
Commands::Emit { event, data } => {
let parsed = serde_json::from_str::<Value>(data).unwrap_or_else(|_| json!({}));
let response = send_request(
&socket,
"emit",
json!({
"event": event,
"data": parsed,
}),
)
.await?;
print_json(&response)?;
}
Commands::Ping => {
let response = send_request(&socket, "ping", json!({})).await?;
print_json(&response)?;
}
Commands::Health => {
let response = send_request(&socket, "health", json!({})).await?;
print_json(&response)?;
}
}
Ok(())
}
fn daemon_socket_path() -> PathBuf {
if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") {
return Path::new(&runtime).join("bread").join("breadd.sock");
}
PathBuf::from("/tmp/bread/breadd.sock")
}
async fn send_request(socket: &Path, method: &str, params: Value) -> Result<Value> {
let stream = UnixStream::connect(socket).await?;
let (read_half, mut write_half) = stream.into_split();
let request = json!({
"id": "1",
"method": method,
"params": params,
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let Some(line) = lines.next_line().await? else {
anyhow::bail!("daemon closed connection without response");
};
let response: Value = serde_json::from_str(&line)?;
if let Some(error) = response.get("error").and_then(Value::as_str) {
anyhow::bail!(error.to_string());
}
Ok(response.get("result").cloned().unwrap_or_else(|| json!({})))
}
async fn stream_events(socket: &Path, filter: Option<String>) -> Result<()> {
let stream = UnixStream::connect(socket).await?;
let (read_half, mut write_half) = stream.into_split();
let request = json!({
"id": "1",
"method": "events.subscribe",
"params": {
"filter": filter,
},
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let value: Value = serde_json::from_str(&line)?;
println!("{}", serde_json::to_string_pretty(&value)?);
}
Ok(())
}
fn print_json(value: &Value) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}

8
bread-shared/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "bread-shared"
version = "0.1.0"
edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true

45
bread-shared/src/lib.rs Normal file
View file

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AdapterSource {
Hyprland,
Udev,
Power,
Network,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent {
pub source: AdapterSource,
pub kind: String,
pub payload: serde_json::Value,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreadEvent {
pub event: String,
pub timestamp: u64,
pub source: AdapterSource,
pub data: serde_json::Value,
}
impl BreadEvent {
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
Self {
event: event.into(),
timestamp: now_unix_ms(),
source,
data,
}
}
}
pub fn now_unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}

27
breadd/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "breadd"
version = "0.1.0"
edition = "2021"
[dependencies]
bread-shared = { path = "../bread-shared" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
async-trait = "0.1"
toml = "0.8"
udev = "0.9"
rtnetlink = "0.9"
zbus = { version = "3.13", features = ["tokio"] }
hex = "0.4"
futures-util = "0.3"
netlink-packet-route = "0.11"
netlink-packet-core = "0.4"
libc = "0.2"
[dev-dependencies]
tempfile = "3.13"

View file

@ -0,0 +1,66 @@
use std::env;
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::adapters::Adapter;
#[derive(Clone, Default)]
pub struct HyprlandAdapter;
#[async_trait::async_trait]
impl Adapter for HyprlandAdapter {
fn name(&self) -> &'static str {
"hyprland"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("hyprland adapter started");
let socket = hyprland_event_socket()?;
let stream = UnixStream::connect(&socket).await?;
let reader = BufReader::new(stream);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await? {
let (kind, data) = parse_hyprland_line(&line);
tx.send(RawEvent {
source: AdapterSource::Hyprland,
kind: "hyprland.event".to_string(),
payload: json!({
"kind": kind,
"raw": line,
"data": data,
}),
timestamp: now_unix_ms(),
})
.await?;
}
warn!("hyprland socket closed");
Err(anyhow!("hyprland socket closed"))
}
}
fn hyprland_event_socket() -> Result<PathBuf> {
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket2.sock"))
}
fn parse_hyprland_line(line: &str) -> (String, String) {
if let Some((kind, data)) = line.split_once(">>") {
return (kind.to_string(), data.to_string());
}
("unknown".to_string(), line.to_string())
}

109
breadd/src/adapters/mod.rs Normal file
View file

@ -0,0 +1,109 @@
use anyhow::Result;
use async_trait::async_trait;
use bread_shared::RawEvent;
use tokio::sync::{mpsc, watch};
use tracing::info;
use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised;
pub mod hyprland;
pub mod network;
pub mod power;
pub mod udev;
pub mod network_rtnetlink;
pub mod power_upower;
#[async_trait]
pub trait Adapter: Send + Sync {
fn name(&self) -> &'static str;
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()>;
async fn on_connect(&self) -> Result<()> {
Ok(())
}
async fn on_disconnect(&self) -> Result<()> {
Ok(())
}
}
pub struct Manager {
raw_tx: mpsc::Sender<RawEvent>,
config: Config,
shutdown_rx: watch::Receiver<bool>,
}
impl Manager {
pub fn new(
raw_tx: mpsc::Sender<RawEvent>,
config: Config,
shutdown_rx: watch::Receiver<bool>,
) -> Self {
Self {
raw_tx,
config,
shutdown_rx,
}
}
pub async fn start_all(&self) -> Result<()> {
info!("starting adapters");
if self.config.adapters.udev.enabled {
let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone());
adapter.enumerate_existing(&self.raw_tx).await?;
self.spawn_adapter(adapter);
}
if self.config.adapters.hyprland.enabled {
self.spawn_adapter(hyprland::HyprlandAdapter::default());
}
if self.config.adapters.power.enabled {
// Prefer UPower DBus adapter; fall back to sysfs poller
let upower = power_upower::UPowerAdapter::new();
if let Ok(adapter) = upower {
self.spawn_adapter(adapter);
} else {
self.spawn_adapter(power::PowerAdapter::new(
self.config.adapters.power.poll_interval_secs,
));
}
}
if self.config.adapters.network.enabled {
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
let rt = network_rtnetlink::RtnetlinkAdapter::new();
if let Ok(adapter) = rt {
self.spawn_adapter(adapter);
} else {
self.spawn_adapter(network::NetworkAdapter::default());
}
}
Ok(())
}
fn spawn_adapter<A>(&self, adapter: A)
where
A: Adapter + Clone + 'static,
{
let name = adapter.name();
let tx = self.raw_tx.clone();
let shutdown_rx = self.shutdown_rx.clone();
let shutdown_for_task = shutdown_rx.clone();
spawn_supervised(name, shutdown_rx, move || {
let adapter = adapter.clone();
let tx = tx.clone();
let mut shutdown_rx = shutdown_for_task.clone();
async move {
adapter.on_connect().await?;
let result = tokio::select! {
result = adapter.run(tx) => result,
_ = shutdown_rx.changed() => Ok(()),
};
adapter.on_disconnect().await?;
result
}
});
}
}

View file

@ -0,0 +1,93 @@
use std::collections::BTreeMap;
use std::fs;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone, Default)]
pub struct NetworkAdapter;
#[async_trait::async_trait]
impl Adapter for NetworkAdapter {
fn name(&self) -> &'static str {
"network"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("network adapter started");
let mut last = read_network_state();
tx.send(network_raw_event(&last)).await?;
loop {
sleep(Duration::from_secs(5)).await;
let now = read_network_state();
if now != last {
tx.send(network_raw_event(&now)).await?;
last = now;
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct NetworkSnapshot {
interfaces: BTreeMap<String, bool>,
online: bool,
}
fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent {
let interfaces = snapshot
.interfaces
.iter()
.map(|(name, up)| (name.clone(), json!({ "up": up })))
.collect::<serde_json::Map<String, serde_json::Value>>();
RawEvent {
source: AdapterSource::Network,
kind: "network.snapshot".to_string(),
payload: json!({
"online": snapshot.online,
"interfaces": interfaces,
}),
timestamp: now_unix_ms(),
}
}
fn read_network_state() -> NetworkSnapshot {
let mut interfaces = BTreeMap::new();
if let Ok(entries) = fs::read_dir("/sys/class/net") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "lo" {
continue;
}
let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default();
let up = oper.trim() == "up";
interfaces.insert(name, up);
}
}
let online = has_default_route();
NetworkSnapshot { interfaces, online }
}
fn has_default_route() -> bool {
if let Ok(routes) = fs::read_to_string("/proc/net/route") {
for line in routes.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() > 2 && cols[1] == "00000000" {
return true;
}
}
}
false
}

View file

@ -0,0 +1,151 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{AdapterSource, RawEvent};
use futures_util::StreamExt;
use netlink_packet_route::RtnlMessage;
use rtnetlink::new_connection;
use serde_json::json;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use tokio::sync::mpsc;
use tracing::{debug, info};
use super::Adapter;
#[derive(Clone, Debug)]
pub struct RtnetlinkAdapter;
impl RtnetlinkAdapter {
pub fn new() -> Result<Self> {
// Try to create a connection to validate presence of rtnetlink
let conn = new_connection();
match conn {
Ok((connection, _handle, _messages)) => {
// Spawn and immediately drop the connection task; we just validated
tokio::spawn(connection);
Ok(Self)
}
Err(e) => Err(anyhow!(e)),
}
}
}
#[async_trait]
impl Adapter for RtnetlinkAdapter {
fn name(&self) -> &'static str {
"rtnetlink-network"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("rtnetlink adapter starting");
let (connection, _handle, mut messages) = new_connection()?;
tokio::spawn(connection);
while let Some((message, _addr)) = messages.next().await {
match message.payload {
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
let ifname = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()),
_ => None,
});
let mtu = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu),
_ => None,
});
let netns_id = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id),
_ => None,
});
let netns_fd = link.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd),
_ => None,
});
let up = link.header.flags & (libc::IFF_UP as u32) != 0;
if let Some(name) = ifname {
let kind = if up { "link.up" } else { "link.down" };
let payload = json!({
"ifname": name,
"index": link.header.index,
"mtu": mtu,
"netns_id": netns_id,
"netns_fd": netns_fd
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
// Heuristic: if destination is default (empty), treat as default-route change
let is_default = route.header.destination_prefix_length == 0;
if is_default {
let gateway = route.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()),
_ => None,
});
let gateway_ip = gateway.as_deref().and_then(ip_from_bytes);
let payload = json!({
"gateway": gateway_ip,
"table": route.header.table
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => {
let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
_ => None,
});
let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
_ => None,
});
let ip = address.as_deref().and_then(ip_from_bytes);
let payload = json!({
"ifindex": addr.header.index,
"prefix_len": addr.header.prefix_len,
"family": addr.header.family,
"address": ip,
"label": label
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => {
let address = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
_ => None,
});
let label = addr.nlas.iter().find_map(|nla| match nla {
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
_ => None,
});
let ip = address.as_deref().and_then(ip_from_bytes);
let payload = json!({
"ifindex": addr.header.index,
"prefix_len": addr.header.prefix_len,
"family": addr.header.family,
"address": ip,
"label": label
});
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
}
_ => {
debug!("unhandled netlink message");
}
}
}
Ok(())
}
}
fn ip_from_bytes(bytes: &[u8]) -> Option<String> {
match bytes.len() {
4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()),
16 => {
let octets: [u8; 16] = bytes.try_into().ok()?;
Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string())
}
_ => None,
}
}

View file

@ -0,0 +1,92 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone)]
pub struct PowerAdapter {
poll_interval_secs: u64,
}
impl PowerAdapter {
pub fn new(poll_interval_secs: u64) -> Self {
Self { poll_interval_secs }
}
}
#[async_trait::async_trait]
impl Adapter for PowerAdapter {
fn name(&self) -> &'static str {
"power"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("power adapter started");
let mut last = read_power_state();
tx.send(power_raw_event(&last)).await?;
loop {
sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await;
let now = read_power_state();
if now != last {
tx.send(power_raw_event(&now)).await?;
last = now;
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct PowerSnapshot {
ac_connected: bool,
battery_percent: Option<u8>,
}
fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent {
RawEvent {
source: AdapterSource::Power,
kind: "power.snapshot".to_string(),
payload: json!({
"ac_connected": snapshot.ac_connected,
"battery_percent": snapshot.battery_percent,
}),
timestamp: now_unix_ms(),
}
}
fn read_power_state() -> PowerSnapshot {
let power_dir = Path::new("/sys/class/power_supply");
let mut ac_connected = false;
let mut battery_percent = None;
if let Ok(entries) = fs::read_dir(power_dir) {
for entry in entries.flatten() {
let path = entry.path();
let typ = fs::read_to_string(path.join("type")).unwrap_or_default();
if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") {
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
if online.trim() == "1" {
ac_connected = true;
}
} else if typ.trim().eq_ignore_ascii_case("Battery") {
let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default();
if let Ok(parsed) = cap.trim().parse::<u8>() {
battery_percent = Some(parsed.min(100));
}
}
}
}
PowerSnapshot {
ac_connected,
battery_percent,
}
}

View file

@ -0,0 +1,147 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{AdapterSource, RawEvent};
use futures_util::StreamExt;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, info};
use zbus::{Message, MessageStream};
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use super::Adapter;
#[derive(Clone, Debug)]
pub struct UPowerAdapter;
impl UPowerAdapter {
pub fn new() -> Result<Self> {
// Attempt to connect to system bus to validate availability
// We don't actually open the connection here because zbus::Connection::system() is async.
Ok(Self)
}
}
#[async_trait]
impl Adapter for UPowerAdapter {
fn name(&self) -> &'static str {
"upower"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("UPower adapter starting (attempting DBus subscription)");
// Defer loading zbus until runtime to avoid build-time optional complexity
match zbus::Connection::system().await {
Ok(conn) => {
let payload = json!({"message": "upower:connected"});
let _ = tx
.send(RawEvent {
source: AdapterSource::Power,
kind: "power.upower.connected".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
})
.await;
let mut stream = MessageStream::from(&conn);
while let Some(result) = stream.next().await {
match result {
Ok(message) => match parse_upower_message(&message) {
Ok(event) => {
let _ = tx.send(event).await;
}
Err(err) => {
debug!("upower parse error: {err:?}");
}
},
Err(err) => {
debug!("upower stream error: {err:?}");
}
}
}
Ok(())
}
Err(e) => {
// If DBus connection fails, fall back to periodic polling handled elsewhere
Err(anyhow!(e))
}
}
}
}
fn parse_upower_message(message: &Message) -> Result<RawEvent> {
let header = message.header()?;
let interface = header.interface()?.map(|v| v.as_str()).unwrap_or("");
let member = header.member()?.map(|v| v.as_str()).unwrap_or("");
let path = header.path()?.map(|v| v.as_str()).unwrap_or("");
if interface == "org.freedesktop.UPower" {
match member {
"DeviceAdded" => {
let (device_path,): (OwnedObjectPath,) = message.body()?;
let payload = json!({"device_path": device_path.as_str()});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.added".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
"DeviceRemoved" => {
let (device_path,): (OwnedObjectPath,) = message.body()?;
let payload = json!({"device_path": device_path.as_str()});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.removed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
_ => {}
}
}
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
let (iface, changed, invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
message.body()?;
if iface == "org.freedesktop.UPower.Device" {
let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({}));
let normalized = json!({
"percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()),
"state": changed_json.get("State").and_then(|v| v.as_u64()),
"time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()),
"time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()),
"is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()),
"battery_type": changed_json.get("Type").and_then(|v| v.as_u64()),
"online": changed_json.get("Online").and_then(|v| v.as_bool()),
"native_path": changed_json.get("NativePath").and_then(|v| v.as_str()),
"model": changed_json.get("Model").and_then(|v| v.as_str()),
"vendor": changed_json.get("Vendor").and_then(|v| v.as_str()),
"serial": changed_json.get("Serial").and_then(|v| v.as_str()),
"update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()),
});
let payload = json!({
"path": path,
"properties": changed_json,
"invalidated": invalidated,
"normalized": normalized
});
return Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.device.changed".to_string(),
payload,
timestamp: bread_shared::now_unix_ms(),
});
}
}
Ok(RawEvent {
source: AdapterSource::Power,
kind: "power.upower.signal".to_string(),
payload: json!({"interface": interface, "member": member, "path": path}),
timestamp: bread_shared::now_unix_ms(),
})
}

265
breadd/src/adapters/udev.rs Normal file
View file

@ -0,0 +1,265 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
#[derive(Clone)]
pub struct UdevAdapter {
subsystems: Vec<String>,
}
impl UdevAdapter {
pub fn new(subsystems: Vec<String>) -> Self {
Self { subsystems }
}
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
scan_devices(&self.subsystems).unwrap_or_default()
});
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?;
}
Ok(())
}
}
#[async_trait::async_trait]
impl Adapter for UdevAdapter {
fn name(&self) -> &'static str {
"udev"
}
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(());
}
// Fallback for environments where monitor sockets are unavailable.
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
loop {
let current = scan_devices(&self.subsystems)?;
let current_map: HashMap<String, ScannedDevice> = current
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
for (id, dev) in &current_map {
if !known.contains_key(id) {
tx.send(raw_change_event("add", dev)).await?;
}
}
for (id, dev) in &known {
if !current_map.contains_key(id) {
tx.send(raw_change_event("remove", dev)).await?;
}
}
known = current_map;
sleep(Duration::from_secs(2)).await;
}
}
}
#[derive(Clone, Debug)]
struct ScannedDevice {
id: String,
name: String,
subsystem: String,
}
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> {
let mut builder = udev::MonitorBuilder::new()?;
for subsystem in &subsystems {
builder = builder.match_subsystem(subsystem)?;
}
let monitor = builder.listen()?;
for event in monitor.iter() {
let action = event
.action()
.map(|a| a.to_string_lossy().to_string())
.unwrap_or_else(|| "change".to_string());
let subsystem = event
.subsystem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let name = event
.property_value("ID_MODEL")
.or_else(|| event.property_value("NAME"))
.map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let id = event
.syspath()
.to_string_lossy()
.to_string();
let msg = RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
"action": action,
"id": id,
"name": name,
"subsystem": subsystem,
}),
timestamp: now_unix_ms(),
};
if tx.blocking_send(msg).is_err() {
break;
}
}
Ok(())
})
.await??;
Ok(())
}
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)
}
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
"action": action,
"id": dev.id,
"name": dev.name,
"subsystem": dev.subsystem,
}),
timestamp: now_unix_ms(),
}
}
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut out = Vec::new();
if subsystems.iter().any(|s| s == "drm") {
let drm_dir = Path::new("/sys/class/drm");
if drm_dir.exists() {
for entry in fs::read_dir(drm_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains('-') {
continue;
}
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
if status.trim() == "connected" {
out.push(ScannedDevice {
id: format!("drm:{name}"),
name,
subsystem: "drm".to_string(),
});
}
}
}
}
if subsystems.iter().any(|s| s == "input") {
let input_dir = Path::new("/dev/input/by-id");
if input_dir.exists() {
for entry in fs::read_dir(input_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("input:{name}"),
name,
subsystem: "input".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "power_supply") {
let pwr_dir = Path::new("/sys/class/power_supply");
if pwr_dir.exists() {
for entry in fs::read_dir(pwr_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("power_supply:{name}"),
name,
subsystem: "power_supply".to_string(),
});
}
}
}
if subsystems.iter().any(|s| s == "usb") {
let usb_dir = Path::new("/sys/bus/usb/devices");
if usb_dir.exists() {
for entry in fs::read_dir(usb_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
out.push(ScannedDevice {
id: format!("usb:{name}"),
name,
subsystem: "usb".to_string(),
});
}
}
}
}
Ok(out)
}

228
breadd/src/core/config.rs Normal file
View 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
View file

@ -0,0 +1,6 @@
pub mod config;
pub mod normalizer;
pub mod state_engine;
pub mod subscriptions;
pub mod supervisor;
pub mod types;

View 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
}

View 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);
}
}

View 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
}

View 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
View 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,
}

272
breadd/src/ipc/mod.rs Normal file
View file

@ -0,0 +1,272 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process;
use std::time::Instant;
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch};
use tracing::{error, info, warn};
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
#[derive(Clone)]
pub struct Server {
socket_path: PathBuf,
state_handle: StateHandle,
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
started_at: Instant,
pid: u32,
}
#[derive(Debug, Deserialize)]
struct IpcRequest {
id: String,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Serialize)]
struct IpcResponse {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl Server {
pub fn new(
socket_path: PathBuf,
state_handle: StateHandle,
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Self {
Self {
socket_path,
state_handle,
event_tx,
lua_runtime,
emit_tx,
started_at: Instant::now(),
pid: process::id(),
}
}
pub async fn serve(&self, mut shutdown_rx: watch::Receiver<bool>) -> Result<()> {
if let Some(parent) = self.socket_path.parent() {
fs::create_dir_all(parent)?;
}
if self.socket_path.exists() {
fs::remove_file(&self.socket_path)?;
}
let listener = UnixListener::bind(&self.socket_path)?;
fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?;
info!(socket = %self.socket_path.display(), "ipc server listening");
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
accept = listener.accept() => {
let (stream, _) = accept?;
let server = self.clone();
tokio::spawn(async move {
if let Err(err) = server.handle_connection(stream).await {
warn!(error = %err, "ipc connection failed");
}
});
}
}
}
Ok(())
}
async fn handle_connection(&self, stream: UnixStream) -> Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let req: IpcRequest = serde_json::from_str(&line)?;
if req.method == "events.subscribe" {
let filter = req
.params
.get("filter")
.and_then(Value::as_str)
.map(ToString::to_string);
let ok = IpcResponse {
id: req.id,
result: Some(json!({ "subscribed": true })),
error: None,
};
write_half
.write_all(format!("{}\n", serde_json::to_string(&ok)?).as_bytes())
.await?;
self.stream_events(&mut write_half, filter).await?;
return Ok(());
}
let response = match self.handle_request(req).await {
Ok(res) => IpcResponse {
id: res.0,
result: Some(res.1),
error: None,
},
Err((id, err)) => IpcResponse {
id,
result: None,
error: Some(err),
},
};
write_half
.write_all(format!("{}\n", serde_json::to_string(&response)?).as_bytes())
.await?;
}
Ok(())
}
async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> {
let id = req.id.clone();
let result = match req.method.as_str() {
"ping" => Ok(json!({ "ok": true })),
"state.get" => {
let key = req.params.get("key").and_then(Value::as_str).unwrap_or("");
let value = self
.state_handle
.state_get(key)
.await
.ok_or_else(|| anyhow!("state path not found"));
value.map_err(|e| e.to_string())
}
"state.dump" => Ok(self.state_handle.state_dump().await),
"modules.list" => {
let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
}
"modules.reload" => self
.lua_runtime
.reload()
.await
.map(|_| json!({ "reloaded": true }))
.map_err(|e| e.to_string()),
"profile.list" => {
let full = self.state_handle.state_dump().await;
let profiles = full
.get("profile")
.and_then(|v| v.get("profiles"))
.cloned()
.unwrap_or_else(|| json!({}));
Ok(profiles)
}
"profile.activate" => {
let Some(name) = req
.params
.get("name")
.and_then(Value::as_str)
else {
return Err((id, "missing profile name".to_string()));
};
self.state_handle.set_profile(name.to_string());
if self
.emit_tx
.send(BreadEvent::new(
"bread.profile.activated",
AdapterSource::System,
json!({ "name": name }),
))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "active": name }))
}
"emit" => {
let Some(event) = req
.params
.get("event")
.and_then(Value::as_str)
else {
return Err((id, "missing event name".to_string()));
};
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::System, data))
.is_err()
{
return Err((id, "emit channel closed".to_string()));
}
Ok(json!({ "emitted": true }))
}
"health" => {
let uptime_ms = self.started_at.elapsed().as_millis();
Ok(json!({
"ok": true,
"pid": self.pid,
"version": env!("CARGO_PKG_VERSION"),
"uptime_ms": uptime_ms,
}))
}
_ => Err("unknown method".to_string()),
};
match result {
Ok(v) => Ok((id, v)),
Err(err) => Err((id, err)),
}
}
async fn stream_events(
&self,
writer: &mut tokio::net::unix::OwnedWriteHalf,
filter: Option<String>,
) -> Result<()> {
let mut rx = self.event_tx.subscribe();
loop {
let evt = rx.recv().await?;
if let Some(filter) = filter.as_deref() {
if !matches_filter(&evt.event, filter) {
continue;
}
}
let line = format!("{}\n", serde_json::to_string(&evt)?);
if let Err(err) = writer.write_all(line.as_bytes()).await {
error!(error = %err, "failed to write event stream line");
return Ok(());
}
}
}
}
fn matches_filter(event_name: &str, pattern: &str) -> bool {
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix);
}
event_name == pattern
}

340
breadd/src/lua/mod.rs Normal file
View file

@ -0,0 +1,340 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, Value};
use tokio::sync::{mpsc, oneshot};
use tracing::{error, info, warn};
use crate::core::config::Config;
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::ModuleLoadState;
pub enum LuaMessage {
Event {
subscription_id: SubscriptionId,
event: BreadEvent,
},
SubscriptionCancelled {
id: SubscriptionId,
},
Reload {
reply: oneshot::Sender<std::result::Result<(), String>>,
},
Shutdown,
}
#[derive(Clone)]
pub struct RuntimeHandle {
tx: mpsc::UnboundedSender<LuaMessage>,
}
impl RuntimeHandle {
pub fn sender(&self) -> mpsc::UnboundedSender<LuaMessage> {
self.tx.clone()
}
pub async fn reload(&self) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.tx
.send(LuaMessage::Reload { reply: tx })
.map_err(|_| anyhow!("lua runtime channel closed"))?;
match rx.await {
Ok(Ok(())) => Ok(()),
Ok(Err(err)) => Err(anyhow!(err)),
Err(_) => Err(anyhow!("lua runtime dropped reload response")),
}
}
pub fn shutdown(&self) {
let _ = self.tx.send(LuaMessage::Shutdown);
}
}
pub fn spawn_runtime(
config: Config,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel();
let handle = RuntimeHandle { tx };
let thread_tx = handle.tx.clone();
std::thread::Builder::new()
.name("breadd-lua".to_string())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create lua runtime thread");
rt.block_on(async move {
let mut engine = match LuaEngine::new(config, state_handle, emit_tx) {
Ok(engine) => engine,
Err(err) => {
error!(error = %err, "failed to initialize lua engine");
return;
}
};
if let Err(err) = engine.reload_internal() {
error!(error = %err, "initial lua load failed");
}
while let Some(msg) = rx.recv().await {
match msg {
LuaMessage::Event {
subscription_id,
event,
} => {
if let Err(err) = engine.handle_event(subscription_id, event) {
error!(error = %err, "lua event handler failed");
}
}
LuaMessage::SubscriptionCancelled { id } => {
engine.remove_handler(id);
}
LuaMessage::Reload { reply } => {
let result = engine.reload_internal().map_err(|e| e.to_string());
let _ = reply.send(result);
}
LuaMessage::Shutdown => {
break;
}
}
}
info!("lua runtime thread exiting");
});
})?;
let _ = thread_tx;
Ok(handle)
}
struct LuaEngine {
lua: Lua,
handlers: Arc<Mutex<HashMap<SubscriptionId, RegistryKey>>>,
next_sub_id: Arc<AtomicU64>,
state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
entry_point: PathBuf,
module_path: PathBuf,
}
impl LuaEngine {
fn new(config: Config, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender<BreadEvent>) -> Result<Self> {
Ok(Self {
lua: Lua::new(),
handlers: Arc::new(Mutex::new(HashMap::new())),
next_sub_id: Arc::new(AtomicU64::new(1)),
state_handle,
emit_tx,
entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(),
})
}
fn reload_internal(&mut self) -> Result<()> {
self.state_handle.clear_subscriptions();
self.lua = Lua::new();
self.handlers
.lock()
.expect("lua handlers mutex poisoned")
.clear();
self.install_api()?;
self.load_init_and_modules()?;
info!("lua runtime reloaded");
Ok(())
}
fn install_api(&self) -> Result<()> {
let globals = self.lua.globals();
let bread = self.lua.create_table()?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let on_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
handlers
.lock()
.map_err(|_| mlua::Error::external("handler lock poisoned"))?
.insert(id, key);
state_handle
.register_subscription(id, pattern, false)
.map_err(mlua::Error::external)?;
Ok(id.0)
})?;
bread.set("on", on_fn)?;
let handlers = self.handlers.clone();
let next_sub_id = self.next_sub_id.clone();
let state_handle = self.state_handle.clone();
let once_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| {
let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed));
let key = lua.create_registry_value(callback)?;
handlers
.lock()
.map_err(|_| mlua::Error::external("handler lock poisoned"))?
.insert(id, key);
state_handle
.register_subscription(id, pattern, true)
.map_err(mlua::Error::external)?;
Ok(id.0)
})?;
bread.set("once", once_fn)?;
let emit_tx = self.emit_tx.clone();
let emit_fn = self.lua.create_function(move |lua, (event_name, payload): (String, Value)| {
let data = match payload {
Value::Nil => serde_json::json!({}),
other => lua
.from_value::<serde_json::Value>(other)
.unwrap_or_else(|_| serde_json::json!({})),
};
emit_tx
.send(BreadEvent::new(event_name, AdapterSource::System, data))
.map_err(|_| mlua::Error::external("event channel closed"))?;
Ok(())
})?;
bread.set("emit", emit_fn)?;
let state_arc = self.state_handle.state_arc();
let state_tbl = self.lua.create_table()?;
let get_fn = self.lua.create_function(move |lua, path: String| {
let snapshot = state_arc.blocking_read();
let mut value = serde_json::to_value(&*snapshot)
.map_err(|e| mlua::Error::external(e.to_string()))?;
if path.is_empty() {
return lua
.to_value(&value)
.map_err(|e| mlua::Error::external(e.to_string()));
}
for part in path.split('.') {
value = value
.get(part)
.cloned()
.ok_or_else(|| mlua::Error::external("state path not found"))?;
}
lua.to_value(&value)
.map_err(|e| mlua::Error::external(e.to_string()))
})?;
state_tbl.set("get", get_fn)?;
bread.set("state", state_tbl)?;
let profile_tbl = self.lua.create_table()?;
let state_handle = self.state_handle.clone();
let activate_fn = self.lua.create_function(move |_lua, name: String| {
state_handle.set_profile(name.clone());
Ok(())
})?;
profile_tbl.set("activate", activate_fn)?;
bread.set("profile", profile_tbl)?;
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
let status = std::process::Command::new("sh")
.arg("-lc")
.arg(&cmd)
.status()
.map_err(mlua::Error::external)?;
Ok(status.code().unwrap_or_default())
})?;
bread.set("exec", exec_fn)?;
globals.set("bread", bread)?;
Ok(())
}
fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init")?;
let mut files = list_lua_files(&self.module_path)?;
files.sort();
for path in files {
let module_name = path
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("unknown")
.to_string();
match self.load_lua_file(&path, &module_name) {
Ok(()) => {
self.state_handle
.set_module_status(module_name, ModuleLoadState::Loaded, None);
}
Err(err) => {
self.state_handle.set_module_status(
module_name,
ModuleLoadState::LoadError,
Some(err.to_string()),
);
}
}
}
Ok(())
}
fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> {
if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping");
self.state_handle.set_module_status(
module_name.to_string(),
ModuleLoadState::NotFound,
None,
);
return Ok(());
}
let src = fs::read_to_string(path)?;
self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
Ok(())
}
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
let handlers = self.handlers.lock().expect("lua handlers mutex poisoned");
let Some(reg) = handlers.get(&id) else {
return Ok(());
};
let callback: Function = self.lua.registry_value(reg)?;
let event_value = self.lua.to_value(&event)?;
if let Err(err) = callback.call::<_, ()>(event_value) {
error!(subscription = id.0, error = %err, "lua callback failed");
}
Ok(())
}
fn remove_handler(&self, id: SubscriptionId) {
if let Ok(mut map) = self.handlers.lock() {
map.remove(&id);
}
}
}
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
if !root.exists() {
return Ok(out);
}
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some("lua") {
out.push(path);
}
}
}
Ok(out)
}

128
breadd/src/main.rs Normal file
View file

@ -0,0 +1,128 @@
mod adapters;
mod core;
mod ipc;
mod lua;
use std::sync::Arc;
use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use crate::core::config::Config;
use crate::core::normalizer::EventNormalizer;
use crate::core::state_engine::{run_state_engine, StateHandle};
use crate::core::types::RuntimeState;
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::load()?;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(config.daemon.log_level.clone()))
.init();
info!("starting breadd");
let state = Arc::new(RwLock::new(RuntimeState::default()));
let (raw_tx, mut raw_rx) = mpsc::channel::<RawEvent>(2048);
let (normalized_tx, normalized_rx) = mpsc::unbounded_channel::<BreadEvent>();
let (state_cmd_tx, state_cmd_rx) = mpsc::unbounded_channel();
let (event_stream_tx, _) = broadcast::channel(2048);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_tx = lua_runtime.sender();
tokio::spawn(run_state_engine(
normalized_rx,
state_cmd_rx,
state.clone(),
lua_tx,
event_stream_tx.clone(),
shutdown_rx.clone(),
));
let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms));
{
let normalizer = normalizer.clone();
let normalized_tx = normalized_tx.clone();
let mut shutdown_rx = shutdown_rx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
maybe_raw = raw_rx.recv() => {
let Some(raw) = maybe_raw else {
break;
};
for event in normalizer.normalize(&raw) {
if normalized_tx.send(event).is_err() {
break;
}
}
}
}
}
});
}
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?;
let _ = normalized_tx.send(BreadEvent::new(
"bread.system.startup",
AdapterSource::System,
serde_json::json!({}),
));
let ipc_server = ipc::Server::new(
config.socket_path(),
state_handle,
event_stream_tx,
lua_runtime.clone(),
normalized_tx,
);
info!("breadd fully started");
tokio::select! {
result = ipc_server.serve(shutdown_rx.clone()) => {
if let Err(err) = result {
error!(error = %err, "ipc server failed");
}
}
_ = wait_for_shutdown() => {
info!("shutdown signal received");
}
}
let _ = shutdown_tx.send(true);
lua_runtime.shutdown();
Ok(())
}
async fn wait_for_shutdown() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}

View file

@ -0,0 +1,210 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::sleep;
#[tokio::test]
async fn ping_and_state_dump_work() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let ping = harness.send_request("ping", json!({})).await?;
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
let health = harness.send_request("health", json!({})).await?;
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
assert!(health.get("version").and_then(Value::as_str).is_some());
assert!(health.get("uptime_ms").and_then(Value::as_u64).is_some());
let dump = harness.send_request("state.dump", json!({})).await?;
assert!(dump.get("devices").is_some());
assert!(dump.get("profile").is_some());
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn events_stream_receives_emitted_events() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let stream = UnixStream::connect(harness.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-1",
"method": "events.subscribe",
"params": {
"filter": "bread.system.*"
}
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
let ack = reader
.next_line()
.await?
.ok_or_else(|| anyhow!("missing subscribe ack"))?;
let ack_json: Value = serde_json::from_str(&ack)?;
assert_eq!(
ack_json
.get("result")
.and_then(|v| v.get("subscribed"))
.and_then(Value::as_bool),
Some(true)
);
harness
.send_request(
"emit",
json!({
"event": "bread.system.test",
"data": { "ok": true }
}),
)
.await?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut got = false;
while Instant::now() < deadline {
let Some(line) = reader.next_line().await? else {
break;
};
let event: Value = serde_json::from_str(&line)?;
if event.get("event").and_then(Value::as_str) == Some("bread.system.test") {
got = true;
break;
}
}
assert!(got, "did not receive emitted event on stream");
harness.shutdown();
Ok(())
}
struct TestHarness {
_temp: TempDir,
child: Child,
socket_path: PathBuf,
}
impl TestHarness {
fn spawn() -> Result<Self> {
let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
let home = temp.path().join("home");
fs::create_dir_all(&runtime_dir)?;
fs::create_dir_all(&config_home)?;
fs::create_dir_all(&home)?;
let bread_cfg = config_home.join("bread");
fs::create_dir_all(bread_cfg.join("modules"))?;
fs::write(
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
fs::write(
bread_cfg.join("breadd.toml"),
r#"
[daemon]
log_level = "error"
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = false
[adapters.udev]
enabled = false
[adapters.power]
enabled = false
[adapters.network]
enabled = false
"#,
)?;
let socket_path = runtime_dir.join("bread").join("breadd.sock");
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
.env("XDG_RUNTIME_DIR", &runtime_dir)
.env("XDG_CONFIG_HOME", &config_home)
.env("HOME", &home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(Self {
_temp: temp,
child,
socket_path,
})
}
fn socket_path(&self) -> &Path {
&self.socket_path
}
async fn wait_until_ready(&self) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
if self.socket_path.exists() {
let ping = self.send_request("ping", json!({})).await;
if ping.is_ok() {
return Ok(());
}
}
sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("daemon did not become ready in time"))
}
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
let stream = UnixStream::connect(self.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let req = json!({
"id": "1",
"method": method,
"params": params,
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let line = lines
.next_line()
.await?
.ok_or_else(|| anyhow!("missing ipc response"))?;
let parsed: Value = serde_json::from_str(&line)?;
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
return Err(anyhow!(err.to_string()));
}
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
}
fn shutdown(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

5
packaging/README.md Normal file
View file

@ -0,0 +1,5 @@
Packaging notes
================
This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under
`packaging/arch/`.

25
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,25 @@
# Maintainer: Your Name <you@example.com>
pkgname=breadd
pkgver=0.1.0
pkgrel=1
pkgdesc="Bread daemon - event normalizer and automation runtime"
arch=('x86_64')
url="https://example.com/bread"
license=('MIT')
depends=('glibc')
makedepends=('rust')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli"
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
}

9
packaging/arch/README.md Normal file
View file

@ -0,0 +1,9 @@
Arch packaging
==============
This is a minimal PKGBUILD skeleton.
Steps to use:
- Update `pkgver`, `source`, `sha256sums`, and `url`.
- Set the correct license and dependencies.
- Ensure the release tarball includes `packaging/systemd/breadd.service`.

View file

@ -0,0 +1,19 @@
[Unit]
Description=Bread Runtime Daemon
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.cargo/bin/breadd
Restart=on-failure
RestartSec=2
UMask=0077
RuntimeDirectory=bread
RuntimeDirectoryMode=0700
KillSignal=SIGTERM
TimeoutStopSec=5
Environment=RUST_LOG=info
[Install]
WantedBy=default.target