diff --git a/.gitignore b/.gitignore index a253843..a92804c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Documentation.md b/Documentation.md index 454f7a9..f1a0aca 100644 --- a/Documentation.md +++ b/Documentation.md @@ -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", + 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.")` 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") @@ -291,41 +455,131 @@ monitors.layout("dock", function() end) monitors.on({ - when = "connected", + when = "connected", monitors = { "HDMI-A-1" }, - run = monitors.apply("dock"), + run = monitors.apply("dock"), }) ``` -- `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", - run = function(event) + when = "connected", + device = "dock", + run = "~/.config/bread/scripts/dock-connected.sh" +}) + +devices.on({ + when = "disconnected", + name = "CalDigit", -- pattern-matched against event.data.name + run = function(event) + bread.log("Dock disconnected: " .. event.data.name) + end, +}) +``` + +#### Functions + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. See options below. | + +#### Device rule options + +```lua +devices.on({ + when = "connected", -- required: "connected" or "disconnected" + device = "keyboard", -- optional: device name from devices.lua + name = "Keychron", -- optional: substring matched against device name + run = function(event) ... end -- required: function or shell string +}) +``` + +- `when` (required): One of `connected` or `disconnected`. +- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name. +- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match). +- `run` (required): Function or shell string to run when the rule matches. + +The callback receives the full device event: +```lua +{ + event = "bread.device.dock.connected", + data = { + id = "/sys/...", + device = "dock", -- name resolved from devices.lua + name = "CalDigit TS4", -- raw device name from udev + subsystem = "usb", + vendor_id = "0x35f5", + product_id = "0x0104", + raw = { ... } -- full udev properties + } +} +``` + +#### Example: Keyboard configuration on connect + +```lua +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.log("Keyboard connected: " .. event.data.name) bread.exec("xset r rate 200 40") end, }) ``` -- `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,26 +587,34 @@ 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") binds.add({ - mods = { "SUPER" }, - key = "Return", + mods = { "SUPER" }, + key = "Return", dispatch = "exec", - args = "kitty", + args = "kitty", }) ``` -- `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..connected` -- `bread.device..disconnected` -- `bread.device..changed` +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` | +| `bread.device.disconnected` | same | +| `bread.device..connected` | `{ id, device }` | +| `bread.device..disconnected` | `{ id, device }` | -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`. +`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.` | emitted by state watches | -- `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`: +`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? }` | diff --git a/README.md b/README.md index adbff67..cc1e32c 100644 --- a/README.md +++ b/README.md @@ -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 # Manually fire an event (for testing) @@ -319,9 +319,8 @@ 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..connected` | Named device attached (name from `devices.lua`) | +| `bread.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) diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index eadd679..ae100c3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -42,8 +42,8 @@ enum Commands { }, /// Stream live normalized events Events { - #[arg(long)] - filter: Option, + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, /// 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" => { diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 4b949be..9b968d9 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -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" diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2ef3731..c032612 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -48,13 +48,36 @@ impl Adapter for HyprlandAdapter { } fn hyprland_event_socket() -> Result { - 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) - .join("hypr") - .join(instance) - .join(".socket2.sock")) + + // 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")); + } + + // 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 = 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) { diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index c3aba56..5af66bc 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -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) -> 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,122 +46,106 @@ 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) - .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 = current - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - for (id, dev) in ¤t_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; - } + run_udev_monitor(self.subsystems.clone(), tx).await } } -#[derive(Clone, Debug)] struct ScannedDevice { id: String, name: String, subsystem: String, - vendor_id: Option, - product_id: Option, } +// 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, 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()?; + let socket = builder.listen()?; + let fd = socket.as_raw_fd(); - 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"), - "vendor_id": prop_str(&event, "ID_VENDOR_ID"), - "product_id": prop_str(&event, "ID_MODEL_ID"), - }), - timestamp: now_unix_ms(), + loop { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, }; - if tx.blocking_send(msg).is_err() { - break; + 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(()); + } + } } } - - Ok(()) }) .await??; Ok(()) } +fn build_event(event: &udev::Event) -> RawEvent { + 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(); + + 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"), + "vendor_id": prop_str(event, "ID_VENDOR_ID"), + "product_id": prop_str(event, "ID_MODEL_ID"), + }), + timestamp: now_unix_ms(), + } +} + fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { @@ -187,125 +165,7 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .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> { - 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) diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 3eaef88..9c31d41 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -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>, + /// 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>, } 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 { 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 { @@ -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 -} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 6e69e6a..784a0e9 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -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), } impl StateHandle { @@ -136,6 +137,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::SetProfile { name }); } + pub fn set_device_rules(&self, rules: Vec) { + let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); + } + pub fn subscription_count(&self) -> Arc { self.subscription_count.clone() } @@ -152,6 +157,7 @@ pub async fn run_state_engine( ) { let mut subscriptions = SubscriptionTable::default(); let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); loop { tokio::select! { @@ -164,13 +170,51 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; + 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::(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) diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 119b7af..38c7f81 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -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, @@ -63,17 +63,30 @@ pub struct Device { pub product_id: Option, } -#[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, + pub product_id: Option, + pub name: Option, + pub vendor: Option, + pub name_contains: Option, + pub id_input_keyboard: Option, + pub id_input_mouse: Option, + pub id_input_tablet: Option, + /// True triggers the compound USB hub + secondary-interface check. + pub usb_hub: Option, + pub id_usb_class: Option, + pub subsystem: Option, +} + +/// 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, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 7caa9c6..7744fdf 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -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 = Vec::new(); + for pair in tbl.sequence_values::() { + 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 = + if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") { + match_tbl + .sequence_values::() + .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 - return false - end - elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then - if not event.event:match("%.disconnected$") then - return false - end + if when == "connected" and not event.event:match("%.connected$") then + return false + elseif when == "disconnected" and not event.event:match("%.disconnected$") then + return false 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) -> Vec { } fn hyprland_request_socket() -> Result { - 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) - .join("hypr") - .join(instance) - .join(".socket.sock")) + + if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")); + } + + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = 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 { @@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result { 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> { let mut out = Vec::new(); if !root.exists() { diff --git a/scripts/install.sh b/scripts/install.sh index 961f792..5440530 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 -systemctl --user enable --now breadd -echo " breadd enabled and started" + +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