Merge origin/master into dev and resolve conflicts
Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com>
This commit is contained in:
commit
007478f82c
4 changed files with 686 additions and 531 deletions
496
Documentation.md
Normal file
496
Documentation.md
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
# Bread Documentation
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Getting started](#getting-started)
|
||||||
|
- [Your first module](#your-first-module)
|
||||||
|
- [Run, reload, and watch](#run-reload-and-watch)
|
||||||
|
- [Debugging tips](#debugging-tips)
|
||||||
|
- [Dictionary: Lua API](#dictionary-lua-api)
|
||||||
|
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
|
||||||
|
- [Dictionary: Event reference](#dictionary-event-reference)
|
||||||
|
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
|
||||||
|
- [Dictionary: IPC protocol](#dictionary-ipc-protocol)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
|
||||||
|
|
||||||
|
- Daemon: long-running Rust process, source of truth for runtime state
|
||||||
|
- Lua runtime: dedicated thread inside the daemon; automation logic lives here
|
||||||
|
- CLI: talks to the daemon over a Unix socket
|
||||||
|
|
||||||
|
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### 1) Create a minimal config
|
||||||
|
|
||||||
|
- Daemon config: `~/.config/bread/breadd.toml`
|
||||||
|
- Lua entry point: `~/.config/bread/init.lua`
|
||||||
|
- Lua modules: `~/.config/bread/modules/`
|
||||||
|
|
||||||
|
### 2) Minimal `init.lua`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require("modules.devices")
|
||||||
|
require("modules.workspaces")
|
||||||
|
|
||||||
|
bread.on("bread.system.startup", function()
|
||||||
|
bread.profile.activate("default")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Add your first module
|
||||||
|
|
||||||
|
Create a Lua file under your modules directory and load it from `init.lua`.
|
||||||
|
|
||||||
|
## Your first module
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local M = bread.module({ name = "hello", version = "0.1.0" })
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.log("hello from bread")
|
||||||
|
|
||||||
|
bread.on("bread.device.*", function(event)
|
||||||
|
bread.log(event.event)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this shape?
|
||||||
|
|
||||||
|
- Every module must call `bread.module` once.
|
||||||
|
- `on_load` is a good place to register subscriptions.
|
||||||
|
- Use `bread.log` early to verify handlers are firing.
|
||||||
|
|
||||||
|
## Run, reload, and watch
|
||||||
|
|
||||||
|
- Start the daemon, then use `bread reload` after editing Lua.
|
||||||
|
- `bread reload --watch` will keep reloading on changes.
|
||||||
|
- See [Examples.md](Examples.md) for real-world ports.
|
||||||
|
|
||||||
|
## Debugging tips
|
||||||
|
|
||||||
|
- Log event payloads with `bread.log(event.data.raw)` when matching devices.
|
||||||
|
- Use `bread.events` in the CLI to see live normalized events.
|
||||||
|
- Use `bread state` to see runtime state as JSON.
|
||||||
|
|
||||||
|
## Lua module system
|
||||||
|
|
||||||
|
### Entry point and module scanning
|
||||||
|
|
||||||
|
- `init.lua` is executed first.
|
||||||
|
- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files.
|
||||||
|
- Every module must call `bread.module` exactly once at top-level.
|
||||||
|
- Modules are ordered by the `after` dependency list.
|
||||||
|
|
||||||
|
### Module declaration
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local M = bread.module({
|
||||||
|
name = "my.module",
|
||||||
|
version = "0.1.0",
|
||||||
|
after = { "bread.devices" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
If a module does not call `bread.module`, it fails to load and is marked as a load error.
|
||||||
|
|
||||||
|
### Require loader
|
||||||
|
|
||||||
|
`require("bread.<path>")` resolves to a Lua file under the module path. For example:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local utils = require("bread.lib.utils")
|
||||||
|
```
|
||||||
|
|
||||||
|
This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior.
|
||||||
|
|
||||||
|
### Lifecycle hooks
|
||||||
|
|
||||||
|
Modules may export any of the following hooks. All are optional.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function M.on_load()
|
||||||
|
-- register subscriptions, initialize module state
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_reload()
|
||||||
|
-- called after a hot reload completes
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_unload()
|
||||||
|
-- called before the Lua instance is dropped
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_error(err)
|
||||||
|
-- called when a handler throws
|
||||||
|
-- return true to keep the subscription, false to cancel it
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module storage
|
||||||
|
|
||||||
|
Each module has a scoped key-value store that survives reloads:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
M.store.set("last_profile", "docked")
|
||||||
|
local value = M.store.get("last_profile")
|
||||||
|
```
|
||||||
|
|
||||||
|
The store lives in the daemon runtime state and is not shared across modules.
|
||||||
|
|
||||||
|
## Dictionary: Lua API
|
||||||
|
|
||||||
|
Every API is exposed through the `bread` global table.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
#### `bread.on(pattern, fn) -> id`
|
||||||
|
Subscribe to matching events. Returns a numeric subscription ID.
|
||||||
|
|
||||||
|
#### `bread.once(pattern, fn) -> id`
|
||||||
|
Subscribe once. The handler is removed after the first match.
|
||||||
|
|
||||||
|
#### `bread.filter(pattern, fn, opts) -> id`
|
||||||
|
Subscribe with a predicate filter. `opts` must contain `filter`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.filter("bread.device.*", function(event)
|
||||||
|
bread.exec("xset r rate 200 40")
|
||||||
|
end, {
|
||||||
|
filter = function(event)
|
||||||
|
return event.data and event.data.class == "keyboard"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bread.off(id)`
|
||||||
|
Unsubscribe an event or state watch by ID.
|
||||||
|
|
||||||
|
#### `bread.emit(event, data)`
|
||||||
|
Emit a custom event into the system pipeline.
|
||||||
|
|
||||||
|
#### `bread.wait(pattern, opts) -> event | nil`
|
||||||
|
Coroutine-only helper that waits for a matching event.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
|
||||||
|
if event then
|
||||||
|
bread.log("dock arrived")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bread.spawn(fn)`
|
||||||
|
Spawn a coroutine and surface errors if the coroutine fails.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
#### `bread.state.get(path)`
|
||||||
|
Read a state subtree by dotted path (e.g. `"network.online"`).
|
||||||
|
|
||||||
|
#### Convenience helpers
|
||||||
|
|
||||||
|
- `bread.state.monitors()`
|
||||||
|
- `bread.state.active_workspace()`
|
||||||
|
- `bread.state.active_window()`
|
||||||
|
- `bread.state.devices()`
|
||||||
|
- `bread.state.power()`
|
||||||
|
- `bread.state.network()`
|
||||||
|
- `bread.state.profile()`
|
||||||
|
|
||||||
|
#### `bread.state.watch(path, fn) -> id`
|
||||||
|
Watch a state path. The callback receives `(new, old)`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.state.watch("power.ac_connected", function(new_val, old_val)
|
||||||
|
if new_val then
|
||||||
|
bread.exec("notify-send 'AC connected'")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
#### `bread.profile.activate(name)`
|
||||||
|
Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own.
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
|
||||||
|
#### `bread.exec(cmd)`
|
||||||
|
Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async).
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
#### `bread.notify(message, opts)`
|
||||||
|
Sends a desktop notification via `notify-send`.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `title` (string, default: `"bread"`)
|
||||||
|
- `urgency` (string, default from config)
|
||||||
|
- `timeout` (ms, default from config)
|
||||||
|
- `icon` (string, optional)
|
||||||
|
|
||||||
|
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
|
||||||
|
|
||||||
|
### Timers
|
||||||
|
|
||||||
|
#### `bread.after(delay_ms, fn) -> id`
|
||||||
|
Run once after delay.
|
||||||
|
|
||||||
|
#### `bread.every(interval_ms, fn) -> id`
|
||||||
|
Run repeatedly on an interval.
|
||||||
|
|
||||||
|
#### `bread.cancel(id)`
|
||||||
|
Cancel a timer created by `after` or `every`.
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
|
||||||
|
Returns a wrapper that only fires after quiet time.
|
||||||
|
|
||||||
|
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
|
||||||
|
Log helpers that accept any Lua value.
|
||||||
|
|
||||||
|
### Hyprland
|
||||||
|
|
||||||
|
The `bread.hyprland` namespace provides compositor bindings:
|
||||||
|
|
||||||
|
- `bread.hyprland.dispatch(cmd, args)`
|
||||||
|
- `bread.hyprland.keyword(key, value)`
|
||||||
|
- `bread.hyprland.active_window()`
|
||||||
|
- `bread.hyprland.monitors()`
|
||||||
|
- `bread.hyprland.workspaces()`
|
||||||
|
- `bread.hyprland.clients()`
|
||||||
|
- `bread.hyprland.on_raw(kind, fn) -> id`
|
||||||
|
|
||||||
|
`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string).
|
||||||
|
|
||||||
|
## Dictionary: Built-in modules
|
||||||
|
|
||||||
|
Built-ins are enabled by default. Disable them via `[modules].disable` in the config.
|
||||||
|
|
||||||
|
### `bread.monitors`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local monitors = require("bread.monitors")
|
||||||
|
|
||||||
|
monitors.layout("dock", function()
|
||||||
|
bread.exec("~/.config/bread/scripts/layout-dock.sh")
|
||||||
|
end)
|
||||||
|
|
||||||
|
monitors.on({
|
||||||
|
when = "connected",
|
||||||
|
monitors = { "HDMI-A-1" },
|
||||||
|
run = monitors.apply("dock"),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `monitors.on({ when, monitors, run })`
|
||||||
|
- `monitors.layout(name, fn)`
|
||||||
|
- `monitors.apply(name) -> fn`
|
||||||
|
|
||||||
|
`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string.
|
||||||
|
|
||||||
|
### `bread.devices`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local devices = require("bread.devices")
|
||||||
|
|
||||||
|
devices.register("Keychron", "keyboard")
|
||||||
|
|
||||||
|
devices.on({
|
||||||
|
when = "connected",
|
||||||
|
class = "keyboard",
|
||||||
|
run = function(event)
|
||||||
|
bread.exec("xset r rate 200 40")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `devices.on({ when, class, name, run })`
|
||||||
|
- `devices.register(pattern, class)`
|
||||||
|
|
||||||
|
`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
|
||||||
|
|
||||||
|
### `bread.workspaces`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local workspaces = require("bread.workspaces")
|
||||||
|
|
||||||
|
workspaces.assign("1", "HDMI-A-1")
|
||||||
|
workspaces.pin({ app = "Firefox", workspace = "2" })
|
||||||
|
```
|
||||||
|
|
||||||
|
- `workspaces.assign(workspace, monitor)`
|
||||||
|
- `workspaces.pin({ app, workspace })`
|
||||||
|
- `workspaces.apply_assignments()`
|
||||||
|
|
||||||
|
### `bread.binds`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local binds = require("bread.binds")
|
||||||
|
|
||||||
|
binds.add({
|
||||||
|
mods = { "SUPER" },
|
||||||
|
key = "Return",
|
||||||
|
dispatch = "exec",
|
||||||
|
args = "kitty",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `binds.add({ mods, key, dispatch, args })`
|
||||||
|
- `binds.remove(key)`
|
||||||
|
- `binds.replace(key, opts)`
|
||||||
|
|
||||||
|
## Dictionary: Event reference
|
||||||
|
|
||||||
|
Events are delivered as a `BreadEvent`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "bread.device.dock.connected",
|
||||||
|
"timestamp": 1710000000000,
|
||||||
|
"source": "Udev",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern matching
|
||||||
|
|
||||||
|
Patterns match event names with glob-style syntax:
|
||||||
|
|
||||||
|
- Exact match: `bread.device.dock.connected`
|
||||||
|
- `*` matches within a single segment (does not cross `.`)
|
||||||
|
- `**` matches across segments (recursive)
|
||||||
|
- `?` matches a single character within a segment
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.on("bread.device.*", handler)
|
||||||
|
bread.on("bread.device.**", handler)
|
||||||
|
bread.on("bread.monitor.?", handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Normalized events
|
||||||
|
|
||||||
|
#### System
|
||||||
|
|
||||||
|
- `bread.system.startup` (data: `{}`)
|
||||||
|
|
||||||
|
#### Devices (udev)
|
||||||
|
|
||||||
|
- `bread.device.connected`
|
||||||
|
- `bread.device.disconnected`
|
||||||
|
- `bread.device.changed`
|
||||||
|
- `bread.device.<class>.connected`
|
||||||
|
- `bread.device.<class>.disconnected`
|
||||||
|
- `bread.device.<class>.changed`
|
||||||
|
|
||||||
|
Payload notes:
|
||||||
|
|
||||||
|
- Device events include `id` and `class`; the generic event also includes `raw`.
|
||||||
|
- `<class>` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
|
||||||
|
|
||||||
|
#### Hyprland
|
||||||
|
|
||||||
|
- `bread.workspace.changed` (raw payload)
|
||||||
|
- `bread.workspace.created` (`{ "workspace": "..." }`)
|
||||||
|
- `bread.workspace.destroyed` (`{ "workspace": "..." }`)
|
||||||
|
- `bread.monitor.connected` (raw payload)
|
||||||
|
- `bread.monitor.disconnected` (raw payload)
|
||||||
|
- `bread.window.focus.changed` (raw payload)
|
||||||
|
- `bread.window.focused` (`{ "address": "..." }`)
|
||||||
|
- `bread.window.opened` (`{ "address", "workspace", "class", "title" }`)
|
||||||
|
- `bread.window.closed` (`{ "address": "..." }`)
|
||||||
|
- `bread.window.moved` (`{ "address", "workspace" }`)
|
||||||
|
- `bread.hyprland.event` (raw payload for unhandled kinds)
|
||||||
|
|
||||||
|
Raw Hyprland payloads contain `kind`, `raw`, and `data` fields.
|
||||||
|
|
||||||
|
#### Power
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
- `bread.power.changed` (fallback)
|
||||||
|
|
||||||
|
Payload includes `ac_connected` and `battery_percent`.
|
||||||
|
|
||||||
|
#### Network
|
||||||
|
|
||||||
|
- `bread.network.connected`
|
||||||
|
- `bread.network.disconnected`
|
||||||
|
|
||||||
|
Payload includes `online` and `interfaces`.
|
||||||
|
|
||||||
|
#### Other system events
|
||||||
|
|
||||||
|
- `bread.profile.activated` (emitted by IPC profile activation)
|
||||||
|
- `bread.notify.sent` (emitted by `bread.notify`)
|
||||||
|
- `bread.state.changed.<path>` (emitted when a state watch fires)
|
||||||
|
|
||||||
|
## Dictionary: Runtime state schema
|
||||||
|
|
||||||
|
`bread.state.get("")` returns the full `RuntimeState`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"monitors": [ { "name": "HDMI-A-1", "connected": true } ],
|
||||||
|
"workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ],
|
||||||
|
"active_workspace": "1",
|
||||||
|
"active_window": "Firefox",
|
||||||
|
"devices": { "connected": [] },
|
||||||
|
"network": { "interfaces": {}, "online": false },
|
||||||
|
"power": { "ac_connected": false, "battery_percent": null, "battery_low": false },
|
||||||
|
"profile": { "active": "default", "history": [], "profiles": {} },
|
||||||
|
"modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dictionary: IPC protocol
|
||||||
|
|
||||||
|
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Available methods:
|
||||||
|
|
||||||
|
- `ping`
|
||||||
|
- `health`
|
||||||
|
- `state.get`
|
||||||
|
- `state.dump`
|
||||||
|
- `modules.list`
|
||||||
|
- `modules.reload`
|
||||||
|
- `profile.list`
|
||||||
|
- `profile.activate`
|
||||||
|
- `events.subscribe`
|
||||||
|
- `events.replay`
|
||||||
|
- `emit`
|
||||||
|
|
||||||
|
`events.subscribe` upgrades the socket to streaming mode and sends events as they occur.
|
||||||
187
Examples.md
Normal file
187
Examples.md
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
# Bread Examples
|
||||||
|
|
||||||
|
These examples show how to translate existing Hyprland automation into Bread's event-driven Lua runtime.
|
||||||
|
|
||||||
|
Each snippet is designed to be drop-in friendly for a `~/.config/bread/modules/*.lua` file. Start with a new module file and `require` it from `~/.config/bread/init.lua`.
|
||||||
|
|
||||||
|
## Example 1: Porting keyboard_and_display_watcher.sh (system script)
|
||||||
|
|
||||||
|
Source inspiration: `~/.config/hypr/scripts/system/keyboard_and_display_watcher.sh`.
|
||||||
|
|
||||||
|
This example covers two parts that port cleanly to Bread:
|
||||||
|
|
||||||
|
- Start/stop the Redox layout viewer when the keyboard appears
|
||||||
|
- Start/stop a display sync service when an external monitor appears
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ~/.config/bread/modules/redox_and_display.lua
|
||||||
|
local M = bread.module({ name = "redox_and_display", version = "1.0.0" })
|
||||||
|
|
||||||
|
local PREVIEW_CMD = "/home/breadway/redox-layout-viewer/target/release/redox-layout-viewer"
|
||||||
|
local APP_NAME = "redox-layout-vi"
|
||||||
|
|
||||||
|
local function start_viewer()
|
||||||
|
bread.exec("pgrep -f '" .. APP_NAME .. "' >/dev/null || " .. PREVIEW_CMD .. " >/dev/null 2>&1 &")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stop_viewer()
|
||||||
|
bread.exec("pkill -f '" .. APP_NAME .. "' >/dev/null 2>&1 || true")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_redox(event)
|
||||||
|
-- Inspect event.data.raw once to find stable identifiers in your environment.
|
||||||
|
-- Typical udev fields include id_vendor, id_model, id_vendor_id, id_model_id, and name.
|
||||||
|
local raw = event.data and event.data.raw or {}
|
||||||
|
local name = tostring(raw.name or "")
|
||||||
|
local vendor = tostring(raw.id_vendor or "")
|
||||||
|
local model = tostring(raw.id_model or "")
|
||||||
|
|
||||||
|
return name:lower():find("redox", 1, true)
|
||||||
|
or vendor:lower():find("redox", 1, true)
|
||||||
|
or model:lower():find("redox", 1, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local external_monitors = 0
|
||||||
|
|
||||||
|
local function update_display_service()
|
||||||
|
if external_monitors > 0 then
|
||||||
|
bread.exec("systemctl --user start hypr-display-sync.service")
|
||||||
|
else
|
||||||
|
bread.exec("systemctl --user stop hypr-display-sync.service")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.on("bread.device.keyboard.connected", function(event)
|
||||||
|
if is_redox(event) then
|
||||||
|
start_viewer()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.device.keyboard.disconnected", function(event)
|
||||||
|
if is_redox(event) then
|
||||||
|
stop_viewer()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.monitor.connected", function(event)
|
||||||
|
local name = event.data and (event.data.name or event.data.raw) or ""
|
||||||
|
-- ignore internal panel (eDP-1) and count only externals
|
||||||
|
if not tostring(name):match("eDP%-1") then
|
||||||
|
external_monitors = external_monitors + 1
|
||||||
|
update_display_service()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.monitor.disconnected", function(event)
|
||||||
|
local name = event.data and (event.data.name or event.data.raw) or ""
|
||||||
|
if not tostring(name):match("eDP%-1") then
|
||||||
|
external_monitors = math.max(0, external_monitors - 1)
|
||||||
|
update_display_service()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Use `bread.log(event.data.raw)` once to see your exact udev fields for matching.
|
||||||
|
- This drops polling and relies on udev/Hyprland events.
|
||||||
|
|
||||||
|
## Example 2: Porting autostart.lua
|
||||||
|
|
||||||
|
Source inspiration: `~/.config/hypr/scripts/system/autostart.lua`.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ~/.config/bread/modules/autostart.lua
|
||||||
|
local M = bread.module({ name = "autostart", version = "1.0.0" })
|
||||||
|
|
||||||
|
local home = os.getenv("HOME") or "/home/breadway"
|
||||||
|
local startup_commands = {
|
||||||
|
"wal -R",
|
||||||
|
home .. "/colorshell/build/colorshell",
|
||||||
|
"awww-daemon",
|
||||||
|
"awww restore",
|
||||||
|
home .. "/.config/hypr/scripts/system/keyboard_and_display_watcher.sh",
|
||||||
|
home .. "/.config/hypr/watch_hypr_scripts.sh",
|
||||||
|
"systemctl --user daemon-reload",
|
||||||
|
"systemctl --user start hypr-display-sync.service",
|
||||||
|
"systemctl --user start hyprpolkitagent",
|
||||||
|
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP",
|
||||||
|
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
|
||||||
|
"flatpak run dev.deedles.Trayscale",
|
||||||
|
"wificonf init",
|
||||||
|
"pkill -f hyprpaper",
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.once("bread.system.startup", function()
|
||||||
|
for _, cmd in ipairs(startup_commands) do
|
||||||
|
bread.exec(cmd)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 3: Porting display/monitors.lua
|
||||||
|
|
||||||
|
Source inspiration: `~/.config/hypr/scripts/display/monitors.lua`.
|
||||||
|
|
||||||
|
This uses Bread events and Hyprland keywords to update monitor layout when external displays change.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ~/.config/bread/modules/monitors.lua
|
||||||
|
local M = bread.module({ name = "monitors", version = "1.0.0" })
|
||||||
|
|
||||||
|
local function apply_internal_mode(has_external)
|
||||||
|
local mode = has_external and "1920x1080@60" or "1920x1200@60"
|
||||||
|
bread.hyprland.keyword("monitor", "eDP-1, " .. mode .. ", 0x0, 1")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_external()
|
||||||
|
bread.hyprland.keyword("monitor", "DP-3, 1920x1080@60, auto, 1, mirror, eDP-1")
|
||||||
|
end
|
||||||
|
|
||||||
|
local externals = 0
|
||||||
|
local function update()
|
||||||
|
apply_internal_mode(externals > 0)
|
||||||
|
if externals > 0 then
|
||||||
|
apply_external()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.on("bread.monitor.connected", function(event)
|
||||||
|
local name = tostring((event.data and (event.data.name or event.data.raw)) or "")
|
||||||
|
if not name:match("eDP%-1") then
|
||||||
|
externals = externals + 1
|
||||||
|
update()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.on("bread.monitor.disconnected", function(event)
|
||||||
|
local name = tostring((event.data and (event.data.name or event.data.raw)) or "")
|
||||||
|
if not name:match("eDP%-1") then
|
||||||
|
externals = math.max(0, externals - 1)
|
||||||
|
update()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
bread.once("bread.system.startup", function()
|
||||||
|
update()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips for porting your own scripts
|
||||||
|
|
||||||
|
- Start by logging the event payload: `bread.log(event.data.raw)`
|
||||||
|
- Replace polling loops with event subscriptions
|
||||||
|
- Use `bread.exec` for shell commands and systemd operations
|
||||||
|
- Use `bread.state.watch` for data that already lives in the runtime state
|
||||||
527
LUA_RUNTIME.md
527
LUA_RUNTIME.md
|
|
@ -1,527 +0,0 @@
|
||||||
# bread — Lua Runtime Architecture
|
|
||||||
### The Bread Scripting and Automation Layer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Lua runtime is the automation half of Bread. Where `breadd` maintains truth about the desktop, the Lua layer decides what to do about it.
|
|
||||||
|
|
||||||
Modules written in Lua subscribe to events, read state, execute shell commands, and activate profiles. The entire scripting surface is exposed through a single `bread.*` global API — stable, versioned, and designed to be hostile to accidents.
|
|
||||||
|
|
||||||
The runtime lives on a dedicated OS thread inside `breadd`. It is reachable from the async side only through a bounded message channel. Lua never touches sockets, sysfs, or compositor IPC directly. Everything flows through the daemon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Runtime Core
|
|
||||||
|
|
||||||
These capabilities exist in the codebase today. Phase 1 is the foundation the Lua runtime is built on.
|
|
||||||
|
|
||||||
### Daemon Stability
|
|
||||||
|
|
||||||
`breadd` is a single long-running Rust process. It survives compositor restarts, module load errors, and Lua runtime panics. The daemon never terminates because a Lua file has a syntax error.
|
|
||||||
|
|
||||||
The Lua runtime thread is spawned once at startup:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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 = LuaEngine::new(config, state_handle, emit_tx)?;
|
|
||||||
engine.reload_internal()?;
|
|
||||||
|
|
||||||
while let Some(msg) = rx.recv().await {
|
|
||||||
match msg { /* ... */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})?;
|
|
||||||
```
|
|
||||||
|
|
||||||
If the initial module load fails, the daemon enters degraded mode: no Lua handlers are active, but the daemon itself remains alive. IPC stays responsive and `bread reload` can be used to recover after the user fixes their config.
|
|
||||||
|
|
||||||
### Event Ingestion
|
|
||||||
|
|
||||||
Every signal that enters `breadd` from an external system flows through a strict pipeline before it reaches Lua:
|
|
||||||
|
|
||||||
```
|
|
||||||
External System (Hyprland / udev / power / network)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Adapter — raw ingestion, owns the connection
|
|
||||||
│ RawEvent
|
|
||||||
▼
|
|
||||||
Normalizer — semantic interpretation
|
|
||||||
│ BreadEvent
|
|
||||||
▼
|
|
||||||
State Engine — state update + fan-out
|
|
||||||
│
|
|
||||||
├──► RuntimeState (updated atomically)
|
|
||||||
└──► Subscription Dispatcher
|
|
||||||
│ BreadEvent (per subscriber)
|
|
||||||
▼
|
|
||||||
Lua Runtime
|
|
||||||
(module handlers)
|
|
||||||
```
|
|
||||||
|
|
||||||
Raw events never reach Lua directly. Lua never observes a `RawEvent` — it only ever sees a normalized `BreadEvent` with a stable namespace string like `bread.device.dock.connected`.
|
|
||||||
|
|
||||||
### Subscriptions
|
|
||||||
|
|
||||||
Modules subscribe to events by pattern. The subscription table maps pattern strings to `(SubscriptionId, is_once)` pairs. The state engine evaluates each incoming `BreadEvent` against the table and dispatches to every matching subscriber.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SubscriptionId(pub u64);
|
|
||||||
```
|
|
||||||
|
|
||||||
The Lua side registers subscriptions via `bread.on` and `bread.once`. Each call allocates a monotonically increasing `SubscriptionId`, stores the callback in the Lua registry, and registers the pattern with the state engine:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.on("bread.device.dock.*", function(event)
|
|
||||||
bread.exec("~/.config/bread/scripts/dock.sh")
|
|
||||||
end)
|
|
||||||
|
|
||||||
bread.once("bread.system.startup", function(event)
|
|
||||||
bread.profile.activate("default")
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
`bread.once` subscriptions are automatically cancelled after first delivery. The handler is removed from both the Lua registry and the subscription table.
|
|
||||||
|
|
||||||
### IPC
|
|
||||||
|
|
||||||
`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON. All IPC requests that affect the Lua runtime route through the `LuaMessage` channel — IPC never touches the Lua thread directly.
|
|
||||||
|
|
||||||
Relevant IPC methods:
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `modules.list` | List loaded modules and their status |
|
|
||||||
| `modules.reload` | Trigger a hot reload of the Lua layer |
|
|
||||||
| `emit` | Inject a synthetic `BreadEvent` into the pipeline |
|
|
||||||
| `state.get` | Read a value from `RuntimeState` by key path |
|
|
||||||
| `state.dump` | Return the full `RuntimeState` as JSON |
|
|
||||||
|
|
||||||
The `emit` method is particularly useful for testing: it allows injecting arbitrary `BreadEvent`s without needing the real hardware event that would normally produce them.
|
|
||||||
|
|
||||||
### Hot Reload
|
|
||||||
|
|
||||||
Hot reload is a first-class feature. The daemon persists; the Lua layer restarts. No process restart required.
|
|
||||||
|
|
||||||
Reload sequence:
|
|
||||||
|
|
||||||
```
|
|
||||||
bread reload (CLI)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
IPC: modules.reload
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
StateEngine: pause event dispatch to Lua
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
LuaRuntime: receive Reload message
|
|
||||||
│
|
|
||||||
├── cancel all active subscriptions
|
|
||||||
├── clear handler registry
|
|
||||||
├── drop Lua instance (all state cleared)
|
|
||||||
├── create fresh Lua instance
|
|
||||||
├── re-register bread.* API
|
|
||||||
├── re-evaluate 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 and the daemon enters degraded mode. There is no rollback — the previous Lua state was dropped before the reload began. This is intentional for V1. A syntax error in a module produces a clear error message from `bread reload`, and the daemon stays alive.
|
|
||||||
|
|
||||||
### State Registry
|
|
||||||
|
|
||||||
The daemon maintains a live `RuntimeState` behind an `Arc<RwLock<RuntimeState>>`. It is the authoritative record of what is true about the desktop right now.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lua accesses this via `bread.state.get(path)`. The call takes a brief read lock, serializes the requested subtree to JSON, and converts it to a Lua value. Lua never holds the lock — the lock is dropped before control returns to the Lua callback:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local monitors = bread.state.get("monitors")
|
|
||||||
local power = bread.state.get("power")
|
|
||||||
local active = bread.state.get("active_workspace")
|
|
||||||
```
|
|
||||||
|
|
||||||
Dotted paths are supported for nested access:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local online = bread.state.get("network.online")
|
|
||||||
```
|
|
||||||
|
|
||||||
State is read-only from Lua. Lua cannot write to `RuntimeState` directly — it can only influence state indirectly by activating a profile or emitting an event that the state engine processes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Lua Runtime
|
|
||||||
|
|
||||||
Phase 2 covers what is not yet built: the features required to make the Lua layer a complete, ergonomic automation platform.
|
|
||||||
|
|
||||||
### Module Loader
|
|
||||||
|
|
||||||
Currently, `breadd` loads modules by scanning `~/.config/bread/modules/` and executing every `.lua` file in sorted order. There is no concept of module identity, exports, or dependency declarations.
|
|
||||||
|
|
||||||
Phase 2 introduces a proper module system:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/bread/
|
|
||||||
├── init.lua ← entry point; declares module list
|
|
||||||
└── modules/
|
|
||||||
├── dock.lua
|
|
||||||
├── display.lua
|
|
||||||
├── power.lua
|
|
||||||
└── lib/
|
|
||||||
└── utils.lua ← shared library, loaded on require
|
|
||||||
```
|
|
||||||
|
|
||||||
**`bread.module` declaration** — each module declares itself at the top of the file:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local M = bread.module({
|
|
||||||
name = "dock",
|
|
||||||
version = "1.0.0",
|
|
||||||
after = { "display" }, -- load after display.lua
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime resolves the dependency graph and loads modules in topological order. Circular dependencies are detected at load time and reported as a load error on the offending module.
|
|
||||||
|
|
||||||
**`require` support** — modules in `lib/` are loadable via `require`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local utils = require("bread.lib.utils")
|
|
||||||
```
|
|
||||||
|
|
||||||
The module loader intercepts `require` calls that begin with `bread.` and resolves them relative to `~/.config/bread/`. Standard Lua `require` semantics apply for everything else.
|
|
||||||
|
|
||||||
**Module status tracking** — each module's load state is reflected in `RuntimeState.modules` and visible via `bread doctor` and `modules.list`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum ModuleLoadState {
|
|
||||||
Loaded,
|
|
||||||
LoadError,
|
|
||||||
NotFound,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Phase 2 extends this with `Degraded` (loaded but encountered a runtime error since last reload) and `Disabled` (explicitly disabled in config).
|
|
||||||
|
|
||||||
### Lifecycle Hooks
|
|
||||||
|
|
||||||
Currently, modules have no way to run code at load time or cleanup code at unload time. Phase 2 adds four lifecycle hooks.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
function M.on_load()
|
|
||||||
-- called once when the module is first loaded
|
|
||||||
-- register subscriptions, initialize module state
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.on_reload()
|
|
||||||
-- called after a hot reload completes
|
|
||||||
-- re-apply any external side effects the module manages
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.on_unload()
|
|
||||||
-- called before the Lua instance is dropped
|
|
||||||
-- cancel external resources, write state if needed
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.on_error(err)
|
|
||||||
-- called when a subscription handler in this module throws
|
|
||||||
-- return true to keep the subscription, false to cancel it
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime calls hooks in a defined order:
|
|
||||||
|
|
||||||
- **Load**: `on_load` is called after the module file executes successfully, in dependency order.
|
|
||||||
- **Reload**: `on_unload` is called in reverse dependency order. After the new Lua instance is ready, `on_load` runs on every module. `on_reload` runs after all `on_load` calls complete.
|
|
||||||
- **Error**: `on_error` is called on the Lua thread immediately after a handler throws. If the module does not define `on_error`, the default behavior is to log the error and keep the subscription alive.
|
|
||||||
|
|
||||||
All hooks are optional. A module with no lifecycle hooks continues to work exactly as it does today.
|
|
||||||
|
|
||||||
### Event APIs
|
|
||||||
|
|
||||||
Phase 2 expands the event surface available to Lua modules.
|
|
||||||
|
|
||||||
**Pattern syntax** — the current subscription API matches event names against patterns using glob-style `*` wildcards. Phase 2 adds `**` for recursive matching and `?` for single-character wildcards:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
|
|
||||||
bread.on("bread.device.**", handler) -- matches any depth under bread.device
|
|
||||||
bread.on("bread.monitor.?", handler) -- single-segment wildcard
|
|
||||||
```
|
|
||||||
|
|
||||||
**`bread.off`** — cancel a subscription by the ID returned from `bread.on`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local id = bread.on("bread.power.*", handler)
|
|
||||||
-- later:
|
|
||||||
bread.off(id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`bread.wait`** — yield until a matching event arrives, with an optional timeout:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
|
|
||||||
if event then
|
|
||||||
-- dock arrived within 5 seconds
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
`bread.wait` is syntactic sugar over a `bread.once` subscription combined with a coroutine yield. It can only be used inside a coroutine context; calling it from a top-level module body is a load error.
|
|
||||||
|
|
||||||
**`bread.filter`** — attach a predicate to a subscription. The handler is only called when the predicate returns true:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.on("bread.device.*", handler, {
|
|
||||||
filter = function(event)
|
|
||||||
return event.data.class == "dock"
|
|
||||||
end
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Timers** — schedule callbacks without relying on an external timer process:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local id = bread.after(500, function()
|
|
||||||
-- called once, 500ms from now
|
|
||||||
end)
|
|
||||||
|
|
||||||
local id = bread.every(30000, function()
|
|
||||||
-- called every 30 seconds
|
|
||||||
end)
|
|
||||||
|
|
||||||
bread.cancel(id) -- cancel either kind
|
|
||||||
```
|
|
||||||
|
|
||||||
Timers are cancelled automatically on reload. A module does not need to track its own timer IDs for cleanup.
|
|
||||||
|
|
||||||
### State Access
|
|
||||||
|
|
||||||
Phase 2 extends `bread.state` from a read-only snapshot query into a richer interface.
|
|
||||||
|
|
||||||
**Typed helpers** — convenience wrappers for the most common state subtrees:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.state.monitors() -- Vec<Monitor>
|
|
||||||
bread.state.active_workspace() -- string | nil
|
|
||||||
bread.state.active_window() -- string | nil
|
|
||||||
bread.state.devices() -- Vec<Device>
|
|
||||||
bread.state.power() -- PowerState
|
|
||||||
bread.state.network() -- NetworkState
|
|
||||||
bread.state.profile() -- ProfileState
|
|
||||||
```
|
|
||||||
|
|
||||||
These are thin wrappers over `bread.state.get` — they add no locking overhead.
|
|
||||||
|
|
||||||
**Reactive state** — watch a state path for changes and receive a callback when it changes:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.state.watch("power.ac_connected", function(new_val, old_val)
|
|
||||||
if new_val then
|
|
||||||
bread.exec("notify-send 'AC connected'")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
State watches are implemented as synthetic subscriptions: the state engine compares the watched path before and after each `RuntimeState` update and synthesizes a `bread.state.changed.<path>` event when a difference is detected. From the Lua runtime's perspective, watches are ordinary subscriptions.
|
|
||||||
|
|
||||||
**Module-scoped storage** — a key-value store persisted across reloads (but not across daemon restarts):
|
|
||||||
|
|
||||||
```lua
|
|
||||||
M.store.set("last_profile", "docked")
|
|
||||||
local p = M.store.get("last_profile") -- "docked"
|
|
||||||
```
|
|
||||||
|
|
||||||
Storage is scoped per module. A module cannot read another module's store. The store is backed by a `HashMap<String, serde_json::Value>` in the `RuntimeState.modules` entry for that module, so it survives hot reload.
|
|
||||||
|
|
||||||
### Hyprland Bindings
|
|
||||||
|
|
||||||
Phase 2 exposes a `bread.hyprland` namespace for direct interaction with the Hyprland compositor. This is the only place in the Lua API that is compositor-specific; all other APIs are compositor-agnostic.
|
|
||||||
|
|
||||||
The bindings communicate over Hyprland's IPC request socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock`), not the event socket. Calls are dispatched to a Tokio task on the async side and awaited transparently from Lua via coroutine suspension.
|
|
||||||
|
|
||||||
**Dispatch**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.hyprland.dispatch("workspace", "2")
|
|
||||||
bread.hyprland.dispatch("movetoworkspace", "2,address:0x...")
|
|
||||||
bread.hyprland.dispatch("exec", "kitty")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Keyword**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local result = bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Active window**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local win = bread.hyprland.active_window()
|
|
||||||
-- { address, title, class, workspace, monitor, ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Monitor and workspace queries**
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local monitors = bread.hyprland.monitors()
|
|
||||||
local workspaces = bread.hyprland.workspaces()
|
|
||||||
local clients = bread.hyprland.clients()
|
|
||||||
```
|
|
||||||
|
|
||||||
All calls return deserialized Lua tables matching Hyprland's JSON response shape. Errors from the compositor (malformed dispatch, unknown keyword) are surfaced as Lua errors catchable with `pcall`.
|
|
||||||
|
|
||||||
**Hyprland-specific events** — the existing `bread.monitor.*` and `bread.workspace.*` event namespaces already cover the most common Hyprland signals. The Phase 2 bindings add lower-level passthrough for events that do not yet have a normalized `BreadEvent` representation:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
bread.hyprland.on_raw("activewindow", function(raw)
|
|
||||||
-- raw is the unparsed string from Hyprland's event socket
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
Raw subscriptions bypass normalization. They are intended for power users and for features not yet covered by the normalized event namespace. Once a raw event pattern is common enough, it graduates to a stable `BreadEvent` and the raw subscription is deprecated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lua ↔ Rust Boundary
|
|
||||||
|
|
||||||
All calls across the 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 Rust functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread and they do not crash the daemon.
|
|
||||||
|
|
||||||
The `LuaMessage` enum is the only channel between the async Tokio runtime and the Lua thread:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum LuaMessage {
|
|
||||||
Event {
|
|
||||||
subscription_id: SubscriptionId,
|
|
||||||
event: BreadEvent,
|
|
||||||
},
|
|
||||||
SubscriptionCancelled {
|
|
||||||
id: SubscriptionId,
|
|
||||||
},
|
|
||||||
Reload {
|
|
||||||
reply: oneshot::Sender<Result<(), String>>,
|
|
||||||
},
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lua is not `Send`. The `LuaEngine` and the `Lua` instance live exclusively on the dedicated Lua OS thread. The async side communicates only by sending `LuaMessage` values through the channel — it never holds a reference to anything inside the Lua VM.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Isolation
|
|
||||||
|
|
||||||
### Handler errors
|
|
||||||
|
|
||||||
Lua errors during event handler execution are caught with `pcall` at the Rust boundary:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The error is logged with the subscription ID and full Lua stack trace. The handler remains registered and will fire again on the next matching event. A persistently failing handler is the module's responsibility to cancel via `bread.off`.
|
|
||||||
|
|
||||||
Phase 2's `on_error` hook gives modules a structured way to respond to handler failures rather than relying solely on the daemon log.
|
|
||||||
|
|
||||||
### Module load errors
|
|
||||||
|
|
||||||
Errors during module load are fatal to that module but not to the daemon or to other modules. The failed module is marked `LoadError` in `RuntimeState.modules`. Remaining modules continue loading in dependency order; only modules that declared `after` the failed module are also skipped (their dependency is broken).
|
|
||||||
|
|
||||||
### Degraded mode
|
|
||||||
|
|
||||||
If the initial load or a hot reload fails such that no Lua instance is running, the daemon enters degraded mode:
|
|
||||||
|
|
||||||
- No Lua handlers are active.
|
|
||||||
- IPC remains fully operational.
|
|
||||||
- `bread reload` can be retried after the user fixes their config.
|
|
||||||
- `bread doctor` reports the load error with the full stack trace.
|
|
||||||
|
|
||||||
The daemon never requires a full restart to recover from a Lua error.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `bread.*` API Surface Summary
|
|
||||||
|
|
||||||
### Phase 1 (implemented)
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `bread.on(pattern, fn)` | Subscribe to a pattern; returns subscription ID |
|
|
||||||
| `bread.once(pattern, fn)` | Subscribe once; auto-cancelled after first delivery |
|
|
||||||
| `bread.emit(event, payload)` | Inject a synthetic `BreadEvent` |
|
|
||||||
| `bread.exec(cmd)` | Fire-and-forget shell command |
|
|
||||||
| `bread.state.get(path)` | Read a value from `RuntimeState` by dotted path |
|
|
||||||
| `bread.profile.activate(name)` | Activate a named profile |
|
|
||||||
|
|
||||||
### Phase 2 (planned)
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `bread.off(id)` | Cancel a subscription by ID |
|
|
||||||
| `bread.wait(pattern, opts)` | Yield until a matching event arrives |
|
|
||||||
| `bread.filter(pattern, fn, opts)` | Subscribe with a predicate guard |
|
|
||||||
| `bread.after(ms, fn)` | One-shot timer |
|
|
||||||
| `bread.every(ms, fn)` | Repeating timer |
|
|
||||||
| `bread.cancel(id)` | Cancel a timer |
|
|
||||||
| `bread.state.watch(path, fn)` | React to state changes at a path |
|
|
||||||
| `bread.state.monitors()` | Typed shorthand for `bread.state.get("monitors")` |
|
|
||||||
| `bread.state.power()` | Typed shorthand for `bread.state.get("power")` |
|
|
||||||
| `bread.state.network()` | Typed shorthand for `bread.state.get("network")` |
|
|
||||||
| `bread.hyprland.dispatch(cmd, args)` | Send a Hyprland dispatch |
|
|
||||||
| `bread.hyprland.keyword(key, val)` | Set a Hyprland keyword |
|
|
||||||
| `bread.hyprland.active_window()` | Query the active window |
|
|
||||||
| `bread.hyprland.monitors()` | Query all monitors |
|
|
||||||
| `bread.hyprland.workspaces()` | Query all workspaces |
|
|
||||||
| `bread.hyprland.clients()` | Query all open clients |
|
|
||||||
| `bread.hyprland.on_raw(event, fn)` | Subscribe to a raw Hyprland event string |
|
|
||||||
| `bread.module(decl)` | Declare a module with name, version, and dependencies |
|
|
||||||
| `M.store.get(key)` | Read from module-scoped persistent storage |
|
|
||||||
| `M.store.set(key, val)` | Write to module-scoped persistent storage |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The Lua runtime is where Bread becomes useful. The daemon provides a reliable, normalized view of the desktop; the Lua layer acts on it.
|
|
||||||
|
|
||||||
Phase 1 delivers the mechanical minimum: a stable thread, a working `bread.*` API, event subscriptions, state access, hot reload, and IPC. That foundation is in the codebase today.
|
|
||||||
|
|
||||||
Phase 2 builds the ergonomics: module identity, lifecycle hooks, reactive state, timers, richer event APIs, and Hyprland control bindings. Each Phase 2 feature is additive — nothing in Phase 1 needs to change to support it.
|
|
||||||
|
|
||||||
The boundary between Rust and Lua is intentionally narrow. The daemon knows nothing about what modules do. Modules know nothing about how events arrive. The `bread.*` API is the entire contract between them.
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Maintainer: Your Name <you@example.com>
|
# Maintainer: Your Name <you@example.com>
|
||||||
|
|
||||||
pkgname=breadd
|
pkgname=bread
|
||||||
pkgver=0.1.0
|
pkgver=0.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Bread daemon - event normalizer and automation runtime"
|
pkgdesc="Bread - event normalizer and automation runtime"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Breadway/bread"
|
url="https://github.com/Breadway/bread"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
|
|
@ -19,7 +19,6 @@ build() {
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
|
|
||||||
install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
|
install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
|
||||||
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
|
install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue