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