Daemon release-ready
This commit is contained in:
parent
730a8b61d7
commit
d537fc9318
9 changed files with 358 additions and 1207 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
target/
|
||||
Overview.md
|
||||
DAEMON.md
|
||||
503
DAEMON.md
503
DAEMON.md
|
|
@ -1,503 +0,0 @@
|
|||
# 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`.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Breadway Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
690
Overview.md
690
Overview.md
|
|
@ -1,690 +0,0 @@
|
|||
# Bread — Architecture & Vision
|
||||
### A Reactive Automation Fabric for Linux Desktops
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Bread is a modular desktop automation fabric for Linux systems, built around a single guiding principle:
|
||||
|
||||
> The desktop should behave like a programmable runtime, not a collection of disconnected configuration files.
|
||||
|
||||
Most advanced Linux setups are a patchwork — compositor config here, a udev rule there, a handful of shell scripts duct-taped together with cron jobs and `~/.profile` hacks. They work until they don't, they're hard to reason about, and they share no understanding of what the system is actually doing at runtime.
|
||||
|
||||
Bread replaces that patchwork with a coherent layer: a long-running Rust daemon that tracks system state, normalizes hardware and compositor events into semantic signals, and exposes a Lua API for writing automation that actually knows what's going on.
|
||||
|
||||
**Bread provides:**
|
||||
|
||||
- A reactive runtime daemon (`breadd`) written in Rust
|
||||
- A Lua-driven automation and configuration layer
|
||||
- A normalized event model that abstracts raw Linux signals
|
||||
- A unified runtime state interface for advanced desktop workflows
|
||||
- A first-class CLI for introspection, debugging, and live control
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### The Problem
|
||||
|
||||
A modern Linux power-user desktop typically involves:
|
||||
|
||||
- Compositor configuration (Hyprland, Sway, etc.)
|
||||
- Monitor hotplug scripts
|
||||
- udev rules for input devices and USB
|
||||
- Workspace layout logic
|
||||
- Keybinding layers
|
||||
- Network state hooks
|
||||
- Power management scripts
|
||||
- Application launchers and session managers
|
||||
- Status bar integrations
|
||||
- Machine-specific environment hacks
|
||||
|
||||
Each of these subsystems is implemented independently. None of them share a common understanding of runtime state. If your dock connects, your monitor script doesn't know your workspace manager ran, your workspace manager doesn't know your keybindings changed, and your keybindings don't know you're now in "desk mode." Everything is blind to everything else.
|
||||
|
||||
### The Solution
|
||||
|
||||
Bread introduces a shared runtime daemon as the connective tissue between these systems. Rather than each subsystem operating in isolation, Bread:
|
||||
|
||||
1. **Ingests** raw signals from the OS, compositor, and hardware
|
||||
2. **Normalizes** them into semantic desktop events
|
||||
3. **Maintains** a canonical view of runtime state
|
||||
4. **Exposes** that state and those events to Lua automation modules
|
||||
|
||||
The goal is not to replace the kernel, systemd, Hyprland, or your package manager. Bread exists *between* the operating system, the compositor, connected hardware, and the user — as an orchestration and automation fabric.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Bread is organized into four primary layers that process system signals from raw input to user behavior.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Lua Modules │
|
||||
│ (automation, bindings, profiles, behavior) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Lua Runtime API │
|
||||
│ (bread.on, bread.state, bread.hypr, bread.exec) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Runtime State Engine │
|
||||
│ (event normalization, state tracking, subscriptions) │
|
||||
├──────────────────┬──────────────────┬───────────────────────┤
|
||||
│ Hyprland IPC │ udev / kernel │ System interfaces │
|
||||
│ Adapter │ Adapter │ (net, power) │
|
||||
└──────────────────┴──────────────────┴───────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 1: Runtime Adapters
|
||||
|
||||
Adapters are the boundary between Bread and the outside world. Each adapter interfaces with a specific external system and translates its raw output into a form the daemon can process.
|
||||
|
||||
Adapters handle:
|
||||
|
||||
- **Hyprland IPC** — workspace changes, monitor events, focus changes, window lifecycle
|
||||
- **udev** — device attachment and removal (keyboards, mice, docks, USB peripherals)
|
||||
- **Power state** — battery level, AC adapter state, suspend/resume
|
||||
- **Monitor topology** — hotplug detection, EDID, display arrangement
|
||||
- **Network interfaces** — link state changes, connection events
|
||||
|
||||
Adapters contain no automation logic. Their only job is ingestion and forwarding.
|
||||
|
||||
### Layer 2: Runtime State Engine
|
||||
|
||||
The state engine is the core of `breadd`. It is the canonical source of truth for everything Bread knows about the system at any given moment.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- **Normalize** raw adapter events into semantic Bread events
|
||||
- **Track** runtime topology (monitors, workspaces, devices, network, power)
|
||||
- **Manage** module subscriptions and event dispatch
|
||||
- **Coordinate** Lua module execution and hot reload
|
||||
- **Expose** structured state queries over IPC
|
||||
|
||||
The normalization step is one of Bread's defining features. Raw Linux signals are often low-level, fragmented, and hardware-specific. The state engine transforms them into stable, meaningful events that modules can rely on.
|
||||
|
||||
**Example: USB-C dock connection**
|
||||
|
||||
Raw udev event:
|
||||
```json
|
||||
{ "source": "udev", "action": "add", "device": "/dev/input/event12", "subsystem": "usb" }
|
||||
```
|
||||
|
||||
Normalized Bread event:
|
||||
```json
|
||||
{ "event": "bread.device.dock.connected", "device": "USB-C Dock", "timestamp": 1718000000 }
|
||||
```
|
||||
|
||||
Modules never see the raw event. They only see the semantic one.
|
||||
|
||||
### Layer 3: Lua Automation Runtime
|
||||
|
||||
The Lua runtime is where user behavior lives. Modules subscribe to events from the state engine and implement desktop automation using Bread's API.
|
||||
|
||||
```lua
|
||||
bread.on("bread.device.dock.connected", function(event)
|
||||
bread.profile.activate("desk")
|
||||
bread.hypr.dispatch("workspace 1")
|
||||
bread.exec("kitty")
|
||||
bread.notify("Desk mode active")
|
||||
end)
|
||||
```
|
||||
|
||||
The runtime provides:
|
||||
|
||||
- Event subscriptions with optional filtering predicates
|
||||
- Desktop APIs (Hyprland, exec, notifications, state access)
|
||||
- Profile management (named environment contexts)
|
||||
- Utility helpers (timers, debounce, logging)
|
||||
- Live reload support — modules can be reloaded without restarting the daemon
|
||||
|
||||
Modules interact with the system exclusively through Bread's APIs. The daemon handles all low-level coordination; modules express intent.
|
||||
|
||||
### Layer 4: CLI Interface
|
||||
|
||||
The CLI (`bread`) is the operator interface for the daemon. It provides runtime introspection, debugging, and control without requiring a GUI.
|
||||
|
||||
```bash
|
||||
bread reload # Hot-reload all Lua modules
|
||||
bread state # Dump current runtime state
|
||||
bread events # Stream live normalized events
|
||||
bread modules # List loaded modules and status
|
||||
bread profile list # Show available profiles
|
||||
bread profile activate desk # Switch active profile
|
||||
bread doctor # Diagnose configuration and daemon health
|
||||
bread emit <event> # Manually fire an event (for testing)
|
||||
```
|
||||
|
||||
The `bread emit` command is particularly useful during module development — it lets you trigger any event without having to physically plug in hardware.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Language | Rationale |
|
||||
|-----------|----------|-----------|
|
||||
| Runtime daemon (`breadd`) | Rust | Safety, performance, predictable concurrency |
|
||||
| Module system | Lua | Rapid iteration, live reload, user extensibility |
|
||||
| User configuration | Lua | Unified with module layer; no separate config DSL |
|
||||
| Automation runtime | Lua | Behavioral layer of the desktop |
|
||||
| IPC transport | JSON over Unix sockets | Simple, debuggable, language-agnostic |
|
||||
| Hyprland integration | Native Lua + IPC | Leverages Hyprland's native Lua config support |
|
||||
| CLI frontend | Rust binary | Fast startup, direct daemon communication |
|
||||
|
||||
### Why Rust + Lua
|
||||
|
||||
Bread intentionally separates runtime infrastructure from user behavior. This split is fundamental to the architecture.
|
||||
|
||||
**Rust** owns everything that must be reliable: the daemon lifecycle, event ingestion, normalization, IPC, subscription management, module loading, concurrency, and hot reload orchestration. Rust's safety guarantees and performance characteristics make it the right choice for a long-running daemon that must never crash or leak.
|
||||
|
||||
**Lua** owns everything that must be flexible: configuration, automation logic, bindings, event handlers, and desktop behavior. Lua enables rapid iteration, live reload, and a low barrier for user customization. Bread treats Lua as the behavioral scripting layer of the desktop — expressive, dynamic, and immediately reloadable.
|
||||
|
||||
The two layers communicate through a well-defined API boundary. Lua calls into Rust-backed functions; Rust dispatches events into the Lua runtime. Neither layer bleeds into the other's concerns.
|
||||
|
||||
---
|
||||
|
||||
## Event Model
|
||||
|
||||
Events are the primary communication mechanism in Bread. Understanding the event model is essential to understanding how automation works.
|
||||
|
||||
### Event Structure
|
||||
|
||||
All normalized Bread events share a common envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "bread.monitor.connected",
|
||||
"timestamp": 1718000000,
|
||||
"source": "udev",
|
||||
"data": {
|
||||
"name": "HDMI-A-1",
|
||||
"resolution": "2560x1440",
|
||||
"position": "right"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Namespacing
|
||||
|
||||
Events follow a dot-separated namespace convention: `bread.<subsystem>.<noun>.<verb>`.
|
||||
|
||||
| Namespace | Events |
|
||||
|-----------|--------|
|
||||
| `bread.device.*` | `dock.connected`, `keyboard.connected`, `mouse.removed` |
|
||||
| `bread.monitor.*` | `connected`, `disconnected`, `layout.changed` |
|
||||
| `bread.workspace.*` | `changed`, `created`, `destroyed` |
|
||||
| `bread.power.*` | `ac.connected`, `battery.low`, `suspend`, `resume` |
|
||||
| `bread.network.*` | `connected`, `disconnected`, `interface.up` |
|
||||
| `bread.profile.*` | `activated`, `deactivated` |
|
||||
| `bread.system.*` | `startup`, `shutdown`, `reload` |
|
||||
|
||||
### Filtered Subscriptions
|
||||
|
||||
Modules can subscribe to broad event patterns or use predicate filters for precision:
|
||||
|
||||
```lua
|
||||
-- Subscribe to all device events
|
||||
bread.on("bread.device.*", function(event)
|
||||
bread.log("Device event: " .. event.event)
|
||||
end)
|
||||
|
||||
-- Subscribe with a filter predicate
|
||||
bread.on("bread.device.keyboard.connected", function(event)
|
||||
if event.data.name == "Keychron K2" then
|
||||
bread.exec("xset r rate 200 40")
|
||||
end
|
||||
end)
|
||||
|
||||
-- One-shot subscription (fires once, then unregisters)
|
||||
bread.once("bread.system.startup", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
Modules can emit custom events, allowing cross-module communication without direct coupling:
|
||||
|
||||
```lua
|
||||
-- In module A: emit a custom event
|
||||
bread.emit("myconfig.mode.gaming", { fps_target = 144 })
|
||||
|
||||
-- In module B: react to it
|
||||
bread.on("myconfig.mode.gaming", function(event)
|
||||
bread.exec("gamemode -r")
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profile System
|
||||
|
||||
Profiles are a first-class primitive in Bread. A profile is a named desktop context — a coherent set of behaviors, bindings, and configurations that apply when certain conditions are met.
|
||||
|
||||
```lua
|
||||
bread.profile.define("desk", {
|
||||
description = "USB-C dock connected, external monitors active",
|
||||
|
||||
on_activate = function()
|
||||
bread.hypr.keyword("monitor HDMI-A-1,2560x1440,0x0,1")
|
||||
bread.hypr.keyword("monitor eDP-1,preferred,2560x0,1.5")
|
||||
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
|
||||
bread.notify("Desk mode")
|
||||
end,
|
||||
|
||||
on_deactivate = function()
|
||||
bread.hypr.keyword("monitor HDMI-A-1,disabled")
|
||||
bread.exec("pkill waybar && waybar")
|
||||
bread.notify("Laptop mode")
|
||||
end
|
||||
})
|
||||
|
||||
-- Automatically activate based on hardware state
|
||||
bread.on("bread.device.dock.connected", function()
|
||||
bread.profile.activate("desk")
|
||||
end)
|
||||
|
||||
bread.on("bread.device.dock.disconnected", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
```
|
||||
|
||||
Profiles can be stacked, nested, or switched manually via the CLI. They give automation a clear semantic structure — rather than writing ad-hoc scripts for each scenario, you define what "desk mode" means once and trigger it from anywhere.
|
||||
|
||||
---
|
||||
|
||||
## Module System
|
||||
|
||||
Bread is fully modular. All automation, integrations, and desktop behavior live in Lua modules loaded by the daemon.
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
~/.config/bread/modules/
|
||||
├── devices.lua # Hardware device handling
|
||||
├── workspaces.lua # Workspace layout logic
|
||||
├── profiles/
|
||||
│ ├── desk.lua # Desk profile definition
|
||||
│ └── travel.lua # Travel profile definition
|
||||
└── apps/
|
||||
└── dev-session.lua # Development environment setup
|
||||
```
|
||||
|
||||
### Module Metadata
|
||||
|
||||
Modules declare their identity and dependencies in a metadata block:
|
||||
|
||||
```lua
|
||||
return {
|
||||
name = "devices",
|
||||
version = "1.0.0",
|
||||
description = "Hardware device automation",
|
||||
|
||||
depends = {
|
||||
"hypr",
|
||||
"notifications"
|
||||
},
|
||||
|
||||
on_load = function()
|
||||
bread.log("Device module loaded")
|
||||
end,
|
||||
|
||||
on_unload = function()
|
||||
-- Clean up subscriptions, timers, etc.
|
||||
end
|
||||
}
|
||||
```
|
||||
|
||||
### Module Lifecycle
|
||||
|
||||
The daemon manages the full module lifecycle:
|
||||
|
||||
1. **Discovery** — scan `~/.config/bread/modules/` for Lua files
|
||||
2. **Dependency resolution** — topological sort based on `depends`
|
||||
3. **Loading** — initialize each module's Lua environment
|
||||
4. **Event wiring** — register all `bread.on` subscriptions
|
||||
5. **Hot reload** — on `bread reload`, unload and reload modules in dependency order without restarting the daemon
|
||||
|
||||
Modules are currently trusted and unrestricted. Security sandboxing is not a V1 goal but is noted as a future consideration.
|
||||
|
||||
---
|
||||
|
||||
## Lua Runtime API
|
||||
|
||||
### Event API
|
||||
|
||||
```lua
|
||||
-- Subscribe to an event
|
||||
bread.on("bread.monitor.connected", function(event)
|
||||
print(event.data.name)
|
||||
end)
|
||||
|
||||
-- Subscribe once
|
||||
bread.once("bread.system.startup", function() end)
|
||||
|
||||
-- Emit a custom event
|
||||
bread.emit("mymodule.something.happened", { key = "value" })
|
||||
|
||||
-- Unsubscribe by handle
|
||||
local handle = bread.on("bread.device.*", handler)
|
||||
handle:cancel()
|
||||
```
|
||||
|
||||
### State API
|
||||
|
||||
```lua
|
||||
-- Read runtime state
|
||||
local monitors = bread.state.get("monitors")
|
||||
local workspace = bread.state.get("workspace.active")
|
||||
local devices = bread.state.get("devices.connected")
|
||||
local profile = bread.state.get("profile.active")
|
||||
|
||||
-- Watch a state value for changes
|
||||
bread.state.watch("workspace.active", function(new, old)
|
||||
print("Switched from workspace " .. old .. " to " .. new)
|
||||
end)
|
||||
```
|
||||
|
||||
### Hyprland API
|
||||
|
||||
```lua
|
||||
-- Dispatch Hyprland commands
|
||||
bread.hypr.dispatch("workspace 2")
|
||||
bread.hypr.dispatch("movetoworkspace 3")
|
||||
|
||||
-- Set Hyprland keywords (monitor config, etc.)
|
||||
bread.hypr.keyword("monitor HDMI-A-1,preferred,0x0,1")
|
||||
bread.hypr.keyword("general:gaps_out = 10")
|
||||
|
||||
-- Query Hyprland state
|
||||
local clients = bread.hypr.clients()
|
||||
local monitors = bread.hypr.monitors()
|
||||
```
|
||||
|
||||
### Utility API
|
||||
|
||||
```lua
|
||||
-- Execute a process
|
||||
bread.exec("kitty")
|
||||
bread.exec("notify-send 'Hello'")
|
||||
|
||||
-- Send a desktop notification
|
||||
bread.notify("Dock connected", { urgency = "normal", timeout = 3000 })
|
||||
|
||||
-- Logging
|
||||
bread.log("Module initialized")
|
||||
bread.warn("Something unexpected happened")
|
||||
|
||||
-- Timers
|
||||
local timer = bread.after(500, function() -- run once after 500ms
|
||||
bread.exec("some-delayed-command")
|
||||
end)
|
||||
|
||||
local interval = bread.every(60000, function() -- run every 60s
|
||||
bread.state.refresh("network")
|
||||
end)
|
||||
|
||||
-- Debounce (useful for rapid hardware events)
|
||||
local handler = bread.debounce(200, function(event)
|
||||
reconfigure_monitors()
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runtime State
|
||||
|
||||
The daemon maintains a live, structured model of the desktop at all times. This is what makes Bread's automation context-aware rather than purely reactive to isolated events.
|
||||
|
||||
**Tracked state includes:**
|
||||
|
||||
| Domain | State |
|
||||
|--------|-------|
|
||||
| Displays | Connected monitors, resolution, position, refresh rate |
|
||||
| Workspaces | Active workspace, workspace list, window assignments |
|
||||
| Devices | Connected keyboards, mice, docks, USB peripherals |
|
||||
| Network | Interface state, active connections |
|
||||
| Power | Battery level, charging state, AC status |
|
||||
| Profiles | Active profile, profile history |
|
||||
| Hyprland | Active window, client list, monitor config |
|
||||
| Modules | Loaded modules, load status, error state |
|
||||
|
||||
State is live — it reflects the current system, not a snapshot. Modules read state synchronously; the daemon updates it as events arrive.
|
||||
|
||||
---
|
||||
|
||||
## Hot Reload
|
||||
|
||||
Hot reload is a core design requirement, not an afterthought.
|
||||
|
||||
The daemon persists across reloads. Only the Lua layer reloads:
|
||||
|
||||
- Lua modules
|
||||
- Configuration
|
||||
- Bindings and event handlers
|
||||
- Profile definitions
|
||||
- Hyprland integration scripts
|
||||
|
||||
Reload is triggered by `bread reload` or by file system watch (if enabled). The daemon:
|
||||
|
||||
1. Calls `on_unload` for each loaded module in reverse dependency order
|
||||
2. Clears all event subscriptions and timers registered by Lua
|
||||
3. Re-evaluates all Lua module files
|
||||
4. Calls `on_load` for each module in dependency order
|
||||
5. Re-registers all `bread.on` subscriptions
|
||||
|
||||
The result: you can edit a module, run `bread reload`, and see the effect immediately — without losing daemon state, without restarting Hyprland, and without interrupting your session.
|
||||
|
||||
---
|
||||
|
||||
## Hyprland Integration
|
||||
|
||||
Bread V1 is Hyprland-first. The architecture is compositor-agnostic in design but Hyprland is the exclusive target for V1.
|
||||
|
||||
This is a deliberate choice. Hyprland now supports Lua configuration natively, which means Bread's Lua layer integrates directly into compositor configuration rather than working around it. Bread becomes the orchestration layer that surrounds and augments Hyprland.
|
||||
|
||||
**Bread does not replace Hyprland.** Hyprland handles:
|
||||
- Window management
|
||||
- Compositor rendering
|
||||
- Keybinding dispatch (at the base level)
|
||||
- Layout algorithms
|
||||
|
||||
Bread handles:
|
||||
- Semantic event interpretation
|
||||
- Hardware-aware workspace automation
|
||||
- Cross-subsystem orchestration
|
||||
- Live behavioral scripting
|
||||
|
||||
The two systems are complementary. A Hyprland config without Bread is static. Bread without Hyprland has no compositor to orchestrate.
|
||||
|
||||
---
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
```
|
||||
~/.config/bread/
|
||||
├── init.lua # Entry point — loads modules, sets defaults
|
||||
├── modules/ # User and community modules
|
||||
│ ├── devices.lua
|
||||
│ ├── workspaces.lua
|
||||
│ └── profiles/
|
||||
│ ├── desk.lua
|
||||
│ └── travel.lua
|
||||
├── environments/ # Named environment definitions (future)
|
||||
├── state/ # Persisted runtime state (optional)
|
||||
├── generated/ # Daemon-generated config fragments
|
||||
├── runtime/ # Active runtime sockets and PIDs
|
||||
└── cache/ # Module cache, compiled chunks
|
||||
```
|
||||
|
||||
`init.lua` is the single entry point. It imports modules, defines global behavior, and wires up the initial profile:
|
||||
|
||||
```lua
|
||||
-- ~/.config/bread/init.lua
|
||||
|
||||
require("modules.devices")
|
||||
require("modules.workspaces")
|
||||
require("modules.profiles.desk")
|
||||
require("modules.profiles.travel")
|
||||
|
||||
bread.on("bread.system.startup", function()
|
||||
bread.profile.activate("default")
|
||||
bread.log("Bread initialized")
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Workflow: Dock Connect / Disconnect
|
||||
|
||||
This end-to-end example shows how Bread's layers work together.
|
||||
|
||||
**User connects a USB-C dock.**
|
||||
|
||||
1. udev fires a raw device add event
|
||||
2. The udev adapter ingests it and forwards it to the state engine
|
||||
3. The state engine recognizes the device signature as a known dock
|
||||
4. Runtime state updates: `devices.dock = { connected: true, name: "USB-C Dock" }`
|
||||
5. Normalized event fires: `bread.device.dock.connected`
|
||||
6. The `devices` module receives the event
|
||||
7. `bread.profile.activate("desk")` is called
|
||||
8. The desk profile's `on_activate` fires:
|
||||
- Monitor layout is configured via `bread.hypr.keyword`
|
||||
- Development applications launch via `bread.exec`
|
||||
- A notification is sent via `bread.notify`
|
||||
9. Desk mode is active
|
||||
|
||||
**User disconnects the dock.**
|
||||
|
||||
1. udev fires a raw device remove event
|
||||
2. State engine updates, fires `bread.device.dock.disconnected`
|
||||
3. `bread.profile.activate("default")` is called
|
||||
4. The desk profile's `on_deactivate` fires
|
||||
5. External monitors are disabled, laptop layout is restored
|
||||
6. Mobile mode is active
|
||||
|
||||
The entire workflow is event-driven, stateful, and expressed entirely in Lua. The user never writes a udev rule, a shell script, or a one-off systemd service.
|
||||
|
||||
---
|
||||
|
||||
## V1 Scope
|
||||
|
||||
V1 is intentionally narrow. The goal is a complete, working, well-designed foundation — not a feature-complete platform.
|
||||
|
||||
### Included in V1
|
||||
|
||||
**Runtime**
|
||||
- Rust daemon (`breadd`)
|
||||
- JSON IPC over Unix sockets
|
||||
- Event subscription and dispatch
|
||||
- Runtime state engine
|
||||
- Hot reload
|
||||
|
||||
**Adapters**
|
||||
- Hyprland IPC
|
||||
- udev (device hotplug)
|
||||
- Monitor topology (hotplug, EDID)
|
||||
- Power state (battery, AC)
|
||||
- Basic network interface state
|
||||
|
||||
**Lua Layer**
|
||||
- Module system with dependency resolution
|
||||
- Full runtime API (`bread.on`, `bread.state`, `bread.exec`, `bread.notify`, `bread.hypr`)
|
||||
- Profile system
|
||||
- Timers, debounce, logging utilities
|
||||
- Live reload
|
||||
|
||||
**CLI**
|
||||
- `bread reload` — hot-reload modules
|
||||
- `bread state` — dump runtime state
|
||||
- `bread events` — stream live events
|
||||
- `bread modules` — list modules and status
|
||||
- `bread profile` — manage profiles
|
||||
- `bread emit` — manually fire events (for development)
|
||||
- `bread doctor` — diagnose configuration and daemon health
|
||||
|
||||
### Explicitly Excluded from V1
|
||||
|
||||
To preserve focus and architectural integrity, V1 does not include:
|
||||
|
||||
- Provisioning or dotfile management
|
||||
- Package management or module marketplace
|
||||
- Cloud sync or distributed state
|
||||
- GUI frontends or system tray integration
|
||||
- Compositor abstraction (non-Hyprland support)
|
||||
- Security sandboxing for modules
|
||||
- Non-Arch Linux support
|
||||
- Non-Wayland support
|
||||
- Reconciliation or declarative config engine
|
||||
|
||||
These are not permanent exclusions — they are deferred to preserve the quality and coherence of V1.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling & Reliability
|
||||
|
||||
A desktop automation daemon must be robust. Bread's reliability strategy:
|
||||
|
||||
**Lua errors are isolated.** A panic in a module's event handler does not crash the daemon. Errors are caught, logged, and reported via `bread doctor`. The daemon continues running.
|
||||
|
||||
**Adapter failures are non-fatal.** If the Hyprland IPC socket disappears (compositor restart), the Hyprland adapter reconnects with exponential backoff. Other adapters continue functioning.
|
||||
|
||||
**Hot reload is atomic.** If a module fails to load during reload (syntax error, missing dependency), the reload aborts and the previous module state is preserved. A partial reload never leaves the daemon in an inconsistent state.
|
||||
|
||||
**State is eventually consistent.** The daemon does not guarantee that state reads are perfectly synchronized with the physical system at every millisecond. It guarantees that state converges to truth as events arrive. For desktop automation, this is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
Bread prioritizes these properties above all else:
|
||||
|
||||
- **Runtime introspection** — you can always ask Bread what it knows
|
||||
- **Event-driven** — behavior is triggered by state changes, not polling
|
||||
- **Modular** — no monolithic config; composable automation units
|
||||
- **Live reconfiguration** — reload without restarting anything
|
||||
- **Hardware-aware** — first-class understanding of device topology
|
||||
- **Operator-focused tooling** — great CLI, great debugging experience
|
||||
- **Predictable** — events have stable names; state has stable structure; APIs don't break
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Bread is not:
|
||||
|
||||
- A desktop environment
|
||||
- A window manager or compositor
|
||||
- A package manager or provisioning system
|
||||
- An init system or service manager
|
||||
- A shell replacement
|
||||
- A Linux distribution
|
||||
- A monolithic platform
|
||||
|
||||
Bread exists as an automation and orchestration fabric layered on top of existing, well-designed Linux tools. It makes those tools work together — it does not replace them.
|
||||
|
||||
---
|
||||
|
||||
## Long-Term Vision
|
||||
|
||||
Bread's V1 is a foundation. The long-term vision is:
|
||||
|
||||
> A programmable automation fabric for Linux desktops — where the desktop is an observable, scriptable, reactive runtime that adapts to the user's context in real time.
|
||||
|
||||
Future directions under consideration:
|
||||
|
||||
- **Broader compositor support** — Sway, niri, others
|
||||
- **Environment abstractions** — portable desktop profiles that work across machines
|
||||
- **Declarative runtime layers** — optional reconciliation for users who prefer that model
|
||||
- **REPL / runtime console** — live Lua evaluation against the daemon state
|
||||
- **Provisioning tooling** — machine bootstrap and dotfile orchestration
|
||||
- **Synchronization** — state and config sync across devices
|
||||
- **Module ecosystem** — community modules and a discovery mechanism
|
||||
|
||||
The core philosophy does not change: Linux desktops should behave like observable, programmable runtime systems.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Bread transforms Linux desktop automation from a fragmented collection of shell scripts, isolated configs, and disconnected runtime hacks into a coherent reactive runtime — powered by Rust, scripted through Lua, and driven by semantic desktop state.
|
||||
|
||||
It is designed for users who want their desktop to behave less like a static configuration and more like a programmable operating environment: one that knows what hardware is connected, what profile is active, what the compositor is doing, and what to do about all of it.
|
||||
274
README.md
Normal file
274
README.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Bread
|
||||
|
||||
**A reactive automation fabric for Linux desktops.**
|
||||
|
||||
Bread is a modular desktop automation runtime built around a single idea: your desktop should behave like a programmable system, not a collection of disconnected config files.
|
||||
|
||||
Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically.
|
||||
|
||||
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Bread runs a long-lived daemon (`breadd`) that:
|
||||
|
||||
1. Ingests raw signals from your compositor, hardware, and OS
|
||||
2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.)
|
||||
3. Maintains a live model of your desktop state
|
||||
4. Delivers those events to Lua modules that implement your automation
|
||||
|
||||
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
|
||||
|
||||
```lua
|
||||
bread.on("bread.device.dock.connected", function()
|
||||
bread.profile.activate("desk")
|
||||
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
|
||||
end)
|
||||
|
||||
bread.on("bread.device.dock.disconnected", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||
packaging/ Arch PKGBUILD and systemd user service
|
||||
```
|
||||
|
||||
The daemon is structured in four layers:
|
||||
|
||||
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces
|
||||
- **Normalizer** — transforms raw adapter signals into semantic Bread events
|
||||
- **State engine** — maintains runtime state and dispatches events to subscribers
|
||||
- **Lua runtime** — loads your modules, registers handlers, executes automation
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux (Arch recommended)
|
||||
- Wayland compositor (Hyprland for full functionality)
|
||||
- Rust toolchain (stable, 2021 edition)
|
||||
- `udev` (standard on systemd systems)
|
||||
|
||||
Optional but preferred:
|
||||
- UPower (for battery events via D-Bus rather than sysfs polling)
|
||||
- rtnetlink (for network events; falls back to sysfs polling without it)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Breadway/bread
|
||||
cd bread
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binaries will be at `target/release/breadd` and `target/release/bread`.
|
||||
|
||||
Install them:
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/breadd /usr/local/bin/breadd
|
||||
sudo install -Dm755 target/release/bread /usr/local/bin/bread
|
||||
```
|
||||
|
||||
### Arch Linux (PKGBUILD)
|
||||
|
||||
```bash
|
||||
cd packaging/arch
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
### systemd user service
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp packaging/systemd/breadd.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now breadd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Bread reads from `~/.config/bread/breadd.toml`. All values are optional — the daemon runs with defaults if the file doesn't exist.
|
||||
|
||||
```toml
|
||||
[daemon]
|
||||
log_level = "info" # trace | debug | info | warn | error
|
||||
|
||||
[lua]
|
||||
entry_point = "~/.config/bread/init.lua"
|
||||
module_path = "~/.config/bread/modules"
|
||||
|
||||
[adapters.hyprland]
|
||||
enabled = true
|
||||
|
||||
[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
|
||||
```
|
||||
|
||||
Your automation lives in `~/.config/bread/init.lua`:
|
||||
|
||||
```lua
|
||||
-- ~/.config/bread/init.lua
|
||||
|
||||
require("modules.devices")
|
||||
require("modules.workspaces")
|
||||
|
||||
bread.on("bread.system.startup", function()
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI reference
|
||||
|
||||
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||
|
||||
```bash
|
||||
bread reload # Hot-reload all Lua modules
|
||||
bread state # Dump full runtime state as JSON
|
||||
bread events # Stream live normalized events
|
||||
bread events --filter bread.device.* # Stream filtered events
|
||||
bread modules # List loaded modules and status
|
||||
bread profile-list # List defined profiles
|
||||
bread profile-activate <name> # Activate a named profile
|
||||
bread emit <event> --data '{}' # Manually fire an event (for testing)
|
||||
bread ping # Check daemon connectivity
|
||||
bread health # Daemon version, uptime, PID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event reference
|
||||
|
||||
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
||||
|
||||
| Event | Trigger |
|
||||
|-------|---------|
|
||||
| `bread.system.startup` | Daemon fully initialized |
|
||||
| `bread.device.connected` | Any device attached |
|
||||
| `bread.device.disconnected` | Any device removed |
|
||||
| `bread.device.dock.connected` | Dock attached |
|
||||
| `bread.device.dock.disconnected` | Dock removed |
|
||||
| `bread.device.keyboard.connected` | Keyboard attached |
|
||||
| `bread.monitor.connected` | Display connected |
|
||||
| `bread.monitor.disconnected` | Display disconnected |
|
||||
| `bread.workspace.changed` | Active workspace changed |
|
||||
| `bread.window.focus.changed` | Focused window changed |
|
||||
| `bread.window.opened` | Window opened |
|
||||
| `bread.window.closed` | Window closed |
|
||||
| `bread.power.ac.connected` | AC adapter plugged in |
|
||||
| `bread.power.ac.disconnected` | AC adapter unplugged |
|
||||
| `bread.power.battery.low` | Battery ≤ 20% |
|
||||
| `bread.power.battery.very_low` | Battery ≤ 10% |
|
||||
| `bread.power.battery.critical` | Battery ≤ 5% |
|
||||
| `bread.power.battery.full` | Battery at 100% |
|
||||
| `bread.network.connected` | Network interface came online |
|
||||
| `bread.network.disconnected` | Network interface went offline |
|
||||
| `bread.profile.activated` | Profile switched |
|
||||
|
||||
---
|
||||
|
||||
## Lua API
|
||||
|
||||
### Events
|
||||
|
||||
```lua
|
||||
-- Subscribe to an event
|
||||
bread.on("bread.monitor.connected", function(event)
|
||||
print(event.data.name)
|
||||
end)
|
||||
|
||||
-- Subscribe once, then auto-unsubscribe
|
||||
bread.once("bread.system.startup", function(event)
|
||||
-- runs exactly once
|
||||
end)
|
||||
|
||||
-- Emit a custom event (for cross-module communication)
|
||||
bread.emit("mymodule.something", { key = "value" })
|
||||
```
|
||||
|
||||
### State
|
||||
|
||||
```lua
|
||||
-- Read a value from runtime state by dot-separated path
|
||||
local monitors = bread.state.get("monitors")
|
||||
local workspace = bread.state.get("active_workspace")
|
||||
local power = bread.state.get("power")
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
```lua
|
||||
bread.profile.activate("desk")
|
||||
bread.profile.activate("default")
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```lua
|
||||
-- Fire-and-forget: returns immediately, process runs in background
|
||||
bread.exec("kitty")
|
||||
bread.exec("notify-send 'Dock connected'")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IPC protocol
|
||||
|
||||
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
|
||||
```
|
||||
|
||||
Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `emit`.
|
||||
|
||||
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Bread is early-stage software. Contributions, issues, and feedback are welcome.
|
||||
|
||||
The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::core::types::DeviceClass;
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
const EVICT_MULTIPLIER: u64 = 60;
|
||||
|
||||
pub struct EventNormalizer {
|
||||
dedup_window_ms: u64,
|
||||
recent: Mutex<HashMap<String, u64>>,
|
||||
recent: RwLock<HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
impl EventNormalizer {
|
||||
pub fn new(dedup_window_ms: u64) -> Self {
|
||||
Self {
|
||||
dedup_window_ms,
|
||||
recent: Mutex::new(HashMap::new()),
|
||||
recent: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,16 +167,36 @@ impl EventNormalizer {
|
|||
|
||||
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;
|
||||
|
||||
// Fast path: check under read lock first.
|
||||
{
|
||||
let recent = self.recent.read().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some(last) = recent.get(&key) {
|
||||
if now.saturating_sub(*last) < self.dedup_window_ms {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: acquire write lock, re-check, insert, and periodically evict.
|
||||
let mut recent = self.recent.write().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
// Re-check after acquiring write lock (another thread may have inserted between locks).
|
||||
if let Some(last) = recent.get(&key) {
|
||||
if now.saturating_sub(*last) < self.dedup_window_ms {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
recent.insert(key, now);
|
||||
recent.insert(key.clone(), now);
|
||||
|
||||
// Evict stale entries to prevent unbounded growth.
|
||||
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||
if evict_before > 0 {
|
||||
recent.retain(|_, &mut last| last >= evict_before);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,19 @@ impl SubscriptionTable {
|
|||
return false;
|
||||
};
|
||||
|
||||
// swap_remove moves the last element into `idx`. We need to update by_id
|
||||
// for that element. But first, remove its stale entry (it was at the last
|
||||
// position before the swap); then re-insert it at the new position.
|
||||
let last_idx = self.entries.len() - 1;
|
||||
self.entries.swap_remove(idx);
|
||||
if let Some(swapped) = self.entries.get(idx) {
|
||||
self.by_id.insert(swapped.id, idx);
|
||||
|
||||
if idx < self.entries.len() {
|
||||
// The element that was at `last_idx` is now at `idx`.
|
||||
let swapped_id = self.entries[idx].id;
|
||||
self.by_id.remove(&swapped_id); // remove stale last_idx entry
|
||||
self.by_id.insert(swapped_id, idx);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use anyhow::{anyhow, Result};
|
|||
use bread_shared::{AdapterSource, BreadEvent};
|
||||
use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, Value};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::core::config::Config;
|
||||
|
|
@ -239,13 +240,27 @@ impl LuaEngine {
|
|||
profile_tbl.set("activate", activate_fn)?;
|
||||
bread.set("profile", profile_tbl)?;
|
||||
|
||||
// Fire-and-forget: the process is launched on a blocking thread and the
|
||||
// Lua handler returns immediately. The Lua runtime is never stalled waiting
|
||||
// for a slow or hanging process. Exit code is logged but not returned to Lua.
|
||||
let exec_fn = self.lua.create_function(move |_lua, cmd: String| {
|
||||
let status = std::process::Command::new("sh")
|
||||
task::spawn_blocking(move || {
|
||||
match std::process::Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(&cmd)
|
||||
.status()
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(status.code().unwrap_or_default())
|
||||
{
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
tracing::warn!(cmd = %cmd, code = ?status.code(), "bread.exec exited non-zero");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(cmd = %cmd, error = %err, "bread.exec failed to spawn");
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("exec", exec_fn)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pkgver=0.1.0
|
|||
pkgrel=1
|
||||
pkgdesc="Bread daemon - event normalizer and automation runtime"
|
||||
arch=('x86_64')
|
||||
url="https://example.com/bread"
|
||||
url="https://github.com/Breadway/bread"
|
||||
license=('MIT')
|
||||
depends=('glibc')
|
||||
makedepends=('rust')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue