feat: add bread-sync module for snapshot and restore functionality

- Introduced `bread-sync` module with core functionalities for syncing system state via Git.
- Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles.
- Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`.
- Created comprehensive tests for sync operations, package parsing, and machine profile management.
- Enhanced `udev` adapter to include vendor and product IDs for scanned devices.
- Updated state engine to handle module clearing commands.
- Introduced Lua integration for accessing machine information and file system operations.
- Improved packaging documentation for Arch Linux and systemd service setup.
This commit is contained in:
Breadway 2026-05-12 00:20:45 +08:00
parent 251c586b6f
commit 364a35142e
25 changed files with 3930 additions and 92 deletions

316
README.md
View file

@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d
Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically.
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development.
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use.
---
@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that:
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
```lua
bread.on("bread.device.dock.connected", function()
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()
bread.on("bread.device.dock.disconnected", function(event)
bread.profile.activate("default")
end)
return M
```
---
@ -40,6 +45,7 @@ end)
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
bread-sync/ Sync engine — snapshot and restore system state via a Git remote
packaging/ Arch PKGBUILD and systemd user service
```
@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system
bash scripts/install.sh
```
Or do it step by step:
Or step by step:
```bash
cargo build --release
@ -141,19 +147,16 @@ default_urgency = "normal"
notify_send_path = "notify-send"
[modules]
builtin = true # load built-in modules (monitors, devices, etc.)
disable = [] # list of built-in module names to disable
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`:
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
require("modules.devices")
require("modules.workspaces")
bread.on("bread.system.startup", function()
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
end)
```
@ -165,19 +168,144 @@ end)
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
# 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 events # Stream live normalized events
bread events --filter bread.device.* # Stream filtered events
bread events --since 60 # Replay events from the last 60 seconds
bread modules # List loaded modules and status
bread emit <event> # Manually fire an event (for testing)
# Profiles
bread profile-list # List defined profiles
bread profile-activate <name> # Activate a named profile
bread emit <event> --data '{}' # Manually fire an event (for testing)
bread ping # Check daemon connectivity
bread health # Daemon version, uptime, PID
bread doctor # Diagnose daemon and module health
# Modules
bread modules list # List installed modules and daemon status
bread modules install github:user/repo # Install from GitHub
bread modules install /local/path # Install from a local directory
bread modules remove <name> # Remove an installed module
bread modules update [name] # Re-install one or all GitHub-sourced modules
bread modules info <name> # Show full manifest and daemon status
# Sync
bread sync init # Initialize sync for this machine
bread sync push # Snapshot and push current state to remote
bread sync pull # Pull and apply latest state from remote
bread sync pull --install-packages # Also install packages from snapshot
bread sync status # Show what has changed since last push
bread sync diff # Show file-level diff vs last commit
bread sync diff --remote # Show diff vs remote
bread sync machines # List known machines from sync repo
```
---
## 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
```bash
# From GitHub (downloads latest release tarball)
bread modules install github:someuser/bread-wifi
# From a local path
bread modules install ~/src/my-module
# From a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0
```
### 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 = "github:someuser/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
```
---
## Sync system
Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore.
```bash
# First-time setup
bread sync init --remote git@github.com:you/bread-config.git
# Push current state
bread sync push
# On another machine: pull and apply
bread sync pull
# Check what's pending
bread sync status
```
Configure what gets synced in `~/.config/bread/sync.toml`:
```toml
[remote]
url = "git@github.com:you/bread-config.git"
branch = "main"
[machine]
name = "hermes"
tags = ["laptop", "battery"]
[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]
[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]
```
The sync repo stores:
```
sync-repo/
├── bread/ ← ~/.config/bread/ snapshot
├── configs/ ← delegate paths (nvim, waybar, etc.)
├── machines/ ← per-machine profiles
└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.)
```
---
@ -209,28 +337,48 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline |
| `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 an event; returns a numeric ID
-- Subscribe to events; returns a subscription ID
local id = bread.on("bread.monitor.connected", function(event)
print(event.data.name)
-- 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, then auto-unsubscribe
-- Subscribe once, auto-unsubscribe after first delivery
bread.once("bread.system.startup", function(event)
-- runs exactly once
bread.profile.activate("default")
end)
-- Subscribe with a predicate filter
-- Subscribe with a filter predicate
bread.filter("bread.device.connected", function(event)
return event.data.class == "keyboard"
end, function(event)
@ -241,18 +389,33 @@ end)
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 a value from runtime state by dot-separated path
-- Read from runtime state by dot-separated path
local monitors = bread.state.get("monitors")
local workspace = bread.state.get("active_workspace")
local power = bread.state.get("power")
local devices = bread.state.get("devices")
local online = bread.state.get("network.online")
-- Watch a state key and fire on changes
bread.state.watch("active_workspace", function(new, old)
print("workspace changed from " .. tostring(old) .. " to " .. tostring(new))
-- 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)
```
@ -266,39 +429,99 @@ bread.profile.activate("default")
### Execution and notifications
```lua
-- Fire-and-forget: returns immediately, process runs in background
-- Fire-and-forget shell command
bread.exec("kitty")
-- Desktop notification
bread.notify("Dock connected", { urgency = "normal", timeout = 3000 })
-- Desktop notification (uses notify-send)
bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" })
bread.notify("Simple message") -- title defaults to "bread"
```
### Timers
```lua
-- Run once after a delay (ms)
bread.after(500, function()
local id = bread.after(500, function()
bread.exec("some-delayed-command")
end)
-- Run on a repeating interval (ms); returns a timer ID
-- 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 (from sync.toml, falls back to 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")
bread.warn("Unexpected state")
bread.error("Something failed")
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)
```
### 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"
```
---
@ -317,9 +540,24 @@ Response:
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
```
Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`.
Available methods:
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects.
| 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 |
| `sync.status` | Return sync initialization state and machine info |
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
---
@ -327,7 +565,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`,
Bread is early-stage software. Contributions, issues, and feedback are welcome.
The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening.
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem.
---