Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers

This commit is contained in:
Breadway 2026-07-19 03:05:57 +08:00
parent 89c5849539
commit 1208c5d1b7
29 changed files with 4098 additions and 339 deletions

11
Cargo.lock generated
View file

@ -309,12 +309,22 @@ dependencies = [
"toml",
]
[[package]]
name = "bread-emit"
version = "0.6.6"
dependencies = [
"bread-shared",
"serde_json",
]
[[package]]
name = "bread-shared"
version = "0.6.6"
dependencies = [
"dirs",
"serde",
"serde_json",
"toml",
]
[[package]]
@ -329,6 +339,7 @@ dependencies = [
"mlua",
"netlink-packet-core",
"netlink-packet-route",
"notify",
"rtnetlink",
"serde",
"serde_json",

View file

@ -3,6 +3,7 @@ members = [
"bread-shared",
"breadd",
"bread-cli",
"bread-emit",
]
resolver = "2"

View file

@ -3,15 +3,19 @@
## 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)
- [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api)
- [Workflows](#workflows-since-v12)
- [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)
@ -23,10 +27,21 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`)
- **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, and BlueZ Bluetooth.
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
@ -214,6 +229,63 @@ 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`.
### State
#### `bread.state.get(path)`
@ -744,6 +816,99 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
| `bread.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches |
#### 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.*
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.
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids.
- **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/lib.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.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
@ -794,11 +959,21 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
"builtin": true,
"store": {}
}
],
"workflows": [
{
"name": "dock-connected",
"state": "running",
"step": "waiting for monitor",
"started_at": 1710000000000,
"updated_at": 1710000001500,
"error": null
}
]
}
```
`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`.
`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))*.
---
@ -823,7 +998,7 @@ Available methods:
| Method | Params | Description |
|--------|--------|-------------|
| `ping` | — | Connectivity check |
| `health` | — | Version, uptime, PID, adapter status |
| `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 |
@ -832,4 +1007,7 @@ Available methods:
| `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` | Inject a synthetic event into the pipeline |
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly tagged `System` (legacy path). 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. 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)* |
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)).

View file

@ -179,6 +179,65 @@ end
return M
```
## Example 4: Multi-step automation workflows
The examples above are all single-event reactions: one trigger, one handler. `bread.wait`/`bread.spawn` (coroutine-based waits) and the `bread.workflow` table build on those to let a module walk through several ordered steps — with a timeout on any wait, and an overall deadline for the whole thing — rather than each step having to re-derive "am I still in the middle of handling the last event."
Full source: `examples/modules/dock-workflow.lua`.
```lua
-- ~/.config/bread/modules/dock-workflow.lua
local M = bread.module({ name = "dock-workflow", version = "1.0.0" })
bread.workflow.define("dock-connected", function()
bread.workflow.step("applying layout")
bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1")
bread.workflow.step("waiting for monitor")
-- wait_any resolves on whichever of these patterns fires first, or
-- returns nil after the timeout — it does not block forever.
local event = bread.wait_any(
{ "bread.monitor.connected", "bread.hyprland.event" },
{ timeout = 5000 }
)
if not event then
error("monitor did not appear in time")
end
bread.workflow.step("activating profile")
bread.profile.activate("docked")
bread.workflow.step("waiting for workspace")
bread.wait("bread.workspace.changed", { timeout = 3000 })
bread.workflow.step("notifying")
bread.notify("Dock connected", { title = "bread" })
end)
function M.on_load()
bread.on("bread.device.dock.connected", function()
-- The deadline covers the whole run, independent of each step's
-- own wait timeout — a safety net against something hanging.
bread.workflow.start("dock-connected", { deadline = 15000 })
end)
end
return M
```
Walking through what each piece buys you over a plain `bread.on` handler:
- **`bread.workflow.define(name, fn)` / `.start(name, opts)`** — registers the body under a name, then runs it as a `bread.spawn`ed coroutine. `opts.deadline` (ms) marks the run `timed_out` in the registry if it hasn't reached a terminal state in time — this is independent of, and layered on top of, any per-step `timeout` inside the body. `opts.args` is passed through as the single argument to the body function, if you need to parameterize a run.
- **`bread.workflow.step(label)`** — call it from inside the running body to record "currently here." It doesn't change control flow at all; it exists purely so `bread.workflow.status("dock-connected")` (or the CLI/dashboard) can answer "is this stuck, and where?" instead of a black box between start and finish.
- **`bread.wait_any(patterns, opts)`** — like `bread.wait`, but resolves on the first of several patterns (useful when you're not sure which specific event a compositor/adapter will actually emit for a given transition — see the two candidate patterns above). `bread.wait_all(patterns, opts)` is the complementary primitive: it resolves once every listed pattern has fired at least once (or the timeout elapses), returning a table keyed by pattern.
- **Errors are captured, not lost.** If the body raises (as it does above when the monitor never appears), the workflow's registry entry moves to `failed` with the message attached — visible via `bread.workflow.status(name)` or the `workflows.list` IPC method — rather than only ever showing up as a one-line log the moment it happened.
Check on a running (or finished) workflow via the IPC method directly (there's no dedicated `bread` subcommand for this yet — see `workflows.list` in the [IPC protocol dictionary](Documentation.md#dictionary-ipc-protocol)):
```bash
echo '{"id":"1","method":"workflows.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock"
```
## Tips for porting your own scripts
- Start by logging the event payload: `bread.log(event.data.raw)`

296
README.md
View file

@ -76,7 +76,7 @@ Optional but preferred:
### From source
```bash
git clone https://github.com/Breadway/bread.git
git clone https://git.breadway.dev/Breadway/bread.git
cd bread
```
@ -256,295 +256,15 @@ return M
---
## Event reference
## Event reference, Lua API, and IPC protocol
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
These are fully documented in [`Documentation.md`](Documentation.md) — the single canonical reference (event catalogue, per-function Lua API, runtime-state schema, and IPC protocol), versioned as **Bread Automation API v1**. This README no longer keeps a parallel copy, to avoid the two drifting apart.
| Event | Trigger |
|-------|---------|
| `bread.system.startup` | Daemon fully initialized |
| `bread.device.connected` | Any device attached |
| `bread.device.disconnected` | Any device removed |
| `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
| `bread.device.<device>.disconnected` | Named device removed |
| `bread.monitor.connected` | Display connected |
| `bread.monitor.disconnected` | Display disconnected |
| `bread.workspace.changed` | Active workspace changed |
| `bread.window.focus.changed` | Focused window changed |
| `bread.window.opened` | Window opened |
| `bread.window.closed` | Window closed |
| `bread.power.ac.connected` | AC adapter plugged in |
| `bread.power.ac.disconnected` | AC adapter unplugged |
| `bread.power.battery.low` | Battery ≤ 20% |
| `bread.power.battery.very_low` | Battery ≤ 10% |
| `bread.power.battery.critical` | Battery ≤ 5% |
| `bread.power.battery.full` | Battery at 100% |
| `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline |
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
| `bread.profile.activated` | Profile switched |
| `bread.notify.sent` | Desktop notification dispatched |
---
## Lua API
### Modules
Every module file must declare itself. The declaration is used for dependency ordering and status tracking.
```lua
local M = bread.module({
name = "my-module",
version = "1.0.0",
after = { "bread.devices" }, -- load after this module
})
-- ... module body ...
return M
```
### Events
```lua
-- Subscribe to events; returns a subscription ID
local id = bread.on("bread.monitor.connected", function(event)
-- event.event → "bread.monitor.connected"
-- event.data → table of event-specific fields
-- event.source → adapter that produced it
bread.log(event.event)
end)
-- Unsubscribe by ID
bread.off(id)
-- Subscribe once, auto-unsubscribe after first delivery
bread.once("bread.system.startup", function(event)
bread.profile.activate("default")
end)
-- Subscribe with a filter predicate. The predicate goes in an opts table.
bread.filter("bread.device.connected", function(event)
bread.exec("xset r rate 200 40")
end, {
filter = function(event)
return event.data.device == "keyboard"
end,
})
-- Emit a custom event (for cross-module communication)
bread.emit("mymodule.something", { key = "value" })
```
Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character):
```lua
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
bread.on("bread.device.**", handler) -- matches any depth under bread.device
```
### State
```lua
-- Read from runtime state by dot-separated path
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
-- Typed shorthands
local monitors = bread.state.monitors()
local workspace = bread.state.active_workspace()
local window = bread.state.active_window()
local devices = bread.state.devices()
local power = bread.state.power()
local network = bread.state.network()
local profile = bread.state.profile()
-- Watch a state path for changes
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.notify("AC connected")
end
end)
```
### Profiles
```lua
bread.profile.activate("desk")
bread.profile.activate("default")
```
### Execution and notifications
```lua
-- Fire-and-forget shell command
bread.exec("kitty")
-- Desktop notification (uses notify-send)
-- First arg is the message body; opts.title sets the notification title (default: "bread")
bread.notify("Something happened", { title = "My Module", urgency = "normal", timeout = 3000, icon = "dialog-info" })
bread.notify("AC connected") -- title defaults to "bread"
```
### Timers
```lua
-- Run once after a delay (ms)
local id = bread.after(500, function()
bread.exec("some-delayed-command")
end)
-- Run on a repeating interval (ms)
local id = bread.every(60000, function()
bread.log("tick")
end)
-- Cancel either kind
bread.cancel(id)
-- Debounce a rapidly-firing handler
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.*", fn)
```
### Wait (inside coroutines)
```lua
-- Yield until a matching event arrives
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
-- dock arrived within 5 seconds
end
```
### Machine and filesystem
```lua
-- Machine identity (system hostname)
local name = bread.machine.name()
local tags = bread.machine.tags() -- array of strings
local ok = bread.machine.has_tag("laptop")
-- Filesystem helpers (~ is expanded)
bread.fs.write("~/.config/some/file", "content")
local content = bread.fs.read("~/.config/some/file") -- nil if not found
local exists = bread.fs.exists("~/some/path")
local abs = bread.fs.expand("~/some/path")
```
### Logging
```lua
bread.log("Module loaded") -- info level
bread.warn("Unexpected state") -- warn level
bread.error("Something failed") -- error level
```
### Hyprland bindings
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypass normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw is the unparsed string from Hyprland's event socket
end)
```
### Bluetooth
The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable.
```lua
-- Power the adapter on or off
bread.bluetooth.power(true)
bread.bluetooth.power(false)
-- Query current power state (returns true/false, or nil if unavailable)
local on = bread.bluetooth.powered()
-- Connect/disconnect a paired device by MAC address
-- Fire-and-forget; result arrives as bread.device.connected/disconnected
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
-- Start or stop device discovery
bread.bluetooth.scan(true)
bread.bluetooth.scan(false)
-- List all devices known to BlueZ
local devs = bread.bluetooth.devices()
-- Returns nil if BlueZ is unavailable, otherwise:
-- { { address, name, connected, paired }, ... }
```
Example — auto-connect headphones when Bluetooth powers on:
```lua
bread.state.watch("power.ac_connected", function(ac)
if ac then
bread.bluetooth.power(true)
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
end
end)
```
### Module-scoped storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local p = M.store.get("last_profile") -- "docked"
```
---
## IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI.
Request:
```json
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
```
Response:
```json
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
```
Available methods:
| Method | Description |
|--------|-------------|
| `ping` | Connectivity check |
| `health` | Version, uptime, PID, adapter status |
| `state.get` | Read a value from `RuntimeState` by dotted key path |
| `state.dump` | Return the full `RuntimeState` as JSON |
| `modules.list` | List all loaded modules and their status |
| `modules.reload` | Hot-reload the Lua runtime |
| `profile.list` | List defined profiles |
| `profile.activate` | Switch active profile |
| `events.subscribe` | Upgrade connection to streaming mode |
| `events.replay` | Replay buffered events from the last N ms |
| `emit` | Inject a synthetic event into the pipeline |
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
- [Dictionary: Event reference](Documentation.md#dictionary-event-reference)
- [Dictionary: Lua API](Documentation.md#dictionary-lua-api)
- [Dictionary: Runtime state schema](Documentation.md#dictionary-runtime-state-schema)
- [Dictionary: IPC protocol](Documentation.md#dictionary-ipc-protocol)
- [API Stability & Versioning](Documentation.md#api-stability--versioning)
---

357
bread-cli/src/hooks_git.rs Normal file
View file

@ -0,0 +1,357 @@
//! `bread hooks install git` — installs small, non-blocking git hooks that
//! emit normalized events (via the `bread-emit` fire-and-forget binary) on
//! commit and branch-change activity.
//!
//! Design constraints this module is built around:
//!
//! - Never touch a hook file bread doesn't own. Frameworks like Husky or the
//! `pre-commit` tool, or a developer's own scripts, commonly already
//! occupy `post-commit` / `post-checkout` / `post-merge`. We only ever
//! overwrite a hook file if it already carries our marker comment (meaning
//! we wrote it on a previous install); otherwise we skip it and tell the
//! user exactly what to add by hand.
//! - Never make git itself slower or block a commit/checkout/merge because
//! breadd is slow or down. The installed scripts background `bread-emit`
//! and unconditionally exit 0.
//! - Respect `core.hooksPath`. If the user has repointed hooks elsewhere, we
//! do not silently write into `.git/hooks` where nothing will ever run
//! them — see [`install_git`] for the exact behavior.
use anyhow::{bail, Context, Result};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Distinctive marker comment written as the second line of every hook
/// script bread installs. Its presence is how we tell "a hook we installed
/// previously, safe to overwrite" apart from "someone else's hook, hands
/// off." Keep this stable across versions — changing it would make bread
/// think its own previously-installed hooks belong to someone else.
pub const MARKER: &str = "# bread-managed-hook";
/// The three git hooks bread installs, in a stable order for display.
const HOOK_NAMES: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
/// Outcome of attempting to install a single hook file.
#[derive(Debug, PartialEq, Eq)]
enum HookOutcome {
Installed,
Skipped,
}
/// Install bread's git hooks (`post-commit`, `post-checkout`, `post-merge`)
/// into the current working directory's git repository.
///
/// This only ever touches the repo rooted at the current directory (via
/// `git rev-parse`, which correctly follows worktrees/submodules to the
/// real git dir) — never a global `core.hooksPath`, never other repos.
pub fn install_git() -> Result<()> {
let git_dir = git_dir()?;
let toplevel = show_toplevel()?;
if let Some(configured) = hooks_path_override()? {
print_hooks_path_warning(&configured);
bail!(
"bread: refusing to install into '{}/hooks' while core.hooksPath is set to '{}'",
git_dir.display(),
configured
);
}
let hooks_dir = git_dir.join("hooks");
fs::create_dir_all(&hooks_dir)
.with_context(|| format!("failed to create {}", hooks_dir.display()))?;
let mut installed = Vec::new();
let mut skipped = Vec::new();
for &name in HOOK_NAMES.iter() {
let path = hooks_dir.join(name);
let script = hook_script(name);
match install_one_hook(&path, &script)? {
HookOutcome::Installed => installed.push(path),
HookOutcome::Skipped => skipped.push(path),
}
}
print_summary(&toplevel, &installed, &skipped);
Ok(())
}
/// Install (or skip) a single hook file at `path` with contents `script`.
///
/// Never overwrites an existing file unless it already carries our marker.
fn install_one_hook(path: &Path, script: &str) -> Result<HookOutcome> {
if path.exists() {
let existing = fs::read_to_string(path)
.with_context(|| format!("failed to read existing hook {}", path.display()))?;
if !is_bread_managed(&existing) {
eprintln!(
"bread: '{}' already exists and was not installed by bread — leaving it \
untouched.\n To also emit bread events from it, add this line to the end \
of the existing script:\n\n {}\n",
path.display(),
emit_line_for(path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
);
return Ok(HookOutcome::Skipped);
}
// It's ours from a previous install — safe to overwrite.
}
fs::write(path, script).with_context(|| format!("failed to write hook {}", path.display()))?;
let mut perms = fs::metadata(path)
.with_context(|| format!("failed to stat {}", path.display()))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
Ok(HookOutcome::Installed)
}
/// Whether `contents` was written by a previous bread install (contains the
/// marker comment anywhere in the file).
fn is_bread_managed(contents: &str) -> bool {
contents.lines().any(|line| line.trim() == MARKER)
}
/// The bare `bread-emit` invocation for a branch checkout, with no guard —
/// callers that already run inside a `[ "$3" = "1" ]` check (our own
/// generated hook script) use this directly.
fn branch_changed_emit_line() -> String {
"bread-emit bread.git.branch.changed --source git --kind branch.changed --data \
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"previous_ref\\\":\\\"$1\\\"}\" >/dev/null 2>&1 &"
.to_string()
}
/// The single `bread-emit` invocation line appropriate for hook `name`,
/// suggested to users who already have their own script at that hook (so it
/// must stand alone, including its own guard where relevant).
fn emit_line_for(name: &str) -> String {
match name {
"post-checkout" => format!("[ \"$3\" = \"1\" ] && {}", branch_changed_emit_line()),
_ => commit_created_emit_line(),
}
}
/// The `bread-emit` invocation shared by `post-commit` and `post-merge`
/// (both are "HEAD moved to a new commit" signals).
fn commit_created_emit_line() -> String {
"bread-emit bread.git.commit.created --source git --kind commit.created --data \
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"sha\\\":\\\"$(git rev-parse HEAD)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"message\\\":\\\"$(git log -1 --pretty=%s | sed 's/\"/\\\\\\\\\"/g')\\\"}\" >/dev/null 2>&1 &"
.to_string()
}
/// Build the full contents of the hook script for hook `name`.
///
/// Every script: starts with the marker (so future installs recognize it as
/// ours), backgrounds the `bread-emit` call so a slow/down daemon can never
/// delay the git operation, and unconditionally exits 0 so bread being
/// unavailable can never fail a `git commit`/`checkout`/`merge` for the user.
fn hook_script(name: &str) -> String {
match name {
"post-commit" => format!(
"#!/bin/sh\n{marker}\n# Emits bread.git.commit.created on every commit. Backgrounded and\n\
# always exits 0 so bread can never slow down or block `git commit`.\n\
{emit}\nexit 0\n",
marker = MARKER,
emit = commit_created_emit_line(),
),
"post-checkout" => format!(
"#!/bin/sh\n{marker}\n# git passes: $1=previous HEAD, $2=new HEAD, $3=1 if a branch\n\
# checkout (0 for a plain file checkout). Only emit on real branch\n\
# switches. previous_branch is not resolvable from a ref alone here,\n\
# so we report the previous HEAD's raw SHA ($1) as previous_ref instead\n\
# of a branch name.\n\
if [ \"$3\" = \"1\" ]; then\n {emit}\nfi\nexit 0\n",
marker = MARKER,
emit = branch_changed_emit_line(),
),
"post-merge" => format!(
"#!/bin/sh\n{marker}\n# A merge moves HEAD to a new commit, same as post-commit; emit the\n\
# same bread.git.commit.created shape so a merge (fast-forward or not)\n\
# also surfaces as a commit-created event. Backgrounded and always\n\
# exits 0 so bread can never slow down or block `git merge`.\n\
{emit}\nexit 0\n",
marker = MARKER,
emit = commit_created_emit_line(),
),
other => unreachable!("unknown hook name: {other}"),
}
}
/// `git rev-parse --git-dir`, resolved to an absolute path. This is the
/// correct git directory even inside worktrees or submodules (unlike
/// hardcoding `.git`).
fn git_dir() -> Result<PathBuf> {
let out = run_git(&["rev-parse", "--git-dir"]).context(
"bread: this does not look like a git repository. Run 'bread hooks install git' from \
inside a git work tree.",
)?;
let raw = PathBuf::from(out);
if raw.is_absolute() {
Ok(raw)
} else {
// `--git-dir` is often relative to CWD (e.g. ".git"); resolve it.
std::env::current_dir()
.map(|cwd| cwd.join(raw))
.context("failed to resolve current directory")
}
}
/// `git rev-parse --show-toplevel` — the repo root, used only for display.
fn show_toplevel() -> Result<PathBuf> {
run_git(&["rev-parse", "--show-toplevel"])
.map(PathBuf::from)
.context(
"bread: this does not look like a git repository. Run 'bread hooks install git' \
from inside a git work tree.",
)
}
/// `git config --get core.hooksPath`, if set to something non-default.
/// Returns `Ok(None)` when unset (the common case).
fn hooks_path_override() -> Result<Option<String>> {
let output = Command::new("git")
.args(["config", "--get", "core.hooksPath"])
.output()
.context("failed to run 'git config --get core.hooksPath' (is git installed?)")?;
if !output.status.success() {
// Exit code 1 from `git config --get` means "key not set" — that's
// the normal, expected case, not an error.
return Ok(None);
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn print_hooks_path_warning(configured: &str) {
eprintln!(
"bread: this repo has 'core.hooksPath' set to '{configured}', so hooks placed in \
'.git/hooks' will never run.\n\
bread will not silently write into '.git/hooks' where they'd be dead code, and it \
will not write into your configured hooksPath without being asked to.\n\n\
To proceed, either:\n\
\x20 - point core.hooksPath back at the default: git config --unset core.hooksPath\n\
\x20 (or set it explicitly to .git/hooks), then re-run this command; or\n\
\x20 - install the three hook scripts into '{configured}' yourself (see \
`bread hooks install git --help` for the exact script contents this command \
would otherwise write).\n"
);
}
fn print_summary(toplevel: &Path, installed: &[PathBuf], skipped: &[PathBuf]) {
println!("bread: git hooks for {}", toplevel.display());
if installed.is_empty() {
println!(" installed: (none)");
} else {
println!(" installed:");
for path in installed {
println!(" {}", path.display());
}
}
if !skipped.is_empty() {
println!(" skipped (already exist, not bread-managed):");
for path in skipped {
println!(" {}", path.display());
}
}
}
/// Run `git <args>` in the current directory and return trimmed stdout.
/// Fails if git is missing or the command exits non-zero.
fn run_git(args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.output()
.with_context(|| format!("failed to run 'git {}' (is git installed?)", args.join(" ")))?;
if !output.status.success() {
bail!("'git {}' failed", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_own_marker() {
let script = format!("#!/bin/sh\n{MARKER}\necho hi\n");
assert!(is_bread_managed(&script));
}
#[test]
fn does_not_falsely_detect_marker() {
let script = "#!/bin/sh\n# some other tool's hook\necho hi\n";
assert!(!is_bread_managed(script));
}
#[test]
fn marker_must_match_whole_trimmed_line() {
// A substring match would be a false positive risk; require the
// trimmed line to equal the marker exactly.
let script = "#!/bin/sh\n# this mentions bread-managed-hook in passing\n";
assert!(!is_bread_managed(script));
}
#[test]
fn empty_file_is_not_managed() {
assert!(!is_bread_managed(""));
}
#[test]
fn all_hook_scripts_start_with_shebang_and_marker() {
for name in HOOK_NAMES {
let script = hook_script(name);
let mut lines = script.lines();
assert_eq!(lines.next(), Some("#!/bin/sh"));
assert_eq!(lines.next(), Some(MARKER));
}
}
#[test]
fn all_hook_scripts_exit_0_unconditionally() {
for name in HOOK_NAMES {
let script = hook_script(name);
assert!(
script.trim_end().ends_with("exit 0"),
"hook {name} does not unconditionally exit 0"
);
}
}
#[test]
fn post_commit_and_post_merge_emit_commit_created() {
for name in ["post-commit", "post-merge"] {
let script = hook_script(name);
assert!(script.contains("bread.git.commit.created"));
assert!(script.contains("--kind commit.created"));
// Must be backgrounded so a slow/down daemon can't block git.
assert!(script.contains("&\nexit 0") || script.contains(" &\n"));
}
}
#[test]
fn post_checkout_only_emits_on_branch_checkout() {
let script = hook_script("post-checkout");
assert!(script.contains("bread.git.branch.changed"));
assert!(script.contains("--kind branch.changed"));
assert!(script.contains("\"$3\" = \"1\""));
}
#[test]
fn hook_script_rejects_unknown_name() {
let result = std::panic::catch_unwind(|| hook_script("pre-push"));
assert!(result.is_err());
}
}

View file

@ -0,0 +1,512 @@
//! `bread hooks install shell` — generates a shell hook script that reports
//! terminal telemetry (command start/finish, cwd changes, remote-session
//! start/end) to the bread daemon.
//!
//! # Why this exists as a generated file, not an rc-file edit
//!
//! This module deliberately **never touches the user's `.bashrc`, `.zshrc`,
//! or fish `config.fish`.** It only writes self-contained hook script(s)
//! under `~/.config/bread/hooks/` and prints the one-line `source` snippet
//! the user needs to add themselves. Editing a user's shell startup file
//! automatically is the kind of silent, hard-to-audit change that belongs
//! to the user, not to a CLI subcommand — if `bread` got it wrong, or the
//! user later doesn't want it, an rc-file edit is much harder to notice and
//! undo than a printed snippet they chose to paste in.
//!
//! # Why bread-emit, not `bread emit`
//!
//! The generated hooks shell out to the separate `bread-emit` binary
//! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap
//! parsing and the Tokio runtime entirely and never waits for a reply — it
//! is cheap enough to call on every single shell prompt. The full `bread`
//! CLI spins up an async runtime per invocation and would be perceptible
//! latency if called twice per prompt.
//!
//! # Why both a `.sh` and a `.fish` file are always written
//!
//! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a
//! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n
//! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has
//! meaningfully different hook primitives and cannot source a POSIX-ish
//! script anyway), regardless of which shell was detected or requested.
//! This keeps both files present and up to date at fixed, predictable
//! paths so a user who switches shells later doesn't need to re-run
//! install — they just add the one extra `source` line to the new shell's
//! rc file. Only the *printed* snippet is specific to the detected/forced
//! shell.
//!
//! Existing hook files at these paths are always overwritten. Unlike git
//! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user
//! might have installed by other means — this file is entirely owned and
//! generated by this command, so overwriting on every run is safe and is
//! in fact required to pick up script changes across `bread` upgrades.
use anyhow::{bail, Result};
use std::fs;
use std::path::PathBuf;
/// Which shell to print the `source` snippet for. Detected from `$SHELL`
/// unless the caller forces one via `bread hooks install shell --shell
/// <name>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetShell {
Bash,
Zsh,
Fish,
/// Detection failed (unrecognized or unset `$SHELL`); both snippets are
/// printed and the user picks the one that matches their shell.
Unknown,
}
impl TargetShell {
fn parse(name: &str) -> Result<TargetShell> {
match name {
"bash" => Ok(TargetShell::Bash),
"zsh" => Ok(TargetShell::Zsh),
"fish" => Ok(TargetShell::Fish),
other => bail!(
"bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)",
other
),
}
}
/// Detect the caller's shell from `$SHELL`'s basename. This is a best
/// effort: `$SHELL` reflects the user's login shell, which is normally
/// also the shell running the CLI, but that's not guaranteed (e.g.
/// invoked from inside a script run under a different interpreter).
fn detect_from_env() -> TargetShell {
let Ok(shell_path) = std::env::var("SHELL") else {
return TargetShell::Unknown;
};
let name = PathBuf::from(shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match name.as_str() {
"bash" => TargetShell::Bash,
"zsh" => TargetShell::Zsh,
"fish" => TargetShell::Fish,
_ => TargetShell::Unknown,
}
}
}
/// Returns the default hooks directory: `~/.config/bread/hooks`.
pub fn hooks_dir() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
return cfg.join("bread").join("hooks");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".config")
.join("bread")
.join("hooks");
}
PathBuf::from(".config/bread/hooks")
}
/// Install (or regenerate) the shell hook scripts and print the snippet the
/// user needs to add to their own rc file. `shell` optionally forces
/// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL`
/// purely to decide which snippet to print — both hook files are written
/// either way (see module docs).
pub fn install_shell(shell: Option<String>) -> Result<()> {
let target = match shell {
Some(name) => TargetShell::parse(&name)?,
None => TargetShell::detect_from_env(),
};
let dir = hooks_dir();
fs::create_dir_all(&dir)
.map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?;
let sh_path = dir.join("shell-hook.sh");
write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?;
let fish_path = dir.join("shell-hook.fish");
write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?;
println!();
match target {
TargetShell::Bash | TargetShell::Zsh => {
println!("Add this line to your ~/.bashrc or ~/.zshrc:");
println!();
println!(" source {}", sh_path.display());
}
TargetShell::Fish => {
println!("Add this line to your ~/.config/fish/config.fish:");
println!();
println!(" source {}", fish_path.display());
}
TargetShell::Unknown => {
println!(
"bread: could not detect your shell from $SHELL; add whichever \
of these matches the shell you use interactively:"
);
println!();
println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:");
println!(" source {}", sh_path.display());
println!();
println!(" fish — add to ~/.config/fish/config.fish:");
println!(" source {}", fish_path.display());
}
}
println!();
println!(
"bread never edits rc files on its own — that line above is something \
only you should add."
);
Ok(())
}
fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> {
let existed = path.exists();
fs::write(path, contents)
.map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?;
if existed {
println!("bread: regenerated {}", path.display());
} else {
println!("bread: wrote {}", path.display());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Hook script bodies
// ---------------------------------------------------------------------------
//
// Both scripts below shell out to `bread-emit` (not `bread emit`) for every
// event, always backgrounded and with output discarded (`>/dev/null 2>&1
// &`), so a down, slow, or stalled daemon can never block the interactive
// shell waiting for the next prompt.
//
// Event/kind pairs emitted (kept in sync with the daemon's normalizer):
// bread.terminal.command.started (kind command.started)
// bread.terminal.command.finished (kind command.finished)
// bread.terminal.cwd.changed (kind cwd.changed)
// bread.remote.session.started (kind session.started)
// bread.remote.session.ended (kind session.ended)
//
// `bread-emit`'s positional <event> argument is technically redundant once
// --source/--kind are both given (the daemon derives the real event name
// from source+kind and ignores the literal positional string in that
// mode), but a sensible name is still passed since bread-emit requires a
// positional argument.
/// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`.
///
/// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the
/// three, so its branch approximates them with a `trap ... DEBUG` (guarded
/// so it only fires once per prompt, not once per pipeline stage) and a
/// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against
/// a remembered previous value to emulate `chpwd`.
const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`.
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
#
# Reports terminal telemetry (command start/finish, cwd changes, remote
# session start/end) to the bread daemon via the lightweight `bread-emit`
# binary. Every call below is backgrounded and has its output discarded so a
# down or slow daemon can never delay the shell prompt.
#
# Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for
# the JSON-escaping helper below) both are standard on Arch Linux.
# Escape a string for embedding inside a JSON string literal: backslashes,
# double quotes, tabs, carriage returns, and embedded newlines. Backslashes
# must be escaped first, before any escape sequence that introduces new
# backslashes, or the newly-added backslashes would themselves get doubled.
_bread_json_escape() {
printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \
| sed ':a;N;$!ba;s/\n/\\n/g'
}
# --- Remote session detection (runs once at shell-init time) --------------
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
# so nested subshells (and the EXIT trap that fires only in this branch)
# don't each fire their own started/ended pair.
if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then
export BREAD_SSH_ANNOUNCED=1
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
_bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}')
_bread_esc_host=$(_bread_json_escape "$_bread_ssh_host")
bread-emit bread.remote.session.started --source remote --kind session.started \
--data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 &
_bread_ssh_exit_hook() {
_bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host")
bread-emit bread.remote.session.ended --source remote --kind session.ended \
--data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 &
}
trap _bread_ssh_exit_hook EXIT
fi
if [ -n "$ZSH_VERSION" ]; then
# --- zsh: preexec / precmd / chpwd are native ---------------------------
autoload -Uz add-zsh-hook
_bread_preexec() {
_bread_cmd_start=$(date +%s%3N)
_bread_cmd_cwd="$PWD"
_bread_cmd_str="$1"
local esc_cmd esc_cwd
esc_cmd=$(_bread_json_escape "$1")
esc_cwd=$(_bread_json_escape "$PWD")
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
}
add-zsh-hook preexec _bread_preexec
_bread_precmd() {
local exit_code=$?
if [ -n "$_bread_cmd_str" ]; then
local now duration_ms esc_cmd esc_cwd
now=$(date +%s%3N)
duration_ms=$(( now - _bread_cmd_start ))
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
_bread_cmd_str=""
fi
}
add-zsh-hook precmd _bread_precmd
_bread_chpwd() {
local esc_cwd esc_prev
esc_cwd=$(_bread_json_escape "$PWD")
esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}")
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
}
add-zsh-hook chpwd _bread_chpwd
elif [ -n "$BASH_VERSION" ]; then
# --- bash: no native preexec/precmd/chpwd; approximate them -------------
# trap ... DEBUG fires before every simple command. `_bread_preexec_guard`
# ensures only the first simple command between prompts is recorded, not
# every stage of a pipeline or every `;`-separated command on one line.
# This relies on bash's default of NOT propagating the DEBUG trap into
# function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off);
# if something in the user's environment turns those on, the guard below
# could fire more than once per prompt.
_bread_preexec() {
[ -n "$_bread_preexec_guard" ] && return
case "$BASH_COMMAND" in
_bread_precmd*|"$PROMPT_COMMAND") return ;;
esac
_bread_preexec_guard=1
_bread_cmd_start=$(date +%s%3N)
_bread_cmd_cwd="$PWD"
_bread_cmd_str="$BASH_COMMAND"
local esc_cmd esc_cwd
esc_cmd=$(_bread_json_escape "$BASH_COMMAND")
esc_cwd=$(_bread_json_escape "$PWD")
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
}
trap '_bread_preexec' DEBUG
_bread_precmd() {
local exit_code=$?
if [ -n "$_bread_preexec_guard" ]; then
local now duration_ms esc_cmd esc_cwd
now=$(date +%s%3N)
duration_ms=$(( now - _bread_cmd_start ))
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
fi
_bread_preexec_guard=""
# chpwd emulation: bash has no native hook, so compare $PWD against a
# remembered previous value on every prompt.
if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then
local esc_cwd2 esc_prev
esc_cwd2=$(_bread_json_escape "$PWD")
esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}")
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
_bread_prev_pwd="$PWD"
fi
}
PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
_bread_prev_pwd="$PWD"
fi
"#;
/// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`.
///
/// Fish's job-control primitives are meaningfully nicer than bash/zsh here:
/// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games),
/// `$status` in the postexec handler gives the exit code directly, and
/// `$CMD_DURATION` is already a builtin millisecond duration — no manual
/// timestamp math needed. `string escape --style=json` (fish >= 3.3) does
/// the JSON-string-literal escaping, quotes included, in one call.
const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`.
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
#
# Reports terminal telemetry (command start/finish, cwd changes, remote
# session start/end) to the bread daemon via the lightweight `bread-emit`
# binary. Every call below is backgrounded and has its output discarded so a
# down or slow daemon can never delay the shell prompt.
#
# Requires fish >= 3.3 for `string escape --style=json`.
function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)'
string escape --style=json -- $argv[1]
end
function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started'
set -g _bread_cmd_cwd $PWD
set -l cmd_json (_bread_json_escape $argv[1])
set -l cwd_json (_bread_json_escape $PWD)
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 &
end
function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished'
# $status must be captured first, before any other command in this
# function has a chance to overwrite it.
set -l exit_code $status
set -l cmd_json (_bread_json_escape $argv[1])
set -l cwd_json (_bread_json_escape $_bread_cmd_cwd)
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 &
end
function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed'
status is-interactive; or return
set -l cwd_json (_bread_json_escape $PWD)
set -l prev_json (_bread_json_escape $_bread_prev_pwd)
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 &
set -g _bread_prev_pwd $PWD
end
set -g _bread_prev_pwd $PWD
# --- Remote session detection (runs once at shell-init time) --------------
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
# so nested fish subshells (and the fish_exit handler, which is only
# registered in this branch) don't each fire their own started/ended pair.
set -l _bread_is_ssh 0
if test -n "$SSH_TTY"
set _bread_is_ssh 1
else if test -n "$SSH_CONNECTION"
set _bread_is_ssh 1
end
if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED"
set -gx BREAD_SSH_ANNOUNCED 1
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
set -l _bread_conn_str $SSH_CONNECTION
if test -z "$_bread_conn_str"
set _bread_conn_str $SSH_CLIENT
end
set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1]
set -l host_json (_bread_json_escape $_bread_ssh_host)
bread-emit bread.remote.session.started --source remote --kind session.started \
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended'
set -l host_json (_bread_json_escape $_bread_ssh_host)
bread-emit bread.remote.session.ended --source remote --kind session.ended \
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
end
end
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_known_shells() {
assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash);
assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh);
assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish);
}
#[test]
fn parse_rejects_unknown_shell() {
assert!(TargetShell::parse("powershell").is_err());
assert!(TargetShell::parse("").is_err());
}
#[test]
fn detect_from_env_reads_shell_basename() {
std::env::set_var("SHELL", "/usr/bin/zsh");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh);
std::env::set_var("SHELL", "/bin/bash");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash);
std::env::set_var("SHELL", "/usr/bin/fish");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish);
std::env::set_var("SHELL", "/usr/bin/tcsh");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
std::env::remove_var("SHELL");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
}
#[test]
fn hook_scripts_reference_bread_emit_not_bread_emit_cli() {
// Guard against accidentally shelling out to the slow `bread emit`
// subcommand instead of the lightweight `bread-emit` binary.
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
assert!(script.contains("bread-emit "));
assert!(!script.contains("bread emit "));
}
}
#[test]
fn hook_scripts_background_every_emit_call() {
// Every bread-emit invocation must be backgrounded with output
// discarded so a stalled daemon can never block the shell. A single
// invocation can span multiple physical lines via a trailing `\`
// continuation, so continuation lines are joined into one logical
// statement before checking, rather than inspecting each line in
// isolation.
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
for statement in join_line_continuations(script) {
if statement.contains("bread-emit ") {
assert!(
statement.contains(">/dev/null 2>&1 &"),
"statement not backgrounded/discarded: {statement}"
);
}
}
}
}
/// Joins physical lines ending in a trailing `\` continuation into a
/// single logical statement, so multi-line shell invocations can be
/// checked as a whole rather than line-by-line.
fn join_line_continuations(script: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
for line in script.lines() {
let trimmed_end = line.trim_end();
if let Some(rest) = trimmed_end.strip_suffix('\\') {
current.push_str(rest);
current.push(' ');
} else {
current.push_str(trimmed_end);
statements.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
statements.push(current);
}
statements
}
}

View file

@ -1,3 +1,5 @@
mod hooks_git;
mod hooks_shell;
mod modules_mgmt;
use anyhow::Result;
@ -58,6 +60,11 @@ enum Commands {
#[command(subcommand)]
subcommand: ModulesCommand,
},
/// Install shell/git hook integrations that feed events into breadd
Hooks {
#[command(subcommand)]
subcommand: HooksCommand,
},
/// List available profiles
ProfileList,
/// Activate a profile
@ -67,6 +74,13 @@ enum Commands {
event: String,
#[arg(short, long, default_value = "{}")]
data: String,
/// Source to tag the event with (terminal/git/remote); routes through
/// the normalizer instead of being tagged System. Requires --kind.
#[arg(long)]
source: Option<String>,
/// Adapter-specific raw kind (e.g. "command.started"); only used with --source.
#[arg(long)]
kind: Option<String>,
},
/// Health check daemon connectivity
Ping,
@ -80,6 +94,19 @@ enum Commands {
},
}
#[derive(Subcommand, Debug)]
enum HooksCommand {
/// Install shell integration hooks (precmd/preexec/chpwd + SSH session
/// detection) for the current or a named shell
InstallShell {
/// Force bash/zsh/fish instead of auto-detecting from $SHELL
shell: Option<String>,
},
/// Install git hooks (post-commit, post-checkout, post-merge) into the
/// current repository
InstallGit,
}
#[derive(Subcommand, Debug)]
enum ModulesCommand {
/// Install a module from a local directory
@ -137,6 +164,10 @@ async fn main() -> Result<()> {
Commands::Modules { subcommand } => {
handle_modules_cmd(subcommand, &socket).await?;
}
Commands::Hooks { subcommand } => match subcommand {
HooksCommand::InstallShell { shell } => hooks_shell::install_shell(shell)?,
HooksCommand::InstallGit => hooks_git::install_git()?,
},
Commands::ProfileList => {
let response = send_request(&socket, "profile.list", json!({})).await?;
print_json(&response)?;
@ -146,17 +177,24 @@ async fn main() -> Result<()> {
send_request(&socket, "profile.activate", json!({ "name": name })).await?;
print_json(&response)?;
}
Commands::Emit { event, data } => {
Commands::Emit {
event,
data,
source,
kind,
} => {
let parsed = serde_json::from_str::<Value>(&data).unwrap_or_else(|_| json!({}));
let response = send_request(
&socket,
"emit",
json!({
let mut params = json!({
"event": event,
"data": parsed,
}),
)
.await?;
});
if let Some(source) = source {
params["source"] = json!(source);
}
if let Some(kind) = kind {
params["kind"] = json!(kind);
}
let response = send_request(&socket, "emit", params).await?;
print_json(&response)?;
}
Commands::Ping => {

12
bread-emit/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "bread-emit"
version = "0.6.6"
edition = "2021"
[[bin]]
name = "bread-emit"
path = "src/main.rs"
[dependencies]
bread-shared = { path = "../bread-shared" }
serde_json.workspace = true

75
bread-emit/src/main.rs Normal file
View file

@ -0,0 +1,75 @@
//! Minimal fire-and-forget emitter for breadd's IPC socket.
//!
//! This exists because shell precmd/preexec hooks (and git hooks) fire on
//! every command / commit — spawning the full `bread` CLI (clap parsing +
//! a Tokio runtime + a round trip) on that path is perceptible latency in
//! an interactive shell. This binary skips all of that: no async runtime,
//! argv is parsed by hand, and it never waits for or reads a reply. A
//! down or slow daemon must never delay or hang the caller's shell.
use serde_json::{json, Value};
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::time::Duration;
fn parse_args(args: &[String]) -> Option<(String, Option<String>, Option<String>, String)> {
let mut event = None;
let mut source = None;
let mut kind = None;
let mut data = "{}".to_string();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--source" => {
source = args.get(i + 1).cloned();
i += 2;
}
"--kind" => {
kind = args.get(i + 1).cloned();
i += 2;
}
"--data" => {
data = args.get(i + 1).cloned().unwrap_or_else(|| "{}".to_string());
i += 2;
}
other => {
if event.is_none() {
event = Some(other.to_string());
}
i += 1;
}
}
}
event.map(|event| (event, source, kind, data))
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some((event, source, kind, data)) = parse_args(&args) else {
eprintln!("usage: bread-emit <event> [--source <source> --kind <kind>] [--data <json>]");
std::process::exit(1);
};
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_else(|_| json!({}));
let mut params = json!({ "event": event, "data": parsed_data });
if let Some(source) = source {
params["source"] = json!(source);
}
if let Some(kind) = kind {
params["kind"] = json!(kind);
}
let request = json!({ "id": "0", "method": "emit", "params": params });
let Ok(line) = serde_json::to_string(&request) else {
return;
};
// Best-effort: connect, write, exit. Never read a reply, never retry,
// never block longer than the write timeout below.
if let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) {
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
}

View file

@ -6,3 +6,5 @@ edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
toml = "0.8"

113
bread-shared/src/apps.rs Normal file
View file

@ -0,0 +1,113 @@
//! The known-apps registry for sibling `bread*` application integration.
//!
//! Adding a new sibling app is a one-line edit to [`KNOWN_APPS`] — no new
//! `AdapterSource` variant, no new normalizer arm, no recompile-driven
//! exhaustiveness churn. This is what makes the sibling-app integration
//! model extensible: see `Documentation.md`'s "Namespaces" section and
//! "Integrating a bread* app" recipe.
/// Registered sibling `bread*` app ids. Each id is also the app's reserved
/// segment in the `bread.<id>.*` (events) and `bread.command.<id>.*`
/// (commands) namespaces.
pub const KNOWN_APPS: &[&str] = &[
"clip", "pad", "bar", "box", "lock", "mon", "paper", "search", "shot", "arr", "crumbs", "help",
"bakery",
];
/// Daemon-internal domains that are reserved and can never be claimed as an
/// app id, even if a future `bread*` app would otherwise want that name —
/// these are the top-level segments the normalizer and built-in event
/// families already use.
const RESERVED_DOMAINS: &[&str] = &[
"terminal",
"git",
"hyprland",
"device",
"power",
"network",
"service",
"container",
"project",
"remote",
"system",
"profile",
"notify",
"command",
"workflow",
];
/// Whether `id` is a registered sibling-app id.
pub fn is_known_app(id: &str) -> bool {
KNOWN_APPS.contains(&id)
}
/// Whether `id` is reserved for daemon-internal use and can never be
/// registered as a sibling-app id.
pub fn is_reserved_domain(id: &str) -> bool {
RESERVED_DOMAINS.contains(&id)
}
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
/// with `bread.<app>.`. An app may only publish within its own namespace
/// segment; this is what the IPC boundary checks before constructing a
/// `RawEvent` tagged `AdapterSource::App(app)`.
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
event.starts_with(&format!("bread.{app}."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_apps_are_recognized() {
assert!(is_known_app("clip"));
assert!(is_known_app("bakery"));
}
#[test]
fn unknown_app_is_not_recognized() {
assert!(!is_known_app("notanapp"));
assert!(!is_known_app(""));
}
#[test]
fn reserved_domains_are_never_known_apps() {
for domain in RESERVED_DOMAINS {
assert!(
!is_known_app(domain),
"reserved domain '{domain}' must not double as a known app id"
);
}
}
#[test]
fn reserved_domains_are_recognized() {
assert!(is_reserved_domain("power"));
assert!(is_reserved_domain("hyprland"));
assert!(!is_reserved_domain("clip"));
}
#[test]
fn validate_app_namespace_accepts_own_namespace() {
assert!(validate_app_namespace("clip", "bread.clip.copied"));
assert!(validate_app_namespace(
"clip",
"bread.clip.stack_trace.captured"
));
}
#[test]
fn validate_app_namespace_rejects_other_namespaces() {
assert!(!validate_app_namespace("clip", "bread.pad.reminder.due"));
assert!(!validate_app_namespace("clip", "bread.power.ac.connected"));
assert!(!validate_app_namespace("clip", "bread.clipboard.copied"));
}
#[test]
fn validate_app_namespace_rejects_bare_prefix_without_trailing_dot() {
// "bread.clipx..." must not satisfy the "clip" namespace just
// because it shares a string prefix.
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
}
}

View file

@ -155,12 +155,18 @@ mod tests {
#[test]
fn dot_double_star_does_not_match_sibling_prefix() {
assert!(!matches_pattern("bread.device.**", "bread.devicex"));
assert!(!matches_pattern("bread.device.**", "bread.network.connected"));
assert!(!matches_pattern(
"bread.device.**",
"bread.network.connected"
));
}
#[test]
fn mid_pattern_star_does_not_cross_dots() {
assert!(matches_pattern("bread.*.connected", "bread.alpha.connected"));
assert!(matches_pattern(
"bread.*.connected",
"bread.alpha.connected"
));
assert!(!matches_pattern(
"bread.*.connected",
"bread.alpha.beta.connected"

View file

@ -8,13 +8,19 @@
use serde::{Deserialize, Serialize};
pub mod apps;
pub mod glob;
/// Identifies which adapter produced an event.
///
/// The state engine uses this to choose a normalization strategy and the
/// IPC layer surfaces it so subscribers can filter by origin.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
///
/// Not `Copy`: the [`App`](AdapterSource::App) variant carries an owned
/// `String` (a sibling `bread*` app id), so callers that used to copy a
/// `AdapterSource` by value now `.clone()` it — see `breadd/src/core/normalizer.rs`
/// for the (small, compiler-driven) set of call sites this touches.
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AdapterSource {
/// The Hyprland compositor IPC socket.
@ -30,6 +36,23 @@ pub enum AdapterSource {
System,
/// BlueZ Bluetooth stack via D-Bus.
Bluetooth,
/// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes).
Terminal,
/// Git hooks (commit/branch) and the in-daemon dirty-state poller.
Git,
/// Project-root file watches (via `notify`/inotify).
Filesystem,
/// systemd --user unit state, via the session D-Bus.
Systemd,
/// Podman container lifecycle, via `podman events`.
Podman,
/// SSH/remote session detection, via the shell hook.
Remote,
/// A sibling `bread*` application (breadclip, breadpad, ...), identified
/// by its registered app id (see [`apps::KNOWN_APPS`]). Confined to the
/// `bread.<app>.*` event namespace — enforced at the IPC boundary via
/// [`apps::validate_app_namespace`], not by this type itself.
App(String),
}
/// An unnormalized event as emitted by an adapter.
@ -91,6 +114,44 @@ pub fn now_unix_ms() -> u64 {
.as_millis() as u64
}
#[derive(Deserialize, Default)]
struct DaemonSection {
#[serde(default)]
socket_path: String,
}
#[derive(Deserialize, Default)]
struct SocketPathConfig {
#[serde(default)]
daemon: DaemonSection,
}
/// Resolve breadd's Unix socket path exactly as `breadd::core::config::Config::socket_path`
/// resolves its own: an explicit `daemon.socket_path` in `~/.config/bread/breadd.toml` wins,
/// otherwise `$XDG_RUNTIME_DIR/bread/breadd.sock`, falling back to `/tmp/bread/breadd.sock`.
///
/// Shared by every socket client that lives outside the daemon itself (`bread-emit`, and
/// `bread-client` in `bread-ecosystem/bread-utils`) so they can't drift from how the daemon
/// actually resolves its own socket — before this existed, `bread-emit` carried its own
/// hand-rolled copy of this exact logic.
pub fn resolve_socket_path() -> std::path::PathBuf {
if let Some(home) = dirs::home_dir() {
let config_path = home.join(".config/bread/breadd.toml");
if let Ok(contents) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = toml::from_str::<SocketPathConfig>(&contents) {
if !cfg.daemon.socket_path.is_empty() {
return expand_path(&cfg.daemon.socket_path);
}
}
}
}
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
std::path::PathBuf::from(runtime_dir)
.join("bread")
.join("breadd.sock")
}
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
///
/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps
@ -164,6 +225,38 @@ mod tests {
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Terminal).unwrap(),
"\"terminal\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Git).unwrap(),
"\"git\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Filesystem).unwrap(),
"\"filesystem\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Systemd).unwrap(),
"\"systemd\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Podman).unwrap(),
"\"podman\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Remote).unwrap(),
"\"remote\""
);
}
#[test]
fn adapter_source_app_serializes_as_externally_tagged_object() {
assert_eq!(
serde_json::to_string(&AdapterSource::App("clip".to_string())).unwrap(),
"{\"app\":\"clip\"}"
);
}
#[test]
@ -175,6 +268,13 @@ mod tests {
AdapterSource::Network,
AdapterSource::System,
AdapterSource::Bluetooth,
AdapterSource::Terminal,
AdapterSource::Git,
AdapterSource::Filesystem,
AdapterSource::Systemd,
AdapterSource::Podman,
AdapterSource::Remote,
AdapterSource::App("clip".to_string()),
] {
let s = serde_json::to_string(&source).unwrap();
let back: AdapterSource = serde_json::from_str(&s).unwrap();

View file

@ -21,6 +21,7 @@ futures-util = "0.3"
netlink-packet-route = "0.11"
netlink-packet-core = "0.4"
libc = "0.2"
notify = "6.1"
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,470 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc as std_mpsc;
use std::time::{Duration, Instant};
use anyhow::Result;
use async_trait::async_trait;
use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::json;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use super::Adapter;
/// Files/directories directly inside a watched root whose presence marks it
/// as a recognizable project.
const MARKERS: [&str; 4] = [".git", "Cargo.toml", "package.json", "go.mod"];
/// Directory names that are excluded from `file.changed` noise anywhere in a
/// watched tree. `.git` and `node_modules` are fully silent; `target`,
/// `dist`, and `build` additionally get a `build_artifact.created` signal
/// when a new file appears inside them.
const EXCLUDED_DIRS: [&str; 5] = [".git", "target", "node_modules", "dist", "build"];
/// Debounce window for `file.changed` — editors frequently perform several
/// writes (temp file + rename, fsync, etc.) for a single logical save.
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(300);
/// Watches a set of project-root glob patterns for filesystem activity and
/// reports project detection, plain file changes, and build-artifact
/// creation as [`RawEvent`]s.
#[derive(Clone)]
pub struct FilesystemAdapter {
/// Raw, unexpanded root patterns as configured (e.g. `["~/Projects/*"]`).
roots: Vec<String>,
}
impl FilesystemAdapter {
pub fn new(roots: Vec<String>) -> Self {
Self { roots }
}
/// Expand `~` and a single `*` glob segment in each configured pattern
/// into concrete directory paths. Patterns are not required to exist on
/// disk yet — existence is checked by the caller.
fn resolve_roots(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for pattern in &self.roots {
let expanded = expand_path(pattern);
out.extend(expand_glob(&expanded));
}
out
}
/// Scan each concrete root for project markers and emit a `"detected"`
/// event for any root that has at least one. Mirrors the
/// `enumerate_existing` convention used by the udev/bluetooth adapters:
/// called once before `run()`, best-effort, never fails the daemon.
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
for root in self.resolve_roots() {
if !root.is_dir() {
debug!(
"filesystem: root {} does not exist, skipping enumeration",
root.display()
);
continue;
}
let markers = detect_markers(&root);
if markers.is_empty() {
continue;
}
let _ = tx
.send(RawEvent {
source: AdapterSource::Filesystem,
kind: "detected".to_string(),
payload: json!({
"root": root.to_string_lossy(),
"markers": markers,
}),
timestamp: now_unix_ms(),
})
.await;
}
}
}
#[async_trait]
impl Adapter for FilesystemAdapter {
fn name(&self) -> &'static str {
"filesystem"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
let mut existing_roots = Vec::new();
for root in self.resolve_roots() {
if root.is_dir() {
existing_roots.push(root);
} else {
warn!(
"filesystem: configured root {} does not exist, skipping",
root.display()
);
}
}
if existing_roots.is_empty() {
debug!("filesystem adapter: no existing roots to watch");
return Ok(());
}
run_watch(existing_roots, tx).await
}
}
/// Sets up a recursive `notify` watch on each root and bridges its
/// synchronous callback into the async `tx` channel via a blocking task.
///
/// Roots that fail to watch (e.g. inotify watch-limit exhaustion) are logged
/// and skipped; the adapter keeps watching whatever roots succeeded.
async fn run_watch(roots: Vec<PathBuf>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
let (std_tx, std_rx) = std_mpsc::channel::<notify::Result<Event>>();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
let _ = std_tx.send(res);
})?;
let mut watched_roots = Vec::new();
for root in &roots {
match watcher.watch(root, RecursiveMode::Recursive) {
Ok(()) => watched_roots.push(root.clone()),
Err(e) => {
warn!(
"filesystem: failed to watch {} ({e}), skipping this root",
root.display()
);
}
}
}
if watched_roots.is_empty() {
warn!("filesystem adapter: no roots could be watched, exiting");
return Ok(());
}
// The blocking task owns the receiving end and the debounce table; it
// runs until `std_rx` disconnects (watcher dropped) or `tx` is closed
// (daemon shutting down). `watcher` is kept alive in this async fn's
// stack across the await below so its background thread keeps feeding
// `std_rx` for as long as this future is polled.
tokio::task::spawn_blocking(move || {
let mut last_seen: HashMap<PathBuf, Instant> = HashMap::new();
while let Ok(res) = std_rx.recv() {
let event = match res {
Ok(event) => event,
Err(e) => {
debug!("filesystem watch error: {e}");
continue;
}
};
for path in &event.paths {
let Some(root) = watched_roots.iter().find(|r| path.starts_with(r)) else {
continue;
};
if let Some(raw) = classify(root, path, &event.kind, &mut last_seen) {
if tx.blocking_send(raw).is_err() {
return;
}
}
}
}
})
.await?;
drop(watcher);
Ok(())
}
/// Classifies a single notify path event into a `RawEvent`, or `None` if it
/// should be silent (inside `.git`/`node_modules`, or debounced).
fn classify(
root: &Path,
path: &Path,
kind: &EventKind,
last_seen: &mut HashMap<PathBuf, Instant>,
) -> Option<RawEvent> {
let relative = path.strip_prefix(root).unwrap_or(path);
match excluded_dir_component(relative) {
Some("target" | "dist" | "build") => {
if matches!(kind, EventKind::Create(_)) {
Some(RawEvent {
source: AdapterSource::Filesystem,
kind: "build_artifact.created".to_string(),
payload: json!({
"path": path.to_string_lossy(),
"project_root": root.to_string_lossy(),
}),
timestamp: now_unix_ms(),
})
} else {
None
}
}
// `.git` / `node_modules`: fully silent, never emit.
Some(_) => None,
None => {
let now = Instant::now();
if let Some(last) = last_seen.get(path) {
if now.duration_since(*last) < DEBOUNCE_WINDOW {
return None;
}
}
last_seen.insert(path.to_path_buf(), now);
Some(RawEvent {
source: AdapterSource::Filesystem,
kind: "file.changed".to_string(),
payload: json!({
"path": path.to_string_lossy(),
"project_root": root.to_string_lossy(),
}),
timestamp: now_unix_ms(),
})
}
}
}
/// Returns the first excluded directory name found anywhere among the
/// components of `relative`, or `None` if it isn't under any of them.
fn excluded_dir_component(relative: &Path) -> Option<&'static str> {
for component in relative.components() {
if let std::path::Component::Normal(name) = component {
let name = name.to_str().unwrap_or("");
if let Some(excluded) = EXCLUDED_DIRS.iter().find(|e| **e == name) {
return Some(excluded);
}
}
}
None
}
/// Checks `root` for the presence of any recognized project marker
/// directly inside it (not recursively).
fn detect_markers(root: &Path) -> Vec<String> {
MARKERS
.iter()
.filter(|marker| root.join(marker).exists())
.map(|marker| marker.to_string())
.collect()
}
/// Expands a single `*` path component (if present) into every directory
/// entry of its parent. Patterns without a `*` are returned unchanged.
/// Only one level of globbing is supported, matching the config contract
/// (e.g. `~/Projects/*`, not `~/Projects/**`).
fn expand_glob(path: &Path) -> Vec<PathBuf> {
let components: Vec<_> = path.components().collect();
let Some(star_idx) = components.iter().position(|c| c.as_os_str() == "*") else {
return vec![path.to_path_buf()];
};
let parent: PathBuf = components[..star_idx].iter().collect();
let suffix: PathBuf = components[star_idx + 1..].iter().collect();
let Ok(entries) = std::fs::read_dir(&parent) else {
debug!(
"filesystem: cannot read {} to expand glob pattern",
parent.display()
);
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.map(|p| {
if suffix.as_os_str().is_empty() {
p
} else {
p.join(&suffix)
}
})
.collect();
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn excluded_dir_component_finds_git_at_top_level() {
assert_eq!(excluded_dir_component(Path::new(".git/HEAD")), Some(".git"));
}
#[test]
fn excluded_dir_component_finds_target_nested_deeply() {
assert_eq!(
excluded_dir_component(Path::new("crates/foo/target/debug/build/out.o")),
Some("target")
);
}
#[test]
fn excluded_dir_component_finds_node_modules() {
assert_eq!(
excluded_dir_component(Path::new("web/node_modules/lodash/index.js")),
Some("node_modules")
);
}
#[test]
fn excluded_dir_component_none_for_ordinary_source_file() {
assert_eq!(excluded_dir_component(Path::new("src/main.rs")), None);
}
#[test]
fn classify_silent_under_git() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/.git/HEAD");
let result = classify(
root,
path,
&EventKind::Create(notify::event::CreateKind::File),
&mut last_seen,
);
assert!(result.is_none());
}
#[test]
fn classify_silent_under_node_modules() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/node_modules/foo/index.js");
let result = classify(
root,
path,
&EventKind::Modify(notify::event::ModifyKind::Any),
&mut last_seen,
);
assert!(result.is_none());
}
#[test]
fn classify_build_artifact_created_in_target() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/target/debug/breadd");
let result = classify(
root,
path,
&EventKind::Create(notify::event::CreateKind::File),
&mut last_seen,
);
let event = result.expect("expected build_artifact.created event");
assert_eq!(event.kind, "build_artifact.created");
assert_eq!(event.payload["path"], json!("/proj/target/debug/breadd"));
assert_eq!(event.payload["project_root"], json!("/proj"));
}
#[test]
fn classify_silent_for_modify_in_target_not_create() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/target/debug/breadd");
let result = classify(
root,
path,
&EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any,
)),
&mut last_seen,
);
assert!(result.is_none());
}
#[test]
fn classify_file_changed_for_ordinary_source_file() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/src/main.rs");
let result = classify(
root,
path,
&EventKind::Modify(notify::event::ModifyKind::Any),
&mut last_seen,
);
let event = result.expect("expected file.changed event");
assert_eq!(event.kind, "file.changed");
assert_eq!(event.payload["path"], json!("/proj/src/main.rs"));
assert_eq!(event.payload["project_root"], json!("/proj"));
}
#[test]
fn classify_debounces_rapid_repeat_events_for_same_path() {
let mut last_seen = HashMap::new();
let root = Path::new("/proj");
let path = Path::new("/proj/src/main.rs");
let kind = EventKind::Modify(notify::event::ModifyKind::Any);
let first = classify(root, path, &kind, &mut last_seen);
assert!(first.is_some());
let second = classify(root, path, &kind, &mut last_seen);
assert!(second.is_none(), "second rapid event should be debounced");
}
#[test]
fn detect_markers_finds_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let markers = detect_markers(dir.path());
assert_eq!(markers, vec!["Cargo.toml".to_string()]);
}
#[test]
fn detect_markers_finds_multiple() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let mut markers = detect_markers(dir.path());
markers.sort();
let mut expected = vec![".git".to_string(), "package.json".to_string()];
expected.sort();
assert_eq!(markers, expected);
}
#[test]
fn detect_markers_empty_for_plain_directory() {
let dir = tempfile::tempdir().unwrap();
assert!(detect_markers(dir.path()).is_empty());
}
#[test]
fn expand_glob_returns_single_path_unchanged_without_star() {
let path = Path::new("/home/user/Projects/bread");
assert_eq!(expand_glob(path), vec![path.to_path_buf()]);
}
#[test]
fn expand_glob_expands_star_to_subdirectories() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("alpha")).unwrap();
fs::create_dir(dir.path().join("beta")).unwrap();
fs::write(dir.path().join("not-a-dir.txt"), "x").unwrap();
let pattern = dir.path().join("*");
let mut results = expand_glob(&pattern);
results.sort();
let mut expected = vec![dir.path().join("alpha"), dir.path().join("beta")];
expected.sort();
assert_eq!(results, expected);
}
#[test]
fn expand_glob_returns_empty_for_nonexistent_parent() {
let pattern = Path::new("/definitely/does/not/exist/*");
assert!(expand_glob(pattern).is_empty());
}
}

524
breadd/src/adapters/git.rs Normal file
View file

@ -0,0 +1,524 @@
//! Polls a configured set of project roots for git dirty/clean transitions
//! and ahead/behind-upstream changes.
//!
//! Scope note: this adapter owns exactly two event families —
//! `state.dirty`/`state.clean` and `ahead_behind.changed`. It deliberately
//! does **not** emit anything for HEAD changes as such (commits, checkouts,
//! branch switches), even though `git status`/`rev-list` are re-run against
//! every tracked repo on every tick and will absolutely see those
//! transitions too. A separate, hook-based path (`post-commit`/
//! `post-checkout` invoking a CLI tool) owns `bread.git.commit.created` /
//! `bread.git.branch.changed`; if this poller also emitted on the same
//! transitions, both paths would fire for one real-world event. (An earlier
//! version of this file tried to skip the subprocess check on ticks where
//! `.git/HEAD`/`.git/index` hadn't changed mtime, as an optimization — that
//! silently broke the primary use case, since a plain working-tree edit
//! never touches either file. Every repo is checked every tick now; see the
//! comment at the top of the poll loop below.)
//!
//! Structurally this mirrors `power.rs`: a plain `tokio::time::interval`
//! poll loop with no external socket, looping forever and relying on the
//! supervisor's outer `tokio::select!` (in `Manager::spawn_adapter`) against
//! `shutdown_rx` to cancel the future — this adapter does not check
//! `tx.is_closed()` itself, and channel-send failures are propagated with
//! `?` exactly as `power.rs` does.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{expand_path, now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::process::Command;
use tokio::sync::{mpsc, Semaphore};
use tokio::time::{interval, Duration};
use tracing::{debug, warn};
use crate::adapters::Adapter;
/// Default poll interval: frequent enough to feel responsive for a dirty/
/// clean or ahead/behind indicator, infrequent enough that idle repos cost
/// nothing beyond a couple of `stat(2)` calls per tick.
const DEFAULT_POLL_INTERVAL_SECS: u64 = 2;
/// Concurrency cap: caps how many `git` subprocesses may be running at once
/// across all repos that changed in a given tick. A `Semaphore` is used
/// (rather than fully sequential processing) because typical dev machines
/// have many idle repos under e.g. `~/Projects/*`, and processing a burst of
/// simultaneously-touched repos (e.g. right after a `git fetch --all` across
/// a monorepo forest, or a batch checkout script) one at a time would add
/// unnecessary tail latency; 4 keeps subprocess fan-out modest without
/// meaningfully serializing typical ticks (where usually 0-1 repos changed).
const MAX_CONCURRENT_GIT_OPS: usize = 4;
/// Polls a set of project roots for git dirty/clean and ahead/behind-upstream
/// transitions.
///
/// Construct with [`GitAdapter::new`], passing root path patterns such as
/// `["~/Projects/*"]`. Patterns are expanded via [`bread_shared::expand_path`]
/// (for a leading `~`) and, if the final path segment is a literal `*`,
/// glob-expanded one level deep via `std::fs::read_dir` on the parent
/// directory (no glob crate dependency; only a single trailing `*` segment
/// is supported, matching the spec this adapter was built against).
#[derive(Clone)]
pub struct GitAdapter {
root_patterns: Vec<String>,
poll_interval: Duration,
}
impl GitAdapter {
/// Uses [`DEFAULT_POLL_INTERVAL_SECS`].
pub fn new(root_patterns: Vec<String>) -> Self {
Self::with_interval(
root_patterns,
Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS),
)
}
/// Same as [`GitAdapter::new`] but with an explicit poll interval —
/// primarily for tests, but also available if a future config key wants
/// to expose it.
pub fn with_interval(root_patterns: Vec<String>, poll_interval: Duration) -> Self {
Self {
root_patterns,
poll_interval,
}
}
}
#[async_trait]
impl Adapter for GitAdapter {
fn name(&self) -> &'static str {
"git"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("git adapter started");
let mut tracks = discover_repos(&self.root_patterns);
if tracks.is_empty() {
debug!("git adapter: no git repositories found under configured roots");
}
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_GIT_OPS));
let mut ticker = interval(self.poll_interval);
loop {
ticker.tick().await;
// Every tracked repo is checked every tick. An earlier version of
// this gated the check on `.git/HEAD`/`.git/index` mtime changing
// first, but that misses the single most common transition this
// adapter exists to report: plain working-tree edits (creating,
// editing, or deleting a file) make `git status --porcelain`
// dirty without ever touching HEAD or the index, so that gate
// silently never fired for it. Subprocess fan-out is still
// bounded by the semaphore below, and `state.dirty`/`state.clean`/
// `ahead_behind.changed` are only actually emitted on a real
// transition (see the apply pass), so an unchanged repo costs a
// `git status --porcelain` (and occasionally `rev-list`) per
// tick, not an emitted event.
let needs_check: Vec<usize> = (0..tracks.len()).collect();
// --- Check pass: bounded-concurrency git subprocesses. ---
let mut handles = Vec::with_capacity(needs_check.len());
for idx in needs_check {
let repo_path = tracks[idx].path.clone();
let permit = semaphore.clone();
handles.push(tokio::spawn(async move {
let _permit = permit.acquire_owned().await;
let dirty = check_dirty(&repo_path).await;
let ahead_behind = check_ahead_behind(&repo_path).await;
(idx, dirty, ahead_behind)
}));
}
// --- Apply pass: sequential, so per-repo last-known state
// doesn't need its own lock. ---
for handle in handles {
let (idx, dirty_result, ahead_behind_result) = match handle.await {
Ok(result) => result,
Err(e) => {
warn!("git adapter: check task failed to join: {e}");
continue;
}
};
let track = &mut tracks[idx];
match dirty_result {
Ok(dirty) => {
if track.last_dirty != Some(dirty) {
let kind = if dirty { "state.dirty" } else { "state.clean" };
track.last_dirty = Some(dirty);
tx.send(RawEvent {
source: AdapterSource::Git,
kind: kind.to_string(),
payload: json!({ "repo": track.path.to_string_lossy() }),
timestamp: now_unix_ms(),
})
.await?;
}
}
Err(e) => warn!(
"git adapter: status check failed for {}: {e}",
track.path.display()
),
}
match ahead_behind_result {
Ok(Some((branch, ahead, behind))) => {
if track.last_ahead_behind != Some((ahead, behind)) {
track.last_ahead_behind = Some((ahead, behind));
tx.send(RawEvent {
source: AdapterSource::Git,
kind: "ahead_behind.changed".to_string(),
payload: json!({
"repo": track.path.to_string_lossy(),
"ahead": ahead,
"behind": behind,
"branch": branch,
}),
timestamp: now_unix_ms(),
})
.await?;
}
}
// No upstream configured for the current branch — not an
// error, just nothing to report for this repo this tick.
Ok(None) => {}
Err(e) => debug!(
"git adapter: ahead/behind check failed for {}: {e}",
track.path.display()
),
}
}
}
}
}
/// Per-repo tracking state carried between poll ticks.
struct RepoTrack {
/// Worktree root — what gets passed to `git -C <path>` and reported in
/// event payloads.
path: PathBuf,
last_dirty: Option<bool>,
last_ahead_behind: Option<(u32, u32)>,
}
impl RepoTrack {
fn new(path: PathBuf) -> Self {
Self {
path,
last_dirty: None,
last_ahead_behind: None,
}
}
}
/// Expands configured root patterns and resolves each concrete root to a
/// [`RepoTrack`] if (and only if) it's a recognizable git checkout. Roots
/// that aren't git repos are skipped with a debug log, not an error — the
/// configured glob is expected to sweep up non-repo directories routinely
/// (e.g. `~/Projects/*` catching a README-only folder).
fn discover_repos(root_patterns: &[String]) -> Vec<RepoTrack> {
let mut tracks = Vec::new();
for root in expand_roots(root_patterns) {
match resolve_git_dir(&root) {
Some(_git_dir) => tracks.push(RepoTrack::new(root)),
None => debug!(
"git adapter: {} is not a git repository, skipping",
root.display()
),
}
}
tracks
}
/// Expands `~` (via `bread_shared::expand_path`) and a single trailing `*`
/// path segment (via a one-level `std::fs::read_dir` on the parent
/// directory — no glob crate). Patterns without a trailing `*` are used
/// literally. Only directories are kept when expanding a `*`.
fn expand_roots(patterns: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
for pattern in patterns {
let expanded = expand_path(pattern);
if expanded.file_name().map(|n| n == "*").unwrap_or(false) {
let parent = expanded.parent().unwrap_or_else(|| Path::new("."));
match std::fs::read_dir(parent) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.push(path);
}
}
}
Err(e) => debug!("git adapter: cannot glob {}: {e}", parent.display()),
}
} else {
out.push(expanded);
}
}
out
}
/// Resolves `<root>/.git` to the actual git directory to use for `HEAD`/
/// `index` stats, handling both shapes:
/// - a plain directory (the common case) — used as-is;
/// - a `.git` *file* containing `gitdir: <path>` (worktrees and submodules)
/// — the pointer is read and resolved (relative to `root` if not
/// absolute), and canonicalized on a best-effort basis.
///
/// Chose to fully resolve the worktree/submodule case rather than skip it,
/// since it's a common setup (git worktrees especially) and the extra
/// parsing is small. Returns `None` if `<root>/.git` doesn't exist or is
/// some other unrecognized shape.
fn resolve_git_dir(root: &Path) -> Option<PathBuf> {
let dotgit = root.join(".git");
let meta = std::fs::symlink_metadata(&dotgit).ok()?;
if meta.is_dir() {
return Some(dotgit);
}
if meta.is_file() {
let content = std::fs::read_to_string(&dotgit).ok()?;
let gitdir_str = parse_gitdir_file(&content)?;
let gitdir_path = PathBuf::from(gitdir_str);
let resolved = if gitdir_path.is_absolute() {
gitdir_path
} else {
root.join(gitdir_path)
};
return Some(std::fs::canonicalize(&resolved).unwrap_or(resolved));
}
None
}
/// Parses the contents of a worktree/submodule `.git` file, e.g.
/// `gitdir: /home/user/Projects/repo/.git/worktrees/feature\n`, returning
/// the path portion.
fn parse_gitdir_file(content: &str) -> Option<&str> {
content
.lines()
.find_map(|line| line.trim().strip_prefix("gitdir:"))
.map(|rest| rest.trim())
}
/// Runs `git -C <repo> status --porcelain` and reports whether the repo is
/// dirty (any output) or clean (empty output).
async fn check_dirty(repo: &Path) -> Result<bool> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"])
.output()
.await
.map_err(|e| anyhow!("failed to spawn git status: {e}"))?;
if !output.status.success() {
return Err(anyhow!(
"git status exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
/// Runs `git -C <repo> rev-list --left-right --count HEAD...@{upstream}` and
/// `git -C <repo> rev-parse --abbrev-ref HEAD`, returning
/// `Some((branch, ahead, behind))`. Returns `Ok(None)` (not an error) when
/// there's no upstream configured for the current branch, since that's the
/// expected/common state for plenty of repos, not a failure.
async fn check_ahead_behind(repo: &Path) -> Result<Option<(String, u32, u32)>> {
let rev_list = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.output()
.await
.map_err(|e| anyhow!("failed to spawn git rev-list: {e}"))?;
if !rev_list.status.success() {
// Most commonly: "no upstream configured for branch". Treat any
// non-zero exit here as "nothing to report", not an adapter error.
return Ok(None);
}
let stdout = String::from_utf8_lossy(&rev_list.stdout);
let Some((ahead, behind)) = parse_ahead_behind(&stdout) else {
return Err(anyhow!("unexpected git rev-list output: {stdout:?}"));
};
let branch_output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
.map_err(|e| anyhow!("failed to spawn git rev-parse: {e}"))?;
let branch = if branch_output.status.success() {
String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string()
} else {
"HEAD".to_string()
};
Ok(Some((branch, ahead, behind)))
}
/// Parses `git rev-list --left-right --count HEAD...@{upstream}` output.
///
/// With `HEAD...@{upstream}`, `HEAD` is the left side and `@{upstream}` is
/// the right side, so `--left-right --count` prints "<left-only-count>
/// <right-only-count>" — i.e. "<ahead> <behind>", in that order,
/// whitespace-separated (typically a single tab).
fn parse_ahead_behind(output: &str) -> Option<(u32, u32)> {
let mut parts = output.split_whitespace();
let ahead = parts.next()?.parse::<u32>().ok()?;
let behind = parts.next()?.parse::<u32>().ok()?;
Some((ahead, behind))
}
#[cfg(test)]
mod tests {
use super::*;
// --- parse_ahead_behind ---
#[test]
fn parse_ahead_behind_parses_tab_separated_counts() {
assert_eq!(parse_ahead_behind("3\t2\n"), Some((3, 2)));
}
#[test]
fn parse_ahead_behind_parses_space_separated_counts() {
assert_eq!(parse_ahead_behind("0 5"), Some((0, 5)));
}
#[test]
fn parse_ahead_behind_handles_zero_zero() {
assert_eq!(parse_ahead_behind("0\t0"), Some((0, 0)));
}
#[test]
fn parse_ahead_behind_rejects_malformed_output() {
assert_eq!(parse_ahead_behind(""), None);
assert_eq!(parse_ahead_behind("only-one"), None);
assert_eq!(parse_ahead_behind("not a number\t2"), None);
}
// --- parse_gitdir_file ---
#[test]
fn parse_gitdir_file_extracts_path() {
assert_eq!(
parse_gitdir_file("gitdir: /home/user/repo/.git/worktrees/feature\n"),
Some("/home/user/repo/.git/worktrees/feature")
);
}
#[test]
fn parse_gitdir_file_trims_whitespace() {
assert_eq!(
parse_gitdir_file("gitdir: ../.git/modules/sub \n"),
Some("../.git/modules/sub")
);
}
#[test]
fn parse_gitdir_file_rejects_unrecognized_content() {
assert_eq!(parse_gitdir_file("not a gitdir pointer\n"), None);
assert_eq!(parse_gitdir_file(""), None);
}
// --- expand_roots ---
#[test]
fn expand_roots_uses_literal_path_without_trailing_star() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("myrepo");
std::fs::create_dir(&repo).unwrap();
let pattern = repo.to_string_lossy().to_string();
let roots = expand_roots(&[pattern]);
assert_eq!(roots, vec![repo]);
}
#[test]
fn expand_roots_globs_single_trailing_star() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_a = dir.path().join("repo-a");
let repo_b = dir.path().join("repo-b");
std::fs::create_dir(&repo_a).unwrap();
std::fs::create_dir(&repo_b).unwrap();
// A stray file alongside the directories must not be treated as a root.
std::fs::write(dir.path().join("not-a-dir.txt"), b"hi").unwrap();
let pattern = format!("{}/*", dir.path().to_string_lossy());
let mut roots = expand_roots(&[pattern]);
roots.sort();
let mut expected = vec![repo_a, repo_b];
expected.sort();
assert_eq!(roots, expected);
}
#[test]
fn expand_roots_skips_unreadable_glob_parent_without_panicking() {
let pattern = "/definitely/does/not/exist/*".to_string();
let roots = expand_roots(&[pattern]);
assert!(roots.is_empty());
}
// --- resolve_git_dir ---
#[test]
fn resolve_git_dir_finds_standard_directory_git() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("repo");
let dotgit = repo.join(".git");
std::fs::create_dir_all(&dotgit).unwrap();
assert_eq!(resolve_git_dir(&repo), Some(dotgit));
}
#[test]
fn resolve_git_dir_resolves_worktree_style_git_file() {
let dir = tempfile::tempdir().expect("tempdir");
let main_repo_gitdir = dir.path().join("main-repo").join(".git");
std::fs::create_dir_all(&main_repo_gitdir).unwrap();
let worktree = dir.path().join("worktree-checkout");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", main_repo_gitdir.to_string_lossy()),
)
.unwrap();
let resolved = resolve_git_dir(&worktree).expect("should resolve worktree gitdir");
assert_eq!(
std::fs::canonicalize(&resolved).unwrap(),
std::fs::canonicalize(&main_repo_gitdir).unwrap()
);
}
#[test]
fn resolve_git_dir_returns_none_for_non_repo() {
let dir = tempfile::tempdir().expect("tempdir");
let not_a_repo = dir.path().join("just-a-folder");
std::fs::create_dir(&not_a_repo).unwrap();
assert_eq!(resolve_git_dir(&not_a_repo), None);
}
}

View file

@ -11,11 +11,15 @@ use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised;
pub mod bluetooth;
pub mod filesystem;
pub mod git;
pub mod hyprland;
pub mod network;
pub mod network_rtnetlink;
pub mod podman;
pub mod power;
pub mod power_upower;
pub mod systemd;
pub mod udev;
#[derive(Debug, Clone, Serialize)]
@ -104,6 +108,32 @@ impl Manager {
}
}
// Filesystem/git/systemd all default to "no roots/units configured",
// in which case they'd have nothing to do — skip spawning them
// entirely rather than running an adapter that can never emit.
if self.config.adapters.filesystem.enabled
&& !self.config.adapters.filesystem.roots.is_empty()
{
let adapter =
filesystem::FilesystemAdapter::new(self.config.adapters.filesystem.roots.clone());
adapter.enumerate_existing(&self.raw_tx).await;
self.spawn_adapter(adapter);
}
if self.config.adapters.git.enabled && !self.config.adapters.git.roots.is_empty() {
let adapter = git::GitAdapter::new(self.config.adapters.git.roots.clone());
self.spawn_adapter(adapter);
}
if self.config.adapters.systemd.enabled && !self.config.adapters.systemd.units.is_empty() {
let adapter = systemd::SystemdAdapter::new(self.config.adapters.systemd.units.clone());
self.spawn_adapter(adapter);
}
if self.config.adapters.podman.enabled {
self.spawn_adapter(podman::PodmanAdapter::new());
}
Ok(())
}

View file

@ -0,0 +1,304 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::{json, Value};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::adapters::Adapter;
/// Watches `podman events --format json` for container lifecycle changes and
/// forwards them as [`RawEvent`]s.
///
/// This is the first adapter in the codebase wrapping a child process rather
/// than a socket/D-Bus/netlink connection, so the process-lifecycle handling
/// here (kill-on-drop, treating any exit as an error so the supervisor
/// retries) is bespoke rather than following an existing pattern.
#[derive(Clone, Debug)]
pub struct PodmanAdapter;
impl PodmanAdapter {
pub fn new() -> Self {
Self
}
}
impl Default for PodmanAdapter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Adapter for PodmanAdapter {
fn name(&self) -> &'static str {
"podman"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("podman adapter starting");
// kill_on_drop(true) ensures that if this future is cancelled (e.g. the
// supervisor tears the adapter down on daemon shutdown, or `tokio::select!`
// in Manager::spawn_adapter races it against the shutdown signal), the
// `podman events` child is killed rather than left running as an orphan
// with its stdout pipe silently discarded.
let mut child = match Command::new("podman")
.args(["events", "--format", "json"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
info!("podman binary not found; will retry on backoff");
return Err(anyhow!("podman binary not found: {e}"));
}
Err(e) => {
return Err(anyhow!("failed to spawn podman events: {e}"));
}
};
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("podman events: child had no stdout"))?;
let mut lines = BufReader::new(stdout).lines();
loop {
let line = match lines.next_line().await {
Ok(Some(line)) => line,
Ok(None) => {
// EOF: the child's stdout closed, meaning the process exited.
// Never surface this as Ok(()) — the supervisor treats a clean
// `run()` return as "stop forever," but a dead `podman events`
// process is exactly the kind of thing we want retried.
let _ = child.kill().await;
return Err(anyhow!("podman events exited"));
}
Err(e) => {
let _ = child.kill().await;
return Err(anyhow!("podman events read error: {e}"));
}
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
debug!("podman events: skipping unparseable line: {e}");
continue;
}
};
if let Some((kind, payload)) = map_podman_event(&value) {
if tx
.send(RawEvent {
source: AdapterSource::Podman,
kind,
payload,
timestamp: now_unix_ms(),
})
.await
.is_err()
{
let _ = child.kill().await;
return Err(anyhow!("podman adapter: downstream channel closed"));
}
}
}
}
}
/// Parses a single `podman events --format json` line and maps it to a
/// `(kind, payload)` pair for `RawEvent`, or `None` if the event should be
/// ignored.
///
/// Only `Type == "container"` events are handled. Action mapping:
/// - `start` -> `container.started`
/// - `died` -> `container.stopped`
/// - `stop` / `remove` -> ignored (see de-dup note below)
/// - `health_status` -> `container.health_status`
/// - anything else -> ignored
///
/// De-dup choice: podman commonly emits `died` followed by `stop` (and
/// sometimes `remove`) for a single container exit. Emitting on all three
/// would fire `container.stopped` multiple times for one real-world
/// transition, which is worse for Lua module authors (who'd need to
/// de-duplicate themselves) than missing the rare case where a container is
/// stopped without ever having been in a running state that produced `died`.
/// `died` fires in the overwhelmingly common paths (normal exit, kill, crash),
/// so it's used as the sole trigger for `container.stopped` and `stop`/`remove`
/// are dropped.
fn map_podman_event(value: &Value) -> Option<(String, Value)> {
let event_type = value.get("Type").and_then(|v| v.as_str())?;
if event_type != "container" {
return None;
}
let action = value.get("Action").and_then(|v| v.as_str())?;
let actor = value.get("Actor");
let id = actor
.and_then(|a| a.get("ID"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let attributes = actor.and_then(|a| a.get("Attributes"));
let name = attributes
.and_then(|a| a.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let image = attributes
.and_then(|a| a.get("image"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
match action {
"start" => Some((
"container.started".to_string(),
json!({
"id": id,
"name": name,
"image": image,
}),
)),
"died" => Some((
"container.stopped".to_string(),
json!({
"id": id,
"name": name,
}),
)),
"stop" | "remove" => {
// Intentionally ignored — see de-dup note on map_podman_event above.
None
}
"health_status" => {
let health = attributes
.and_then(|a| a.get("health_status"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Some((
"container.health_status".to_string(),
json!({
"id": id,
"name": name,
"health": health,
}),
))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn container_event(action: &str, extra_attrs: Value) -> Value {
let mut attributes = json!({
"name": "my-container",
"image": "docker.io/library/nginx:latest",
});
if let (Some(attrs_obj), Some(extra_obj)) =
(attributes.as_object_mut(), extra_attrs.as_object())
{
for (k, v) in extra_obj {
attrs_obj.insert(k.clone(), v.clone());
}
}
json!({
"Type": "container",
"Action": action,
"Actor": {
"ID": "abc123fullid",
"Attributes": attributes,
},
"Status": action,
"time": 1_700_000_000,
})
}
#[test]
fn maps_start_event() {
let event = container_event("start", json!({}));
let (kind, payload) = map_podman_event(&event).expect("should map start event");
assert_eq!(kind, "container.started");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
assert_eq!(payload["image"], "docker.io/library/nginx:latest");
}
#[test]
fn maps_died_event() {
let event = container_event("died", json!({}));
let (kind, payload) = map_podman_event(&event).expect("should map died event");
assert_eq!(kind, "container.stopped");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
}
#[test]
fn ignores_stop_event_to_avoid_double_emit_with_died() {
let event = container_event("stop", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn ignores_remove_event() {
let event = container_event("remove", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn maps_health_status_event() {
let event = container_event("health_status", json!({ "health_status": "healthy" }));
let (kind, payload) = map_podman_event(&event).expect("should map health_status event");
assert_eq!(kind, "container.health_status");
assert_eq!(payload["id"], "abc123fullid");
assert_eq!(payload["name"], "my-container");
assert_eq!(payload["health"], "healthy");
}
#[test]
fn ignores_unknown_action() {
let event = container_event("exec_die", json!({}));
assert!(map_podman_event(&event).is_none());
}
#[test]
fn ignores_non_container_type() {
let event = json!({
"Type": "network",
"Action": "start",
"Actor": { "ID": "netid", "Attributes": {} },
});
assert!(map_podman_event(&event).is_none());
}
#[test]
fn missing_fields_fall_back_to_unknown() {
let event = json!({
"Type": "container",
"Action": "start",
"Actor": { "ID": "onlyid" },
});
let (kind, payload) = map_podman_event(&event).expect("should still map");
assert_eq!(kind, "container.started");
assert_eq!(payload["id"], "onlyid");
assert_eq!(payload["name"], "unknown");
assert_eq!(payload["image"], "unknown");
}
}

View file

@ -0,0 +1,293 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use futures_util::StreamExt;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use zbus::{Connection, Message, MessageStream};
use super::Adapter;
const MANAGER_DEST: &str = "org.freedesktop.systemd1";
const MANAGER_PATH: &str = "/org/freedesktop/systemd1";
const MANAGER_IFACE: &str = "org.freedesktop.systemd1.Manager";
const UNIT_IFACE: &str = "org.freedesktop.systemd1.Unit";
const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties";
/// Watches an allowlist of `systemd --user` units on the session bus and emits
/// start/stop/failure lifecycle events.
///
/// Only units named in the allowlist are tracked — subscribing to every user
/// unit's transitions is noisy (timers, transient scopes, etc. fire
/// constantly), so we filter down to what the user's config explicitly named.
#[derive(Clone, Debug)]
pub struct SystemdAdapter {
units: Vec<String>,
}
impl SystemdAdapter {
pub fn new(units: Vec<String>) -> Self {
Self { units }
}
}
#[async_trait]
impl Adapter for SystemdAdapter {
fn name(&self) -> &'static str {
"systemd"
}
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
info!("systemd adapter starting");
let conn = Connection::session()
.await
.map_err(|e| anyhow!("systemd session bus unavailable: {e}"))?;
// Job/property signals aren't delivered until a client asks the manager
// to start tracking them.
conn.call_method(
Some(MANAGER_DEST),
MANAGER_PATH,
Some(MANAGER_IFACE),
"Subscribe",
&(),
)
.await
.map_err(|e| anyhow!("systemd Manager.Subscribe failed: {e}"))?;
// Resolve each allowlisted unit name to its object path up front, so
// PropertiesChanged messages (which arrive addressed by path, not name)
// can be matched back to a unit without a lookup on every message. A
// unit that fails to resolve (not currently loaded, typo'd name, etc.)
// is skipped rather than failing the whole adapter — it simply won't be
// watched for `unit.failed` until the adapter restarts.
let mut path_to_unit: HashMap<String, String> = HashMap::new();
for unit in &self.units {
match get_unit_path(&conn, unit).await {
Ok(path) => {
debug!("systemd resolved unit '{unit}' -> {path}");
path_to_unit.insert(path, unit.clone());
}
Err(e) => {
warn!("systemd: could not resolve unit '{unit}' (not loaded?): {e}");
}
}
}
let mut stream = MessageStream::from(&conn);
while let Some(result) = stream.next().await {
match result {
Ok(message) => {
if let Some(event) =
handle_message(&conn, &message, &self.units, &path_to_unit).await
{
if tx.send(event).await.is_err() {
return Ok(());
}
}
}
Err(e) => debug!("systemd stream error: {e}"),
}
}
Ok(())
}
}
/// Resolve a unit name to its `/org/freedesktop/systemd1/unit/...` object path
/// via `Manager.GetUnit`.
async fn get_unit_path(conn: &Connection, unit_name: &str) -> Result<String> {
let msg = conn
.call_method(
Some(MANAGER_DEST),
MANAGER_PATH,
Some(MANAGER_IFACE),
"GetUnit",
&(unit_name,),
)
.await?;
let path: OwnedObjectPath = msg.body()?;
Ok(path.as_str().to_string())
}
/// Read the current `ActiveState` property (`"active"`, `"inactive"`,
/// `"failed"`, ...) off a resolved unit object path.
async fn query_active_state(conn: &Connection, unit_path: &str) -> Option<String> {
let msg = conn
.call_method(
Some(MANAGER_DEST),
unit_path,
Some(PROPS_IFACE),
"Get",
&(UNIT_IFACE, "ActiveState"),
)
.await
.ok()?;
let value: OwnedValue = msg.body().ok()?;
serde_json::to_value(&value)
.ok()?
.as_str()
.map(|s| s.to_string())
}
async fn handle_message(
conn: &Connection,
message: &Message,
units: &[String],
path_to_unit: &HashMap<String, String>,
) -> Option<RawEvent> {
let header = message.header().ok()?;
let interface = header.interface().ok()??.as_str().to_string();
let member = header.member().ok()??.as_str().to_string();
let path = header
.path()
.ok()
.flatten()
.map(|p| p.as_str().to_string())
.unwrap_or_default();
// Start/stop: a job affecting one of our allowlisted units has completed.
// JobRemoved alone doesn't say whether the job was a start or a stop (or
// what it settled on), so we re-query ActiveState once the job is done to
// find out what actually happened. "failed" is deliberately left to the
// PropertiesChanged branch below, so each transition is only emitted once.
if interface == MANAGER_IFACE && member == "JobRemoved" {
let (_id, _job_path, unit_name, _result): (u32, OwnedObjectPath, String, String) =
message.body().ok()?;
if !units.iter().any(|u| u == &unit_name) {
return None;
}
let unit_path = get_unit_path(conn, &unit_name).await.ok()?;
let state = query_active_state(conn, &unit_path).await?;
let kind = active_state_to_kind(&state)?;
return Some(RawEvent {
source: AdapterSource::Systemd,
kind: kind.to_string(),
payload: json!({ "unit": unit_name }),
timestamp: now_unix_ms(),
});
}
// Failed: ActiveState flipped to "failed" on a unit we resolved at startup.
// This covers unit failures that occur without an explicit job completing
// from our point of view (e.g. a crash detected asynchronously).
if interface == PROPS_IFACE && member == "PropertiesChanged" {
let unit_name = path_to_unit.get(&path)?;
let (iface, changed, _invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
message.body().ok()?;
if iface != UNIT_IFACE {
return None;
}
let changed_json = serde_json::to_value(&changed).ok()?;
if !is_failed_transition(&changed_json) {
return None;
}
let result = failure_result(&changed_json);
return Some(RawEvent {
source: AdapterSource::Systemd,
kind: "unit.failed".to_string(),
payload: json!({ "unit": unit_name, "result": result }),
timestamp: now_unix_ms(),
});
}
None
}
/// Map a unit's `ActiveState` to the lifecycle event kind it represents.
/// `"failed"` is intentionally excluded — that transition is reported via the
/// dedicated `PropertiesChanged` branch instead, so it isn't double-reported
/// once here and once there. Intermediate states (`activating`, `deactivating`,
/// `reloading`) aren't a resting state yet, so they're ignored too.
fn active_state_to_kind(state: &str) -> Option<&'static str> {
match state {
"active" => Some("unit.started"),
"inactive" | "dead" => Some("unit.stopped"),
_ => None,
}
}
/// Whether a decoded `PropertiesChanged` payload represents a transition into
/// the `failed` active state.
fn is_failed_transition(changed: &serde_json::Value) -> bool {
changed
.get("ActiveState")
.and_then(|v| v.as_str())
.map(|s| s == "failed")
.unwrap_or(false)
}
/// Extract the `Result` property (e.g. `"exit-code"`, `"timeout"`) from a
/// decoded `PropertiesChanged` payload, if it was included in this batch.
fn failure_result(changed: &serde_json::Value) -> Option<String> {
changed
.get("Result")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_state_to_kind_maps_active_to_started() {
assert_eq!(active_state_to_kind("active"), Some("unit.started"));
}
#[test]
fn active_state_to_kind_maps_inactive_and_dead_to_stopped() {
assert_eq!(active_state_to_kind("inactive"), Some("unit.stopped"));
assert_eq!(active_state_to_kind("dead"), Some("unit.stopped"));
}
#[test]
fn active_state_to_kind_excludes_failed() {
// Failed transitions are reported via PropertiesChanged instead, so
// JobRemoved handling must not also emit for this state.
assert_eq!(active_state_to_kind("failed"), None);
}
#[test]
fn active_state_to_kind_ignores_transitional_states() {
assert_eq!(active_state_to_kind("activating"), None);
assert_eq!(active_state_to_kind("deactivating"), None);
assert_eq!(active_state_to_kind("reloading"), None);
}
#[test]
fn is_failed_transition_detects_failed_active_state() {
let changed = json!({ "ActiveState": "failed", "SubState": "failed" });
assert!(is_failed_transition(&changed));
}
#[test]
fn is_failed_transition_ignores_other_active_states() {
let changed = json!({ "ActiveState": "active" });
assert!(!is_failed_transition(&changed));
}
#[test]
fn is_failed_transition_ignores_unrelated_property_changes() {
// A PropertiesChanged batch that doesn't touch ActiveState at all
// (e.g. just MemoryCurrent ticking) must not be treated as a failure.
let changed = json!({ "MemoryCurrent": 12345 });
assert!(!is_failed_transition(&changed));
}
#[test]
fn failure_result_extracts_reason_when_present() {
let changed = json!({ "ActiveState": "failed", "Result": "exit-code" });
assert_eq!(failure_result(&changed), Some("exit-code".to_string()));
}
#[test]
fn failure_result_is_none_when_absent() {
let changed = json!({ "ActiveState": "failed" });
assert_eq!(failure_result(&changed), None);
}
}

View file

@ -57,6 +57,14 @@ pub struct AdaptersConfig {
pub network: AdapterToggle,
#[serde(default)]
pub bluetooth: AdapterToggle,
#[serde(default)]
pub filesystem: RootsConfig,
#[serde(default)]
pub systemd: SystemdConfig,
#[serde(default)]
pub podman: AdapterToggle,
#[serde(default)]
pub git: RootsConfig,
}
#[derive(Debug, Clone, Deserialize)]
@ -81,6 +89,30 @@ pub struct PowerConfig {
pub poll_interval_secs: u64,
}
/// Shared shape for adapters scoped to a list of project-root glob patterns
/// (e.g. `~/Projects/*`) — used by both the filesystem and git adapters.
/// `roots` defaults to empty: these adapters do nothing until the user opts
/// in with actual paths, since there's no universally-safe default directory
/// to watch.
#[derive(Debug, Clone, Deserialize)]
pub struct RootsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub roots: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SystemdConfig {
#[serde(default = "default_true")]
pub enabled: bool,
/// Allowlist of `systemd --user` unit names to watch. Empty by default —
/// subscribing to every user unit's transitions is noisy, so nothing is
/// watched until the user names specific units.
#[serde(default)]
pub units: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EventsConfig {
#[serde(default = "default_dedup_window")]
@ -150,6 +182,24 @@ impl Default for PowerConfig {
}
}
impl Default for RootsConfig {
fn default() -> Self {
Self {
enabled: default_true(),
roots: Vec::new(),
}
}
}
impl Default for SystemdConfig {
fn default() -> Self {
Self {
enabled: default_true(),
units: Vec::new(),
}
}
}
impl Default for EventsConfig {
fn default() -> Self {
Self {
@ -206,7 +256,20 @@ fn config_path() -> PathBuf {
expand_home("~/.config/bread/breadd.toml")
}
/// Expands a leading `~/`. `~/.config/...` paths specifically prefer
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point`
/// / `lua.module_path` values (`"~/.config/bread/init.lua"` and
/// `"~/.config/bread/modules"`) would silently ignore `XDG_CONFIG_HOME` even
/// though the config file that sets them was found via that same variable,
/// which is exactly the kind of inconsistency that made init.lua/module
/// loading silently no-op for a XDG_CONFIG_HOME-only test setup.
fn expand_home(input: &str) -> PathBuf {
if let Some(stripped) = input.strip_prefix("~/.config/") {
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
return Path::new(&xdg_config).join(stripped);
}
}
if let Some(stripped) = input.strip_prefix("~/") {
if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(stripped);
@ -451,8 +514,9 @@ log_level = "trace"
#[test]
fn lua_entry_point_and_module_path_expand_tilde() {
let _g = EnvGuard::new(&["HOME"]);
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
std::env::set_var("HOME", "/synthetic/home");
std::env::remove_var("XDG_CONFIG_HOME");
let cfg = Config::default();
assert_eq!(
cfg.lua_entry_point(),
@ -464,6 +528,27 @@ log_level = "trace"
);
}
#[test]
fn lua_entry_point_and_module_path_prefer_xdg_config_home_when_set() {
// config_path() (finding breadd.toml itself) already prefers
// XDG_CONFIG_HOME over HOME; the `~/.config/...` defaults for
// entry_point/module_path must resolve consistently with it, or a
// XDG_CONFIG_HOME-only setup (no matching $HOME/.config layout)
// silently fails to find its own init.lua/modules.
let _g = EnvGuard::new(&["HOME", "XDG_CONFIG_HOME"]);
std::env::set_var("HOME", "/synthetic/home");
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
let cfg = Config::default();
assert_eq!(
cfg.lua_entry_point(),
PathBuf::from("/synthetic/xdg-config/bread/init.lua")
);
assert_eq!(
cfg.lua_module_path(),
PathBuf::from("/synthetic/xdg-config/bread/modules")
);
}
#[test]
fn lua_entry_point_returns_absolute_path_unchanged() {
let mut cfg = Config::default();

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::RwLock;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
use serde_json::{json, Value};
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
@ -26,16 +26,23 @@ impl EventNormalizer {
}
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut out = match raw.source {
let mut out = match &raw.source {
AdapterSource::Udev => self.normalize_udev(raw),
AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw),
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
AdapterSource::Terminal => self.normalize_terminal(raw),
AdapterSource::Git => self.normalize_git(raw),
AdapterSource::Filesystem => self.normalize_filesystem(raw),
AdapterSource::Systemd => self.normalize_systemd(raw),
AdapterSource::Podman => self.normalize_podman(raw),
AdapterSource::Remote => self.normalize_remote(raw),
AdapterSource::App(_) => self.normalize_app(raw),
AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source,
source: raw.source.clone(),
data: raw.payload.clone(),
}],
};
@ -418,7 +425,105 @@ impl EventNormalizer {
}]
}
// Adapter contracts: each of these adapters emits `RawEvent.kind` already
// namespaced for its family (e.g. filesystem emits "file.changed",
// "detected", "build_artifact.created"), so normalization here is just a
// `bread.<family>.` prefix — except systemd (`unit.*` -> `service.*`) and
// podman's health-status rename, which need a small rewrite.
fn normalize_terminal(&self, raw: &RawEvent) -> Vec<BreadEvent> {
vec![BreadEvent {
event: format!("bread.terminal.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn normalize_remote(&self, raw: &RawEvent) -> Vec<BreadEvent> {
vec![BreadEvent {
event: format!("bread.remote.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn normalize_git(&self, raw: &RawEvent) -> Vec<BreadEvent> {
vec![BreadEvent {
event: format!("bread.git.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn normalize_filesystem(&self, raw: &RawEvent) -> Vec<BreadEvent> {
vec![BreadEvent {
event: format!("bread.project.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn normalize_systemd(&self, raw: &RawEvent) -> Vec<BreadEvent> {
// Adapter emits "unit.started"/"unit.stopped"/"unit.failed"; the public
// namespace is `service.*`, not `unit.*`.
let suffix = raw.kind.strip_prefix("unit.").unwrap_or(raw.kind.as_str());
vec![BreadEvent {
event: format!("bread.service.{suffix}"),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn normalize_podman(&self, raw: &RawEvent) -> Vec<BreadEvent> {
// Adapter emits "container.started"/"container.stopped"/"container.health_status";
// the public name for the latter is `container.health.changed`.
let event = if raw.kind == "container.health_status" {
"bread.container.health.changed".to_string()
} else {
format!("bread.{}", raw.kind)
};
vec![BreadEvent {
event,
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
/// Sibling `bread*` app events. Unlike the other sources, `raw.kind`
/// already carries the full dotted event name (the IPC boundary builds
/// it that way before construction), so this is validate-and-wrap, not
/// a transform. The namespace check is defense in depth — the IPC layer
/// already validates before constructing the `RawEvent` — so a
/// malformed event here is dropped silently rather than treated as an
/// adapter failure.
fn normalize_app(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let AdapterSource::App(app) = &raw.source else {
return vec![];
};
if !validate_app_namespace(app, &raw.kind) {
return vec![];
}
vec![BreadEvent {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source.clone(),
data: raw.payload.clone(),
}]
}
fn accept(&self, event: &BreadEvent) -> bool {
// Terminal commands legitimately repeat (running the same command twice
// in quick succession); the dedup window exists for noisy hardware
// signals, not user-initiated terminal activity, so exempt it.
if matches!(&event.source, AdapterSource::Terminal) {
return true;
}
let key = format!("{}:{}", event.event, event.data);
let now = event.timestamp;

View file

@ -14,6 +14,7 @@ pub struct RuntimeState {
pub power: PowerState,
pub profile: ProfileState,
pub modules: Vec<ModuleStatus>,
pub workflows: Vec<WorkflowStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -128,3 +129,34 @@ pub enum ModuleLoadState {
Degraded,
Disabled,
}
/// Introspectable state for a `bread.workflow` instance, surfaced via the
/// `workflows.list` IPC method. One entry per workflow *name* — starting a
/// workflow with a name that's already running replaces its entry (this is
/// a live-status registry, not a run history).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStatus {
pub name: String,
pub state: WorkflowState,
/// The most recent `bread.workflow.step(label)` call inside the body,
/// if any.
pub step: Option<String>,
pub started_at: u64,
pub updated_at: u64,
/// Set when `state` is `Failed`: the captured Lua error message.
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowState {
Running,
Done,
Failed,
/// The `opts.deadline` timer fired before the workflow reached a
/// terminal state. Note: since a suspended coroutine isn't forcibly
/// killed, it's possible (rare) for a workflow to still complete after
/// this and overwrite the status again — this is a status marker, not
/// a hard cancellation.
TimedOut,
}

View file

@ -8,7 +8,8 @@ use std::sync::Arc;
use std::time::Instant;
use anyhow::{anyhow, Result};
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
use bread_shared::apps::{is_known_app, validate_app_namespace};
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -20,6 +21,14 @@ use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle;
/// The Bread Automation API version (Lua API surface + IPC methods + event
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
/// Stability & Versioning" section. Bump the minor version when adding
/// something new-but-additive (a binding, an event, an IPC param); bump the
/// major version only for a breaking change, which should not happen inside
/// this daemon's v1 lifetime per that section's stated policy.
const API_VERSION: &str = "1.2.0";
#[derive(Clone)]
pub struct Server {
socket_path: PathBuf,
@ -27,6 +36,7 @@ pub struct Server {
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
raw_tx: mpsc::Sender<RawEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
@ -61,6 +71,7 @@ impl Server {
event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>,
raw_tx: mpsc::Sender<RawEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
@ -71,6 +82,7 @@ impl Server {
event_tx,
lua_runtime,
emit_tx,
raw_tx,
adapter_status,
subscription_count,
event_buffer,
@ -141,9 +153,7 @@ impl Server {
error: Some(format!("parse error: {e}")),
};
write_half
.write_all(
format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes(),
)
.write_all(format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes())
.await?;
continue;
}
@ -208,6 +218,10 @@ impl Server {
let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
}
"workflows.list" => {
let full = self.state_handle.state_dump().await;
Ok(full.get("workflows").cloned().unwrap_or_else(|| json!([])))
}
"modules.reload" => {
let started = Instant::now();
if let Err(err) = self.lua_runtime.reload().await {
@ -252,10 +266,62 @@ impl Server {
Ok(json!({ "active": name }))
}
"emit" => {
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
// Sourced emit: hook-originated events (shell/git/ssh) and
// sibling bread* app events both go through the same
// RawEvent -> normalizer pipeline as in-process adapters,
// instead of being tagged System. `source` is restricted to
// the fixed hook-fed set plus registered app ids — allowing
// arbitrary sources here would let any socket client spoof
// e.g. a power/bluetooth event, or another app's namespace.
if let Some(source_str) = req.params.get("source").and_then(Value::as_str) {
let source = match source_str {
"terminal" => AdapterSource::Terminal,
"git" => AdapterSource::Git,
"remote" => AdapterSource::Remote,
other if is_known_app(other) => AdapterSource::App(other.to_string()),
other => {
return Err((
id,
format!("source '{other}' is not externally injectable"),
));
}
};
let Some(kind) = req.params.get("kind").and_then(Value::as_str) else {
return Err((id, "missing kind for sourced emit".to_string()));
};
// For a sibling-app source, `kind` is the full dotted event
// name (e.g. "bread.clip.copied"), not a bare suffix — it
// must live inside that app's own namespace.
if let AdapterSource::App(app) = &source {
if !validate_app_namespace(app, kind) {
return Err((
id,
format!(
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')"
),
));
}
}
if self
.raw_tx
.send(RawEvent {
source,
kind: kind.to_string(),
payload: data,
timestamp: now_unix_ms(),
})
.await
.is_err()
{
return Err((id, "raw channel closed".to_string()));
}
Ok(json!({ "emitted": true }))
} else {
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
return Err((id, "missing event name".to_string()));
};
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
if self
.emit_tx
.send(BreadEvent::new(event, AdapterSource::System, data))
@ -265,6 +331,7 @@ impl Server {
}
Ok(json!({ "emitted": true }))
}
}
"health" => {
let uptime_ms = self.started_at.elapsed().as_millis();
let state = self.state_handle.state_dump().await;
@ -278,6 +345,7 @@ impl Server {
"ok": true,
"pid": self.pid,
"version": env!("CARGO_PKG_VERSION"),
"api_version": API_VERSION,
"uptime_ms": uptime_ms,
"socket": self.socket_path.to_string_lossy(),
"adapters": adapters,

View file

@ -20,7 +20,9 @@ use tracing::{error, info, warn};
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState};
use crate::core::types::{
DeviceRule, MatchCondition, ModuleLoadState, RuntimeState, WorkflowState, WorkflowStatus,
};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
@ -1006,6 +1008,7 @@ impl LuaEngine {
globals.set("bread", bread)?;
self.install_require_loader()?;
self.install_wait_helper()?;
self.install_workflow_helpers()?;
self.install_log_helpers()?;
self.install_debounce()?;
Ok(())
@ -1135,10 +1138,7 @@ impl LuaEngine {
let (ordered, dep_errors) = order_module_decls(decls);
let mut decl_map = self
.module_decls
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut decl_map = self.module_decls.lock().unwrap_or_else(|e| e.into_inner());
decl_map.clear();
for decl in &ordered {
decl_map.insert(decl.name.clone(), decl.clone());
@ -1173,10 +1173,7 @@ impl LuaEngine {
}
}
*self
.module_order
.lock()
.unwrap_or_else(|e| e.into_inner()) = load_order;
*self.module_order.lock().unwrap_or_else(|e| e.into_inner()) = load_order;
Ok(())
}
@ -1685,6 +1682,359 @@ impl LuaEngine {
.exec()?;
Ok(())
}
/// `bread.workflow` (define/start/step/status/list) and the multi-condition
/// wait helpers `bread.wait_any`/`bread.wait_all`. Composition (spawning,
/// yielding, timeouts) is plain Lua built on the existing `bread.spawn`/
/// `bread.on`/`bread.once`/`bread.after`/`bread.cancel`/`bread.off`
/// primitives from [`install_wait_helper`](Self::install_wait_helper) —
/// mirroring how that method itself works. Only the *introspectable
/// status* piece needs a Rust host bridge (the `__workflow_*` functions
/// below), since `workflows.list` is served over IPC from the async side
/// while the workflow body runs as a Lua coroutine on this dedicated Lua
/// thread; both sides read/write the same `Arc<RwLock<RuntimeState>>`
/// that `module_store_get`/`module_store_set` already use for exactly
/// this kind of cross-thread bridging.
fn install_workflow_helpers(&self) -> Result<()> {
let globals = self.lua.globals();
let bread: Table = globals.get("bread")?;
let state_arc = self.state_handle.state_arc();
let register_fn = self.lua.create_function(move |_lua, name: String| {
workflow_register(&state_arc, &name);
Ok(())
})?;
bread.set("__workflow_register", register_fn)?;
let state_arc = self.state_handle.state_arc();
let step_fn = self
.lua
.create_function(move |_lua, (name, label): (String, String)| {
workflow_step(&state_arc, &name, &label);
Ok(())
})?;
bread.set("__workflow_step", step_fn)?;
let state_arc = self.state_handle.state_arc();
let finish_fn = self.lua.create_function(move |_lua, name: String| {
workflow_finish(&state_arc, &name);
Ok(())
})?;
bread.set("__workflow_finish", finish_fn)?;
let state_arc = self.state_handle.state_arc();
let fail_fn = self
.lua
.create_function(move |_lua, (name, error): (String, String)| {
workflow_fail(&state_arc, &name, &error);
Ok(())
})?;
bread.set("__workflow_fail", fail_fn)?;
let state_arc = self.state_handle.state_arc();
let timeout_fn = self.lua.create_function(move |_lua, name: String| {
workflow_timeout(&state_arc, &name);
Ok(())
})?;
bread.set("__workflow_timeout", timeout_fn)?;
let state_arc = self.state_handle.state_arc();
let status_fn = self.lua.create_function(move |lua, name: String| {
match workflow_status_json(&state_arc, &name) {
Some(json) => lua
.to_value(&json)
.map_err(|e| LuaError::external(e.to_string())),
None => Ok(Value::Nil),
}
})?;
bread.set("__workflow_status", status_fn)?;
let state_arc = self.state_handle.state_arc();
let list_fn = self.lua.create_function(move |lua, ()| {
let json = workflow_list_json(&state_arc);
lua.to_value(&json)
.map_err(|e| LuaError::external(e.to_string()))
})?;
bread.set("__workflow_list", list_fn)?;
self.lua
.load(
r#"
bread.wait_any = function(patterns, opts)
if type(patterns) ~= "table" then
error("bread.wait_any requires a table of patterns")
end
opts = opts or {}
local co = coroutine.running()
if not co then
error("bread.wait_any must be called inside a coroutine")
end
local ids = {}
local timer
local resumed = false
local function cleanup()
for _, id in ipairs(ids) do
bread.off(id)
end
if timer then
bread.cancel(timer)
end
end
for _, pattern in ipairs(patterns) do
local id = bread.once(pattern, function(event)
if resumed then return end
resumed = true
cleanup()
coroutine.resume(co, event, pattern)
end)
table.insert(ids, id)
end
if opts.timeout then
timer = bread.after(opts.timeout, function()
if resumed then return end
resumed = true
cleanup()
coroutine.resume(co, nil, nil)
end)
end
return coroutine.yield()
end
bread.wait_all = function(patterns, opts)
if type(patterns) ~= "table" then
error("bread.wait_all requires a table of patterns")
end
opts = opts or {}
local co = coroutine.running()
if not co then
error("bread.wait_all must be called inside a coroutine")
end
local remaining = {}
local count = 0
for _, p in ipairs(patterns) do
if remaining[p] == nil then
remaining[p] = true
count = count + 1
end
end
local results = {}
local got = 0
local timer
local resumed = false
local ids = {}
local function finish(timed_out)
if resumed then return end
resumed = true
for _, id in ipairs(ids) do
bread.off(id)
end
if timer then
bread.cancel(timer)
end
if timed_out then
results.timed_out = true
end
coroutine.resume(co, results)
end
for _, pattern in ipairs(patterns) do
local id = bread.once(pattern, function(event)
if remaining[pattern] then
remaining[pattern] = nil
results[pattern] = event
got = got + 1
if got >= count then
finish(false)
end
end
end)
table.insert(ids, id)
end
if opts.timeout then
timer = bread.after(opts.timeout, function()
finish(true)
end)
end
return coroutine.yield()
end
bread.workflow = {}
local __workflow_bodies = {}
local __co_to_workflow = setmetatable({}, { __mode = "k" })
bread.workflow.define = function(name, fn)
if type(name) ~= "string" then
error("bread.workflow.define requires a name string")
end
__workflow_bodies[name] = fn
end
bread.workflow.start = function(name, opts)
local fn = __workflow_bodies[name]
if not fn then
error("bread.workflow.start: no workflow defined with name '" .. tostring(name) .. "'")
end
opts = opts or {}
bread.__workflow_register(name)
local deadline_timer
if opts.deadline then
deadline_timer = bread.after(opts.deadline, function()
bread.__workflow_timeout(name)
end)
end
local co = coroutine.create(function()
local ok, err = pcall(fn, opts.args)
if deadline_timer then
bread.cancel(deadline_timer)
end
if ok then
bread.__workflow_finish(name)
else
bread.__workflow_fail(name, tostring(err))
end
end)
__co_to_workflow[co] = name
local ok, err = coroutine.resume(co)
if not ok then
bread.__workflow_fail(name, tostring(err))
end
end
bread.workflow.step = function(label)
local co = coroutine.running()
local name = co and __co_to_workflow[co]
if not name then
error("bread.workflow.step must be called inside a running workflow body")
end
bread.__workflow_step(name, label)
end
bread.workflow.status = function(name)
return bread.__workflow_status(name)
end
bread.workflow.list = function()
return bread.__workflow_list()
end
"#,
)
.exec()?;
Ok(())
}
}
fn workflow_register(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
let now = now_unix_ms();
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
entry.state = WorkflowState::Running;
entry.step = None;
entry.started_at = now;
entry.updated_at = now;
entry.error = None;
} else {
guard.workflows.push(WorkflowStatus {
name: name.to_string(),
state: WorkflowState::Running,
step: None,
started_at: now,
updated_at: now,
error: None,
});
}
}
fn workflow_step(state_arc: &Arc<RwLock<RuntimeState>>, name: &str, label: &str) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
entry.step = Some(label.to_string());
entry.updated_at = now_unix_ms();
}
}
fn workflow_finish(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
entry.state = WorkflowState::Done;
entry.updated_at = now_unix_ms();
}
}
fn workflow_fail(state_arc: &Arc<RwLock<RuntimeState>>, name: &str, error: &str) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
entry.state = WorkflowState::Failed;
entry.error = Some(error.to_string());
entry.updated_at = now_unix_ms();
}
}
fn workflow_timeout(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) {
let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
if let Some(entry) = guard.workflows.iter_mut().find(|w| w.name == name) {
// Don't clobber a workflow that already reached a terminal state
// between the deadline firing and this callback running.
if entry.state == WorkflowState::Running {
entry.state = WorkflowState::TimedOut;
entry.updated_at = now_unix_ms();
}
}
}
fn workflow_status_json(state_arc: &Arc<RwLock<RuntimeState>>, name: &str) -> Option<JsonValue> {
let guard = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
let entry = guard.workflows.iter().find(|w| w.name == name)?;
serde_json::to_value(entry).ok()
}
fn workflow_list_json(state_arc: &Arc<RwLock<RuntimeState>>) -> JsonValue {
let guard = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
std::thread::yield_now();
};
serde_json::to_value(&guard.workflows).unwrap_or_else(|_| JsonValue::Array(vec![]))
}
fn order_module_decls(decls: Vec<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String, String)>) {
@ -2312,7 +2662,9 @@ where
.build()
{
Ok(rt) => rt.block_on(factory()),
Err(e) => Err(anyhow::anyhow!("bluetooth query: failed to build tokio runtime: {e}")),
Err(e) => Err(anyhow::anyhow!(
"bluetooth query: failed to build tokio runtime: {e}"
)),
};
let _ = tx.send(result);
});

View file

@ -80,6 +80,7 @@ async fn main() -> Result<()> {
});
}
let ipc_raw_tx = raw_tx.clone();
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?;
@ -111,6 +112,7 @@ async fn main() -> Result<()> {
event_stream_tx,
lua_runtime.clone(),
normalized_tx,
ipc_raw_tx,
adapter_status,
subscription_count,
event_buffer,

View file

@ -99,6 +99,138 @@ async fn emit_without_event_errors() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn emit_with_internal_source_is_rejected() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
// "power" is a real internal AdapterSource — a socket client must not be
// able to spoof it via sourced emit.
let result = harness
.send_request(
"emit",
json!({ "source": "power", "kind": "ac.connected", "data": {} }),
)
.await;
assert!(
result.is_err(),
"spoofing an internal source must be rejected"
);
let msg = result.err().unwrap().to_string();
assert!(msg.contains("not externally injectable"), "got: {msg}");
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn emit_with_unregistered_app_source_is_rejected() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let result = harness
.send_request(
"emit",
json!({ "source": "notanapp", "kind": "bread.notanapp.thing", "data": {} }),
)
.await;
assert!(result.is_err(), "unregistered app id must be rejected");
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn emit_with_known_app_source_routes_through_normalizer() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
let stream = UnixStream::connect(harness.socket_path()).await?;
let (read_half, mut write_half) = stream.into_split();
let subscribe = json!({
"id": "sub-app",
"method": "events.subscribe",
"params": { "filter": "bread.clip.**" }
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
.await?;
let mut reader = BufReader::new(read_half).lines();
reader
.next_line()
.await?
.ok_or_else(|| anyhow!("missing subscribe ack"))?;
harness
.send_request(
"emit",
json!({
"source": "clip",
"kind": "bread.clip.copied",
"data": { "kind": "url", "len": 42 }
}),
)
.await?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut received: Option<Value> = None;
while Instant::now() < deadline {
let Some(line) = reader.next_line().await? else {
break;
};
let event: Value = serde_json::from_str(&line)?;
if event.get("event").and_then(Value::as_str) == Some("bread.clip.copied") {
received = Some(event);
break;
}
}
let event = received.expect("did not receive bread.clip.copied on the stream");
assert_eq!(
event
.get("source")
.and_then(|s| s.get("app"))
.and_then(Value::as_str),
Some("clip")
);
assert_eq!(
event.get("data").and_then(|d| d.get("len")),
Some(&json!(42))
);
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn emit_with_app_source_rejects_wrong_namespace() -> Result<()> {
let harness = TestHarness::spawn()?;
harness.wait_until_ready().await?;
// "clip" is a registered app id, but the event name belongs to "pad" —
// an app may only publish within its own namespace segment.
let result = harness
.send_request(
"emit",
json!({
"source": "clip",
"kind": "bread.pad.reminder.due",
"data": {}
}),
)
.await;
assert!(
result.is_err(),
"cross-app namespace claim must be rejected"
);
let msg = result.err().unwrap().to_string();
assert!(msg.contains("namespace"), "got: {msg}");
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn state_get_returns_specific_subtree() -> Result<()> {
let harness = TestHarness::spawn()?;
@ -389,6 +521,129 @@ async fn events_stream_receives_emitted_events() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn workflow_reaches_done_via_wait_any_happy_path() -> Result<()> {
let harness = TestHarness::spawn_with_init(
r#"
bread.workflow.define("test-flow", function()
bread.workflow.step("started")
local event = bread.wait_any({"bread.test.a", "bread.test.b"}, { timeout = 5000 })
bread.workflow.step("waited")
if not event then
error("did not receive expected event")
end
end)
bread.on("bread.test.trigger", function()
bread.workflow.start("test-flow")
end)
"#,
)?;
harness.wait_until_ready().await?;
harness
.send_request("emit", json!({ "event": "bread.test.trigger", "data": {} }))
.await?;
// Give the workflow a moment to register and reach the wait_any point
// before firing the event it's blocked on.
sleep(Duration::from_millis(200)).await;
harness
.send_request("emit", json!({ "event": "bread.test.a", "data": {} }))
.await?;
let status = poll_workflow_status(&harness, "test-flow", "done").await?;
assert_eq!(status.get("step").and_then(Value::as_str), Some("waited"));
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn workflow_times_out_when_deadline_exceeded() -> Result<()> {
let harness = TestHarness::spawn_with_init(
r#"
bread.workflow.define("timeout-flow", function()
bread.workflow.step("waiting")
bread.wait("bread.test.never")
bread.workflow.step("unreachable")
end)
bread.on("bread.test.trigger", function()
bread.workflow.start("timeout-flow", { deadline = 300 })
end)
"#,
)?;
harness.wait_until_ready().await?;
harness
.send_request("emit", json!({ "event": "bread.test.trigger", "data": {} }))
.await?;
let status = poll_workflow_status(&harness, "timeout-flow", "timed_out").await?;
assert_eq!(status.get("step").and_then(Value::as_str), Some("waiting"));
harness.shutdown();
Ok(())
}
#[tokio::test]
async fn workflow_captures_error_on_failure() -> Result<()> {
let harness = TestHarness::spawn_with_init(
r#"
bread.workflow.define("fail-flow", function()
bread.workflow.step("about-to-fail")
error("boom")
end)
bread.on("bread.test.trigger", function()
bread.workflow.start("fail-flow")
end)
"#,
)?;
harness.wait_until_ready().await?;
harness
.send_request("emit", json!({ "event": "bread.test.trigger", "data": {} }))
.await?;
let status = poll_workflow_status(&harness, "fail-flow", "failed").await?;
let error = status
.get("error")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(error.contains("boom"), "got error: {error}");
harness.shutdown();
Ok(())
}
/// Polls `workflows.list` until `name` is present with `expected_state`, or
/// times out after 5 seconds. Returns the matching entry.
async fn poll_workflow_status(
harness: &TestHarness,
name: &str,
expected_state: &str,
) -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let list = harness.send_request("workflows.list", json!({})).await?;
if let Some(entries) = list.as_array() {
if let Some(entry) = entries
.iter()
.find(|e| e.get("name").and_then(Value::as_str) == Some(name))
{
if entry.get("state").and_then(Value::as_str) == Some(expected_state) {
return Ok(entry.clone());
}
}
}
sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"workflow '{name}' did not reach state '{expected_state}' in time"
))
}
struct TestHarness {
_temp: TempDir,
child: Child,
@ -397,6 +652,10 @@ struct TestHarness {
impl TestHarness {
fn spawn() -> Result<Self> {
Self::spawn_with_init("bread.on('bread.system.startup', function() end)\n")
}
fn spawn_with_init(init_lua: &str) -> Result<Self> {
let temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config");
@ -408,10 +667,7 @@ impl TestHarness {
let bread_cfg = config_home.join("bread");
fs::create_dir_all(bread_cfg.join("modules"))?;
fs::write(
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
fs::write(bread_cfg.join("init.lua"), init_lua)?;
fs::write(
bread_cfg.join("breadd.toml"),

View file

@ -0,0 +1,53 @@
-- dock-workflow — a worked example of bread's workflow engine
-- (bread.workflow + bread.wait_any), not just a single-event reaction like
-- the other examples in this directory.
--
-- Scenario: when a dock is connected, apply a monitor layout, wait (with a
-- timeout) for Hyprland to actually report a new monitor, activate a
-- "docked" profile, wait for the workspace to settle, then notify. Each
-- step is recorded via bread.workflow.step() so `bread workflows` (or the
-- `workflows.list` IPC method) can show exactly where a run is — useful for
-- diagnosing a dock that isn't behaving, since you can see whether it got
-- stuck waiting for the monitor or the workspace change.
--
-- Drop-in: copy into ~/.config/bread/modules/ and adjust the dock device
-- name (see devices.lua) and profile name for your setup.
local M = bread.module({ name = "dock-workflow", version = "1.0.0" })
bread.workflow.define("dock-connected", function()
bread.workflow.step("applying layout")
bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1")
bread.workflow.step("waiting for monitor")
local event = bread.wait_any(
{ "bread.monitor.connected", "bread.hyprland.event" },
{ timeout = 5000 }
)
if not event then
-- Hyprland never reported the new monitor within 5s — leave a
-- breadcrumb rather than silently continuing as if it worked.
bread.warn("dock-workflow: timed out waiting for monitor to appear")
error("monitor did not appear in time")
end
bread.workflow.step("activating profile")
bread.profile.activate("docked")
bread.workflow.step("waiting for workspace")
bread.wait("bread.workspace.changed", { timeout = 3000 })
bread.workflow.step("notifying")
bread.notify("Dock connected", { title = "bread" })
end)
function M.on_load()
bread.on("bread.device.dock.connected", function()
-- deadline covers the whole workflow, independent of each step's
-- own wait timeout — a safety net in case something hangs
-- unexpectedly rather than failing cleanly.
bread.workflow.start("dock-connected", { deadline = 15000 })
end)
end
return M

View file

@ -1,11 +1,11 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
# Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=bread
pkgver=0.6.6
pkgrel=1
pkgdesc="A reactive automation fabric for Linux desktops"
arch=('x86_64')
url="https://github.com/Breadway/bread"
url="https://git.breadway.dev/Breadway/bread"
license=('MIT')
# mlua builds Lua from vendored C source. makepkg's default -flto=auto would
# emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read,