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", "toml",
] ]
[[package]]
name = "bread-emit"
version = "0.6.6"
dependencies = [
"bread-shared",
"serde_json",
]
[[package]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.6.6" version = "0.6.6"
dependencies = [ dependencies = [
"dirs",
"serde", "serde",
"serde_json", "serde_json",
"toml",
] ]
[[package]] [[package]]
@ -329,6 +339,7 @@ dependencies = [
"mlua", "mlua",
"netlink-packet-core", "netlink-packet-core",
"netlink-packet-route", "netlink-packet-route",
"notify",
"rtnetlink", "rtnetlink",
"serde", "serde",
"serde_json", "serde_json",

View file

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

View file

@ -3,15 +3,19 @@
## Contents ## Contents
- [Overview](#overview) - [Overview](#overview)
- [API Stability & Versioning](#api-stability--versioning)
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [Your first module](#your-first-module) - [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch) - [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage) - [Modules: install and manage](#modules-install-and-manage)
- [Debugging tips](#debugging-tips) - [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api) - [Dictionary: Lua API](#dictionary-lua-api)
- [Workflows](#workflows-since-v12)
- [Bluetooth](#bluetooth) - [Bluetooth](#bluetooth)
- [Dictionary: Built-in modules](#dictionary-built-in-modules) - [Dictionary: Built-in modules](#dictionary-built-in-modules)
- [Dictionary: Event reference](#dictionary-event-reference) - [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: Runtime state schema](#dictionary-runtime-state-schema)
- [Dictionary: IPC protocol](#dictionary-ipc-protocol) - [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 - **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
- **CLI** (`bread`) — talks to the daemon over a Unix socket - **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. 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 ## Getting started
### 1) Create a minimal config ### 1) Create a minimal config
@ -214,6 +229,63 @@ end)
#### `bread.spawn(fn)` #### `bread.spawn(fn)`
Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`. 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 ### State
#### `bread.state.get(path)` #### `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.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches | | `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 ## Dictionary: Runtime state schema
@ -794,11 +959,21 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
"builtin": true, "builtin": true,
"store": {} "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 | | Method | Params | Description |
|--------|--------|-------------| |--------|--------|-------------|
| `ping` | — | Connectivity check | | `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.get` | `key` (dotted path) | Read a value from `RuntimeState` |
| `state.dump` | — | Return the full `RuntimeState` as JSON | | `state.dump` | — | Return the full `RuntimeState` as JSON |
| `modules.list` | — | List all loaded modules and their status | | `modules.list` | — | List all loaded modules and their status |
@ -832,4 +1007,7 @@ Available methods:
| `profile.activate` | `name` | Switch active profile | | `profile.activate` | `name` | Switch active profile |
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line | | `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
| `events.replay` | `since_ms` | Replay buffered events from the last N ms | | `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 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 ## Tips for porting your own scripts
- Start by logging the event payload: `bread.log(event.data.raw)` - 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 ### From source
```bash ```bash
git clone https://github.com/Breadway/bread.git git clone https://git.breadway.dev/Breadway/bread.git
cd bread 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 | - [Dictionary: Event reference](Documentation.md#dictionary-event-reference)
|-------|---------| - [Dictionary: Lua API](Documentation.md#dictionary-lua-api)
| `bread.system.startup` | Daemon fully initialized | - [Dictionary: Runtime state schema](Documentation.md#dictionary-runtime-state-schema)
| `bread.device.connected` | Any device attached | - [Dictionary: IPC protocol](Documentation.md#dictionary-ipc-protocol)
| `bread.device.disconnected` | Any device removed | - [API Stability & Versioning](Documentation.md#api-stability--versioning)
| `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.
--- ---

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; mod modules_mgmt;
use anyhow::Result; use anyhow::Result;
@ -58,6 +60,11 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
subcommand: ModulesCommand, subcommand: ModulesCommand,
}, },
/// Install shell/git hook integrations that feed events into breadd
Hooks {
#[command(subcommand)]
subcommand: HooksCommand,
},
/// List available profiles /// List available profiles
ProfileList, ProfileList,
/// Activate a profile /// Activate a profile
@ -67,6 +74,13 @@ enum Commands {
event: String, event: String,
#[arg(short, long, default_value = "{}")] #[arg(short, long, default_value = "{}")]
data: String, 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 /// Health check daemon connectivity
Ping, 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)] #[derive(Subcommand, Debug)]
enum ModulesCommand { enum ModulesCommand {
/// Install a module from a local directory /// Install a module from a local directory
@ -137,6 +164,10 @@ async fn main() -> Result<()> {
Commands::Modules { subcommand } => { Commands::Modules { subcommand } => {
handle_modules_cmd(subcommand, &socket).await?; 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 => { Commands::ProfileList => {
let response = send_request(&socket, "profile.list", json!({})).await?; let response = send_request(&socket, "profile.list", json!({})).await?;
print_json(&response)?; print_json(&response)?;
@ -146,17 +177,24 @@ async fn main() -> Result<()> {
send_request(&socket, "profile.activate", json!({ "name": name })).await?; send_request(&socket, "profile.activate", json!({ "name": name })).await?;
print_json(&response)?; 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 parsed = serde_json::from_str::<Value>(&data).unwrap_or_else(|_| json!({}));
let response = send_request( let mut params = json!({
&socket,
"emit",
json!({
"event": event, "event": event,
"data": parsed, "data": parsed,
}), });
) if let Some(source) = source {
.await?; params["source"] = json!(source);
}
if let Some(kind) = kind {
params["kind"] = json!(kind);
}
let response = send_request(&socket, "emit", params).await?;
print_json(&response)?; print_json(&response)?;
} }
Commands::Ping => { 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] [dependencies]
serde.workspace = true serde.workspace = true
serde_json.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] #[test]
fn dot_double_star_does_not_match_sibling_prefix() { fn dot_double_star_does_not_match_sibling_prefix() {
assert!(!matches_pattern("bread.device.**", "bread.devicex")); assert!(!matches_pattern("bread.device.**", "bread.devicex"));
assert!(!matches_pattern("bread.device.**", "bread.network.connected")); assert!(!matches_pattern(
"bread.device.**",
"bread.network.connected"
));
} }
#[test] #[test]
fn mid_pattern_star_does_not_cross_dots() { 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( assert!(!matches_pattern(
"bread.*.connected", "bread.*.connected",
"bread.alpha.beta.connected" "bread.alpha.beta.connected"

View file

@ -8,13 +8,19 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod apps;
pub mod glob; pub mod glob;
/// Identifies which adapter produced an event. /// Identifies which adapter produced an event.
/// ///
/// The state engine uses this to choose a normalization strategy and the /// The state engine uses this to choose a normalization strategy and the
/// IPC layer surfaces it so subscribers can filter by origin. /// 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")] #[serde(rename_all = "snake_case")]
pub enum AdapterSource { pub enum AdapterSource {
/// The Hyprland compositor IPC socket. /// The Hyprland compositor IPC socket.
@ -30,6 +36,23 @@ pub enum AdapterSource {
System, System,
/// BlueZ Bluetooth stack via D-Bus. /// BlueZ Bluetooth stack via D-Bus.
Bluetooth, 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. /// An unnormalized event as emitted by an adapter.
@ -91,6 +114,44 @@ pub fn now_unix_ms() -> u64 {
.as_millis() as 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. /// 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 /// 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(), serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\"" "\"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] #[test]
@ -175,6 +268,13 @@ mod tests {
AdapterSource::Network, AdapterSource::Network,
AdapterSource::System, AdapterSource::System,
AdapterSource::Bluetooth, 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 s = serde_json::to_string(&source).unwrap();
let back: AdapterSource = serde_json::from_str(&s).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-route = "0.11"
netlink-packet-core = "0.4" netlink-packet-core = "0.4"
libc = "0.2" libc = "0.2"
notify = "6.1"
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true 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; use crate::core::supervisor::spawn_supervised;
pub mod bluetooth; pub mod bluetooth;
pub mod filesystem;
pub mod git;
pub mod hyprland; pub mod hyprland;
pub mod network; pub mod network;
pub mod network_rtnetlink; pub mod network_rtnetlink;
pub mod podman;
pub mod power; pub mod power;
pub mod power_upower; pub mod power_upower;
pub mod systemd;
pub mod udev; pub mod udev;
#[derive(Debug, Clone, Serialize)] #[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(()) 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, pub network: AdapterToggle,
#[serde(default)] #[serde(default)]
pub bluetooth: AdapterToggle, 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)] #[derive(Debug, Clone, Deserialize)]
@ -81,6 +89,30 @@ pub struct PowerConfig {
pub poll_interval_secs: u64, 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)] #[derive(Debug, Clone, Deserialize)]
pub struct EventsConfig { pub struct EventsConfig {
#[serde(default = "default_dedup_window")] #[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 { impl Default for EventsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -206,7 +256,20 @@ fn config_path() -> PathBuf {
expand_home("~/.config/bread/breadd.toml") 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 { 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 Some(stripped) = input.strip_prefix("~/") {
if let Ok(home) = env::var("HOME") { if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(stripped); return Path::new(&home).join(stripped);
@ -451,8 +514,9 @@ log_level = "trace"
#[test] #[test]
fn lua_entry_point_and_module_path_expand_tilde() { 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::set_var("HOME", "/synthetic/home");
std::env::remove_var("XDG_CONFIG_HOME");
let cfg = Config::default(); let cfg = Config::default();
assert_eq!( assert_eq!(
cfg.lua_entry_point(), 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] #[test]
fn lua_entry_point_returns_absolute_path_unchanged() { fn lua_entry_point_returns_absolute_path_unchanged() {
let mut cfg = Config::default(); let mut cfg = Config::default();

View file

@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::RwLock; 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}; use serde_json::{json, Value};
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction. /// 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> { 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::Udev => self.normalize_udev(raw),
AdapterSource::Hyprland => self.normalize_hyprland(raw), AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw), AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw), AdapterSource::Network => self.normalize_network(raw),
AdapterSource::Bluetooth => self.normalize_bluetooth(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 { AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(), event: raw.kind.clone(),
timestamp: raw.timestamp, timestamp: raw.timestamp,
source: raw.source, source: raw.source.clone(),
data: raw.payload.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 { 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 key = format!("{}:{}", event.event, event.data);
let now = event.timestamp; let now = event.timestamp;

View file

@ -14,6 +14,7 @@ pub struct RuntimeState {
pub power: PowerState, pub power: PowerState,
pub profile: ProfileState, pub profile: ProfileState,
pub modules: Vec<ModuleStatus>, pub modules: Vec<ModuleStatus>,
pub workflows: Vec<WorkflowStatus>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -128,3 +129,34 @@ pub enum ModuleLoadState {
Degraded, Degraded,
Disabled, 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 std::time::Instant;
use anyhow::{anyhow, Result}; 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::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -20,6 +21,14 @@ use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle; use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle; 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)] #[derive(Clone)]
pub struct Server { pub struct Server {
socket_path: PathBuf, socket_path: PathBuf,
@ -27,6 +36,7 @@ pub struct Server {
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
raw_tx: mpsc::Sender<RawEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>, adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>, subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>, event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
@ -61,6 +71,7 @@ impl Server {
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
raw_tx: mpsc::Sender<RawEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>, adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>, subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>, event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
@ -71,6 +82,7 @@ impl Server {
event_tx, event_tx,
lua_runtime, lua_runtime,
emit_tx, emit_tx,
raw_tx,
adapter_status, adapter_status,
subscription_count, subscription_count,
event_buffer, event_buffer,
@ -141,9 +153,7 @@ impl Server {
error: Some(format!("parse error: {e}")), error: Some(format!("parse error: {e}")),
}; };
write_half write_half
.write_all( .write_all(format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes())
format!("{}\n", serde_json::to_string(&err_resp)?).as_bytes(),
)
.await?; .await?;
continue; continue;
} }
@ -208,6 +218,10 @@ impl Server {
let full = self.state_handle.state_dump().await; let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) 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" => { "modules.reload" => {
let started = Instant::now(); let started = Instant::now();
if let Err(err) = self.lua_runtime.reload().await { if let Err(err) = self.lua_runtime.reload().await {
@ -252,10 +266,62 @@ impl Server {
Ok(json!({ "active": name })) Ok(json!({ "active": name }))
} }
"emit" => { "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 { let Some(event) = req.params.get("event").and_then(Value::as_str) else {
return Err((id, "missing event name".to_string())); return Err((id, "missing event name".to_string()));
}; };
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
if self if self
.emit_tx .emit_tx
.send(BreadEvent::new(event, AdapterSource::System, data)) .send(BreadEvent::new(event, AdapterSource::System, data))
@ -265,6 +331,7 @@ impl Server {
} }
Ok(json!({ "emitted": true })) Ok(json!({ "emitted": true }))
} }
}
"health" => { "health" => {
let uptime_ms = self.started_at.elapsed().as_millis(); let uptime_ms = self.started_at.elapsed().as_millis();
let state = self.state_handle.state_dump().await; let state = self.state_handle.state_dump().await;
@ -278,6 +345,7 @@ impl Server {
"ok": true, "ok": true,
"pid": self.pid, "pid": self.pid,
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"api_version": API_VERSION,
"uptime_ms": uptime_ms, "uptime_ms": uptime_ms,
"socket": self.socket_path.to_string_lossy(), "socket": self.socket_path.to_string_lossy(),
"adapters": adapters, "adapters": adapters,

View file

@ -20,7 +20,9 @@ use tracing::{error, info, warn};
use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle; use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId; 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; use bread_shared::now_unix_ms;
pub enum LuaMessage { pub enum LuaMessage {
@ -1006,6 +1008,7 @@ impl LuaEngine {
globals.set("bread", bread)?; globals.set("bread", bread)?;
self.install_require_loader()?; self.install_require_loader()?;
self.install_wait_helper()?; self.install_wait_helper()?;
self.install_workflow_helpers()?;
self.install_log_helpers()?; self.install_log_helpers()?;
self.install_debounce()?; self.install_debounce()?;
Ok(()) Ok(())
@ -1135,10 +1138,7 @@ impl LuaEngine {
let (ordered, dep_errors) = order_module_decls(decls); let (ordered, dep_errors) = order_module_decls(decls);
let mut decl_map = self let mut decl_map = self.module_decls.lock().unwrap_or_else(|e| e.into_inner());
.module_decls
.lock()
.unwrap_or_else(|e| e.into_inner());
decl_map.clear(); decl_map.clear();
for decl in &ordered { for decl in &ordered {
decl_map.insert(decl.name.clone(), decl.clone()); decl_map.insert(decl.name.clone(), decl.clone());
@ -1173,10 +1173,7 @@ impl LuaEngine {
} }
} }
*self *self.module_order.lock().unwrap_or_else(|e| e.into_inner()) = load_order;
.module_order
.lock()
.unwrap_or_else(|e| e.into_inner()) = load_order;
Ok(()) Ok(())
} }
@ -1685,6 +1682,359 @@ impl LuaEngine {
.exec()?; .exec()?;
Ok(()) 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)>) { fn order_module_decls(decls: Vec<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String, String)>) {
@ -2312,7 +2662,9 @@ where
.build() .build()
{ {
Ok(rt) => rt.block_on(factory()), 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); 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()); let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?; adapter_manager.start_all().await?;
@ -111,6 +112,7 @@ async fn main() -> Result<()> {
event_stream_tx, event_stream_tx,
lua_runtime.clone(), lua_runtime.clone(),
normalized_tx, normalized_tx,
ipc_raw_tx,
adapter_status, adapter_status,
subscription_count, subscription_count,
event_buffer, event_buffer,

View file

@ -99,6 +99,138 @@ async fn emit_without_event_errors() -> Result<()> {
Ok(()) 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] #[tokio::test]
async fn state_get_returns_specific_subtree() -> Result<()> { async fn state_get_returns_specific_subtree() -> Result<()> {
let harness = TestHarness::spawn()?; let harness = TestHarness::spawn()?;
@ -389,6 +521,129 @@ async fn events_stream_receives_emitted_events() -> Result<()> {
Ok(()) 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 { struct TestHarness {
_temp: TempDir, _temp: TempDir,
child: Child, child: Child,
@ -397,6 +652,10 @@ struct TestHarness {
impl TestHarness { impl TestHarness {
fn spawn() -> Result<Self> { 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 temp = tempfile::tempdir()?;
let runtime_dir = temp.path().join("runtime"); let runtime_dir = temp.path().join("runtime");
let config_home = temp.path().join("config"); let config_home = temp.path().join("config");
@ -408,10 +667,7 @@ impl TestHarness {
let bread_cfg = config_home.join("bread"); let bread_cfg = config_home.join("bread");
fs::create_dir_all(bread_cfg.join("modules"))?; fs::create_dir_all(bread_cfg.join("modules"))?;
fs::write( fs::write(bread_cfg.join("init.lua"), init_lua)?;
bread_cfg.join("init.lua"),
"bread.on('bread.system.startup', function() end)\n",
)?;
fs::write( fs::write(
bread_cfg.join("breadd.toml"), 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 pkgname=bread
pkgver=0.6.6 pkgver=0.6.6
pkgrel=1 pkgrel=1
pkgdesc="A reactive automation fabric for Linux desktops" pkgdesc="A reactive automation fabric for Linux desktops"
arch=('x86_64') arch=('x86_64')
url="https://github.com/Breadway/bread" url="https://git.breadway.dev/Breadway/bread"
license=('MIT') license=('MIT')
# mlua builds Lua from vendored C source. makepkg's default -flto=auto would # 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, # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read,