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