Docs already said any module or app could publish bread.command.<app>.<verb>, but command is reserved so both unsourced bread-emit and sourced App emit rejected the whole namespace. Keep command unclaimable as an app id; accept bread.command.<known-app>.<verb> (and let an app command another known app). Also ship bread-emit and bread-module-host, and give udev enumerate the same classification fields as a live add so boot-time devices are not all unknown.
1768 lines
85 KiB
Markdown
1768 lines
85 KiB
Markdown
# Bread Documentation
|
||
|
||
## Contents
|
||
|
||
- [Overview](#overview)
|
||
- [API Stability & Versioning](#api-stability--versioning)
|
||
- [Getting started](#getting-started)
|
||
- [Your first module](#your-first-module)
|
||
- [Run, reload, and watch](#run-reload-and-watch)
|
||
- [Modules: install and manage](#modules-install-and-manage)
|
||
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
|
||
- [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16)
|
||
- [Debugging tips](#debugging-tips)
|
||
- [Dictionary: Lua API](#dictionary-lua-api)
|
||
- [Workflows](#workflows-since-v12)
|
||
- [Widgets](#widgets-since-v13)
|
||
- [Bluetooth](#bluetooth)
|
||
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
|
||
- [Dictionary: Event reference](#dictionary-event-reference)
|
||
- [Namespaces](#namespaces)
|
||
- [Integrating a bread* app](#integrating-a-bread-app)
|
||
- [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** (`breadd`) — long-running Rust process; source of truth for runtime state
|
||
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
|
||
- **CLI** (`bread`) — talks to the daemon over a Unix socket
|
||
|
||
Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, BlueZ Bluetooth, shell precmd/preexec hooks (terminal), git hooks + a dirty-state poller, project-root filesystem watches, `systemd --user` unit state, Podman container events, and SSH/remote session detection. Sibling `bread*` applications (breadclip, breadpad, and others across the BOS ecosystem) integrate through the same pipeline under a reserved `bread.<app>.*` namespace — see [Namespaces](#namespaces).
|
||
|
||
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
|
||
|
||
## API Stability & Versioning
|
||
|
||
The Lua API surface, the IPC method set, the event-name vocabulary, and the runtime-state schema documented in this file are collectively **Bread Automation API v1**. This is what "locking in the schema" means operationally:
|
||
|
||
- **Additive-only within a major version.** New bindings, new events, new state fields, and new optional IPC params may be added in a minor release. Existing binding signatures, event names, event `data` shapes, state field meanings, and IPC method contracts do not change or disappear within v1.
|
||
- **Deprecation window.** Anything slated for removal is marked `Deprecated` in this file for at least one minor release cycle and continues to function until the next major version (v2).
|
||
- **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline.
|
||
- **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session.
|
||
|
||
This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `CLAUDE.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality.
|
||
|
||
## Getting started
|
||
|
||
### 1) Create a minimal config
|
||
|
||
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
|
||
- Declarative rules (optional, no Lua required): `~/.config/bread/rules.toml`
|
||
- Lua entry point: `~/.config/bread/init.lua`
|
||
- Lua modules: `~/.config/bread/modules/`
|
||
|
||
### 2) The fast path: `rules.toml` *(Since: v1.5)*
|
||
|
||
For the common "when event X happens, do Y" case, you don't need Lua at
|
||
all. Create `~/.config/bread/rules.toml`:
|
||
|
||
```toml
|
||
[[rule]]
|
||
on = "device.dock.connected"
|
||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||
|
||
[[rule]]
|
||
on = "power.ac.disconnected"
|
||
notify = "Unplugged"
|
||
|
||
[[rule]]
|
||
on = "device.keyboard.connected"
|
||
exec = "xset r rate 200 40"
|
||
```
|
||
|
||
Each `[[rule]]` needs exactly two things: an `on` (an event-name suffix —
|
||
`bread.` is implied, so `"device.dock.connected"` matches the real event
|
||
`bread.device.dock.connected`; wildcards `*`/`**`/`?` work the same way they
|
||
do in `bread.on()`) and exactly one action:
|
||
|
||
| Action | Meaning |
|
||
|--------|---------|
|
||
| `run = "<path>"` | Run exactly one script/program at that path. The path is tilde-expanded and quoted as a single unit for you, so spaces in it are safe — it will *not* be word-split into a command plus arguments. |
|
||
| `exec = "<command line>"` | Run a full shell command line via `bread.exec()`, exactly as if you'd typed it in a shell — quote/escape arguments yourself. |
|
||
| `notify = "<message>"` | Show a desktop notification with this text via `bread.notify()`. |
|
||
|
||
`rules.toml` is entirely optional and purely additive alongside
|
||
`init.lua` — both can coexist, rules load before user-defined modules, and
|
||
an absent file is not an error. A malformed rule (missing/empty `on`, or
|
||
zero/multiple action keys set) doesn't stop the rest of the file from
|
||
working: the other rules in the file still register, and the specific bad
|
||
rule shows up via `bread doctor` (see [Debugging tips](#debugging-tips))
|
||
the same way a broken Lua module's error would.
|
||
|
||
This covers the common cases directly. For fuzzier matching (substring
|
||
device-name matching, filtering by a list of monitors, etc.) or any logic
|
||
beyond "run this one action," reach for `bread.devices` /
|
||
`bread.monitors` or hand-written Lua in `init.lua` — see
|
||
[Dictionary: Built-in modules](#dictionary-built-in-modules) and the next
|
||
section.
|
||
|
||
### 3) Minimal `init.lua`
|
||
|
||
```lua
|
||
bread.on("bread.system.startup", function(event)
|
||
bread.profile.activate("default")
|
||
bread.log("bread started on " .. bread.machine.name())
|
||
end)
|
||
```
|
||
|
||
### 4) Start the daemon
|
||
|
||
```bash
|
||
systemctl --user start breadd
|
||
|
||
# Or directly:
|
||
breadd
|
||
```
|
||
|
||
### 5) Check that it's running
|
||
|
||
```bash
|
||
bread ping
|
||
bread doctor
|
||
```
|
||
|
||
## Your first module
|
||
|
||
Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`.
|
||
|
||
```lua
|
||
local M = bread.module({ name = "hello", version = "0.1.0" })
|
||
|
||
function M.on_load()
|
||
bread.log("hello from bread on " .. bread.machine.name())
|
||
|
||
bread.on("bread.device.*", function(event)
|
||
bread.log("device event: " .. event.event)
|
||
end)
|
||
end
|
||
|
||
return M
|
||
```
|
||
|
||
Key rules:
|
||
|
||
- Every module must call `bread.module` exactly once at the top level.
|
||
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
|
||
- Use `bread.log` early to verify handlers are firing.
|
||
|
||
A flat file like `modules/hello.lua` with no manifest gets full, unscoped
|
||
`bread.*` access — exactly what you see above, unchanged. That's fine for a
|
||
personal one-off. Once you install a module properly (`bread modules
|
||
install`), it's worth declaring what it actually uses — see
|
||
[Capability-scoped modules](#capability-scoped-modules-since-v15).
|
||
|
||
## Run, reload, and watch
|
||
|
||
```bash
|
||
# Hot-reload the Lua runtime after editing config
|
||
bread reload
|
||
|
||
# Watch for file changes and reload automatically
|
||
bread reload --watch
|
||
```
|
||
|
||
If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again.
|
||
|
||
## Modules: install and manage
|
||
|
||
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
|
||
|
||
Modules install from a **local directory only**. They run with full
|
||
`bread.exec()` privileges and are not sandboxed; remote installation was
|
||
removed so that reviewing third-party code stays an explicit, manual step. To
|
||
use a module published on a git host, clone it yourself, review it, then
|
||
install from the checkout.
|
||
|
||
```bash
|
||
# Clone and review, then install from the local checkout
|
||
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
||
bread modules install ~/src/bread-wifi
|
||
|
||
# List installed modules and their daemon status
|
||
bread modules list
|
||
|
||
# Show full manifest for one module (including its declared permissions)
|
||
bread modules info bread-wifi
|
||
|
||
# Get a suggested [[permissions]] block from a static scan of the module's
|
||
# Lua source — see "Capability-scoped modules" below
|
||
bread modules audit bread-wifi
|
||
|
||
# Remove a module
|
||
bread modules remove bread-wifi
|
||
bread modules remove bread-wifi --yes # skip confirmation
|
||
```
|
||
|
||
Each installed module has a `bread.module.toml` manifest:
|
||
|
||
```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"
|
||
|
||
[[permissions]]
|
||
type = "exec"
|
||
bin = "nmcli"
|
||
|
||
[[permissions]]
|
||
type = "notify"
|
||
```
|
||
|
||
`permissions` is optional *(Since: v1.5)*. Omitting it entirely — every
|
||
manifest written before v1.5, and any manifest an author just hasn't gotten
|
||
around to annotating — means the module runs exactly like it always has:
|
||
full, unscoped `bread.*` access. See the next section for what declaring it
|
||
actually buys you and the full permission taxonomy.
|
||
|
||
## Capability-scoped modules *(Since: v1.5)*
|
||
|
||
By default every third-party module gets the full `bread` table — the same
|
||
one built-in modules and `init.lua` see. `[[permissions]]` in
|
||
`bread.module.toml` narrows that: a module only sees the `bread.*` bindings
|
||
it was granted, plus a fixed **baseline** every module gets regardless.
|
||
Anything not granted is genuinely **absent** — `bread.fs == nil`, not
|
||
`bread.fs.read()` throwing a permission error — so a module written
|
||
defensively (`if bread.fs then ... end`) degrades exactly the way it would
|
||
if, say, Bluetooth hardware weren't present.
|
||
|
||
*Since: v1.6* — declaring `[[permissions]]` at all (even an empty list)
|
||
also determines **where** the module runs: see [Out-of-process module
|
||
sandboxing](#out-of-process-module-sandboxing-since-v16) below. The
|
||
`bread` table shape described in this section is what such a module sees
|
||
either way; what changed is what backs it and what happens if the module
|
||
ignores it entirely and reaches for `os`/`io` directly.
|
||
|
||
### Baseline (always available, no manifest entry needed)
|
||
|
||
Event subscription and timers are how a module does anything at all, so
|
||
they're never gated: `bread.on`/`once`/`filter`/`off`/`emit`,
|
||
`bread.after`/`every`/`cancel`. Also baseline: `bread.json` (pure decode,
|
||
no I/O), `bread.module` (required just to register), `bread.log`/`warn`/
|
||
`error` (diagnostics), and the pure-Lua sugar built entirely on top of the
|
||
above — `bread.debounce`, `bread.spawn`/`wait`/`wait_any`/`wait_all`,
|
||
`bread.workflow.*`.
|
||
|
||
### Gated — requires a matching `[[permissions]]` entry
|
||
|
||
| `type` | Grants | Notes |
|
||
|--------|--------|-------|
|
||
| `state.read` | `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/`.profile` | Read-only snapshots of daemon state. `path` is an advisory scoping hint (e.g. `"monitors"`), not yet enforced per-call — see the note below. |
|
||
| `state.watch` | `bread.state.watch` | Split from `state.read`: a standing subscription is a more persistent capability than a one-off read. |
|
||
| `profile.activate` | `bread.profile.activate` | Switches the daemon's system-wide active profile — a real cross-module side effect. |
|
||
| `exec` | `bread.exec`, `bread.exec_capture` | Spawns an arbitrary shell command. `bin` is an advisory hint (e.g. `"hyprpaper"`). |
|
||
| `notify` | `bread.notify` | Desktop notifications. |
|
||
| `machine` | `bread.machine.name`/`.tags`/`.has_tag` | Reads hostname/tags, including an optional on-disk `sync.toml`. |
|
||
| `hyprland` | `bread.hyprland.*` | Compositor IPC — `dispatch`/`keyword`/`eval` control the session, `monitors`/`workspaces`/`clients`/`active_window`/`on_raw` observe it. Not split further; grant it for either. |
|
||
| `widget` | `bread.widget.register`/`.update`/`.remove`/`.list` | Registers UI in a sibling `bread*` app (breadbar). |
|
||
| `fs.read` | `bread.fs.read`/`.exists`/`.readlink`/`.expand` | Read-only filesystem access. `path` is an advisory scoping hint. |
|
||
| `fs.write` | `bread.fs.write` | Filesystem writes. Split from `fs.read` — a module that only reads shouldn't need to declare write access. |
|
||
| `bluetooth` | `bread.bluetooth.*` | BlueZ control — power/connect/disconnect/scan/devices. |
|
||
|
||
Example — a module that switches wallpaper via `hyprpaper` based on the
|
||
current monitor layout, and reads images from one directory:
|
||
|
||
```toml
|
||
[[permissions]]
|
||
type = "exec"
|
||
bin = "hyprpaper"
|
||
|
||
[[permissions]]
|
||
type = "state.read"
|
||
path = "monitors"
|
||
|
||
[[permissions]]
|
||
type = "fs.read"
|
||
path = "~/Wallpapers"
|
||
```
|
||
|
||
That module's `bread` table has `bread.exec`, `bread.state` (read
|
||
functions only — no `bread.state.watch`), and `bread.fs` (read functions
|
||
only — no `bread.fs.write`), plus the full baseline. `bread.hyprland`,
|
||
`bread.bluetooth`, `bread.notify`, `bread.machine`, and `bread.widget` are
|
||
all `nil`.
|
||
|
||
An explicit empty list (`permissions = []`) is a deliberate "baseline only"
|
||
declaration — different from omitting the key entirely. It scopes the
|
||
module down for real but is *not* flagged by `bread doctor`, since the
|
||
author made a conscious choice rather than just not knowing about this
|
||
feature yet.
|
||
|
||
### `path`/`bin` enforcement depends on where the module runs
|
||
|
||
This section describes the **in-process** scoping mechanism
|
||
(`build_scoped_env` in `breadd/src/lua/mod.rs`), which only ever gated
|
||
*presence* of a `bread.*` binding — the `path`/`bin` fields on each
|
||
permission were recorded in the manifest but never checked against the
|
||
actual arguments a module passed at runtime, and `os.execute`/`io.open`/
|
||
`debug.*` remained fully reachable from Lua's standard library regardless
|
||
of what a module's `bread` table contained. That's still exactly true for
|
||
a module with **no manifest at all** (the legacy/backward-compat path,
|
||
`ungated: true` in `modules.list`) — see [Out-of-process module
|
||
sandboxing](#out-of-process-module-sandboxing-since-v16) below.
|
||
|
||
*Since: v1.6* — a module that declares `[[permissions]]` (any, including
|
||
an explicit empty list) no longer runs in-process at all. It's spawned as
|
||
a separate, OS-sandboxed `bread-module-host` process instead, and for that
|
||
process `path`/`bin` *are* enforced for real, at the kernel level, via a
|
||
Landlock ruleset — independent of whether the module even uses the
|
||
documented `bread.*` API or goes straight for `os.execute`/`io.open`. See
|
||
the linked section for exactly what's covered and what's still deferred.
|
||
|
||
### `require("bread.devices")` still works from a scoped module
|
||
|
||
Builtin library modules (`bread.devices`, `bread.monitors`, `bread.workspaces`,
|
||
`bread.binds`) always load with the full ambient `bread` table — they're
|
||
never subject to manifest-based scoping, regardless of what any third-party
|
||
module that `require`s them declares. `require("bread.devices")` resolves
|
||
via Lua's real `package.loaded` table (already populated by the time any
|
||
third-party module loads, since builtins load first) — a real global,
|
||
reachable from a scoped module through a metatable fallback to the true
|
||
globals for everything that isn't `bread` itself (`pairs`, `string`,
|
||
`table`, `require`, `package`, ...). The returned module's own functions
|
||
(`devices.on()` etc.) were defined while `bread.devices` loaded unscoped,
|
||
so they close over the *real* `bread` table as a Lua upvalue — closures
|
||
capture their defining environment lexically, not the caller's — which is
|
||
exactly why calling `devices.on(...)` from inside a scoped module works
|
||
with no special-casing needed.
|
||
|
||
### `bread modules audit <name>`
|
||
|
||
Best-effort static scan of an installed module's `.lua` files (its entry
|
||
file plus any others in the same directory) for `bread.*` call-site
|
||
patterns, printing a suggested `[[permissions]]` block to review and paste
|
||
into `bread.module.toml`:
|
||
|
||
```bash
|
||
bread modules audit bread-wifi
|
||
```
|
||
|
||
This is a text scan, not a Lua parser — false positives (suggesting a
|
||
permission the module doesn't strictly need) are expected and fine; false
|
||
negatives on a plain `bread.exec("...")`-style call site should be rare,
|
||
but dynamic/computed call sites (`bread[method_name](...)`) won't be
|
||
detected.
|
||
|
||
## Out-of-process module sandboxing *(Since: v1.6)*
|
||
|
||
### The gap this closes
|
||
|
||
Capability-scoped modules (above) gate the *documented* `bread.*` API
|
||
surface — a module without `fs.read` sees `bread.fs == nil`. They never
|
||
gated Lua's own standard library: `os.execute`, `io.open`, `debug.*`
|
||
remained fully reachable from a scoped module's chunk regardless of what
|
||
its `bread` table contained, because that chunk still ran as ordinary Lua
|
||
code inside `breadd`'s own OS process, sharing its real filesystem/exec
|
||
access at the kernel level. A well-behaved module degrades correctly when
|
||
a permission is missing; a deliberately adversarial one just calls
|
||
`os.execute("cat /etc/shadow")` directly and the in-process mechanism has
|
||
nothing left to say about it.
|
||
|
||
This workstream closes that gap for any module that declares
|
||
`[[permissions]]` in `bread.module.toml` — including an explicit empty
|
||
list — by running it in a **separate OS process**, sandboxed at the kernel
|
||
level via [Landlock](https://docs.kernel.org/userspace-api/landlock.html),
|
||
instead of inside `breadd`'s own process.
|
||
|
||
### What still runs in-process
|
||
|
||
A module with **no manifest at all** (no `bread.module.toml`, or one with
|
||
no `permissions` key) keeps today's pre-v1.6 behavior unchanged: loaded
|
||
in-process, full ungated `bread` table, `os`/`io`/`debug` reachable —
|
||
surfaced as `"ungated": true` in `modules.list`/`state.get "modules"`,
|
||
which is exactly what `bread doctor` reads to warn about it. This is a
|
||
deliberate scope decision, not an oversight: Landlock needs concrete rules
|
||
to build a ruleset from, and "no manifest at all" carries no information
|
||
to build one. A module author who wants real OS-level isolation writes a
|
||
manifest — that's the whole point of the capability system this reuses.
|
||
Built-in modules (`bread.devices`/`monitors`/`workspaces`/`binds`) are
|
||
completely unaffected either way; they never go through manifest-based
|
||
scoping.
|
||
|
||
### Architecture
|
||
|
||
```
|
||
breadd (trusted) bread-module-host (sandboxed, per module)
|
||
│ │
|
||
├─ spawns child, applies a Landlock ──► │ (restriction applied by the
|
||
│ ruleset via Command::pre_exec │ PARENT before the child's
|
||
│ BEFORE execve() │ own main() ever runs)
|
||
│ │
|
||
├─ hands it a one-time token via │
|
||
│ $BREAD_MODULE_TOKEN (env, not argv) │
|
||
│ │
|
||
│◄── connects to breadd's existing ──────┤
|
||
│ IPC socket, presents the token │
|
||
│ via module_host.hello │
|
||
│ │
|
||
├─ looks up which module/permissions ──► │ learns its own identity +
|
||
│ the token was issued for, replies │ granted permissions from
|
||
│ │ breadd's answer (never
|
||
│ │ trusted from self-assertion)
|
||
│ │
|
||
│◄── module_host.on/off/emit/after/ ─────┤ loads init.lua into a fresh
|
||
│ every/cancel/fs_read/fs_write/ │ Lua VM; bread.* functions
|
||
│ exec/exec_capture/state_get/status │ are RPC-backed proxies, not
|
||
│ (RPC bridge, belt) │ direct bindings
|
||
│ │
|
||
│ Landlock ruleset (suspenders, │ os.execute/io.open/debug.*
|
||
│ enforced by the kernel independent │ still exist in this Lua VM
|
||
│ of whether the RPC bridge is used) ──►│ but are bounded by the
|
||
│ kernel regardless
|
||
```
|
||
|
||
One `bread-module-host` process per out-of-process module. Its own
|
||
dependency footprint is deliberately minimal (`mlua`, `tokio`,
|
||
`serde_json`, `bread-shared`) — it's reviewable attack surface in its own
|
||
right, running one module's untrusted Lua.
|
||
|
||
### The token/identity handshake
|
||
|
||
Workstream A deliberately did not build a generic IPC connection-identity
|
||
system — it closed a narrower spoofing gap instead — so there was no
|
||
`module:<name>` identity concept to reuse. `breadd` generates a random
|
||
one-time token (a v4 UUID) when spawning a module-host child and passes it
|
||
via the `$BREAD_MODULE_TOKEN` **environment variable**, not argv — argv is
|
||
visible to any process on the system via `/proc/<pid>/cmdline`, env vars
|
||
are not without `/proc/<pid>/environ` and matching privileges. The child's
|
||
first message on the IPC socket, `module_host.hello {token}`, presents
|
||
that token; `breadd` looks up which module name/permission set the token
|
||
was issued for (`ModuleHostRegistry::take_pending`, a one-time,
|
||
consume-on-read lookup) and replies with that identity. The child never
|
||
asserts its own name and has that trusted — an adversarial process holding
|
||
a *stolen or guessed* token still can't claim to be a different module
|
||
than the one `breadd` actually spawned that token for, and a token is
|
||
consumed on first use so it can't be replayed.
|
||
|
||
Other env vars passed to the child: `$BREAD_MODULE_ENTRY` (absolute path
|
||
to the module's `init.lua`) and `$BREAD_MODULE_SOCKET` (breadd's socket
|
||
path, for test harnesses that override it — production defaults to the
|
||
same `bread_shared::resolve_socket_path()` every other client uses).
|
||
`$BREAD_MODULE_NAME` is also passed, but purely informational (early log
|
||
lines before the hello handshake completes) — never trusted for identity
|
||
or permission lookup.
|
||
|
||
### The Landlock sandbox
|
||
|
||
[Landlock](https://docs.kernel.org/userspace-api/landlock.html) (Linux
|
||
5.13+) was chosen over wrapping every spawn in `bubblewrap`/`firejail`:
|
||
it's a pure-Rust crate calling the LSM's syscalls directly
|
||
(`landlock_create_ruleset`/`landlock_restrict_self`), unprivileged (no
|
||
setuid helper, no `CAP_SYS_ADMIN`), and fits this workspace's existing
|
||
preference for native Rust crates over shelling out to external tools
|
||
(same reasoning as `udev`/`zbus`/`rtnetlink` instead of CLI wrappers).
|
||
`bubblewrap`-wrapping remains a documented fallback for a target kernel
|
||
that lacks Landlock (pre-5.13, or compiled out) — not implemented, since
|
||
Landlock covers this project's actual target.
|
||
|
||
The ruleset is built in `breadd` (the parent) and applied via
|
||
`Command::pre_exec` — the closure runs in the forked child, after
|
||
`fork()` but before `execve()`, so the restriction covers the module-host
|
||
binary's own startup, not just the Lua that runs after. Because of that,
|
||
`bread-module-host` itself needs **zero** Landlock-related code or
|
||
dependency — by the time its `main()` runs, the restriction is already
|
||
active and inherited across the `execve()` that started it.
|
||
|
||
What the ruleset grants, from `breadd/src/module_host.rs`'s
|
||
`apply_sandbox`:
|
||
|
||
| Grant | Access | Why |
|
||
|-------|--------|-----|
|
||
| System library directories (`/usr/lib`, `/lib`, ...) + `/etc/ld.so.cache`/`.preload` | Read + **Execute** | The dynamic linker needs this to start *any* dynamically-linked binary at all — see the note below on why `Execute` is required here, not just `Read`. |
|
||
| The `bread-module-host` binary's own resolved path | Read + Execute | The one `execve()` this process is expected to have already performed. |
|
||
| The module's own directory (`init.lua`'s parent) | Read | So the bootstrap process can load the module's Lua at all — distinct from any `fs.read` grant, which governs the module's *own* runtime file I/O, not breadd's ability to hand it its own source. |
|
||
| `fs.read` with a `path` hint | Read, scoped to that (`~`-expanded) path prefix | Direct mapping from the manifest. |
|
||
| `fs.write` with a `path` hint | Read + Write + create, scoped to that path prefix | Matches `bread.fs.write`'s own `create_dir_all` + `write` behavior. |
|
||
| `exec` with a `bin` hint | Read + Execute, scoped to that binary's resolved path | Absolute paths used as-is; bare names resolved via a `$PATH` search, `which`-style. |
|
||
|
||
**No `fs.read`/`fs.write`/`exec` granted at all means no corresponding
|
||
Landlock rule exists, full stop** — the sandboxed process cannot read,
|
||
write, or execute anything outside the fixed baseline above, regardless
|
||
of what it tries via `os`/`io` directly.
|
||
|
||
**A note on `Execute` and shared libraries**: an earlier version of this
|
||
mechanism assumed Landlock's `Execute` right only gates `execve()`, and
|
||
that plain `Read` would be enough for the dynamic linker's `mmap(...,
|
||
PROT_EXEC, ...)` of `.so` files. That assumption was wrong — verified
|
||
empirically (not just reasoned about) by spawning a real sandboxed child:
|
||
with library directories restricted to `Read`-only, even `/bin/sh -c
|
||
"true"` failed to start at all (`EACCES` on `execve` before a single line
|
||
of script ran); granting `Execute` on those directories too fixed it. The
|
||
practical consequence: a module-host child's direct `os.execute`/`io.open`
|
||
escape hatch, if it names a path under a system library directory
|
||
specifically, is not denied the way an arbitrary path elsewhere is — the
|
||
baseline necessarily grants real `Execute` there. This is a materially
|
||
smaller exposure than no sandbox at all (bounded to files already shipped
|
||
in the system's own library directories, not the whole filesystem), but
|
||
it's a real, known trade-off, not swept under the rug. See
|
||
`breadd/src/module_host.rs`'s `apply_sandbox` doc comment for the full
|
||
reasoning, including why a fully static (`x86_64-unknown-linux-musl`)
|
||
build of `bread-module-host` — confirmed available on this project's dev
|
||
machine — would remove the need for this baseline entirely, and why that
|
||
wasn't attempted in this pass (a build/packaging change, not a sandbox
|
||
logic change).
|
||
|
||
**fs.read/fs.write with no `path` hint**: the RPC bridge's own
|
||
belt-and-suspenders permission check still applies, but no Landlock rule
|
||
is added — Landlock scoping needs a concrete path, and a hint-less grant
|
||
carries none. A module author who wants the direct `os`/`io` escape hatch
|
||
mediated at the kernel level too needs to declare a `path`.
|
||
|
||
**Network access is explicitly out of scope for this pass** (P2). Landlock
|
||
gained TCP bind/connect mediation in ABI v4+ (kernel 6.7+), but wiring a
|
||
`network` permission kind through the manifest schema and the sandbox
|
||
builder wasn't attempted here.
|
||
|
||
### RPC bridge coverage
|
||
|
||
`bread-module-host`'s `bread` table is built entirely from RPC-backed
|
||
proxies to `breadd` (`breadd/src/ipc/module_host_bridge.rs`), not direct
|
||
in-process bindings. Covered:
|
||
|
||
- **Baseline**, always present: `bread.on`/`.once`/`.off`/`.emit`,
|
||
`bread.after`/`.every`/`.cancel`, `bread.json.decode`, `bread.module`
|
||
(with a process-local `.store` — see the note below), `bread.log`/
|
||
`.warn`/`.error`. Also `bread.spawn`/`bread.wait` — the same pure-Lua
|
||
coroutine sugar `breadd`'s own `install_wait_helper` uses, since it's
|
||
built entirely on top of `on`/`once`/`after`/`cancel`, all of which are
|
||
bridged; the source is currently duplicated between `breadd` and
|
||
`bread-module-host` rather than extracted to `bread-shared` (flagged as
|
||
follow-up below).
|
||
- **Gated**, mirroring the permission table above: `bread.fs.read`/
|
||
`.write` (`fs.read`/`fs.write`), `bread.exec`/`.exec_capture` (`exec`),
|
||
`bread.state.get` (`state.read`).
|
||
|
||
Events/timers are delivered as unsolicited, tagged push messages
|
||
interleaved with ordinary request/response lines on the same connection
|
||
(`bread_shared::module_host_ipc::ModuleHostPush`) — a subscription
|
||
registered via `module_host.on`/`.once` is matched server-side against the
|
||
same event broadcast every other IPC subscriber reads from.
|
||
|
||
**Not yet bridged** (P1/P2 — see below): `bread.state.monitors`/
|
||
`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/
|
||
`.profile` shorthands, `bread.state.watch`, `bread.fs.exists`/`.readlink`/
|
||
`.expand`, `bread.profile.activate`, `bread.notify`, `bread.machine.*`,
|
||
`bread.hyprland.*`, `bread.widget.*`, `bread.bluetooth.*`,
|
||
`bread.wait_any`/`.wait_all`/`bread.workflow.*`. These namespaces are
|
||
simply absent (`nil`) from an out-of-process module's `bread` table
|
||
regardless of what the manifest grants — a real coverage gap versus the
|
||
in-process mechanism, not a permission-check bug.
|
||
|
||
**`bread.module().store` is process-local**, not synced back to `breadd`'s
|
||
`RuntimeState` — a real, known limitation versus the in-process mechanism
|
||
(where `M.store.set`/`.get` persists in daemon state and is visible to
|
||
`bread modules info`/other tooling). Fine for a module's own private
|
||
scratch state; not fine yet for anything expecting cross-process
|
||
visibility. Modules that need to report results/state externally should
|
||
use `bread.emit(...)` instead, which does cross the process boundary.
|
||
|
||
### Crash isolation
|
||
|
||
Each spawned `bread-module-host` child is reaped by a dedicated thread in
|
||
`breadd` (`std::process::Child::wait()`, blocking on that thread only —
|
||
never blocking the IPC server or the Lua engine). On exit for any reason —
|
||
clean shutdown, a Lua panic, `kill -9` — `breadd` emits
|
||
`bread.module.crashed` with `{ module, pid, reason, exit_code, signal }`
|
||
and updates that module's status. Verified end-to-end
|
||
(`breadd/tests/module_host_sandbox.rs`): killing a module-host child with
|
||
`SIGKILL` leaves `breadd` itself and every other module (in-process or
|
||
out-of-process) fully responsive, and the crash event fires with the
|
||
correct module name and `signal: 9`.
|
||
|
||
This is deliberately **detection and reporting**, not a restart/backoff
|
||
policy — a crashed module-host stays down until the next `bread reload`
|
||
(or daemon restart) respawns it. Richer supervision (auto-restart,
|
||
backoff, a circuit breaker) is flagged as follow-up work, not attempted
|
||
here.
|
||
|
||
### New IPC methods
|
||
|
||
*Since: v1.6 — `API_VERSION` bumped from `1.5.0` to `1.6.0` in
|
||
`breadd/src/ipc/mod.rs` for this addition.* All new methods live under the
|
||
`module_host.*` prefix and are only meaningful on a connection that has
|
||
completed the `module_host.hello` handshake (see the token/identity
|
||
section above) — see [Dictionary: IPC protocol](#dictionary-ipc-protocol)
|
||
for the full list alongside the pre-existing methods.
|
||
|
||
### What's implemented vs. deferred
|
||
|
||
**Landed (P0)**:
|
||
- The `bread-module-host` binary, spawn + token-based identity handshake.
|
||
- Real Landlock sandboxing built from a module's `ModulePermission` list,
|
||
independently verified at the OS level (`breadd/src/module_host.rs`'s
|
||
`landlock_denies_reads_outside_granted_path`/
|
||
`no_exec_permission_means_binary_cannot_be_executed_at_all` unit tests
|
||
against a real spawned child; `breadd/tests/module_host_sandbox.rs`'s
|
||
`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`
|
||
end-to-end, going through a real IPC handshake and real Lua calling
|
||
`os.execute`/`io.open` directly).
|
||
- RPC bridge for the baseline set plus `fs.read`/`fs.write`/`exec`/
|
||
`exec_capture`/`state.read` (`state.get` only).
|
||
- Crash isolation: kill-9 of a module-host child doesn't take `breadd` or
|
||
any other module down, and is reported via `bread.module.crashed`
|
||
(`breadd/tests/module_host_sandbox.rs`'s
|
||
`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`).
|
||
|
||
**Landed beyond the minimum (still P0-adjacent)**:
|
||
- `bread.spawn`/`bread.wait` (pure-Lua coroutine sugar) work out-of-process
|
||
too, since they're built entirely on already-bridged primitives.
|
||
- `bread.state.get` (not originally required for the P0 minimum, added
|
||
because a pre-existing capability-manifest test exercised it).
|
||
|
||
**Deferred (P1 — do next if this workstream continues)**:
|
||
- `trust = "in-process"` manifest escape hatch for latency-sensitive
|
||
modules that want to opt back into today's D-mechanism deliberately.
|
||
- Extracting `bread.spawn`/`bread.wait`'s embedded Lua source (currently
|
||
duplicated between `breadd` and `bread-module-host`) into a shared
|
||
`bread-shared` module so the two copies can't drift.
|
||
- The remaining `bread.*` namespaces over RPC: `bread.state.watch` and the
|
||
`.monitors`/`.active_workspace`/etc. shorthands, `bread.fs.exists`/
|
||
`.readlink`/`.expand`, `bread.profile.activate`, `bread.notify`,
|
||
`bread.machine.*`, `bread.hyprland.*`, `bread.widget.*`,
|
||
`bread.bluetooth.*`, `bread.wait_any`/`.wait_all`/`bread.workflow.*` —
|
||
mechanically the same pattern as the ones already bridged.
|
||
|
||
**Deferred (P2 — explicitly out of scope for this pass)**:
|
||
- Network sandboxing / a `network` permission kind.
|
||
- `bread modules info` showing the resolved sandbox profile.
|
||
- Full restart/backoff supervision policy for crashed module-hosts.
|
||
- A fully static (musl) build of `bread-module-host`, which would remove
|
||
the library-directory `Execute` baseline grant entirely.
|
||
- 100% RPC coverage of every remaining namespace.
|
||
|
||
## Debugging tips
|
||
|
||
- Run `bread events` to see live normalized events.
|
||
- Run `bread events --tree` *(Since: v1.5)* to render events as a causality tree instead of a flat stream — events that a Lua handler emitted via `bread.emit()` in reaction to another event are nested underneath it, following the `caused_by` chain (see [Dictionary: Event reference](#dictionary-event-reference)). Useful for untangling "why did this event fire" when several modules chain-react to each other.
|
||
- Run `bread state` to see full runtime state as JSON.
|
||
- Run `bread doctor` to check adapter and module health, including modules
|
||
running with full, ungated `bread.*` access because they have no
|
||
`permissions` declared.
|
||
- Log event payloads with `bread.log(tostring(event.data))`.
|
||
- Use `RUST_LOG=debug breadd` for verbose daemon output.
|
||
|
||
---
|
||
|
||
## Dictionary: Lua API
|
||
|
||
Every API is exposed through the `bread` global table.
|
||
|
||
### Module declaration
|
||
|
||
Every module must call `bread.module` exactly once at the top level.
|
||
|
||
```lua
|
||
local M = bread.module({
|
||
name = "my.module",
|
||
version = "0.1.0",
|
||
after = { "bread.devices" }, -- optional: load after this module
|
||
})
|
||
|
||
return M
|
||
```
|
||
|
||
If a module does not call `bread.module`, it fails to load and is marked as a load error.
|
||
|
||
### Events
|
||
|
||
#### `bread.on(pattern, fn) -> id`
|
||
Subscribe to matching events. Returns a numeric subscription ID.
|
||
|
||
```lua
|
||
local id = bread.on("bread.device.*", function(event)
|
||
-- event.event → the full event name string
|
||
-- event.data → table of event-specific fields
|
||
-- event.source → adapter that produced it ("Udev", "Hyprland", etc.)
|
||
bread.log(event.event)
|
||
end)
|
||
```
|
||
|
||
#### `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. `opts` must contain a `filter` function:
|
||
|
||
```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 handler or state watch by ID.
|
||
|
||
#### `bread.emit(event, data)`
|
||
Emit a custom event into the system pipeline. Useful for cross-module communication. If called synchronously from inside a `bread.on` subscriber callback (i.e. in reaction to a matched event), the emitted event's `caused_by` *(Since: v1.5)* is set to the id of the event that triggered the callback, threading causality across chains of modules that react to each other — see [Dictionary: Event reference](#dictionary-event-reference).
|
||
|
||
#### `bread.wait(pattern, opts) -> event | nil`
|
||
Coroutine-only helper that suspends until a matching event arrives.
|
||
|
||
```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 it fails. Required for using `bread.wait`.
|
||
|
||
#### `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)*
|
||
Coroutine-only. Like `bread.wait`, but resolves on the first of several patterns to match; returns `nil` after `opts.timeout` if none do.
|
||
|
||
```lua
|
||
bread.spawn(function()
|
||
local event = bread.wait_any(
|
||
{ "bread.monitor.connected", "bread.hyprland.event" },
|
||
{ timeout = 5000 }
|
||
)
|
||
if event then
|
||
bread.log("a monitor-related event arrived")
|
||
end
|
||
end)
|
||
```
|
||
|
||
#### `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*
|
||
Coroutine-only. Resolves once every listed pattern has fired at least once, or `opts.timeout` elapses. Returns a table keyed by pattern → event; on timeout, the table additionally has `timed_out = true` and contains whichever patterns had already fired.
|
||
|
||
### Workflows *(Since: v1.2)*
|
||
|
||
Multi-step automations built on `bread.spawn`/`bread.wait` (and `wait_any`/`wait_all`), with status introspectable from outside the running coroutine — via Lua (`bread.workflow.status`/`.list`) or over IPC (`workflows.list`). See [Examples.md](Examples.md#example-4-multi-step-automation-workflows) for a full worked example.
|
||
|
||
#### `bread.workflow.define(name, fn)`
|
||
Register a workflow body under `name`. `fn` receives one argument: whatever `opts.args` was passed to `.start()` (or `nil`).
|
||
|
||
#### `bread.workflow.start(name, opts)`
|
||
Run the workflow registered as `name` (spawned as a coroutine, same mechanics as `bread.spawn`). `opts` (optional):
|
||
|
||
| Key | Type | Description |
|
||
|-----|------|-------------|
|
||
| `deadline` | ms | If the workflow hasn't reached a terminal state by then, its status becomes `timed_out`. Independent of any per-`wait` timeout inside the body — a safety net for the whole run, not a replacement for step-level timeouts. |
|
||
| `args` | any | Passed through as the sole argument to the workflow body function. |
|
||
|
||
Starting a workflow under a name that's already running **replaces** its registry entry — this is a live-status registry, not a run history.
|
||
|
||
#### `bread.workflow.step(label)`
|
||
Call from inside a running workflow body to record "currently here." Purely observational — it does not affect control flow. Errors if called outside a running workflow body.
|
||
|
||
#### `bread.workflow.status(name) -> table | nil`
|
||
Returns the current status for `name`, or `nil` if no workflow with that name has ever been started. Shape:
|
||
|
||
```json
|
||
{
|
||
"name": "dock-connected",
|
||
"state": "running",
|
||
"step": "waiting for monitor",
|
||
"started_at": 1710000000000,
|
||
"updated_at": 1710000001500,
|
||
"error": null
|
||
}
|
||
```
|
||
|
||
`state` is one of `running`, `done`, `failed`, `timed_out`. `error` is set (the captured Lua error message) only when `state` is `failed`.
|
||
|
||
#### `bread.workflow.list() -> table`
|
||
Returns an array of every workflow's current status, in the same shape as `bread.workflow.status`.
|
||
|
||
### Widgets *(Since: v1.3)*
|
||
|
||
Declarative, live-updating widgets rendered by sibling `bread*` apps (breadbar) in their own bar/popover free space. A widget is a small tree of typed nodes — `box`, `label`, `icon`, `progress` — not raw markup: this keeps rendering generic across every consuming app and keeps a node's appearance confined to a bounded, typed `style` vocabulary the renderer already knows about (see `style` below), with no style/CSS injection surface from Lua.
|
||
|
||
Widgets are registered per-module and are re-registered fresh on every hot reload (the whole registry is cleared right before the Lua VM resets, same as `bread.module`'s per-reload re-execution) — call `bread.widget.register` at module top level or in `on_load`, not somewhere that only runs once ever.
|
||
|
||
#### `bread.widget.register(spec) -> ok, err`
|
||
Registers (or replaces, if `spec.id` already exists for this module) a widget. `spec`:
|
||
|
||
| Key | Type | Description |
|
||
|-----|------|-------------|
|
||
| `id` | string | Local id, unique within your module. Stored/addressed elsewhere as `"<module>.<id>"`. |
|
||
| `placement` | string | One of `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` — which fixed slot in the consuming app's layout this widget renders into. |
|
||
| `order` | number | Optional, default `0`. Sort priority within a placement; lower sorts first. |
|
||
| `visible` | bool | Optional, default `true`. |
|
||
| `tooltip` | string | Optional. |
|
||
| `root` | node | The render tree (see Node types below). |
|
||
|
||
Returns `true` on success, or `false, err` if `root` fails validation (tree too deep, too many nodes, or an invalid `class`), `root` contains a `style` field with a value outside its enum (a deserialization error, reported the same way), or `bread.widget.register` was called outside a module.
|
||
|
||
##### Node types
|
||
|
||
Every node accepts an optional `style` (a bounded, typed vocabulary — see below; this is the primary way to control a node's appearance), an optional `class` (a small freeform escape hatch, see [Style vs. class](#style-vs-class) below), and an optional `on_click` (any Lua value, passed through opaquely — see Click events below).
|
||
|
||
| `type` | Fields |
|
||
|--------|--------|
|
||
| `box` | `orientation` (`"horizontal"` \| `"vertical"`, default horizontal), `spacing`, `children` (array of nodes) |
|
||
| `label` | `text` |
|
||
| `icon` | `name` (bundled icon) or `path` (arbitrary SVG file) — exactly one; `size` |
|
||
| `progress` | `value` (0.0–1.0) |
|
||
|
||
A tree is capped at depth 4 (root counts as depth 1) and 50 total nodes — comfortably enough for a status readout, not enough to build a full custom UI.
|
||
|
||
```lua
|
||
bread.widget.register({
|
||
id = "weather",
|
||
placement = "left_of_stats",
|
||
tooltip = "Sydney: Partly cloudy",
|
||
root = {
|
||
type = "box",
|
||
children = {
|
||
{ type = "icon", name = "cloud" },
|
||
{ type = "label", text = "22°C", style = { color = "dim" }, on_click = "refresh" },
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
##### `style` *(Since: v1.4)*
|
||
|
||
`style` is a bounded, typed vocabulary for a node's appearance — every field is a small closed enum, not a string, so a typo is a `bread.widget.register` validation failure at registration time, not a silently-ignored CSS class. There is deliberately **no raw CSS/style-string field** anywhere in this API: a module can only ever pick from the fixed set below, never inject arbitrary style.
|
||
|
||
| Field | Type | Values |
|
||
|-------|------|--------|
|
||
| `color` | string | `fg`, `dim` (muted foreground), `accent`, `red`, `green`, `yellow`, `blue`, `pink`, `teal` |
|
||
| `weight` | string | `normal`, `bold` |
|
||
| `size` | string | `xs`, `sm`, `md`, `lg`, `xl` — text size in px (10/12/14/16/20); `sm`/`md` match the bread design system's own secondary/base font sizes |
|
||
| `align` | string | `start`, `center`, `end` |
|
||
| `background` | string | `none`, `surface`, `card` (surface + rounded corners + padding) |
|
||
| `radius` | string | `none`, `sm`, `md`, `full` (pill) |
|
||
| `padding` | string | `none`, `xs`, `sm`, `md` |
|
||
|
||
Every field is optional and independent — set only what you need. Colors, font sizes, radii, and padding all reuse the exact same palette, font, and spacing scale every other `bread*` GUI (breadbar, bos-settings, breadpad, ...) is themed from, so a widget recolors with the rest of the desktop when pywal's palette changes instead of drifting out of sync.
|
||
|
||
```lua
|
||
{ type = "label", text = "LOW BATTERY", style = { color = "yellow", weight = "bold" } }
|
||
```
|
||
|
||
##### Style vs. `class`
|
||
|
||
`class` still exists as an escape hatch for a CSS class the *consuming app's own stylesheet* happens to define (restricted to `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) — useful if you're targeting a specific app you know the internals of, but undiscoverable and app-specific otherwise. As of this writing, breadbar's stylesheet only gives real meaning to `dim` this way (fades a node to 60% opacity) — everything else a module needs (color, weight, size, alignment, background, radius, padding) should go through `style` instead, which every renderer is expected to understand identically.
|
||
|
||
#### `bread.widget.update(id, patch) -> ok, err`
|
||
Patches an already-registered widget (local `id`, not the fully-qualified form). Any of `root`, `tooltip`, `visible`, `order` may be given; omitted fields are left as-is. `root`, when given, replaces the whole tree — there is no node-level patching. Returns `false, "no such widget"` if `id` isn't registered.
|
||
|
||
```lua
|
||
bread.widget.update("weather", {
|
||
root = { type = "box", children = { { type = "label", text = "23°C" } } },
|
||
})
|
||
```
|
||
|
||
#### `bread.widget.remove(id) -> bool`
|
||
Removes a widget registered by the calling module. Returns whether anything was removed.
|
||
|
||
#### `bread.widget.list() -> table`
|
||
Returns an array of every widget the calling module currently has registered.
|
||
|
||
##### Click events
|
||
|
||
A clicked node's `on_click` value doesn't travel back through `breadd` directly — the rendering app (breadbar) emits `bread.bar.widget_clicked` with `{ widget_id, action }` (`action` being whatever you put in `on_click`), because a rendering app may only publish inside its own `bread.<app_id>.*` namespace (see [Namespaces](#namespaces)). React to it like any other event, filtering on `widget_id`:
|
||
|
||
```lua
|
||
bread.on("bread.bar.widget_clicked", function(e)
|
||
if e.data.widget_id == "weather.weather" then
|
||
-- e.data.action == "refresh"
|
||
end
|
||
end)
|
||
```
|
||
|
||
### State
|
||
|
||
#### `bread.state.get(path)`
|
||
Read a state subtree by dotted path.
|
||
|
||
```lua
|
||
local monitors = bread.state.get("monitors")
|
||
local online = bread.state.get("network.online")
|
||
```
|
||
|
||
#### Typed shorthands
|
||
|
||
```lua
|
||
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 for changes. The callback receives `(new_value, old_value)`.
|
||
|
||
```lua
|
||
bread.state.watch("power.ac_connected", function(new_val, old_val)
|
||
if new_val then
|
||
bread.notify("AC connected")
|
||
end
|
||
end)
|
||
```
|
||
|
||
### Profiles
|
||
|
||
#### `bread.profile.activate(name)`
|
||
Activate a named profile. Emits `bread.profile.activated` over IPC.
|
||
|
||
### Execution
|
||
|
||
#### `bread.exec(cmd)`
|
||
Run a shell command. Fire-and-forget (async, does not block Lua).
|
||
|
||
#### `bread.exec_capture(cmd, opts) -> ok, stdout`
|
||
Run a shell command and return its result: `ok` is whether it exited zero,
|
||
`stdout` is its captured standard output. Unlike `bread.exec`, this blocks
|
||
the calling Lua callback until the command exits (or the timeout below
|
||
elapses), so it's only appropriate for fast, local commands — e.g.
|
||
`git -C <dir> rev-parse --abbrev-ref HEAD`, not anything that hits the
|
||
network or waits on user input.
|
||
|
||
```lua
|
||
local ok, branch = bread.exec_capture("git -C " .. dir .. " rev-parse --abbrev-ref HEAD")
|
||
if ok then
|
||
branch = branch:gsub("%s+$", "") -- trailing newline
|
||
end
|
||
```
|
||
|
||
Options:
|
||
|
||
| Key | Type | Default |
|
||
|-----|------|---------|
|
||
| `timeout_ms` | number | `2000` |
|
||
|
||
On timeout or spawn failure, returns `false, ""`.
|
||
|
||
### Notifications
|
||
|
||
#### `bread.notify(message, opts)`
|
||
Send a desktop notification via `notify-send`.
|
||
|
||
Options:
|
||
|
||
| Key | Type | Default |
|
||
|-----|------|---------|
|
||
| `title` | string | `"bread"` |
|
||
| `urgency` | string | from config |
|
||
| `timeout` | ms | from config |
|
||
| `icon` | string | none |
|
||
|
||
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
|
||
|
||
### Timers
|
||
|
||
#### `bread.after(delay_ms, fn) -> id`
|
||
Run once after a delay.
|
||
|
||
#### `bread.every(interval_ms, fn) -> id`
|
||
Run on a repeating interval.
|
||
|
||
#### `bread.cancel(id)`
|
||
Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload.
|
||
|
||
### Utilities
|
||
|
||
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
|
||
Returns a wrapper that fires only after `delay_ms` of quiet time.
|
||
|
||
```lua
|
||
local fn = bread.debounce(200, function(event)
|
||
reconfigure_monitors()
|
||
end)
|
||
bread.on("bread.monitor.**", fn)
|
||
```
|
||
|
||
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
|
||
Logging helpers. Accept any Lua value (coerced via `tostring`).
|
||
|
||
### Machine and filesystem
|
||
|
||
#### `bread.machine.name() -> string`
|
||
Returns the system hostname. If an external tool has written a
|
||
`~/.config/bread/sync.toml` with a `[machine].name`, that value takes
|
||
precedence (bread reads the file if present but does not create it).
|
||
|
||
#### `bread.machine.tags() -> string[]`
|
||
Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file
|
||
exists, otherwise `{}`.
|
||
|
||
#### `bread.machine.has_tag(tag) -> bool`
|
||
Returns true if the machine has the given tag.
|
||
|
||
#### `bread.fs.write(path, content)`
|
||
Write a file. Creates parent directories as needed. `~` is expanded.
|
||
|
||
#### `bread.fs.read(path) -> string | nil`
|
||
Read a file. Returns `nil` if the file does not exist. `~` is expanded.
|
||
|
||
#### `bread.fs.exists(path) -> bool`
|
||
Returns true if the path exists. `~` is expanded.
|
||
|
||
#### `bread.fs.readlink(path) -> string | nil`
|
||
Read a symlink's target. Returns `nil` if the path doesn't exist or isn't a
|
||
symlink. Distinct from `bread.fs.read`, which opens and reads file
|
||
*contents* — for something like `/proc/<pid>/cwd`, the payload is the link
|
||
target itself, not a file to read.
|
||
|
||
#### `bread.fs.expand(path) -> string`
|
||
Expand `~` to the home directory.
|
||
|
||
#### `bread.json.decode(str) -> table | nil`
|
||
Parse a JSON string into a Lua table. Returns `nil` on malformed input.
|
||
Pairs naturally with `bread.exec_capture` for consuming JSON output from a
|
||
CLI (e.g. `kitty @ ls`).
|
||
|
||
### Hyprland
|
||
|
||
The `bread.hyprland` namespace provides compositor 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")
|
||
|
||
-- Send a raw request to the Hyprland socket, e.g. to evaluate a config-file
|
||
-- expression the way `hyprctl eval <expr>` does; returns the raw response string
|
||
local result = bread.hyprland.eval("some expression")
|
||
|
||
-- Query compositor state (returns deserialized Lua tables)
|
||
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 (bypasses normalization)
|
||
bread.hyprland.on_raw("activewindow", function(raw)
|
||
-- raw payload includes: kind, raw (original string), data
|
||
end)
|
||
```
|
||
|
||
### Bluetooth
|
||
|
||
The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`.
|
||
|
||
#### `bread.bluetooth.power(enabled)`
|
||
Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget.
|
||
|
||
#### `bread.bluetooth.powered() -> bool | nil`
|
||
Returns the current power state of the adapter, or `nil` if unavailable.
|
||
|
||
```lua
|
||
if bread.bluetooth.powered() then
|
||
bread.log("Bluetooth is on")
|
||
end
|
||
```
|
||
|
||
#### `bread.bluetooth.connect(address)`
|
||
Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds.
|
||
|
||
```lua
|
||
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||
```
|
||
|
||
#### `bread.bluetooth.disconnect(address)`
|
||
Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`.
|
||
|
||
#### `bread.bluetooth.scan(enabled)`
|
||
Start (`true`) or stop (`false`) device discovery.
|
||
|
||
#### `bread.bluetooth.devices() -> table | nil`
|
||
Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable.
|
||
|
||
```lua
|
||
local devs = bread.bluetooth.devices()
|
||
if devs then
|
||
for _, dev in ipairs(devs) do
|
||
bread.log(dev.name .. " " .. dev.address
|
||
.. (dev.connected and " [connected]" or ""))
|
||
end
|
||
end
|
||
```
|
||
|
||
Each device table:
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` |
|
||
| `name` | string | Device name from BlueZ (Alias or Name property) |
|
||
| `connected` | bool | Whether the device is currently connected |
|
||
| `paired` | bool | Whether the device is paired |
|
||
|
||
#### Example: auto-connect headphones on AC power
|
||
|
||
```lua
|
||
local M = bread.module({ name = "headphones", version = "1.0.0" })
|
||
local HEADPHONES = "AA:BB:CC:DD:EE:FF"
|
||
|
||
function M.on_load()
|
||
bread.state.watch("power.ac_connected", function(ac)
|
||
if ac then
|
||
bread.bluetooth.power(true)
|
||
bread.bluetooth.connect(HEADPHONES)
|
||
end
|
||
end)
|
||
end
|
||
|
||
return M
|
||
```
|
||
|
||
#### Example: turn off Bluetooth on battery
|
||
|
||
```lua
|
||
bread.state.watch("power.ac_connected", function(ac)
|
||
bread.bluetooth.power(ac)
|
||
end)
|
||
```
|
||
|
||
### Module lifecycle hooks
|
||
|
||
All hooks are optional.
|
||
|
||
```lua
|
||
function M.on_load()
|
||
-- Called after the module loads. Register subscriptions here.
|
||
end
|
||
|
||
function M.on_reload()
|
||
-- Called after a hot reload completes across all modules.
|
||
end
|
||
|
||
function M.on_unload()
|
||
-- Called before the Lua instance is dropped.
|
||
end
|
||
|
||
function M.on_error(err)
|
||
-- Called when a subscription handler in this module throws.
|
||
-- Return true to keep the subscription alive, false to cancel it.
|
||
return true
|
||
end
|
||
```
|
||
|
||
### Module storage
|
||
|
||
Survives hot reload; does not survive daemon restart.
|
||
|
||
```lua
|
||
M.store.set("last_profile", "docked")
|
||
local value = M.store.get("last_profile")
|
||
```
|
||
|
||
Storage is scoped per module and is not shared across modules.
|
||
|
||
---
|
||
|
||
## Dictionary: Built-in modules
|
||
|
||
Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
|
||
|
||
### `bread.rules` *(Since: v1.5)*
|
||
|
||
The Lua side of the `rules.toml` declarative automation layer described in
|
||
[Getting started](#getting-started) — there is no separate API to call
|
||
here, it's driven entirely by `~/.config/bread/rules.toml`. Listed here (and
|
||
disable-able via `[modules].disable = ["bread.rules"]` like every other
|
||
built-in) because it's a real module the same way `bread.devices` is, just
|
||
one whose configuration lives in TOML instead of Lua.
|
||
|
||
```toml
|
||
# ~/.config/bread/rules.toml
|
||
[[rule]]
|
||
on = "device.dock.connected"
|
||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||
|
||
[[rule]]
|
||
on = "power.ac.disconnected"
|
||
notify = "Unplugged"
|
||
|
||
[[rule]]
|
||
on = "device.keyboard.connected"
|
||
exec = "xset r rate 200 40"
|
||
```
|
||
|
||
Each rule's `on` becomes a `bread.on("bread." .. on, ...)` subscription —
|
||
see [Getting started](#getting-started) for the full `run`/`exec`/`notify`
|
||
semantics and validation rules. `rules.toml`'s absence is not an error;
|
||
parse/validation problems are reported the same way a broken hand-written
|
||
module's `on_load` error would be — via `bread doctor` / `modules.list`,
|
||
against the `bread.rules` module name.
|
||
|
||
### `bread.monitors`
|
||
|
||
High-level declarative monitor event handlers.
|
||
|
||
```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"),
|
||
})
|
||
```
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) |
|
||
| `M.layout(name, fn)` | Register a named layout function |
|
||
| `M.apply(name) -> fn` | Returns a function that calls the named layout |
|
||
|
||
`when` is one of `connected`, `disconnected`, `changed`.
|
||
|
||
### `bread.devices`
|
||
|
||
Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals.
|
||
|
||
Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers.
|
||
|
||
```lua
|
||
local devices = require("bread.devices")
|
||
|
||
devices.on({
|
||
when = "connected",
|
||
device = "keyboard",
|
||
run = function(event)
|
||
bread.exec("xset r rate 200 40")
|
||
end,
|
||
})
|
||
|
||
devices.on({
|
||
when = "connected",
|
||
device = "dock",
|
||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||
})
|
||
|
||
devices.on({
|
||
when = "disconnected",
|
||
name = "CalDigit", -- pattern-matched against event.data.name
|
||
run = function(event)
|
||
bread.log("Dock disconnected: " .. event.data.name)
|
||
end,
|
||
})
|
||
```
|
||
|
||
#### Functions
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `M.on(opts)` | Register a device rule. See options below. |
|
||
|
||
#### Device rule options
|
||
|
||
```lua
|
||
devices.on({
|
||
when = "connected", -- required: "connected" or "disconnected"
|
||
device = "keyboard", -- optional: device name from devices.lua
|
||
name = "Keychron", -- optional: substring matched against device name
|
||
run = function(event) ... end -- required: function or shell string
|
||
})
|
||
```
|
||
|
||
- `when` (required): One of `connected` or `disconnected`.
|
||
- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name.
|
||
- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match).
|
||
- `run` (required): Function or shell string to run when the rule matches.
|
||
|
||
The callback receives the full device event:
|
||
```lua
|
||
{
|
||
event = "bread.device.dock.connected",
|
||
data = {
|
||
id = "/sys/...",
|
||
device = "dock", -- name resolved from devices.lua
|
||
name = "CalDigit TS4", -- raw device name from udev
|
||
subsystem = "usb",
|
||
vendor_id = "0x35f5",
|
||
product_id = "0x0104",
|
||
raw = { ... } -- full udev properties
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Example: Keyboard configuration on connect
|
||
|
||
```lua
|
||
devices.on({
|
||
when = "connected",
|
||
device = "keyboard",
|
||
run = function(event)
|
||
bread.log("Keyboard connected: " .. event.data.name)
|
||
bread.exec("xset r rate 200 40")
|
||
end,
|
||
})
|
||
```
|
||
|
||
#### Example: Dock-specific setup
|
||
|
||
```lua
|
||
-- devices.lua defines: { device = "dock", vendor_id = "35f5" }
|
||
|
||
devices.on({
|
||
when = "connected",
|
||
device = "dock",
|
||
run = function(event)
|
||
bread.log("Dock connected")
|
||
bread.exec("~/.config/bread/scripts/dock-connected.sh")
|
||
end,
|
||
})
|
||
|
||
devices.on({
|
||
when = "disconnected",
|
||
device = "dock",
|
||
run = function(event)
|
||
bread.log("Dock disconnected")
|
||
bread.exec("~/.config/bread/scripts/dock-disconnected.sh")
|
||
end,
|
||
})
|
||
```
|
||
|
||
### `bread.workspaces`
|
||
|
||
Workspace-to-monitor assignment and app pinning.
|
||
|
||
```lua
|
||
local workspaces = require("bread.workspaces")
|
||
|
||
workspaces.assign("1", "HDMI-A-1")
|
||
workspaces.pin({ app = "Firefox", workspace = "2" })
|
||
```
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `M.assign(workspace, monitor)` | Assign a workspace to a monitor |
|
||
| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` |
|
||
| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch |
|
||
|
||
### `bread.binds`
|
||
|
||
Runtime keybind management via Hyprland.
|
||
|
||
```lua
|
||
local binds = require("bread.binds")
|
||
|
||
binds.add({
|
||
mods = { "SUPER" },
|
||
key = "Return",
|
||
dispatch = "exec",
|
||
args = "kitty",
|
||
})
|
||
```
|
||
|
||
| Function | Description |
|
||
|----------|-------------|
|
||
| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` |
|
||
| `M.remove(key)` | Remove a keybind by key |
|
||
| `M.replace(key, opts)` | Remove and re-add a keybind |
|
||
|
||
---
|
||
|
||
## Dictionary: Event reference
|
||
|
||
Events are delivered as a `BreadEvent`:
|
||
|
||
```json
|
||
{
|
||
"event": "bread.device.dock.connected",
|
||
"timestamp": 1710000000000,
|
||
"source": "Udev",
|
||
"data": {},
|
||
"id": "b3f2c9a0-4e6d-4b8a-9c1e-7a2f5d8e0c11",
|
||
"caused_by": null
|
||
}
|
||
```
|
||
|
||
- **`id`** *(Since: v1.5)* — a unique id assigned to this specific event instance at construction. Every `BreadEvent`, regardless of origin (adapter-normalized, IPC `emit`, Lua `bread.emit()`, or a daemon-internal send like `bread.system.startup`), gets one.
|
||
- **`caused_by`** *(Since: v1.5)* — the `id` of the event whose Lua subscriber handler emitted this event via `bread.emit()`, or `null` if this event did not originate from inside a running handler (adapter events, IPC `emit`, daemon-internal sends). This lets you reconstruct causality chains across modules that react to each other's events: if module A's handler for event X calls `bread.emit("Y", ...)`, then Y's `caused_by` is X's `id`. See `bread events --tree` below for a rendering of these chains.
|
||
|
||
### Pattern matching
|
||
|
||
| Pattern | Matches |
|
||
|---------|---------|
|
||
| `bread.device.dock.connected` | Exact match only |
|
||
| `bread.device.*` | One segment wildcard (does not cross `.`) |
|
||
| `bread.device.**` | Any depth under `bread.device` |
|
||
| `bread.monitor.?` | Single character within one segment |
|
||
|
||
### Normalized events
|
||
|
||
#### System
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.system.startup` | `{}` |
|
||
| `bread.module.crashed` *(Since: v1.6)* | `{ module, pid, reason, exit_code, signal }` — an out-of-process `bread-module-host` child exited (crash, panic, `kill -9`, ...). `exit_code`/`signal` are mutually exclusive (whichever applies); see [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16). |
|
||
|
||
#### Devices (udev / Bluetooth)
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
|
||
| `bread.device.disconnected` | same |
|
||
| `bread.device.<device>.connected` | `{ id, device }` |
|
||
| `bread.device.<device>.disconnected` | `{ id, device }` |
|
||
|
||
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
|
||
|
||
Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`:
|
||
|
||
| `subsystem` | Source | Unique identifier field |
|
||
|-------------|--------|------------------------|
|
||
| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` |
|
||
| `"bluetooth"` | BlueZ | `address` (MAC address) |
|
||
|
||
#### Bluetooth (BlueZ)
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` |
|
||
| `bread.device.disconnected` | same |
|
||
| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` |
|
||
| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` |
|
||
|
||
`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects.
|
||
|
||
`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup.
|
||
|
||
#### Hyprland
|
||
|
||
*Since: v1.5 — the `bread.hyprland.*` namespaced forms below. Bread's event vocabulary is meant to be portable across a future second compositor backend; a flat `bread.workspace.*`/`bread.monitor.*`/`bread.window.*` name gave no way to tell a genuinely cross-backend event (like `bread.power.*`) apart from one that is Hyprland-specific. The 10 rows marked `Deprecated: v1.5` are unaffected functionally — they keep firing — but new automation should subscribe to their `bread.hyprland.*` sibling instead.*
|
||
|
||
Every Hyprland-sourced event below is dual-emitted: the daemon fires both the legacy flat name and its `bread.hyprland.<rest>` equivalent with identical `data`/`timestamp`/`source`, unless `[compat] legacy_hyprland_event_names = false` is set (see below), in which case only the namespaced name fires. A module that subscribes only to `bread.hyprland.*` always gets full workspace/monitor/window coverage regardless of that setting.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.workspace.changed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.changed`)* | raw payload |
|
||
| `bread.hyprland.workspace.changed` *(Since: v1.5)* | raw payload |
|
||
| `bread.workspace.created` *(Deprecated: v1.5 — use `bread.hyprland.workspace.created`)* | `{ workspace }` |
|
||
| `bread.hyprland.workspace.created` *(Since: v1.5)* | `{ workspace }` |
|
||
| `bread.workspace.destroyed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.destroyed`)* | `{ workspace }` |
|
||
| `bread.hyprland.workspace.destroyed` *(Since: v1.5)* | `{ workspace }` |
|
||
| `bread.monitor.connected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.connected`)* | raw payload |
|
||
| `bread.hyprland.monitor.connected` *(Since: v1.5)* | raw payload |
|
||
| `bread.monitor.disconnected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.disconnected`)* | raw payload |
|
||
| `bread.hyprland.monitor.disconnected` *(Since: v1.5)* | raw payload |
|
||
| `bread.window.focus.changed` *(Deprecated: v1.5 — use `bread.hyprland.window.focus.changed`)* | raw payload |
|
||
| `bread.hyprland.window.focus.changed` *(Since: v1.5)* | raw payload |
|
||
| `bread.window.focused` *(Deprecated: v1.5 — use `bread.hyprland.window.focused`)* | `{ address }` |
|
||
| `bread.hyprland.window.focused` *(Since: v1.5)* | `{ address }` |
|
||
| `bread.window.opened` *(Deprecated: v1.5 — use `bread.hyprland.window.opened`)* | `{ address, workspace, class, title }` |
|
||
| `bread.hyprland.window.opened` *(Since: v1.5)* | `{ address, workspace, class, title }` |
|
||
| `bread.window.closed` *(Deprecated: v1.5 — use `bread.hyprland.window.closed`)* | `{ address }` |
|
||
| `bread.hyprland.window.closed` *(Since: v1.5)* | `{ address }` |
|
||
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
|
||
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
|
||
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
|
||
|
||
##### Compatibility: `[compat]` config
|
||
|
||
```toml
|
||
[compat]
|
||
legacy_hyprland_event_names = true # default during the deprecation window
|
||
```
|
||
|
||
Set to `false` to suppress the 10 legacy flat names above and emit only their `bread.hyprland.*` equivalents. This defaults to `true` for now; per the [API Stability & Versioning](#api-stability--versioning) deprecation-window policy, the default will flip to `false` in a later release once the window closes. Removing the legacy names entirely is a further, separate follow-up — see the note in `DEPRECATIONS.md`.
|
||
|
||
#### Power
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` |
|
||
| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` |
|
||
| `bread.power.battery.low` | `{ battery_percent }` |
|
||
| `bread.power.battery.very_low` | `{ battery_percent }` |
|
||
| `bread.power.battery.critical` | `{ battery_percent }` |
|
||
| `bread.power.battery.full` | `{ battery_percent }` |
|
||
| `bread.power.changed` | `{ ac_connected, battery_percent }` |
|
||
|
||
#### Network
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.network.connected` | `{ online, interfaces }` |
|
||
| `bread.network.disconnected` | `{ online, interfaces }` |
|
||
|
||
#### System events
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.profile.activated` | `{ name }` |
|
||
| `bread.notify.sent` | `{ title, message, urgency }` |
|
||
| `bread.state.changed.<path>` | emitted by state watches |
|
||
|
||
#### Widgets *(Since: v1.3)*
|
||
|
||
Emitted by `breadd` itself on every `bread.widget.*` mutation — see [Widgets](#widgets-since-v13). `data` is the full `WidgetSpec` for `registered`/`updated`; just `{ id }` for `removed`.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.widget.registered` | `{ id, module, placement, order, visible, tooltip, root, updated_at }` |
|
||
| `bread.widget.updated` | same shape as `registered` |
|
||
| `bread.widget.removed` | `{ id }` |
|
||
| `bread.widget.cleared` | `{}` — fired once at the end of every module reload (`bread reload`), whether or not the widget set actually changed. The registry itself is wiped and re-populated as modules re-run; this is a "go re-fetch" signal for consumers that only react to `bread.widget.*` events, so a module that stops registering widgets (e.g. gets disabled) is noticed even though nothing else fires. |
|
||
|
||
#### Terminal (shell precmd/preexec hooks)
|
||
|
||
Requires `bread hooks install shell` and sourcing the generated script from your shell rc — see the CLI reference. Fires via the `bread-emit` helper, not the daemon reaching out.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.terminal.command.started` | `{ cmd, cwd }` |
|
||
| `bread.terminal.command.finished` | `{ cmd, cwd, exit_code, duration_ms }` |
|
||
| `bread.terminal.cwd.changed` | `{ cwd, prev_cwd }` |
|
||
|
||
Terminal events are exempt from the daemon's event dedup window (running the same command twice in quick succession is legitimate, not noise).
|
||
|
||
#### Git (hooks + dirty-state poller)
|
||
|
||
`bread.git.commit.created`/`bread.git.branch.changed` come from git hooks installed via `bread hooks install git` (current repo only; never overwrites an existing hook). `bread.git.state.*`/`bread.git.ahead_behind.changed` come from an in-daemon poller over configured project roots (`[adapters.git] roots = [...]` in `breadd.toml`) and never fire for the same transition a hook already reported.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.git.commit.created` | `{ repo, sha, branch, message }` |
|
||
| `bread.git.branch.changed` | `{ repo, branch, previous_ref }` |
|
||
| `bread.git.state.dirty` | `{ repo }` |
|
||
| `bread.git.state.clean` | `{ repo }` |
|
||
| `bread.git.ahead_behind.changed` | `{ repo, ahead, behind, branch }` |
|
||
|
||
#### Filesystem / project detection
|
||
|
||
Scoped to configured project roots (`[adapters.filesystem] roots = [...]`), not the whole filesystem. `.git`/`node_modules` are always silent; `target`/`dist`/`build` are silent for edits but reported on new-file creation as `build_artifact.created`.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.project.detected` | `{ root, markers }` (markers: any of `.git`, `Cargo.toml`, `package.json`, `go.mod`) |
|
||
| `bread.project.file.changed` | `{ path, project_root }` |
|
||
| `bread.project.build_artifact.created` | `{ path, project_root }` |
|
||
|
||
#### Systemd (`systemd --user` units)
|
||
|
||
Only units named in `[adapters.systemd] units = [...]` are watched — subscribing to every user unit is noisy.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.service.started` | `{ unit }` |
|
||
| `bread.service.stopped` | `{ unit }` |
|
||
| `bread.service.failed` | `{ unit, result }` (`result` may be `null`) |
|
||
|
||
#### Podman (containers)
|
||
|
||
Degrades to simply not emitting if the `podman` binary isn't installed — no daemon startup dependency on it.
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.container.started` | `{ id, name, image }` |
|
||
| `bread.container.stopped` | `{ id, name }` |
|
||
| `bread.container.health.changed` | `{ id, name, health }` |
|
||
|
||
#### Remote (SSH session detection)
|
||
|
||
Rides the same shell-hook transport as Terminal events (`bread hooks install shell`).
|
||
|
||
| Event | Data |
|
||
|-------|------|
|
||
| `bread.remote.session.started` | `{ host }` |
|
||
| `bread.remote.session.ended` | `{ host }` |
|
||
|
||
---
|
||
|
||
## Namespaces
|
||
|
||
*Since: v1.1 — the `AdapterSource::App` variant and the known-apps registry (`bread_shared::apps::KNOWN_APPS`). No sibling app emits through this path yet as of this writing except the breadclip pilot (see its own `EVENTS.md` once that lands); the daemon-side plumbing and the convention itself are what v1.1 adds.*
|
||
|
||
*Since: v1.3 — breadbar is now an active `bread-client` consumer under the `bar` app id (already present in `KNOWN_APPS`): it emits `bread.bar.widget_clicked` for widget clicks (see [Widgets](#widgets-since-v13)) and reads `bread.widget.*` to render the [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)'s `widgets` field.*
|
||
|
||
Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions:
|
||
|
||
- **`bread.<app>.*`** — inbound events published *by* a sibling `bread*` application about its own state (e.g. `bread.clip.copied`). An app may only publish within its own segment; the daemon enforces this at the IPC boundary (a socket client claiming a `source` of an app id it doesn't own is rejected the same way spoofing `power`/`hyprland` is rejected today).
|
||
- **`bread.command.<app>.<verb>`** — outbound commands *to* a sibling application (e.g. `bread.command.clip.clear`). Any module or app may publish; only the target app subscribes. This reuses the existing event bus in both directions — there is no separate request/response protocol. *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` names (`known-app` ∈ `KNOWN_APPS`, verb a non-empty extra dotted segment) are allowed on the unsourced/`bread-emit` path and via sourced `AdapterSource::App` emit (an app may publish a command to another known app). `BreadClient::command` in bread-utils is the typed helper for the same path. `command` remains in `RESERVED_DOMAINS` so it cannot be claimed as an app id; `bread.command.power.off` and `bread.command.notanapp.x` are still rejected. See [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
||
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS` in `bread-shared/src/apps.rs`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. *Since: v1.5 — `bluetooth`, `workspace`, `window`, and `monitor` added to this list (event families the Bluetooth and Hyprland adapters already published under, but that were missing from it); this same list is now also the boundary the IPC `emit` method's no-`source` path checks event names against, see [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
||
- **Commands are best-effort.** Publishing `bread.command.<app>.<verb>` with no subscriber (the app isn't installed or isn't running) is a silent no-op — there is nothing to special-case, and no error is raised. An app that acts on a command *should* emit a corresponding `bread.<app>.<verb>.done` (or `.failed`) confirmation; a module that needs to know a command was actually honored must `bread.wait`/`bread.wait_any` on that confirmation with a timeout rather than assume success. There is no mandatory request/response correlation layer — most commands are legitimately fire-and-forget, and building one would contradict the "no listener, no-op" degradation property.
|
||
- **`bread.exec("<cli> ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response.
|
||
|
||
---
|
||
|
||
## Integrating a bread\* app
|
||
|
||
This is the checklist for adding a new sibling `bread*` application to the fabric — it's deliberately short, because the whole design goal of the name-based app registry (over one `AdapterSource` enum variant per app) is that this never requires a daemon change beyond step 1. **breadclip is the reference implementation** — see its own `EVENTS.md` for a worked example of every step below.
|
||
|
||
1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/apps.rs` (a one-line, one-word-per-app list) — this is the only change to the `bread` repo itself a new integration needs.
|
||
2. **Depend on `bread-utils` with the `bread-client` feature.** In your app's daemon (the long-running piece, if you have one — a short-lived CLI tool can use `bread-emit` instead, see below), add `bread-utils = { ..., features = ["bread-client"] }` and use `bread_utils::bread_client::BreadClient`:
|
||
- `BreadClient::connect(app_id)` — cheap, cannot fail (there is no persistent connection to fail at construction time).
|
||
- `client.emit(event, data)` — publish within your own `bread.<app_id>.*` namespace. Each call is its own short-lived connection (fire-and-forget, like `bread-emit`) — safe to call from a short-lived per-event process invocation, not just from inside a long-running loop.
|
||
- `client.command(target, verb, data)` — publish `bread.command.<target>.<verb>` to another known app. Same fire-and-forget socket write as `emit`; this is the typed helper for the command-bus path that `bread-emit bread.command.<app>.<verb>` uses. *Since: v1.7 — the daemon actually accepts these on the unsourced and sourced-app emit paths; see [Namespaces](#namespaces).*
|
||
- `client.subscribe("bread.command.<app_id>.**", |event| { ... })` — receive commands addressed to you, on a background thread with its own reconnect/backoff loop.
|
||
3. **If you don't have a persistent daemon at all** (just a CLI tool invoked occasionally), skip `bread-client` entirely and shell out to `bread-emit` instead (see `bread-emit`'s own `--help`) — it's built for exactly that case (occasional callers that can't justify holding a socket open).
|
||
4. **Emit confirmations for commands you honor.** `bread.<app_id>.<verb>.done` or `.failed` after acting on a `bread.command.<app_id>.<verb>` — optional, but it's what lets a Lua workflow `bread.wait`/`bread.wait_any` for the real outcome instead of assuming success the moment it publishes a command.
|
||
5. **Write an `EVENTS.md`** in your app's own repo cataloguing every event you publish and every command verb you honor, with `data` shapes — the per-app companion to this file. Be honest about what's *not* implemented yet rather than stubbing a verb that does nothing (see breadclip's `EVENTS.md` for how it documents `pin`/`select` as deliberately deferred, not silently dropped).
|
||
6. **Make it opt-out, not opt-in-only, and fail silent.** Your app should work exactly the same whether breadd is installed or not — connecting/emitting/subscribing must never block, error, or crash your app just because the daemon is absent. `BreadClient` is built this way already (dropped no-op on a failed `emit`, transparent reconnect on `subscribe`); if you roll your own transport instead, keep that property.
|
||
|
||
---
|
||
|
||
## Dictionary: Runtime state schema
|
||
|
||
`bread state` and `bread.state.get("")` return the full `RuntimeState`:
|
||
|
||
```json
|
||
{
|
||
"monitors": [
|
||
{ "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
|
||
],
|
||
"workspaces": [
|
||
{ "id": "1", "monitor": "HDMI-A-1" }
|
||
],
|
||
"active_workspace": "1",
|
||
"active_window": "0x...",
|
||
"devices": {
|
||
"connected": [
|
||
{
|
||
"id": "/sys/...",
|
||
"name": "CalDigit TS4",
|
||
"device": "dock",
|
||
"subsystem": "usb",
|
||
"vendor_id": "0x35f5",
|
||
"product_id": "0x0104"
|
||
}
|
||
]
|
||
},
|
||
"network": {
|
||
"interfaces": { "eth0": { "up": true } },
|
||
"online": true
|
||
},
|
||
"power": {
|
||
"ac_connected": true,
|
||
"battery_percent": 87,
|
||
"battery_low": false
|
||
},
|
||
"profile": {
|
||
"active": "default",
|
||
"history": [],
|
||
"profiles": {}
|
||
},
|
||
"modules": [
|
||
{
|
||
"name": "bread.monitors",
|
||
"status": "loaded",
|
||
"last_error": null,
|
||
"builtin": true,
|
||
"store": {}
|
||
}
|
||
],
|
||
"workflows": [
|
||
{
|
||
"name": "dock-connected",
|
||
"state": "running",
|
||
"step": "waiting for monitor",
|
||
"started_at": 1710000000000,
|
||
"updated_at": 1710000001500,
|
||
"error": null
|
||
}
|
||
],
|
||
"widgets": [
|
||
{
|
||
"id": "weather.weather",
|
||
"module": "weather",
|
||
"placement": "left_of_stats",
|
||
"order": 0,
|
||
"visible": true,
|
||
"tooltip": "Sydney: Partly cloudy",
|
||
"root": {
|
||
"type": "box",
|
||
"orientation": "horizontal",
|
||
"children": [
|
||
{ "type": "icon", "name": "cloud" },
|
||
{ "type": "label", "text": "22°C" }
|
||
]
|
||
},
|
||
"updated_at": 1710000001500
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. `widgets[].placement` values: `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` *(Since: v1.3 — see [Widgets](#widgets-since-v13))*.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
| Method | Params | Description |
|
||
|--------|--------|-------------|
|
||
| `ping` | — | Connectivity check |
|
||
| `health` | — | Version, uptime, PID, adapter status, `api_version` |
|
||
| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` |
|
||
| `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` | `name` | Switch active profile |
|
||
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
||
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
||
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly, tagged `Manual` *(Since: v1.5 — previously tagged `System`; see below)*, for manually testing Lua handlers (this is what `bread emit <event>` and `bread-emit` use). Well-formed `bread.command.<known-app>.<verb>` is allowed on this path *(Since: v1.7)*; other reserved domains stay rejected. With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. A sourced app may also publish a well-formed command to another known app. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. |
|
||
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
|
||
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
||
|
||
*Since: v1.6* — `module_host.*`: the RPC bridge an out-of-process
|
||
`bread-module-host` child uses in place of direct in-process `bread.*`
|
||
bindings (see [Out-of-process module
|
||
sandboxing](#out-of-process-module-sandboxing-since-v16)). Meaningful only
|
||
on a connection that has completed the handshake below; not intended for
|
||
direct use by other clients.
|
||
|
||
| Method | Params | Description |
|
||
|--------|--------|-------------|
|
||
| `module_host.hello` | `token` | One-time handshake. Consumes the token, replies with `{ module, permissions, api_version }` or an error for an unknown/expired token. Takes over the rest of the connection's lifetime as a bidirectional RPC bridge, same as `events.subscribe` does for a plain event stream. |
|
||
| `module_host.on` / `.once` | `pattern` | Subscribe; replies `{ subscription_id }`. Matches are pushed asynchronously as `{"push":"event", subscription_id, event}` lines interleaved with ordinary responses. |
|
||
| `module_host.off` | `id` | Cancel a subscription. |
|
||
| `module_host.after` / `.every` | `delay_ms` / `interval_ms` | Server-managed timer; replies `{ timer_id }`. Fires are pushed as `{"push":"timer", timer_id}`. |
|
||
| `module_host.cancel` | `id` | Cancel a timer. |
|
||
| `module_host.emit` | `event`, `data` | Same manual-emit semantics (and reserved-domain guard) as the top-level `emit` method. |
|
||
| `module_host.log` / `.warn` / `.error` | `message` | Forwarded to `breadd`'s own tracing log, prefixed with the module name. |
|
||
| `module_host.fs_read` | `path` | Requires `fs.read` granted; path-prefix-checked against the manifest's `path` hint if one was declared. Replies `{ content }` (`null` if unreadable). |
|
||
| `module_host.fs_write` | `path`, `content` | Requires `fs.write`, same scoping check. |
|
||
| `module_host.exec` | `cmd` | Requires `exec`; `bin`-hint-checked (by leading command word) if declared. Fire-and-forget, matching `bread.exec`'s own semantics. |
|
||
| `module_host.exec_capture` | `cmd`, `timeout_ms` | Requires `exec`. Replies `{ ok, stdout }`. |
|
||
| `module_host.state_get` | `key` | Requires `state.read`. Replies `{ value }`. |
|
||
| `module_host.status` | `state` (`"loaded"`\|`"load_error"`), `error` | The module-host reports its own load outcome after running `init.lua`; updates `modules.list` status and unblocks `breadd`'s spawn-side wait. |
|
||
|
||
Every gated method above checks the module's granted `PermissionKind`s
|
||
(learned at hello-time) before attempting the call — belt-and-suspenders
|
||
alongside the Landlock sandbox enforced at the OS level on the
|
||
module-host process itself, not a replacement for it.
|
||
|
||
The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)).
|
||
|
||
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
||
- *The unsourced path is tagged `AdapterSource::Manual`, not `System` — `System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
|
||
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS` — `terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
|
||
- *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.*
|
||
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*
|