bread/README.md
Breadway 1fda781b4c
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
release / build (push) Successful in 1m17s
Build and publish package / package (push) Successful in 1m42s
CI: migrate release workflow from GitHub Actions to Forgejo Actions
GitHub Actions self-hosted runners need per-repo registration on a
personal account; Forgejo Actions' runner already serves every repo
with zero setup. Moves release publishing there (dl.breadway.dev stays
the primary bakery target; GitHub release upload is kept as the
fallback via an explicit token, since Forgejo Actions has no ambient
GITHUB_TOKEN) and adds a mirror workflow to keep GitHub in sync
automatically.
2026-07-03 14:09:59 +08:00

561 lines
16 KiB
Markdown

# Bread
**A reactive automation fabric for Linux desktops.**
Bread is a modular desktop automation runtime built around a single idea: your desktop should behave like a programmable system, not a collection of disconnected config files.
Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically.
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use.
---
## How it works
Bread runs a long-lived daemon (`breadd`) that:
1. Ingests raw signals from your compositor, hardware, and OS
2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.)
3. Maintains a live model of your desktop state
4. Delivers those events to Lua modules that implement your automation
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
```lua
local M = bread.module({ name = "dock", version = "1.0.0" })
bread.on("bread.device.dock.connected", function(event)
bread.profile.activate("desk")
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
bread.notify("Dock connected", { urgency = "low" })
end)
bread.on("bread.device.dock.disconnected", function(event)
bread.profile.activate("default")
end)
return M
```
---
## Architecture
```
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
bread-cli/ CLI frontend — talks to breadd over a Unix socket
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
packaging/ Arch PKGBUILD and systemd user service
```
The daemon is structured in four layers:
- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
- **Normalizer** — transforms raw adapter signals into semantic Bread events
- **State engine** — maintains runtime state and dispatches events to subscribers
- **Lua runtime** — loads your modules, registers handlers, executes automation
---
## Requirements
- Linux (Arch recommended)
- Wayland compositor (Hyprland for full functionality)
- Rust toolchain (stable, 2021 edition)
- `udev` (standard on systemd systems)
Optional but preferred:
- UPower (for battery events via D-Bus rather than sysfs polling)
- rtnetlink (for network events; falls back to sysfs polling without it)
- BlueZ (for Bluetooth device events and control)
---
## Installation
### From source
```bash
git clone https://github.com/Breadway/bread.git
cd bread
```
Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
```bash
bash scripts/install.sh
```
Or step by step (system-wide install):
```bash
cargo build --release
sudo install -Dm755 target/release/breadd /usr/bin/breadd
sudo install -Dm755 target/release/bread /usr/bin/bread
```
### Arch Linux (PKGBUILD)
```bash
cd packaging/arch
makepkg -si
```
### systemd user service
```bash
mkdir -p ~/.config/systemd/user
cp packaging/systemd/breadd.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now breadd
```
---
## Configuration
Bread reads from `~/.config/bread/breadd.toml`. All values are optional — the daemon runs with defaults if the file doesn't exist.
```toml
[daemon]
log_level = "info" # trace | debug | info | warn | error
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = true
[adapters.udev]
enabled = true
subsystems = ["usb", "input", "drm", "power_supply"]
[adapters.power]
enabled = true
poll_interval_secs = 30
[adapters.network]
enabled = true
[adapters.bluetooth]
enabled = true
[events]
dedup_window_ms = 100
[notifications]
default_timeout_ms = 5000
default_urgency = "normal"
notify_send_path = "notify-send"
[modules]
builtin = true # load built-in modules (monitors, devices, workspaces, binds)
disable = [] # list of built-in module names to disable
```
Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`:
```lua
-- ~/.config/bread/init.lua
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
end)
```
---
## CLI reference
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
```bash
# Daemon
bread ping # Check daemon connectivity
bread health # Daemon version, uptime, PID
bread doctor # Diagnose daemon and module health
bread doctor --json # Output raw JSON
# Lua runtime
bread reload # Hot-reload all Lua modules
bread reload --watch # Watch config dir and reload on changes
# State and events
bread state # Dump full runtime state as JSON
bread state network # Read a single path from state
bread state --json # Output raw JSON
bread events # Stream live normalized events
bread events bread.device.* # Stream filtered events
bread events --since 60 # Replay events from the last 60 seconds
bread events --fields event,data # Limit output to specific fields
bread events --json # Output raw JSON
bread emit <event> # Manually fire an event (for testing)
# Profiles
bread profile-list # List defined profiles
bread profile-activate <name> # Activate a named profile
# Modules
bread modules list # List installed modules and daemon status
bread modules install /local/path # Install from a local module directory
bread modules remove <name> # Remove an installed module (--yes skips confirmation)
bread modules info <name> # Show full manifest and daemon status
```
---
## Module system
Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest.
### Installing modules
Modules install from a local directory only. Modules run with full
`bread.exec()` privileges and are **not** sandboxed, so to use a module
published on a git host, clone it yourself and review the Lua before
installing from the local checkout:
```bash
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
# review ~/src/bread-wifi, then:
bread modules install ~/src/bread-wifi
```
### Writing a module
A module directory looks like:
```
~/.config/bread/modules/
└── wifi/
├── bread.module.toml ← required manifest
└── init.lua ← entry point
```
`bread.module.toml`:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "/home/you/src/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
```
`init.lua`:
```lua
local M = bread.module({ name = "wifi", version = "1.0.0" })
bread.on("bread.network.connected", function(event)
bread.log("Network up: " .. (event.data.interface or "unknown"))
end)
return M
```
---
## Event reference
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| Event | Trigger |
|-------|---------|
| `bread.system.startup` | Daemon fully initialized |
| `bread.device.connected` | Any device attached |
| `bread.device.disconnected` | Any device removed |
| `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
| `bread.device.<device>.disconnected` | Named device removed |
| `bread.monitor.connected` | Display connected |
| `bread.monitor.disconnected` | Display disconnected |
| `bread.workspace.changed` | Active workspace changed |
| `bread.window.focus.changed` | Focused window changed |
| `bread.window.opened` | Window opened |
| `bread.window.closed` | Window closed |
| `bread.power.ac.connected` | AC adapter plugged in |
| `bread.power.ac.disconnected` | AC adapter unplugged |
| `bread.power.battery.low` | Battery ≤ 20% |
| `bread.power.battery.very_low` | Battery ≤ 10% |
| `bread.power.battery.critical` | Battery ≤ 5% |
| `bread.power.battery.full` | Battery at 100% |
| `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline |
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
| `bread.profile.activated` | Profile switched |
| `bread.notify.sent` | Desktop notification dispatched |
---
## Lua API
### Modules
Every module file must declare itself. The declaration is used for dependency ordering and status tracking.
```lua
local M = bread.module({
name = "my-module",
version = "1.0.0",
after = { "bread.devices" }, -- load after this module
})
-- ... module body ...
return M
```
### Events
```lua
-- Subscribe to events; returns a subscription ID
local id = bread.on("bread.monitor.connected", function(event)
-- event.event → "bread.monitor.connected"
-- event.data → table of event-specific fields
-- event.source → adapter that produced it
bread.log(event.event)
end)
-- Unsubscribe by ID
bread.off(id)
-- Subscribe once, auto-unsubscribe after first delivery
bread.once("bread.system.startup", function(event)
bread.profile.activate("default")
end)
-- Subscribe with a filter predicate. The predicate goes in an opts table.
bread.filter("bread.device.connected", function(event)
bread.exec("xset r rate 200 40")
end, {
filter = function(event)
return event.data.device == "keyboard"
end,
})
-- Emit a custom event (for cross-module communication)
bread.emit("mymodule.something", { key = "value" })
```
Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character):
```lua
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
bread.on("bread.device.**", handler) -- matches any depth under bread.device
```
### State
```lua
-- Read from runtime state by dot-separated path
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
-- Typed shorthands
local monitors = bread.state.monitors()
local workspace = bread.state.active_workspace()
local window = bread.state.active_window()
local devices = bread.state.devices()
local power = bread.state.power()
local network = bread.state.network()
local profile = bread.state.profile()
-- Watch a state path for changes
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.notify("AC connected")
end
end)
```
### Profiles
```lua
bread.profile.activate("desk")
bread.profile.activate("default")
```
### Execution and notifications
```lua
-- Fire-and-forget shell command
bread.exec("kitty")
-- Desktop notification (uses notify-send)
-- First arg is the message body; opts.title sets the notification title (default: "bread")
bread.notify("Something happened", { title = "My Module", urgency = "normal", timeout = 3000, icon = "dialog-info" })
bread.notify("AC connected") -- title defaults to "bread"
```
### Timers
```lua
-- Run once after a delay (ms)
local id = bread.after(500, function()
bread.exec("some-delayed-command")
end)
-- Run on a repeating interval (ms)
local id = bread.every(60000, function()
bread.log("tick")
end)
-- Cancel either kind
bread.cancel(id)
-- Debounce a rapidly-firing handler
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.*", fn)
```
### Wait (inside coroutines)
```lua
-- Yield until a matching event arrives
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
-- dock arrived within 5 seconds
end
```
### Machine and filesystem
```lua
-- Machine identity (system hostname)
local name = bread.machine.name()
local tags = bread.machine.tags() -- array of strings
local ok = bread.machine.has_tag("laptop")
-- Filesystem helpers (~ is expanded)
bread.fs.write("~/.config/some/file", "content")
local content = bread.fs.read("~/.config/some/file") -- nil if not found
local exists = bread.fs.exists("~/some/path")
local abs = bread.fs.expand("~/some/path")
```
### Logging
```lua
bread.log("Module loaded") -- info level
bread.warn("Unexpected state") -- warn level
bread.error("Something failed") -- error level
```
### Hyprland bindings
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypass normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw is the unparsed string from Hyprland's event socket
end)
```
### Bluetooth
The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable.
```lua
-- Power the adapter on or off
bread.bluetooth.power(true)
bread.bluetooth.power(false)
-- Query current power state (returns true/false, or nil if unavailable)
local on = bread.bluetooth.powered()
-- Connect/disconnect a paired device by MAC address
-- Fire-and-forget; result arrives as bread.device.connected/disconnected
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
-- Start or stop device discovery
bread.bluetooth.scan(true)
bread.bluetooth.scan(false)
-- List all devices known to BlueZ
local devs = bread.bluetooth.devices()
-- Returns nil if BlueZ is unavailable, otherwise:
-- { { address, name, connected, paired }, ... }
```
Example — auto-connect headphones when Bluetooth powers on:
```lua
bread.state.watch("power.ac_connected", function(ac)
if ac then
bread.bluetooth.power(true)
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
end
end)
```
### Module-scoped storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local p = M.store.get("last_profile") -- "docked"
```
---
## IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI.
Request:
```json
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
```
Response:
```json
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
```
Available methods:
| Method | Description |
|--------|-------------|
| `ping` | Connectivity check |
| `health` | Version, uptime, PID, adapter status |
| `state.get` | Read a value from `RuntimeState` by dotted key path |
| `state.dump` | Return the full `RuntimeState` as JSON |
| `modules.list` | List all loaded modules and their status |
| `modules.reload` | Hot-reload the Lua runtime |
| `profile.list` | List defined profiles |
| `profile.activate` | Switch active profile |
| `events.subscribe` | Upgrade connection to streaming mode |
| `events.replay` | Replay buffered events from the last N ms |
| `emit` | Inject a synthetic event into the pipeline |
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
---
## Contributing
Bread is early-stage software. Contributions, issues, and feedback are welcome.
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API and module system.
---
## License
MIT — see [LICENSE](LICENSE).