Merge pull request #5 from Breadway/dev

Enhance device normalization and classification with Lua support
This commit is contained in:
Breadway 2026-05-12 21:31:15 +08:00 committed by GitHub
commit cbf42ce70f
12 changed files with 1133 additions and 674 deletions

32
.gitignore vendored
View file

@ -1,4 +1,36 @@
# Rust build artifacts
target/
# Editor and IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS artifacts
.DS_Store
Thumbs.db
desktop.ini
# Environment and secrets
.env
.env.*
*.env
*.pem
*.key
*.p12
secrets/
# Log files
*.log
logs/
# Runtime files
*.sock
*.pid
# Internal project docs and spec files kept out of public history
Overview.md
DAEMON.md
LUA_RUNTIME.md

View file

@ -6,6 +6,8 @@
- [Getting started](#getting-started)
- [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage)
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
- [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api)
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
@ -15,11 +17,11 @@
## Overview
Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
- Daemon: long-running Rust process, source of truth for runtime state
- Lua runtime: dedicated thread inside the daemon; automation logic lives here
- CLI: talks to the daemon over a Unix socket
- **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state
- **Lua runtime** dedicated thread inside the daemon; automation logic lives here
- **CLI** (`bread`) — talks to the daemon over a Unix socket
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
@ -27,75 +29,189 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th
### 1) Create a minimal config
- Daemon config: `~/.config/bread/bread.toml`
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
- Lua entry point: `~/.config/bread/init.lua`
- Lua modules: `~/.config/bread/modules/`
### 2) Minimal `init.lua`
```lua
require("modules.devices")
require("modules.workspaces")
bread.on("bread.system.startup", function()
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
bread.log("bread started on " .. bread.machine.name())
end)
```
### 3) Add your first module
### 3) Start the daemon
Create a Lua file under your modules directory and load it from `init.lua`.
```bash
systemctl --user start breadd
# Or directly:
breadd
```
### 4) Check that it's running
```bash
bread ping
bread doctor
```
## Your first module
Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`.
```lua
local M = bread.module({ name = "hello", version = "0.1.0" })
function M.on_load()
bread.log("hello from bread")
bread.log("hello from bread on " .. bread.machine.name())
bread.on("bread.device.*", function(event)
bread.log(event.event)
bread.log("device event: " .. event.event)
end)
end
return M
```
Why this shape?
Key rules:
- Every module must call `bread.module` once.
- `on_load` is a good place to register subscriptions.
- Every module must call `bread.module` exactly once at the top level.
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
- Use `bread.log` early to verify handlers are firing.
## Run, reload, and watch
- Start the daemon, then use `bread reload` after editing Lua.
- `bread reload --watch` will keep reloading on changes.
- See [Examples.md](Examples.md) for real-world ports.
```bash
# Hot-reload the Lua runtime after editing config
bread reload
# Watch for file changes and reload automatically
bread reload --watch
```
If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again.
## Modules: install and manage
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
```bash
# Install from GitHub (downloads and extracts the default branch tarball)
bread modules install github:someuser/bread-wifi
# Install from a local directory
bread modules install ~/src/my-module
# Install a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0
# List installed modules and their daemon status
bread modules list
# Show full manifest for one module
bread modules info bread-wifi
# Re-install all GitHub-sourced modules (pick up upstream changes)
bread modules update
# Remove a module
bread modules remove bread-wifi
bread modules remove bread-wifi --yes # skip confirmation
```
Each installed module has a `bread.module.toml` manifest:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
```
## Sync: snapshot and restore
Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state.
```bash
# First-time setup
bread sync init --remote git@github.com:you/bread-config.git
# Snapshot and push
bread sync push
# On another machine: pull and apply
bread sync pull
# Also reinstall packages from snapshot
bread sync pull --install-packages
# See what has changed
bread sync status
bread sync diff
bread sync diff --remote
# List known machines
bread sync machines
```
Configure sync in `~/.config/bread/sync.toml`:
```toml
[remote]
url = "git@github.com:you/bread-config.git"
branch = "main"
[machine]
name = "hermes"
tags = ["laptop", "battery"]
[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]
[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]
```
The sync repo stores:
```
~/.local/share/bread/sync-repo/
├── bread/ ← ~/.config/bread/ snapshot
├── configs/ ← delegate paths (nvim, waybar, etc.)
├── machines/ ← per-machine profiles with tags and last-sync time
└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.)
```
## Debugging tips
- Log event payloads with `bread.log(event.data.raw)` when matching devices.
- Use `bread.events` in the CLI to see live normalized events.
- Use `bread state` to see runtime state as JSON.
- Run `bread events` to see live normalized events.
- Run `bread state` to see full runtime state as JSON.
- Run `bread doctor` to check adapter and module health.
- Log event payloads with `bread.log(tostring(event.data))`.
- Use `RUST_LOG=debug breadd` for verbose daemon output.
## Lua module system
---
### Entry point and module scanning
## Dictionary: Lua API
- `init.lua` is executed first.
- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files.
- Every module must call `bread.module` exactly once at top-level.
- Modules are ordered by the `after` dependency list.
Every API is exposed through the `bread` global table.
### Module declaration
Every module must call `bread.module` exactly once at the top level.
```lua
local M = bread.module({
name = "my.module",
version = "0.1.0",
after = { "bread.devices" },
after = { "bread.devices" }, -- optional: load after this module
})
return M
@ -103,65 +219,25 @@ return M
If a module does not call `bread.module`, it fails to load and is marked as a load error.
### Require loader
`require("bread.<path>")` resolves to a Lua file under the module path. For example:
```lua
local utils = require("bread.lib.utils")
```
This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior.
### Lifecycle hooks
Modules may export any of the following hooks. All are optional.
```lua
function M.on_load()
-- register subscriptions, initialize module state
end
function M.on_reload()
-- called after a hot reload completes
end
function M.on_unload()
-- called before the Lua instance is dropped
end
function M.on_error(err)
-- called when a handler throws
-- return true to keep the subscription, false to cancel it
return true
end
```
### Module storage
Each module has a scoped key-value store that survives reloads:
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
The store lives in the daemon runtime state and is not shared across modules.
## Dictionary: Lua API
Every API is exposed through the `bread` global table.
### Events
#### `bread.on(pattern, fn) -> id`
Subscribe to matching events. Returns a numeric subscription ID.
```lua
local id = bread.on("bread.device.*", function(event)
-- event.event → the full event name string
-- event.data → table of event-specific fields
-- event.source → adapter that produced it ("Udev", "Hyprland", etc.)
bread.log(event.event)
end)
```
#### `bread.once(pattern, fn) -> id`
Subscribe once. The handler is removed after the first match.
#### `bread.filter(pattern, fn, opts) -> id`
Subscribe with a predicate filter. `opts` must contain `filter`:
Subscribe with a predicate. `opts` must contain a `filter` function:
```lua
bread.filter("bread.device.*", function(event)
@ -174,13 +250,13 @@ end, {
```
#### `bread.off(id)`
Unsubscribe an event or state watch by ID.
Unsubscribe an event handler or state watch by ID.
#### `bread.emit(event, data)`
Emit a custom event into the system pipeline.
Emit a custom event into the system pipeline. Useful for cross-module communication.
#### `bread.wait(pattern, opts) -> event | nil`
Coroutine-only helper that waits for a matching event.
Coroutine-only helper that suspends until a matching event arrives.
```lua
bread.spawn(function()
@ -192,30 +268,37 @@ end)
```
#### `bread.spawn(fn)`
Spawn a coroutine and surface errors if the coroutine fails.
Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`.
### State
#### `bread.state.get(path)`
Read a state subtree by dotted path (e.g. `"network.online"`).
Read a state subtree by dotted path.
#### Convenience helpers
```lua
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
```
- `bread.state.monitors()`
- `bread.state.active_workspace()`
- `bread.state.active_window()`
- `bread.state.devices()`
- `bread.state.power()`
- `bread.state.network()`
- `bread.state.profile()`
#### Typed shorthands
```lua
bread.state.monitors()
bread.state.active_workspace()
bread.state.active_window()
bread.state.devices()
bread.state.power()
bread.state.network()
bread.state.profile()
```
#### `bread.state.watch(path, fn) -> id`
Watch a state path. The callback receives `(new, old)`.
Watch a state path for changes. The callback receives `(new_value, old_value)`.
```lua
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.exec("notify-send 'AC connected'")
bread.notify("AC connected")
end
end)
```
@ -223,66 +306,147 @@ end)
### Profiles
#### `bread.profile.activate(name)`
Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own.
Activate a named profile. Emits `bread.profile.activated` over IPC.
### Execution
#### `bread.exec(cmd)`
Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async).
Run a shell command. Fire-and-forget (async, does not block Lua).
### Notifications
#### `bread.notify(message, opts)`
Sends a desktop notification via `notify-send`.
Send a desktop notification via `notify-send`.
Options:
- `title` (string, default: `"bread"`)
- `urgency` (string, default from config)
- `timeout` (ms, default from config)
- `icon` (string, optional)
| Key | Type | Default |
|-----|------|---------|
| `title` | string | `"bread"` |
| `urgency` | string | from config |
| `timeout` | ms | from config |
| `icon` | string | none |
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
### Timers
#### `bread.after(delay_ms, fn) -> id`
Run once after delay.
Run once after a delay.
#### `bread.every(interval_ms, fn) -> id`
Run repeatedly on an interval.
Run on a repeating interval.
#### `bread.cancel(id)`
Cancel a timer created by `after` or `every`.
Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload.
### Utilities
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
Returns a wrapper that only fires after quiet time.
Returns a wrapper that fires only after `delay_ms` of quiet time.
```lua
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.**", fn)
```
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
Log helpers that accept any Lua value.
Logging helpers. Accept any Lua value (coerced via `tostring`).
### Machine and filesystem
#### `bread.machine.name() -> string`
Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized.
#### `bread.machine.tags() -> string[]`
Returns the tags array from `sync.toml`, or `{}` if sync is not initialized.
#### `bread.machine.has_tag(tag) -> bool`
Returns true if the machine has the given tag.
#### `bread.fs.write(path, content)`
Write a file. Creates parent directories as needed. `~` is expanded.
#### `bread.fs.read(path) -> string | nil`
Read a file. Returns `nil` if the file does not exist. `~` is expanded.
#### `bread.fs.exists(path) -> bool`
Returns true if the path exists. `~` is expanded.
#### `bread.fs.expand(path) -> string`
Expand `~` to the home directory.
### Hyprland
The `bread.hyprland` namespace provides compositor bindings:
The `bread.hyprland` namespace provides compositor bindings.
- `bread.hyprland.dispatch(cmd, args)`
- `bread.hyprland.keyword(key, value)`
- `bread.hyprland.active_window()`
- `bread.hyprland.monitors()`
- `bread.hyprland.workspaces()`
- `bread.hyprland.clients()`
- `bread.hyprland.on_raw(kind, fn) -> id`
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string).
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state (returns deserialized Lua tables)
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypasses normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw payload includes: kind, raw (original string), data
end)
```
### Module lifecycle hooks
All hooks are optional.
```lua
function M.on_load()
-- Called after the module loads. Register subscriptions here.
end
function M.on_reload()
-- Called after a hot reload completes across all modules.
end
function M.on_unload()
-- Called before the Lua instance is dropped.
end
function M.on_error(err)
-- Called when a subscription handler in this module throws.
-- Return true to keep the subscription alive, false to cancel it.
return true
end
```
### Module storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
Storage is scoped per module and is not shared across modules.
---
## Dictionary: Built-in modules
Built-ins are enabled by default. Disable them via `[modules].disable` in the config.
Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
### `bread.monitors`
High-level declarative monitor event handlers.
```lua
local monitors = require("bread.monitors")
@ -297,35 +461,125 @@ monitors.on({
})
```
- `monitors.on({ when, monitors, run })`
- `monitors.layout(name, fn)`
- `monitors.apply(name) -> fn`
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) |
| `M.layout(name, fn)` | Register a named layout function |
| `M.apply(name) -> fn` | Returns a function that calls the named layout |
`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string.
`when` is one of `connected`, `disconnected`, `changed`.
### `bread.devices`
Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals.
Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers.
```lua
local devices = require("bread.devices")
devices.register("Keychron", "keyboard")
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.exec("xset r rate 200 40")
end,
})
devices.on({
when = "connected",
class = "keyboard",
device = "dock",
run = "~/.config/bread/scripts/dock-connected.sh"
})
devices.on({
when = "disconnected",
name = "CalDigit", -- pattern-matched against event.data.name
run = function(event)
bread.log("Dock disconnected: " .. event.data.name)
end,
})
```
#### Functions
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a device rule. See options below. |
#### Device rule options
```lua
devices.on({
when = "connected", -- required: "connected" or "disconnected"
device = "keyboard", -- optional: device name from devices.lua
name = "Keychron", -- optional: substring matched against device name
run = function(event) ... end -- required: function or shell string
})
```
- `when` (required): One of `connected` or `disconnected`.
- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name.
- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match).
- `run` (required): Function or shell string to run when the rule matches.
The callback receives the full device event:
```lua
{
event = "bread.device.dock.connected",
data = {
id = "/sys/...",
device = "dock", -- name resolved from devices.lua
name = "CalDigit TS4", -- raw device name from udev
subsystem = "usb",
vendor_id = "0x35f5",
product_id = "0x0104",
raw = { ... } -- full udev properties
}
}
```
#### Example: Keyboard configuration on connect
```lua
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.log("Keyboard connected: " .. event.data.name)
bread.exec("xset r rate 200 40")
end,
})
```
- `devices.on({ when, class, name, run })`
- `devices.register(pattern, class)`
#### Example: Dock-specific setup
`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
```lua
-- devices.lua defines: { device = "dock", vendor_id = "35f5" }
devices.on({
when = "connected",
device = "dock",
run = function(event)
bread.log("Dock connected")
bread.exec("~/.config/bread/scripts/dock-connected.sh")
end,
})
devices.on({
when = "disconnected",
device = "dock",
run = function(event)
bread.log("Dock disconnected")
bread.exec("~/.config/bread/scripts/dock-disconnected.sh")
end,
})
```
### `bread.workspaces`
Workspace-to-monitor assignment and app pinning.
```lua
local workspaces = require("bread.workspaces")
@ -333,12 +587,16 @@ workspaces.assign("1", "HDMI-A-1")
workspaces.pin({ app = "Firefox", workspace = "2" })
```
- `workspaces.assign(workspace, monitor)`
- `workspaces.pin({ app, workspace })`
- `workspaces.apply_assignments()`
| Function | Description |
|----------|-------------|
| `M.assign(workspace, monitor)` | Assign a workspace to a monitor |
| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` |
| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch |
### `bread.binds`
Runtime keybind management via Hyprland.
```lua
local binds = require("bread.binds")
@ -350,9 +608,13 @@ binds.add({
})
```
- `binds.add({ mods, key, dispatch, args })`
- `binds.remove(key)`
- `binds.replace(key, opts)`
| Function | Description |
|----------|-------------|
| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` |
| `M.remove(key)` | Remove a keybind by key |
| `M.replace(key, opts)` | Remove and re-add a keybind |
---
## Dictionary: Event reference
@ -369,103 +631,136 @@ Events are delivered as a `BreadEvent`:
### Pattern matching
Patterns match event names with glob-style syntax:
- Exact match: `bread.device.dock.connected`
- `*` matches within a single segment (does not cross `.`)
- `**` matches across segments (recursive)
- `?` matches a single character within a segment
Examples:
```lua
bread.on("bread.device.*", handler)
bread.on("bread.device.**", handler)
bread.on("bread.monitor.?", handler)
```
| Pattern | Matches |
|---------|---------|
| `bread.device.dock.connected` | Exact match only |
| `bread.device.*` | One segment wildcard (does not cross `.`) |
| `bread.device.**` | Any depth under `bread.device` |
| `bread.monitor.?` | Single character within one segment |
### Normalized events
#### System
- `bread.system.startup` (data: `{}`)
| Event | Data |
|-------|------|
| `bread.system.startup` | `{}` |
#### Devices (udev)
- `bread.device.connected`
- `bread.device.disconnected`
- `bread.device.changed`
- `bread.device.<class>.connected`
- `bread.device.<class>.disconnected`
- `bread.device.<class>.changed`
| Event | Data |
|-------|------|
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
| `bread.device.disconnected` | same |
| `bread.device.<device>.connected` | `{ id, device }` |
| `bread.device.<device>.disconnected` | `{ id, device }` |
Payload notes:
- Device events include `id` and `class`; the generic event also includes `raw`.
- `<class>` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
#### Hyprland
- `bread.workspace.changed` (raw payload)
- `bread.workspace.created` (`{ "workspace": "..." }`)
- `bread.workspace.destroyed` (`{ "workspace": "..." }`)
- `bread.monitor.connected` (raw payload)
- `bread.monitor.disconnected` (raw payload)
- `bread.window.focus.changed` (raw payload)
- `bread.window.focused` (`{ "address": "..." }`)
- `bread.window.opened` (`{ "address", "workspace", "class", "title" }`)
- `bread.window.closed` (`{ "address": "..." }`)
- `bread.window.moved` (`{ "address", "workspace" }`)
- `bread.hyprland.event` (raw payload for unhandled kinds)
Raw Hyprland payloads contain `kind`, `raw`, and `data` fields.
| Event | Data |
|-------|------|
| `bread.workspace.changed` | raw payload |
| `bread.workspace.created` | `{ workspace }` |
| `bread.workspace.destroyed` | `{ workspace }` |
| `bread.monitor.connected` | raw payload |
| `bread.monitor.disconnected` | raw payload |
| `bread.window.focus.changed` | raw payload |
| `bread.window.focused` | `{ address }` |
| `bread.window.opened` | `{ address, workspace, class, title }` |
| `bread.window.closed` | `{ address }` |
| `bread.window.moved` | `{ address, workspace }` |
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) |
#### Power
- `bread.power.ac.connected`
- `bread.power.ac.disconnected`
- `bread.power.battery.low`
- `bread.power.battery.very_low`
- `bread.power.battery.critical`
- `bread.power.battery.full`
- `bread.power.changed` (fallback)
Payload includes `ac_connected` and `battery_percent`.
| Event | Data |
|-------|------|
| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` |
| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` |
| `bread.power.battery.low` | `{ battery_percent }` |
| `bread.power.battery.very_low` | `{ battery_percent }` |
| `bread.power.battery.critical` | `{ battery_percent }` |
| `bread.power.battery.full` | `{ battery_percent }` |
| `bread.power.changed` | `{ ac_connected, battery_percent }` |
#### Network
- `bread.network.connected`
- `bread.network.disconnected`
| Event | Data |
|-------|------|
| `bread.network.connected` | `{ online, interfaces }` |
| `bread.network.disconnected` | `{ online, interfaces }` |
Payload includes `online` and `interfaces`.
#### System events
#### Other system events
| Event | Data |
|-------|------|
| `bread.profile.activated` | `{ name }` |
| `bread.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches |
- `bread.profile.activated` (emitted by IPC profile activation)
- `bread.notify.sent` (emitted by `bread.notify`)
- `bread.state.changed.<path>` (emitted when a state watch fires)
---
## Dictionary: Runtime state schema
`bread.state.get("")` returns the full `RuntimeState`:
`bread state` and `bread.state.get("")` return the full `RuntimeState`:
```json
{
"monitors": [ { "name": "HDMI-A-1", "connected": true } ],
"workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ],
"monitors": [
{ "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
],
"workspaces": [
{ "id": "1", "monitor": "HDMI-A-1" }
],
"active_workspace": "1",
"active_window": "Firefox",
"devices": { "connected": [] },
"network": { "interfaces": {}, "online": false },
"power": { "ac_connected": false, "battery_percent": null, "battery_low": false },
"profile": { "active": "default", "history": [], "profiles": {} },
"modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ]
"active_window": "0x...",
"devices": {
"connected": [
{
"id": "/sys/...",
"name": "CalDigit TS4",
"device": "dock",
"subsystem": "usb",
"vendor_id": "0x35f5",
"product_id": "0x0104"
}
]
},
"network": {
"interfaces": { "eth0": { "up": true } },
"online": true
},
"power": {
"ac_connected": true,
"battery_percent": 87,
"battery_low": false
},
"profile": {
"active": "default",
"history": [],
"profiles": {}
},
"modules": [
{
"name": "bread.monitors",
"status": "loaded",
"last_error": null,
"builtin": true,
"store": {}
}
]
}
```
`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`.
---
## Dictionary: IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON.
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON.
Request:
@ -481,16 +776,17 @@ Response:
Available methods:
- `ping`
- `health`
- `state.get`
- `state.dump`
- `modules.list`
- `modules.reload`
- `profile.list`
- `profile.activate`
- `events.subscribe`
- `events.replay`
- `emit`
`events.subscribe` upgrades the socket to streaming mode and sends events as they occur.
| Method | Params | Description |
|--------|--------|-------------|
| `ping` | — | Connectivity check |
| `health` | — | Version, uptime, PID, adapter status |
| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` |
| `state.dump` | — | Return the full `RuntimeState` as JSON |
| `modules.list` | — | List all loaded modules and their status |
| `modules.reload` | — | Hot-reload the Lua runtime |
| `profile.list` | — | List defined profiles |
| `profile.activate` | `name` | Switch active profile |
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
| `emit` | `event`, `data` | Inject a synthetic event into the pipeline |
| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` |

View file

@ -180,7 +180,7 @@ bread reload --watch # Watch config dir and reload on changes
# State and events
bread state # Dump full runtime state as JSON
bread events # Stream live normalized events
bread events --filter bread.device.* # Stream filtered events
bread events bread.device.* # Stream filtered events
bread events --since 60 # Replay events from the last 60 seconds
bread emit <event> # Manually fire an event (for testing)
@ -319,9 +319,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.system.startup` | Daemon fully initialized |
| `bread.device.connected` | Any device attached |
| `bread.device.disconnected` | Any device removed |
| `bread.device.dock.connected` | Dock attached |
| `bread.device.dock.disconnected` | Dock removed |
| `bread.device.keyboard.connected` | Keyboard attached |
| `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 |
@ -380,7 +379,7 @@ end)
-- Subscribe with a filter predicate
bread.filter("bread.device.connected", function(event)
return event.data.class == "keyboard"
return event.data.device == "keyboard"
end, function(event)
bread.exec("xset r rate 200 40")
end)

View file

@ -42,8 +42,8 @@ enum Commands {
},
/// Stream live normalized events
Events {
#[arg(long)]
filter: Option<String>,
/// Optional glob pattern to filter events (e.g. bread.device.*, bread.**)
pattern: Option<String>,
/// Output raw JSON
#[arg(long)]
json: bool,
@ -169,12 +169,12 @@ async fn main() -> Result<()> {
}
}
Commands::Events {
filter,
pattern,
json,
fields,
since,
} => {
stream_events(&socket, filter, json, fields, since).await?;
stream_events(&socket, pattern, json, fields, since).await?;
}
Commands::Modules { subcommand } => {
handle_modules_cmd(subcommand, &socket).await?;
@ -769,8 +769,7 @@ fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()>
}
"pip" => {
let mut cmd = std::process::Command::new("pip");
cmd.args(["install", "--user", "-r"])
.arg(file.to_str().unwrap_or(""));
cmd.args(["install", "--user", "-r"]).arg(&file);
let _ = cmd.status();
}
"npm" => {

View file

@ -14,7 +14,7 @@ tracing-subscriber.workspace = true
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
async-trait = "0.1"
toml = "0.8"
udev = "0.9"
udev = { version = "0.9", features = ["send"] }
rtnetlink = "0.9"
zbus = { version = "3.13", features = ["tokio"] }
hex = "0.4"

View file

@ -48,13 +48,36 @@ impl Adapter for HyprlandAdapter {
}
fn hyprland_event_socket() -> Result<PathBuf> {
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime)
// If the env var is set, use it directly.
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket2.sock"))
.join(".socket2.sock"));
}
// Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance.
// Hyprland creates a per-instance directory there containing .socket2.sock.
// This handles the case where breadd starts as a systemd user service before
// Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment.
let hypr_dir = PathBuf::from(&runtime).join("hypr");
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
.flatten()
.map(|e| e.path().join(".socket2.sock"))
.filter(|p| p.exists())
.collect();
match sockets.len() {
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
1 => Ok(sockets.remove(0)),
n => {
warn!("found {n} Hyprland instances, using first");
Ok(sockets.remove(0))
}
}
}
fn parse_hyprland_line(line: &str) -> (String, String) {

View file

@ -1,12 +1,9 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::os::unix::io::AsRawFd;
use anyhow::Result;
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tracing::debug;
use crate::adapters::Adapter;
@ -22,10 +19,7 @@ impl UdevAdapter {
}
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
scan_devices(&self.subsystems).unwrap_or_default()
});
let devices = enumerate_with_udev(&self.subsystems)?;
for device in devices {
tx.send(RawEvent {
source: AdapterSource::Udev,
@ -52,68 +46,66 @@ impl Adapter for UdevAdapter {
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("udev adapter started");
match run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
Ok(()) => return Ok(()),
Err(err) => {
tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)");
run_udev_monitor(self.subsystems.clone(), tx).await
}
}
// Fallback: poll sysfs every 2 seconds for environments where the
// netlink socket is unavailable (missing plugdev membership, containers, etc).
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)
.unwrap_or_default()
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
loop {
let current = scan_devices(&self.subsystems).unwrap_or_default();
let current_map: HashMap<String, ScannedDevice> = current
.into_iter()
.map(|d| (d.id.clone(), d))
.collect();
for (id, dev) in &current_map {
if !known.contains_key(id) {
if tx.send(raw_change_event("add", dev)).await.is_err() {
return Ok(());
}
}
}
for (id, dev) in &known {
if !current_map.contains_key(id) {
if tx.send(raw_change_event("remove", dev)).await.is_err() {
return Ok(());
}
}
}
known = current_map;
sleep(Duration::from_secs(2)).await;
}
}
}
#[derive(Clone, Debug)]
struct ScannedDevice {
id: String,
name: String,
subsystem: String,
vendor_id: Option<String>,
product_id: Option<String>,
}
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
// first polling the fd returns None immediately and exits the loop — which is
// why the old code silently fell back to sysfs on every start. We use poll(2)
// inside spawn_blocking so the thread truly blocks until events are available.
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
tokio::task::spawn_blocking(move || -> Result<()> {
let mut builder = udev::MonitorBuilder::new()?;
for subsystem in &subsystems {
builder = builder.match_subsystem(subsystem)?;
}
let monitor = builder.listen()?;
let socket = builder.listen()?;
let fd = socket.as_raw_fd();
for event in monitor.iter() {
loop {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, 1000) };
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(err.into());
}
if ret == 0 {
// Timeout: bail if the downstream channel has been dropped.
if tx.is_closed() {
return Ok(());
}
continue;
}
if pfd.revents & libc::POLLIN != 0 {
while let Some(event) = socket.iter().next() {
if tx.blocking_send(build_event(&event)).is_err() {
return Ok(());
}
}
}
}
})
.await??;
Ok(())
}
fn build_event(event: &udev::Event) -> RawEvent {
let action = event
.action()
.map(|a| a.to_string_lossy().to_string())
@ -128,12 +120,9 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
.map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string());
let id = event
.syspath()
.to_string_lossy()
.to_string();
let id = event.syspath().to_string_lossy().to_string();
let msg = RawEvent {
RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
@ -141,33 +130,22 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
"id": id,
"name": name,
"subsystem": subsystem,
"id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"),
"id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"),
"id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"),
"id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"),
"id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"),
"id_usb_class": prop_str(&event, "ID_USB_CLASS"),
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(&event, "ID_VENDOR"),
"id_model": prop_str(&event, "ID_MODEL"),
"vendor_id": prop_str(&event, "ID_VENDOR_ID"),
"product_id": prop_str(&event, "ID_MODEL_ID"),
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(event, "ID_VENDOR"),
"id_model": prop_str(event, "ID_MODEL"),
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
"product_id": prop_str(event, "ID_MODEL_ID"),
}),
timestamp: now_unix_ms(),
};
if tx.blocking_send(msg).is_err() {
break;
}
}
Ok(())
})
.await??;
Ok(())
}
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut enumerator = udev::Enumerator::new()?;
for subsystem in subsystems {
@ -187,125 +165,7 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string());
let id = dev.syspath().to_string_lossy().to_string();
let vendor_id = dev
.property_value("ID_VENDOR_ID")
.map(|v| v.to_string_lossy().to_string());
let product_id = dev
.property_value("ID_MODEL_ID")
.map(|v| v.to_string_lossy().to_string());
out.push(ScannedDevice {
id,
name,
subsystem,
vendor_id,
product_id,
});
}
Ok(out)
}
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
RawEvent {
source: AdapterSource::Udev,
kind: "udev.change".to_string(),
payload: json!({
"action": action,
"id": dev.id,
"name": dev.name,
"subsystem": dev.subsystem,
"vendor_id": dev.vendor_id,
"product_id": dev.product_id,
}),
timestamp: now_unix_ms(),
}
}
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let mut out = Vec::new();
if subsystems.iter().any(|s| s == "drm") {
let drm_dir = Path::new("/sys/class/drm");
if drm_dir.exists() {
for entry in fs::read_dir(drm_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains('-') {
continue;
}
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
if status.trim() == "connected" {
out.push(ScannedDevice {
id: format!("drm:{name}"),
name,
subsystem: "drm".to_string(),
vendor_id: None,
product_id: None,
});
}
}
}
}
if subsystems.iter().any(|s| s == "input") {
let input_dir = Path::new("/dev/input/by-id");
if input_dir.exists() {
for entry in fs::read_dir(input_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("input:{name}"),
name,
subsystem: "input".to_string(),
vendor_id: None,
product_id: None,
});
}
}
}
if subsystems.iter().any(|s| s == "power_supply") {
let pwr_dir = Path::new("/sys/class/power_supply");
if pwr_dir.exists() {
for entry in fs::read_dir(pwr_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
out.push(ScannedDevice {
id: format!("power_supply:{name}"),
name,
subsystem: "power_supply".to_string(),
vendor_id: None,
product_id: None,
});
}
}
}
if subsystems.iter().any(|s| s == "usb") {
let usb_dir = Path::new("/sys/bus/usb/devices");
if usb_dir.exists() {
for entry in fs::read_dir(usb_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
let syspath = entry.path();
let vendor_id = fs::read_to_string(syspath.join("idVendor"))
.ok()
.map(|s| s.trim().to_string());
let product_id = fs::read_to_string(syspath.join("idProduct"))
.ok()
.map(|s| s.trim().to_string());
out.push(ScannedDevice {
id: format!("usb:{name}"),
name,
subsystem: "usb".to_string(),
vendor_id,
product_id,
});
}
}
}
out.push(ScannedDevice { id, name, subsystem });
}
Ok(out)

View file

@ -4,14 +4,16 @@ use std::sync::RwLock;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use serde_json::{json, Value};
use crate::core::types::DeviceClass;
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
const EVICT_MULTIPLIER: u64 = 60;
pub struct EventNormalizer {
dedup_window_ms: u64,
recent: RwLock<HashMap<String, u64>>,
/// Tracks the first time a physical device (keyed by verb+vendor_id+product_id)
/// fired within the current window, so subsequent child-node events from the
/// same plug-in are suppressed at the normalizer level.
seen_devices: RwLock<HashMap<String, u64>>,
}
impl EventNormalizer {
@ -19,6 +21,7 @@ impl EventNormalizer {
Self {
dedup_window_ms,
recent: RwLock::new(HashMap::new()),
seen_devices: RwLock::new(HashMap::new()),
}
}
@ -42,40 +45,75 @@ impl EventNormalizer {
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
let class = classify_device(&raw.payload);
let class_str = serde_json::to_string(&class)
.unwrap_or_else(|_| "\"unknown\"".to_string())
.replace('"', "");
// "bind" is the kernel attaching a driver to an interface — not a meaningful
// device state change for automation purposes.
if action == "bind" {
return vec![];
}
let name = raw.payload.get("name").and_then(Value::as_str).unwrap_or("unknown");
let vendor = raw.payload.get("id_vendor").and_then(Value::as_str).unwrap_or_default();
let vendor_id = raw.payload.get("vendor_id").and_then(Value::as_str).unwrap_or_default();
let product_id = raw.payload.get("product_id").and_then(Value::as_str).unwrap_or_default();
let subsystem = raw.payload.get("subsystem").and_then(Value::as_str).unwrap_or_default();
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
// no identity information — they are USB protocol artefacts, not devices.
if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() {
return vec![];
}
// For connected/disconnected, suppress duplicate events from child nodes of
// the same physical device (e.g. input66, mouse0, event17 all from one plug-in).
// Key by verb+vendor_id+product_id so a second distinct device of the same
// model plugged in after the window still fires correctly.
let verb = match action {
"add" => "connected",
"remove" => "disconnected",
_ => "changed",
};
let mut events = vec![BreadEvent {
if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() {
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
let now = raw.timestamp;
let already_seen = {
let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner());
seen.get(&device_key)
.map(|&last| now.saturating_sub(last) < self.dedup_window_ms)
.unwrap_or(false)
};
if already_seen {
return vec![];
}
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
seen.insert(device_key, now);
// Evict stale entries
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
if evict_before > 0 {
seen.retain(|_, &mut last| last >= evict_before);
}
}
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
// Device name is always "unknown" here; the state engine applies user-defined
// classification rules from devices.lua before dispatching to subscribers.
vec![BreadEvent {
event: format!("bread.device.{}", verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
"device": "unknown",
"name": name,
"vendor": vendor,
"vendor_id": vendor_id,
"product_id": product_id,
"subsystem": subsystem,
"raw": raw.payload,
}),
}];
events.push(BreadEvent {
event: format!("bread.device.{}.{}", class_str, verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
}),
});
events
}]
}
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
@ -109,13 +147,13 @@ impl EventNormalizer {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
data: json!({ "name": data }),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
data: json!({ "name": data }),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
data.split(">>").collect()
}
fn classify_device(payload: &Value) -> DeviceClass {
let subsystem = payload
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
// --- Property-based classification (reliable, hardware-agnostic) ---
// udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device.
if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Keyboard;
}
// ID_INPUT_MOUSE=1 covers mice and trackballs.
if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Mouse;
}
// ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc).
if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Tablet;
}
// USB class 0x09 = Hub. Docks expose a hub interface; they also typically
// expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces.
// We check for hub + at least one of those secondary interfaces.
if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) {
let ifaces_lc = ifaces.to_lowercase();
let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902");
let has_secondary = ifaces_lc.contains(":0e") // video
|| ifaces_lc.contains(":0200") // CDC ethernet
|| ifaces_lc.contains(":0100") // audio
|| ifaces_lc.contains(":0801"); // mass storage
if has_hub && has_secondary {
return DeviceClass::Dock;
}
}
// USB class 0x01 = Audio.
if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) {
if cls == "01" || cls.to_lowercase() == "0x01" {
return DeviceClass::Audio;
}
// USB class 0x08 = Mass Storage.
if cls == "08" || cls.to_lowercase() == "0x08" {
return DeviceClass::Storage;
}
}
// DRM subsystem = display connector.
if subsystem == "drm" {
return DeviceClass::Display;
}
// Block devices = storage.
if subsystem == "block" {
return DeviceClass::Storage;
}
// Sound subsystem = audio.
if subsystem == "sound" {
return DeviceClass::Audio;
}
// --- Name-based fallback (catches user-registered patterns and obvious names) ---
// This runs last so the property-based rules above always win.
let name = payload
.get("name")
.and_then(Value::as_str)
.or_else(|| payload.get("id_model").and_then(Value::as_str))
.unwrap_or_default()
.to_lowercase();
let vendor = payload
.get("id_vendor")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let combined = format!("{name} {vendor}");
if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") {
return DeviceClass::Dock;
}
if combined.contains("keyboard") || combined.contains("kbd") {
return DeviceClass::Keyboard;
}
if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") {
return DeviceClass::Mouse;
}
if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") {
return DeviceClass::Tablet;
}
if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") {
return DeviceClass::Audio;
}
if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") {
return DeviceClass::Storage;
}
DeviceClass::Unknown
}

View file

@ -9,7 +9,7 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::warn;
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState};
use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState};
use crate::lua::LuaMessage;
#[derive(Clone)]
@ -46,6 +46,7 @@ pub enum StateCommand {
SetProfile {
name: String,
},
SetDeviceRules(Vec<DeviceRule>),
}
impl StateHandle {
@ -136,6 +137,10 @@ impl StateHandle {
let _ = self.command_tx.send(StateCommand::SetProfile { name });
}
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
}
pub fn subscription_count(&self) -> Arc<AtomicU64> {
self.subscription_count.clone()
}
@ -152,6 +157,7 @@ pub async fn run_state_engine(
) {
let mut subscriptions = SubscriptionTable::default();
let mut watches: HashMap<SubscriptionId, String> = HashMap::new();
let mut device_rules: Vec<DeviceRule> = Vec::new();
loop {
tokio::select! {
@ -164,13 +170,51 @@ pub async fn run_state_engine(
let Some(cmd) = maybe_cmd else {
break;
};
if let StateCommand::SetDeviceRules(rules) = cmd {
device_rules = rules;
} else {
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
}
}
maybe_event = event_rx.recv() => {
let Some(event) = maybe_event else {
let Some(mut event) = maybe_event else {
break;
};
// Resolve device name from user rules and patch the event data before
// any subscriber sees it, then emit the named companion event.
let device_event = if event.event == "bread.device.connected"
|| event.event == "bread.device.disconnected"
{
let is_disconnect = event.event == "bread.device.disconnected";
let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string();
// On disconnect, udev strips vendor/product identifiers from the event.
// Look up the device by id in the current state (it's still present
// because apply_event_to_state hasn't run yet) and reuse the stored name.
let device = if is_disconnect {
state.read().await
.devices.connected.iter()
.find(|d| d.id == id)
.map(|d| d.device.clone())
.unwrap_or_else(|| resolve_device(&device_rules, &event.data))
} else {
resolve_device(&device_rules, &event.data)
};
if let Some(data) = event.data.as_object_mut() {
data.insert("device".to_string(), Value::String(device.clone()));
}
let verb = if is_disconnect { "disconnected" } else { "connected" };
Some(BreadEvent::new(
format!("bread.device.{}.{}", device, verb),
AdapterSource::Udev,
json!({ "id": id, "device": device }),
))
} else {
None
};
let (before_snapshot, after_snapshot) = if watches.is_empty() {
(None, None)
} else {
@ -188,6 +232,13 @@ pub async fn run_state_engine(
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
if let Some(dev_ev) = device_event {
let mut guard = state.write().await;
apply_event_to_state(&mut guard, &dev_ev);
drop(guard);
dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
}
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
for (_id, path) in watches.iter() {
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
@ -273,6 +324,9 @@ async fn handle_command(
guard.profile.active = name;
}
}
StateCommand::SetDeviceRules(_) => {
// Handled directly in run_state_engine before this function is called.
}
}
}
@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
}
}
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
for rule in rules {
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) {
return rule.device.clone();
}
}
"unknown".to_string()
}
fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
if let Some(ref expected) = cond.vendor_id {
let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or("");
if actual.to_lowercase() != expected.to_lowercase() {
return false;
}
}
if let Some(ref expected) = cond.product_id {
let actual = data.get("product_id").and_then(Value::as_str).unwrap_or("");
if actual.to_lowercase() != expected.to_lowercase() {
return false;
}
}
if let Some(ref expected) = cond.name {
let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
if actual != expected.to_lowercase() {
return false;
}
}
if let Some(ref expected) = cond.vendor {
let actual = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
if actual != expected.to_lowercase() {
return false;
}
}
if let Some(ref contains) = cond.name_contains {
let name = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
let vendor = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
let combined = format!("{name} {vendor}");
if !combined.contains(contains.to_lowercase().as_str()) {
return false;
}
}
if let Some(expected) = cond.id_input_keyboard {
if data.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) != expected {
return false;
}
}
if let Some(expected) = cond.id_input_mouse {
if data.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) != expected {
return false;
}
}
if let Some(expected) = cond.id_input_tablet {
if data.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) != expected {
return false;
}
}
if cond.usb_hub == Some(true) {
let ifaces = data
.get("id_usb_interfaces")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902");
let has_secondary = ifaces.contains(":0e")
|| ifaces.contains(":0200")
|| ifaces.contains(":0100")
|| ifaces.contains(":0801");
if !(has_hub && has_secondary) {
return false;
}
}
if let Some(ref expected) = cond.id_usb_class {
let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or("");
if actual.to_lowercase() != expected.to_lowercase()
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
{
return false;
}
}
if let Some(ref expected) = cond.subsystem {
let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase();
if actual != expected.to_lowercase() {
return false;
}
}
true
}
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
let id = data
.get("id")
@ -411,10 +554,11 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
return;
}
let class = data
.get("class")
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
.unwrap_or(DeviceClass::Unknown);
let device = data
.get("device")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
state.devices.connected.push(Device {
id,
@ -423,7 +567,7 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
class,
device,
subsystem: data
.get("subsystem")
.and_then(Value::as_str)

View file

@ -55,7 +55,7 @@ pub struct DeviceTopology {
pub struct Device {
pub id: String,
pub name: String,
pub class: DeviceClass,
pub device: String,
pub subsystem: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub vendor_id: Option<String>,
@ -63,17 +63,30 @@ pub struct Device {
pub product_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DeviceClass {
Dock,
Keyboard,
Mouse,
Tablet,
Display,
Storage,
Audio,
Unknown,
/// One set of match conditions. All provided fields must match.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MatchCondition {
pub vendor_id: Option<String>,
pub product_id: Option<String>,
pub name: Option<String>,
pub vendor: Option<String>,
pub name_contains: Option<String>,
pub id_input_keyboard: Option<bool>,
pub id_input_mouse: Option<bool>,
pub id_input_tablet: Option<bool>,
/// True triggers the compound USB hub + secondary-interface check.
pub usb_hub: Option<bool>,
pub id_usb_class: Option<String>,
pub subsystem: Option<String>,
}
/// A device rule from `devices.lua`. The device name is assigned if ANY
/// condition in `conditions` matches (OR semantics across conditions,
/// AND semantics within a condition).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceRule {
pub device: String,
pub conditions: Vec<MatchCondition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]

View file

@ -21,7 +21,7 @@ use tracing::{error, info, warn};
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{ModuleLoadState, RuntimeState};
use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState};
use bread_shared::now_unix_ms;
pub enum LuaMessage {
@ -275,6 +275,8 @@ impl LuaEngine {
.clear();
self.install_api()?;
self.load_device_rules()?;
self.load_profiles()?;
self.load_init_and_modules()?;
self.run_on_reload();
info!("lua runtime reloaded");
@ -515,8 +517,14 @@ impl LuaEngine {
let profile_tbl = self.lua.create_table()?;
let state_handle = self.state_handle.clone();
let emit_tx = self.emit_tx.clone();
let activate_fn = self.lua.create_function(move |_lua, name: String| {
state_handle.set_profile(name.clone());
let _ = emit_tx.send(BreadEvent::new(
"bread.profile.activated",
AdapterSource::System,
serde_json::json!({ "name": name }),
));
Ok(())
})?;
profile_tbl.set("activate", activate_fn)?;
@ -700,6 +708,13 @@ impl LuaEngine {
})?;
hyprland_tbl.set("keyword", keyword_fn)?;
let eval_fn = self.lua.create_function(move |_lua, expr: String| {
let resp = hyprland_request(&format!("eval {expr}"))
.map_err(|e| LuaError::external(e.to_string()))?;
Ok(resp)
})?;
hyprland_tbl.set("eval", eval_fn)?;
let active_window_fn = self.lua.create_function(move |lua, ()| {
let resp = hyprland_request("j/activewindow")
.map_err(|e| LuaError::external(e.to_string()))?;
@ -835,6 +850,11 @@ impl LuaEngine {
ModuleInfo { table_key: key },
);
// Register in package.loaded so require("bread.devices") etc. works
let package: Table = lua.globals().get("package")?;
let loaded: Table = package.get("loaded")?;
loaded.set(decl.name.clone(), module_tbl.clone())?;
Ok(module_tbl)
})?;
bread.set("module", module_fn)?;
@ -907,6 +927,98 @@ impl LuaEngine {
Ok(())
}
fn load_device_rules(&self) -> Result<()> {
let devices_path = self
.entry_point
.parent()
.map(|p| p.join("devices.lua"))
.unwrap_or_else(|| std::path::PathBuf::from("devices.lua"));
if !devices_path.exists() {
return Ok(());
}
let source = fs::read_to_string(&devices_path)
.map_err(|e| anyhow!("failed to read devices.lua: {e}"))?;
let rules_value: mlua::Value = self
.lua
.load(&source)
.set_name("devices.lua")
.eval()
.map_err(|e| anyhow!("devices.lua error: {e}"))?;
let mlua::Value::Table(tbl) = rules_value else {
return Err(anyhow!("devices.lua must return a table of rules"));
};
let mut rules: Vec<DeviceRule> = Vec::new();
for pair in tbl.sequence_values::<mlua::Table>() {
let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?;
let device: String = entry.get("device").unwrap_or_default();
if device.is_empty() {
continue;
}
// If the rule has a `match` key, each entry in it is a separate condition (OR logic).
// Otherwise the rule table itself is the single condition.
let conditions: Vec<MatchCondition> =
if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") {
match_tbl
.sequence_values::<mlua::Table>()
.filter_map(|r| r.ok())
.map(|t| parse_match_condition(&t))
.collect()
} else {
vec![parse_match_condition(&entry)]
};
if !conditions.is_empty() {
rules.push(DeviceRule { device, conditions });
}
}
self.state_handle.set_device_rules(rules);
Ok(())
}
fn load_profiles(&self) -> Result<()> {
let profiles_path = self
.entry_point
.parent()
.map(|p| p.join("profiles.lua"))
.unwrap_or_else(|| PathBuf::from("profiles.lua"));
if !profiles_path.exists() {
return Ok(());
}
let path_str = profiles_path.to_string_lossy().to_string();
self.lua.globals().set("__profiles_path", path_str)?;
self.lua
.load(
r#"
local ok, result = pcall(loadfile, __profiles_path)
__profiles_path = nil
if ok and type(result) == "function" then
ok, result = pcall(result)
end
if ok and type(result) == "table" then
bread.on("bread.profile.activated", function(event)
local name = event.data and event.data.name
local fn = name and result[name]
if type(fn) == "function" then
fn(event)
end
end)
end
"#,
)
.set_name("profiles.lua")
.exec()
.map_err(|e| anyhow!("profiles.lua error: {e}"))
}
fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init", false)?;
@ -1796,24 +1908,18 @@ const BUILTIN_DEVICES: &str = r#"
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
local rules = {}
local user_patterns = {} -- { { pattern = "...", class = "..." }, ... }
local function matches_rule(rule, event)
local class = rule.class
local when = rule.when
local data = event.data or {}
if when == "connected" and event.event ~= "bread.device.connected" then
if not event.event:match("%.connected$") then
if when == "connected" and not event.event:match("%.connected$") then
return false
end
elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then
if not event.event:match("%.disconnected$") then
elseif when == "disconnected" and not event.event:match("%.disconnected$") then
return false
end
end
if class and data.class ~= class then
if rule.device and data.device ~= rule.device then
return false
end
@ -1832,55 +1938,15 @@ local function run_rule(rule, event)
end
end
-- Reclassify an event's data.class based on user-registered name patterns.
-- Called before rule matching so that user-registered patterns take effect
-- even for devices that the daemon classified as Unknown.
local function apply_user_patterns(event)
if not event.data then return event end
local name = tostring(event.data.name or ""):lower()
local vendor = tostring(event.data.vendor or ""):lower()
local combined = name .. " " .. vendor
for _, p in ipairs(user_patterns) do
if combined:find(p.pattern, 1, true) then
-- Return a shallow copy with the class overridden so we don't
-- mutate the original event that other handlers may receive.
local patched = {}
for k, v in pairs(event) do patched[k] = v end
patched.data = {}
for k, v in pairs(event.data) do patched.data[k] = v end
patched.data.class = p.class
return patched
end
end
return event
end
function M.on(opts)
table.insert(rules, opts)
end
-- Register a user-defined device pattern so the daemon can correctly classify
-- hardware that the automatic classifier doesn't recognise.
--
-- Usage:
-- local devices = require("bread.devices")
-- devices.register("CalDigit", "dock")
-- devices.register("Keychron", "keyboard")
-- devices.register("MX Master", "mouse")
--
-- The pattern is matched case-insensitively against the device name and vendor
-- combined. The class must be one of: dock, keyboard, mouse, tablet, display,
-- storage, audio, unknown.
function M.register(pattern, class)
table.insert(user_patterns, { pattern = pattern:lower(), class = class })
end
function M.on_load()
bread.on("bread.device.**", function(event)
local patched = apply_user_patterns(event)
for _, rule in ipairs(rules) do
if matches_rule(rule, patched) then
run_rule(rule, patched)
if matches_rule(rule, event) then
run_rule(rule, event)
end
end
end)
@ -2018,13 +2084,28 @@ fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
}
fn hyprland_request_socket() -> Result<PathBuf> {
let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime)
if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr")
.join(instance)
.join(".socket.sock"))
.join(".socket.sock"));
}
let hypr_dir = PathBuf::from(&runtime).join("hypr");
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
.flatten()
.map(|e| e.path().join(".socket.sock"))
.filter(|p| p.exists())
.collect();
match sockets.len() {
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
1 => Ok(sockets.remove(0)),
_ => Ok(sockets.remove(0)),
}
}
fn hyprland_request(request: &str) -> Result<String> {
@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result<String> {
Ok(buffer)
}
fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition {
MatchCondition {
vendor_id: tbl.get("vendor_id").ok(),
product_id: tbl.get("product_id").ok(),
name: tbl.get("name").ok(),
vendor: tbl.get("vendor").ok(),
name_contains: tbl.get("name_contains").ok(),
id_input_keyboard: tbl.get("id_input_keyboard").ok(),
id_input_mouse: tbl.get("id_input_mouse").ok(),
id_input_tablet: tbl.get("id_input_tablet").ok(),
usb_hub: tbl.get("usb_hub").ok(),
id_usb_class: tbl.get("id_usb_class").ok(),
subsystem: tbl.get("subsystem").ok(),
}
}
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
if !root.exists() {

View file

@ -2,35 +2,97 @@
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
SERVICE_DIR="${HOME}/.config/systemd/user"
CONFIG_DIR="${HOME}/.config/bread"
MODULES_DIR="${CONFIG_DIR}/modules"
# ── build ──────────────────────────────────────────────────────────────────────
echo "building bread (release)..."
cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml"
echo ""
# ── install binaries ───────────────────────────────────────────────────────────
echo "installing binaries to $INSTALL_PREFIX (requires sudo)..."
sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd"
sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread"
echo " installed $INSTALL_PREFIX/breadd"
echo " installed $INSTALL_PREFIX/bread"
# ── symlinks ───────────────────────────────────────────────────────────────────
echo "symlinking binaries into $BIN_DIR..."
mkdir -p "$BIN_DIR"
ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd"
ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread"
echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd"
echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread"
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo ""
echo " note: $BIN_DIR is not in PATH — add to your shell profile:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
echo ""
# ── config ─────────────────────────────────────────────────────────────────────
echo "setting up config..."
mkdir -p "$CONFIG_DIR" "$MODULES_DIR"
if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then
cat > "$CONFIG_DIR/breadd.toml" << 'EOF'
[daemon]
log_level = "info"
[lua]
entry_point = "~/.config/bread/init.lua"
module_path = "~/.config/bread/modules"
[adapters.hyprland]
enabled = true
[adapters.udev]
enabled = true
[adapters.power]
enabled = true
[adapters.network]
enabled = true
EOF
echo " created $CONFIG_DIR/breadd.toml"
else
echo " $CONFIG_DIR/breadd.toml already exists, skipping"
fi
if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then
cat > "$CONFIG_DIR/init.lua" << 'EOF'
-- bread init.lua — loaded before modules, use for global setup
bread.log("bread started")
EOF
echo " created $CONFIG_DIR/init.lua"
else
echo " $CONFIG_DIR/init.lua already exists, skipping"
fi
echo ""
# ── systemd user service ───────────────────────────────────────────────────────
echo "installing systemd user service..."
mkdir -p "$SERVICE_DIR"
install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service"
echo " installed $SERVICE_DIR/breadd.service"
# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin.
sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \
"$REPO_ROOT/packaging/systemd/breadd.service" \
> "$SERVICE_DIR/breadd.service"
echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)"
systemctl --user daemon-reload
if systemctl --user is-active --quiet breadd 2>/dev/null; then
systemctl --user restart breadd
echo " breadd restarted"
else
systemctl --user enable --now breadd
echo " breadd enabled and started"
fi
echo ""
# ── verify ─────────────────────────────────────────────────────────────────────
sleep 0.5
if bread ping &>/dev/null; then
echo ""
bread doctor
if "$BIN_DIR/bread" ping &>/dev/null; then
"$BIN_DIR/bread" doctor
else
echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20"
echo "warning: daemon did not respond to ping"
echo " check: journalctl --user -u breadd -n 20"
fi