Compare commits
29 commits
4f4bb46eed
...
fd1189dc89
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd1189dc89 | ||
|
|
23bb4f8977 | ||
|
|
4072f64fcb | ||
|
|
20d6a8b6fa | ||
|
|
425b746780 | ||
|
|
aa967fda8f | ||
|
|
434fe1721c | ||
|
|
22f591e0e6 | ||
|
|
74a4082d10 | ||
|
|
9dcdc67b5e | ||
|
|
762e6a6a59 | ||
|
|
007478f82c | ||
|
|
364a35142e | ||
|
|
251c586b6f | ||
|
|
d27323d2a2 | ||
|
|
f05d6ba602 | ||
|
|
45d5979252 | ||
|
|
61000d8811 | ||
|
|
e5611567c2 | ||
|
|
28df873b92 | ||
|
|
f5cf547875 | ||
|
|
f0ef411697 | ||
|
|
e339660084 | ||
|
|
7c29befc0d | ||
|
|
65f81db562 | ||
|
|
8de31cd1fc | ||
|
|
16f3765b65 | ||
|
|
3614f628ae | ||
|
|
bde30928a0 |
47 changed files with 12519 additions and 1256 deletions
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
rust: [stable]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
- name: Cargo cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
- name: Build
|
||||
run: cargo build --workspace --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --verbose
|
||||
- name: Build release
|
||||
run: cargo build --workspace --release
|
||||
- name: Package artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bread-${{ matrix.os }}
|
||||
path: dist/*.tgz
|
||||
37
.gitignore
vendored
37
.gitignore
vendored
|
|
@ -1,4 +1,39 @@
|
|||
# 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
|
||||
.github/
|
||||
LUA_RUNTIME.md
|
||||
CLAUDE_SPEC.md
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
|
|
|||
1030
Cargo.lock
generated
1030
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,8 @@
|
|||
members = [
|
||||
"bread-shared",
|
||||
"breadd",
|
||||
"bread-cli"
|
||||
"bread-cli",
|
||||
"bread-sync",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] }
|
|||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
git2 = "0.18"
|
||||
dirs = "5.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3"
|
||||
glob = "0.3"
|
||||
|
|
|
|||
927
Documentation.md
Normal file
927
Documentation.md
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
# Bread Documentation
|
||||
|
||||
## Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [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)
|
||||
- [Bluetooth](#bluetooth)
|
||||
- [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** (`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
|
||||
|
||||
Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, and BlueZ Bluetooth.
|
||||
|
||||
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` (all values optional)
|
||||
- Lua entry point: `~/.config/bread/init.lua`
|
||||
- Lua modules: `~/.config/bread/modules/`
|
||||
|
||||
### 2) Minimal `init.lua`
|
||||
|
||||
```lua
|
||||
bread.on("bread.system.startup", function(event)
|
||||
bread.profile.activate("default")
|
||||
bread.log("bread started on " .. bread.machine.name())
|
||||
end)
|
||||
```
|
||||
|
||||
### 3) Start the daemon
|
||||
|
||||
```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 on " .. bread.machine.name())
|
||||
|
||||
bread.on("bread.device.*", function(event)
|
||||
bread.log("device event: " .. event.event)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
Key rules:
|
||||
|
||||
- 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
|
||||
|
||||
```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 config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required.
|
||||
|
||||
```bash
|
||||
# First-time setup (remote optional)
|
||||
bread sync init
|
||||
bread sync init --remote git@github.com:you/bread-config.git
|
||||
|
||||
# Commit local snapshot
|
||||
bread sync push
|
||||
bread sync push --message "before reinstall"
|
||||
|
||||
# Apply snapshot to this machine
|
||||
bread sync pull
|
||||
|
||||
# Also reinstall packages from snapshot
|
||||
bread sync pull --install-packages
|
||||
|
||||
# See what has changed
|
||||
bread sync status
|
||||
bread sync diff
|
||||
|
||||
# List known machines
|
||||
bread sync machines
|
||||
```
|
||||
|
||||
### Portable export/import
|
||||
|
||||
`export` creates a self-contained snapshot directory or `.tar.gz` — no git auth needed.
|
||||
|
||||
```bash
|
||||
# Create a portable snapshot (defaults to ./bread-export-<machine>-<date>.tar.gz)
|
||||
bread sync export
|
||||
|
||||
# Export to a specific path
|
||||
bread sync export --output ~/backups/bread.tar.gz
|
||||
bread sync export --output /mnt/usb/bread-snapshot/ # directory
|
||||
|
||||
# Apply a snapshot on another machine
|
||||
bread sync import bread-export-hermes-2026-05-16.tar.gz
|
||||
bread sync import /mnt/usb/bread-snapshot/
|
||||
|
||||
# Also install packages from the snapshot
|
||||
bread sync import bread-export.tar.gz --install-packages
|
||||
|
||||
# Skip cloning git repos back to their original locations
|
||||
bread sync import bread-export.tar.gz --no-clone-repos
|
||||
|
||||
# Skip confirmation prompt
|
||||
bread sync import bread-export.tar.gz --yes
|
||||
```
|
||||
|
||||
Each export snapshot includes:
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `bread/` | `~/.config/bread/` (your Bread config) |
|
||||
| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) |
|
||||
| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. |
|
||||
| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) |
|
||||
| `local-fonts/` | `~/.local/share/fonts/` |
|
||||
| `systemd/` | `~/.config/systemd/user/` units |
|
||||
| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) |
|
||||
| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) |
|
||||
| `machines/` | Per-machine profile with tags and last-sync time |
|
||||
| `manifest.toml` | Path map for exact restoration on import |
|
||||
| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) |
|
||||
|
||||
**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back.
|
||||
|
||||
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"]
|
||||
```
|
||||
|
||||
## Debugging tips
|
||||
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Dictionary: Lua API
|
||||
|
||||
Every API is exposed through the `bread` global table.
|
||||
|
||||
### Module declaration
|
||||
|
||||
Every module must call `bread.module` exactly once at the top level.
|
||||
|
||||
```lua
|
||||
local M = bread.module({
|
||||
name = "my.module",
|
||||
version = "0.1.0",
|
||||
after = { "bread.devices" }, -- optional: load after this module
|
||||
})
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
If a module does not call `bread.module`, it fails to load and is marked as a load error.
|
||||
|
||||
### 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. `opts` must contain a `filter` function:
|
||||
|
||||
```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 handler or state watch by ID.
|
||||
|
||||
#### `bread.emit(event, data)`
|
||||
Emit a custom event into the system pipeline. Useful for cross-module communication.
|
||||
|
||||
#### `bread.wait(pattern, opts) -> event | nil`
|
||||
Coroutine-only helper that suspends until a matching event arrives.
|
||||
|
||||
```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 it fails. Required for using `bread.wait`.
|
||||
|
||||
### State
|
||||
|
||||
#### `bread.state.get(path)`
|
||||
Read a state subtree by dotted path.
|
||||
|
||||
```lua
|
||||
local monitors = bread.state.get("monitors")
|
||||
local online = bread.state.get("network.online")
|
||||
```
|
||||
|
||||
#### 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 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.notify("AC connected")
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
||||
#### `bread.profile.activate(name)`
|
||||
Activate a named profile. Emits `bread.profile.activated` over IPC.
|
||||
|
||||
### Execution
|
||||
|
||||
#### `bread.exec(cmd)`
|
||||
Run a shell command. Fire-and-forget (async, does not block Lua).
|
||||
|
||||
### Notifications
|
||||
|
||||
#### `bread.notify(message, opts)`
|
||||
Send a desktop notification via `notify-send`.
|
||||
|
||||
Options:
|
||||
|
||||
| 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 a delay.
|
||||
|
||||
#### `bread.every(interval_ms, fn) -> id`
|
||||
Run on a repeating interval.
|
||||
|
||||
#### `bread.cancel(id)`
|
||||
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 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)`
|
||||
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.
|
||||
|
||||
```lua
|
||||
-- Dispatch a Hyprland command
|
||||
bread.hyprland.dispatch("workspace", "2")
|
||||
bread.hyprland.dispatch("exec", "kitty")
|
||||
|
||||
-- Set a keyword
|
||||
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
|
||||
|
||||
-- Query compositor state (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)
|
||||
```
|
||||
|
||||
### Bluetooth
|
||||
|
||||
The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`.
|
||||
|
||||
#### `bread.bluetooth.power(enabled)`
|
||||
Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget.
|
||||
|
||||
#### `bread.bluetooth.powered() -> bool | nil`
|
||||
Returns the current power state of the adapter, or `nil` if unavailable.
|
||||
|
||||
```lua
|
||||
if bread.bluetooth.powered() then
|
||||
bread.log("Bluetooth is on")
|
||||
end
|
||||
```
|
||||
|
||||
#### `bread.bluetooth.connect(address)`
|
||||
Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds.
|
||||
|
||||
```lua
|
||||
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||
```
|
||||
|
||||
#### `bread.bluetooth.disconnect(address)`
|
||||
Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`.
|
||||
|
||||
#### `bread.bluetooth.scan(enabled)`
|
||||
Start (`true`) or stop (`false`) device discovery.
|
||||
|
||||
#### `bread.bluetooth.devices() -> table | nil`
|
||||
Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable.
|
||||
|
||||
```lua
|
||||
local devs = bread.bluetooth.devices()
|
||||
if devs then
|
||||
for _, dev in ipairs(devs) do
|
||||
bread.log(dev.name .. " " .. dev.address
|
||||
.. (dev.connected and " [connected]" or ""))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Each device table:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` |
|
||||
| `name` | string | Device name from BlueZ (Alias or Name property) |
|
||||
| `connected` | bool | Whether the device is currently connected |
|
||||
| `paired` | bool | Whether the device is paired |
|
||||
|
||||
#### Example: auto-connect headphones on AC power
|
||||
|
||||
```lua
|
||||
local M = bread.module({ name = "headphones", version = "1.0.0" })
|
||||
local HEADPHONES = "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
function M.on_load()
|
||||
bread.state.watch("power.ac_connected", function(ac)
|
||||
if ac then
|
||||
bread.bluetooth.power(true)
|
||||
bread.bluetooth.connect(HEADPHONES)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
#### Example: turn off Bluetooth on battery
|
||||
|
||||
```lua
|
||||
bread.state.watch("power.ac_connected", function(ac)
|
||||
bread.bluetooth.power(ac)
|
||||
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 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")
|
||||
|
||||
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"),
|
||||
})
|
||||
```
|
||||
|
||||
| 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`.
|
||||
|
||||
### `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.on({
|
||||
when = "connected",
|
||||
device = "keyboard",
|
||||
run = function(event)
|
||||
bread.exec("xset r rate 200 40")
|
||||
end,
|
||||
})
|
||||
|
||||
devices.on({
|
||||
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,
|
||||
})
|
||||
```
|
||||
|
||||
#### Example: Dock-specific setup
|
||||
|
||||
```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")
|
||||
|
||||
workspaces.assign("1", "HDMI-A-1")
|
||||
workspaces.pin({ app = "Firefox", workspace = "2" })
|
||||
```
|
||||
|
||||
| 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",
|
||||
dispatch = "exec",
|
||||
args = "kitty",
|
||||
})
|
||||
```
|
||||
|
||||
| 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
|
||||
|
||||
Events are delivered as a `BreadEvent`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "bread.device.dock.connected",
|
||||
"timestamp": 1710000000000,
|
||||
"source": "Udev",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern matching
|
||||
|
||||
| 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
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.system.startup` | `{}` |
|
||||
|
||||
#### Devices (udev / Bluetooth)
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
|
||||
| `bread.device.disconnected` | same |
|
||||
| `bread.device.<device>.connected` | `{ id, device }` |
|
||||
| `bread.device.<device>.disconnected` | `{ id, device }` |
|
||||
|
||||
`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`.
|
||||
|
||||
Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`:
|
||||
|
||||
| `subsystem` | Source | Unique identifier field |
|
||||
|-------------|--------|------------------------|
|
||||
| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` |
|
||||
| `"bluetooth"` | BlueZ | `address` (MAC address) |
|
||||
|
||||
#### Bluetooth (BlueZ)
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` |
|
||||
| `bread.device.disconnected` | same |
|
||||
| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` |
|
||||
| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` |
|
||||
|
||||
`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects.
|
||||
|
||||
`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup.
|
||||
|
||||
#### Hyprland
|
||||
|
||||
| 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
|
||||
|
||||
| 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
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.network.connected` | `{ online, interfaces }` |
|
||||
| `bread.network.disconnected` | `{ online, interfaces }` |
|
||||
|
||||
#### System events
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.profile.activated` | `{ name }` |
|
||||
| `bread.notify.sent` | `{ title, message, urgency }` |
|
||||
| `bread.state.changed.<path>` | emitted by state watches |
|
||||
|
||||
---
|
||||
|
||||
## Dictionary: Runtime state schema
|
||||
|
||||
`bread state` and `bread.state.get("")` return the full `RuntimeState`:
|
||||
|
||||
```json
|
||||
{
|
||||
"monitors": [
|
||||
{ "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
|
||||
],
|
||||
"workspaces": [
|
||||
{ "id": "1", "monitor": "HDMI-A-1" }
|
||||
],
|
||||
"active_workspace": "1",
|
||||
"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/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:
|
||||
|
||||
| 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? }` |
|
||||
187
Examples.md
Normal file
187
Examples.md
Normal file
|
|
@ -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
|
||||
527
LUA_RUNTIME.md
527
LUA_RUNTIME.md
|
|
@ -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<RwLock<RuntimeState>>`. It is the authoritative record of what is true about the desktop right now.
|
||||
|
||||
```rust
|
||||
pub struct RuntimeState {
|
||||
pub monitors: Vec<Monitor>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub active_workspace: Option<String>,
|
||||
pub active_window: Option<String>,
|
||||
pub devices: DeviceTopology,
|
||||
pub network: NetworkState,
|
||||
pub power: PowerState,
|
||||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
}
|
||||
```
|
||||
|
||||
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<Monitor>
|
||||
bread.state.active_workspace() -- string | nil
|
||||
bread.state.active_window() -- string | nil
|
||||
bread.state.devices() -- Vec<Device>
|
||||
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.<path>` 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<String, serde_json::Value>` 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<Result<(), String>>,
|
||||
},
|
||||
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.
|
||||
443
README.md
443
README.md
|
|
@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d
|
|||
|
||||
Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically.
|
||||
|
||||
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development.
|
||||
> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that:
|
|||
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
|
||||
|
||||
```lua
|
||||
bread.on("bread.device.dock.connected", function()
|
||||
local M = bread.module({ name = "dock", version = "1.0.0" })
|
||||
|
||||
bread.on("bread.device.dock.connected", function(event)
|
||||
bread.profile.activate("desk")
|
||||
bread.exec("waybar --config ~/.config/waybar/desk.jsonc")
|
||||
bread.notify("Dock connected", { urgency = "low" })
|
||||
end)
|
||||
|
||||
bread.on("bread.device.dock.disconnected", function()
|
||||
bread.on("bread.device.dock.disconnected", function(event)
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -40,12 +45,13 @@ end)
|
|||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||
bread-sync/ Sync engine — snapshot and restore system state via a Git remote
|
||||
packaging/ Arch PKGBUILD and systemd user service
|
||||
```
|
||||
|
||||
The daemon is structured in four layers:
|
||||
|
||||
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces
|
||||
- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
|
||||
- **Normalizer** — transforms raw adapter signals into semantic Bread events
|
||||
- **State engine** — maintains runtime state and dispatches events to subscribers
|
||||
- **Lua runtime** — loads your modules, registers handlers, executes automation
|
||||
|
|
@ -62,6 +68,7 @@ The daemon is structured in four layers:
|
|||
Optional but preferred:
|
||||
- UPower (for battery events via D-Bus rather than sysfs polling)
|
||||
- rtnetlink (for network events; falls back to sysfs polling without it)
|
||||
- BlueZ (for Bluetooth device events and control)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -70,18 +77,22 @@ Optional but preferred:
|
|||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Breadway/bread
|
||||
git clone https://github.com/Breadway/bread.git
|
||||
cd bread
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binaries will be at `target/release/breadd` and `target/release/bread`.
|
||||
|
||||
Install them:
|
||||
Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/breadd /usr/local/bin/breadd
|
||||
sudo install -Dm755 target/release/bread /usr/local/bin/bread
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
Or step by step (system-wide install):
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
sudo install -Dm755 target/release/breadd /usr/bin/breadd
|
||||
sudo install -Dm755 target/release/bread /usr/bin/bread
|
||||
```
|
||||
|
||||
### Arch Linux (PKGBUILD)
|
||||
|
|
@ -128,19 +139,28 @@ poll_interval_secs = 30
|
|||
[adapters.network]
|
||||
enabled = true
|
||||
|
||||
[adapters.bluetooth]
|
||||
enabled = true
|
||||
|
||||
[events]
|
||||
dedup_window_ms = 100
|
||||
|
||||
[notifications]
|
||||
default_timeout_ms = 5000
|
||||
default_urgency = "normal"
|
||||
notify_send_path = "notify-send"
|
||||
|
||||
[modules]
|
||||
builtin = true # load built-in modules (monitors, devices, workspaces, binds)
|
||||
disable = [] # list of built-in module names to disable
|
||||
```
|
||||
|
||||
Your automation lives in `~/.config/bread/init.lua`:
|
||||
Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`:
|
||||
|
||||
```lua
|
||||
-- ~/.config/bread/init.lua
|
||||
|
||||
require("modules.devices")
|
||||
require("modules.workspaces")
|
||||
|
||||
bread.on("bread.system.startup", function()
|
||||
bread.on("bread.system.startup", function(event)
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
```
|
||||
|
|
@ -152,16 +172,160 @@ end)
|
|||
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||
|
||||
```bash
|
||||
bread reload # Hot-reload all Lua modules
|
||||
bread state # Dump full runtime state as JSON
|
||||
bread events # Stream live normalized events
|
||||
bread events --filter bread.device.* # Stream filtered events
|
||||
bread modules # List loaded modules and status
|
||||
bread profile-list # List defined profiles
|
||||
bread profile-activate <name> # Activate a named profile
|
||||
bread emit <event> --data '{}' # Manually fire an event (for testing)
|
||||
# Daemon
|
||||
bread ping # Check daemon connectivity
|
||||
bread health # Daemon version, uptime, PID
|
||||
bread doctor # Diagnose daemon and module health
|
||||
|
||||
# Lua runtime
|
||||
bread reload # Hot-reload all Lua modules
|
||||
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 bread.device.* # Stream filtered events
|
||||
bread events --since 60 # Replay events from the last 60 seconds
|
||||
bread emit <event> # Manually fire an event (for testing)
|
||||
|
||||
# Profiles
|
||||
bread profile-list # List defined profiles
|
||||
bread profile-activate <name> # Activate a named profile
|
||||
|
||||
# Modules
|
||||
bread modules list # List installed modules and daemon status
|
||||
bread modules install github:user/repo # Install from GitHub
|
||||
bread modules install /local/path # Install from a local directory
|
||||
bread modules remove <name> # Remove an installed module
|
||||
bread modules update [name] # Re-install one or all GitHub-sourced modules
|
||||
bread modules info <name> # Show full manifest and daemon status
|
||||
|
||||
# Sync
|
||||
bread sync init # Initialize sync for this machine (remote optional)
|
||||
bread sync push # Commit local snapshot
|
||||
bread sync push --message "note" # Commit with a custom message
|
||||
bread sync pull # Apply local snapshot to this machine
|
||||
bread sync pull --install-packages # Also install packages from snapshot
|
||||
bread sync status # Show what has changed since last push
|
||||
bread sync diff # Show file-level diff vs last commit
|
||||
bread sync machines # List known machines from sync repo
|
||||
bread sync export # Create a portable .tar.gz snapshot (no git auth)
|
||||
bread sync export --output path # Export to a specific file or directory
|
||||
bread sync import <path> # Apply a portable snapshot (.tar.gz or directory)
|
||||
bread sync import <path> --install-packages # Also install packages
|
||||
bread sync import <path> --no-clone-repos # Skip cloning git repos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module system
|
||||
|
||||
Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest.
|
||||
|
||||
### Installing modules
|
||||
|
||||
```bash
|
||||
# From GitHub (downloads latest release tarball)
|
||||
bread modules install github:someuser/bread-wifi
|
||||
|
||||
# From a local path
|
||||
bread modules install ~/src/my-module
|
||||
|
||||
# From a specific ref
|
||||
bread modules install github:someuser/bread-wifi@v1.2.0
|
||||
```
|
||||
|
||||
### Writing a module
|
||||
|
||||
A module directory looks like:
|
||||
|
||||
```
|
||||
~/.config/bread/modules/
|
||||
└── wifi/
|
||||
├── bread.module.toml ← required manifest
|
||||
└── init.lua ← entry point
|
||||
```
|
||||
|
||||
`bread.module.toml`:
|
||||
```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"
|
||||
```
|
||||
|
||||
`init.lua`:
|
||||
```lua
|
||||
local M = bread.module({ name = "wifi", version = "1.0.0" })
|
||||
|
||||
bread.on("bread.network.connected", function(event)
|
||||
bread.log("Network up: " .. (event.data.interface or "unknown"))
|
||||
end)
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sync system
|
||||
|
||||
Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote.
|
||||
|
||||
```bash
|
||||
# First-time setup (remote is optional)
|
||||
bread sync init
|
||||
bread sync init --remote git@github.com:you/bread-config.git
|
||||
|
||||
# Commit a local snapshot
|
||||
bread sync push
|
||||
|
||||
# Create a portable .tar.gz (no git auth required)
|
||||
bread sync export
|
||||
|
||||
# On another machine: apply the snapshot
|
||||
bread sync import bread-export-hermes-2026-05-16.tar.gz
|
||||
|
||||
# Also install packages on import
|
||||
bread sync import bread-export.tar.gz --install-packages
|
||||
```
|
||||
|
||||
Configure what gets synced in `~/.config/bread/sync.toml`:
|
||||
|
||||
```toml
|
||||
[remote]
|
||||
url = "git@github.com:you/bread-config.git" # optional
|
||||
branch = "main"
|
||||
|
||||
[machine]
|
||||
name = "hermes"
|
||||
tags = ["laptop", "battery"]
|
||||
|
||||
[packages]
|
||||
enabled = true
|
||||
managers = ["pacman", "pip", "cargo"]
|
||||
|
||||
[delegates]
|
||||
include = ["~/.config/nvim", "~/.config/waybar"]
|
||||
exclude = ["**/.git", "**/*.cache"]
|
||||
```
|
||||
|
||||
A portable export snapshot contains:
|
||||
|
||||
```
|
||||
bread-export-hermes-2026-05-16/
|
||||
├── bread/ ← ~/.config/bread/
|
||||
├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, …
|
||||
├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, …
|
||||
├── local-bin/ ← ~/.local/bin/ scripts
|
||||
├── local-fonts/ ← ~/.local/share/fonts/
|
||||
├── systemd/ ← ~/.config/systemd/user/ units
|
||||
├── system/ ← udev rules, modprobe, sysctl (sudo required for some)
|
||||
├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt
|
||||
├── machines/ ← per-machine profiles
|
||||
├── manifest.toml ← path map for exact restore
|
||||
└── restore.sh ← shell script for manual restore
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -175,9 +339,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
|||
| `bread.system.startup` | Daemon fully initialized |
|
||||
| `bread.device.connected` | Any device attached |
|
||||
| `bread.device.disconnected` | Any device removed |
|
||||
| `bread.device.dock.connected` | Dock attached |
|
||||
| `bread.device.dock.disconnected` | Dock removed |
|
||||
| `bread.device.keyboard.connected` | Keyboard attached |
|
||||
| `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
|
||||
| `bread.device.<device>.disconnected` | Named device removed |
|
||||
| `bread.monitor.connected` | Display connected |
|
||||
| `bread.monitor.disconnected` | Display disconnected |
|
||||
| `bread.workspace.changed` | Active workspace changed |
|
||||
|
|
@ -192,36 +355,91 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
|||
| `bread.power.battery.full` | Battery at 100% |
|
||||
| `bread.network.connected` | Network interface came online |
|
||||
| `bread.network.disconnected` | Network interface went offline |
|
||||
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
|
||||
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
|
||||
| `bread.profile.activated` | Profile switched |
|
||||
| `bread.notify.sent` | Desktop notification dispatched |
|
||||
|
||||
---
|
||||
|
||||
## Lua API
|
||||
|
||||
### Modules
|
||||
|
||||
Every module file must declare itself. The declaration is used for dependency ordering and status tracking.
|
||||
|
||||
```lua
|
||||
local M = bread.module({
|
||||
name = "my-module",
|
||||
version = "1.0.0",
|
||||
after = { "bread.devices" }, -- load after this module
|
||||
})
|
||||
|
||||
-- ... module body ...
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```lua
|
||||
-- Subscribe to an event
|
||||
bread.on("bread.monitor.connected", function(event)
|
||||
print(event.data.name)
|
||||
-- Subscribe to events; returns a subscription ID
|
||||
local id = bread.on("bread.monitor.connected", function(event)
|
||||
-- event.event → "bread.monitor.connected"
|
||||
-- event.data → table of event-specific fields
|
||||
-- event.source → adapter that produced it
|
||||
bread.log(event.event)
|
||||
end)
|
||||
|
||||
-- Subscribe once, then auto-unsubscribe
|
||||
-- Unsubscribe by ID
|
||||
bread.off(id)
|
||||
|
||||
-- Subscribe once, auto-unsubscribe after first delivery
|
||||
bread.once("bread.system.startup", function(event)
|
||||
-- runs exactly once
|
||||
bread.profile.activate("default")
|
||||
end)
|
||||
|
||||
-- Subscribe with a filter predicate. The predicate goes in an opts table.
|
||||
bread.filter("bread.device.connected", function(event)
|
||||
bread.exec("xset r rate 200 40")
|
||||
end, {
|
||||
filter = function(event)
|
||||
return event.data.device == "keyboard"
|
||||
end,
|
||||
})
|
||||
|
||||
-- Emit a custom event (for cross-module communication)
|
||||
bread.emit("mymodule.something", { key = "value" })
|
||||
```
|
||||
|
||||
Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character):
|
||||
```lua
|
||||
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
|
||||
bread.on("bread.device.**", handler) -- matches any depth under bread.device
|
||||
```
|
||||
|
||||
### State
|
||||
|
||||
```lua
|
||||
-- Read a value from runtime state by dot-separated path
|
||||
-- Read from runtime state by dot-separated path
|
||||
local monitors = bread.state.get("monitors")
|
||||
local workspace = bread.state.get("active_workspace")
|
||||
local power = bread.state.get("power")
|
||||
local online = bread.state.get("network.online")
|
||||
|
||||
-- Typed shorthands
|
||||
local monitors = bread.state.monitors()
|
||||
local workspace = bread.state.active_workspace()
|
||||
local window = bread.state.active_window()
|
||||
local devices = bread.state.devices()
|
||||
local power = bread.state.power()
|
||||
local network = bread.state.network()
|
||||
local profile = bread.state.profile()
|
||||
|
||||
-- Watch a state path for changes
|
||||
bread.state.watch("power.ac_connected", function(new_val, old_val)
|
||||
if new_val then
|
||||
bread.notify("AC connected")
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
### Profiles
|
||||
|
|
@ -231,12 +449,140 @@ bread.profile.activate("desk")
|
|||
bread.profile.activate("default")
|
||||
```
|
||||
|
||||
### Execution
|
||||
### Execution and notifications
|
||||
|
||||
```lua
|
||||
-- Fire-and-forget: returns immediately, process runs in background
|
||||
-- Fire-and-forget shell command
|
||||
bread.exec("kitty")
|
||||
bread.exec("notify-send 'Dock connected'")
|
||||
|
||||
-- Desktop notification (uses notify-send)
|
||||
bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" })
|
||||
bread.notify("Simple message") -- title defaults to "bread"
|
||||
```
|
||||
|
||||
### Timers
|
||||
|
||||
```lua
|
||||
-- Run once after a delay (ms)
|
||||
local id = bread.after(500, function()
|
||||
bread.exec("some-delayed-command")
|
||||
end)
|
||||
|
||||
-- Run on a repeating interval (ms)
|
||||
local id = bread.every(60000, function()
|
||||
bread.log("tick")
|
||||
end)
|
||||
|
||||
-- Cancel either kind
|
||||
bread.cancel(id)
|
||||
|
||||
-- Debounce a rapidly-firing handler
|
||||
local fn = bread.debounce(200, function(event)
|
||||
reconfigure_monitors()
|
||||
end)
|
||||
bread.on("bread.monitor.*", fn)
|
||||
```
|
||||
|
||||
### Wait (inside coroutines)
|
||||
|
||||
```lua
|
||||
-- Yield until a matching event arrives
|
||||
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
|
||||
if event then
|
||||
-- dock arrived within 5 seconds
|
||||
end
|
||||
```
|
||||
|
||||
### Machine and filesystem
|
||||
|
||||
```lua
|
||||
-- Machine identity (from sync.toml, falls back to hostname)
|
||||
local name = bread.machine.name()
|
||||
local tags = bread.machine.tags() -- array of strings
|
||||
local ok = bread.machine.has_tag("laptop")
|
||||
|
||||
-- Filesystem helpers (~ is expanded)
|
||||
bread.fs.write("~/.config/some/file", "content")
|
||||
local content = bread.fs.read("~/.config/some/file") -- nil if not found
|
||||
local exists = bread.fs.exists("~/some/path")
|
||||
local abs = bread.fs.expand("~/some/path")
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```lua
|
||||
bread.log("Module loaded") -- info level
|
||||
bread.warn("Unexpected state") -- warn level
|
||||
bread.error("Something failed") -- error level
|
||||
```
|
||||
|
||||
### Hyprland bindings
|
||||
|
||||
```lua
|
||||
-- Dispatch a Hyprland command
|
||||
bread.hyprland.dispatch("workspace", "2")
|
||||
bread.hyprland.dispatch("exec", "kitty")
|
||||
|
||||
-- Set a keyword
|
||||
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
|
||||
|
||||
-- Query compositor state
|
||||
local win = bread.hyprland.active_window()
|
||||
local monitors = bread.hyprland.monitors()
|
||||
local workspaces = bread.hyprland.workspaces()
|
||||
local clients = bread.hyprland.clients()
|
||||
|
||||
-- Subscribe to raw Hyprland events (bypass normalization)
|
||||
bread.hyprland.on_raw("activewindow", function(raw)
|
||||
-- raw is the unparsed string from Hyprland's event socket
|
||||
end)
|
||||
```
|
||||
|
||||
### Bluetooth
|
||||
|
||||
The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable.
|
||||
|
||||
```lua
|
||||
-- Power the adapter on or off
|
||||
bread.bluetooth.power(true)
|
||||
bread.bluetooth.power(false)
|
||||
|
||||
-- Query current power state (returns true/false, or nil if unavailable)
|
||||
local on = bread.bluetooth.powered()
|
||||
|
||||
-- Connect/disconnect a paired device by MAC address
|
||||
-- Fire-and-forget; result arrives as bread.device.connected/disconnected
|
||||
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||
bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
|
||||
|
||||
-- Start or stop device discovery
|
||||
bread.bluetooth.scan(true)
|
||||
bread.bluetooth.scan(false)
|
||||
|
||||
-- List all devices known to BlueZ
|
||||
local devs = bread.bluetooth.devices()
|
||||
-- Returns nil if BlueZ is unavailable, otherwise:
|
||||
-- { { address, name, connected, paired }, ... }
|
||||
```
|
||||
|
||||
Example — auto-connect headphones when Bluetooth powers on:
|
||||
|
||||
```lua
|
||||
bread.state.watch("power.ac_connected", function(ac)
|
||||
if ac then
|
||||
bread.bluetooth.power(true)
|
||||
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
### Module-scoped storage
|
||||
|
||||
Survives hot reload; does not survive daemon restart.
|
||||
|
||||
```lua
|
||||
M.store.set("last_profile", "docked")
|
||||
local p = M.store.get("last_profile") -- "docked"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -255,9 +601,24 @@ Response:
|
|||
{ "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`, `emit`.
|
||||
Available methods:
|
||||
|
||||
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects.
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ping` | Connectivity check |
|
||||
| `health` | Version, uptime, PID, adapter status |
|
||||
| `state.get` | Read a value from `RuntimeState` by dotted key path |
|
||||
| `state.dump` | Return the full `RuntimeState` as JSON |
|
||||
| `modules.list` | List all loaded modules and their status |
|
||||
| `modules.reload` | Hot-reload the Lua runtime |
|
||||
| `profile.list` | List defined profiles |
|
||||
| `profile.activate` | Switch active profile |
|
||||
| `events.subscribe` | Upgrade connection to streaming mode |
|
||||
| `events.replay` | Replay buffered events from the last N ms |
|
||||
| `emit` | Inject a synthetic event into the pipeline |
|
||||
| `sync.status` | Return sync initialization state and machine info |
|
||||
|
||||
`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -265,7 +626,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`,
|
|||
|
||||
Bread is early-stage software. Contributions, issues, and feedback are welcome.
|
||||
|
||||
The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening.
|
||||
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
[package]
|
||||
name = "bread-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bread"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "bread_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
bread-shared = { path = "../bread-shared" }
|
||||
bread-sync = { path = "../bread-sync" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
notify = "6.1"
|
||||
libc = "0.2"
|
||||
toml = "0.8"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
2
bread-cli/src/lib.rs
Normal file
2
bread-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// Module management (install, remove, list, update, info).
|
||||
pub mod modules_mgmt;
|
||||
File diff suppressed because it is too large
Load diff
181
bread-cli/src/modules_mgmt.rs
Normal file
181
bread-cli/src/modules_mgmt.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Contents of `bread.module.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleManifest {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub author: String,
|
||||
pub source: String,
|
||||
pub installed_at: String,
|
||||
}
|
||||
|
||||
/// Parsed install source.
|
||||
pub enum InstallSource {
|
||||
GitHub {
|
||||
user: String,
|
||||
repo: String,
|
||||
git_ref: Option<String>,
|
||||
},
|
||||
LocalPath(PathBuf),
|
||||
}
|
||||
|
||||
/// Parse a source string into an `InstallSource`.
|
||||
pub fn parse_source(source: &str) -> Result<InstallSource> {
|
||||
if let Some(rest) = source.strip_prefix("github:") {
|
||||
let (repo_part, ref_part) = rest
|
||||
.split_once('@')
|
||||
.map(|(r, v)| (r, Some(v.to_string())))
|
||||
.unwrap_or((rest, None));
|
||||
let (user, repo) = repo_part.split_once('/').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'",
|
||||
source
|
||||
)
|
||||
})?;
|
||||
Ok(InstallSource::GitHub {
|
||||
user: user.to_string(),
|
||||
repo: repo.to_string(),
|
||||
git_ref: ref_part,
|
||||
})
|
||||
} else if source.starts_with('/')
|
||||
|| source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('~')
|
||||
{
|
||||
let expanded = bread_sync::config::expand_path(source);
|
||||
Ok(InstallSource::LocalPath(expanded))
|
||||
} else {
|
||||
bail!(
|
||||
"bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path",
|
||||
source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a module from a local directory into `modules_dir`.
|
||||
/// `source_str` is the original source string recorded in the manifest.
|
||||
pub fn install_from_local(
|
||||
src: &Path,
|
||||
source_str: &str,
|
||||
modules_dir: &Path,
|
||||
) -> Result<ModuleManifest> {
|
||||
let manifest_path = src.join("bread.module.toml");
|
||||
if !manifest_path.exists() {
|
||||
bail!("bread: no bread.module.toml found in {}", src.display());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&manifest_path)
|
||||
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
|
||||
let mut manifest: ModuleManifest =
|
||||
toml::from_str(&raw).context("failed to parse bread.module.toml")?;
|
||||
|
||||
manifest.source = source_str.to_string();
|
||||
manifest.installed_at = Utc::now().to_rfc3339();
|
||||
|
||||
let dest = modules_dir.join(&manifest.name);
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)
|
||||
.with_context(|| format!("failed to remove existing module at {}", dest.display()))?;
|
||||
}
|
||||
copy_dir(src, &dest)?;
|
||||
|
||||
// Rewrite the manifest with the updated fields.
|
||||
let manifest_dest = dest.join("bread.module.toml");
|
||||
let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?;
|
||||
fs::write(&manifest_dest, out)
|
||||
.with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?;
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Remove a module directory from `modules_dir`.
|
||||
pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> {
|
||||
let module_dir = modules_dir.join(name);
|
||||
if !module_dir.exists() {
|
||||
bail!("bread: module '{}' is not installed", name);
|
||||
}
|
||||
fs::remove_dir_all(&module_dir)
|
||||
.with_context(|| format!("failed to remove {}", module_dir.display()))
|
||||
}
|
||||
|
||||
/// List all installed modules in `modules_dir`.
|
||||
pub fn list_modules(modules_dir: &Path) -> Result<Vec<ModuleManifest>> {
|
||||
if !modules_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(modules_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let manifest_path = path.join("bread.module.toml");
|
||||
if manifest_path.exists() {
|
||||
if let Ok(m) = read_manifest_file(&manifest_path) {
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a module manifest by name.
|
||||
pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result<ModuleManifest> {
|
||||
let manifest_path = modules_dir.join(name).join("bread.module.toml");
|
||||
if !manifest_path.exists() {
|
||||
bail!("bread: module '{}' is not installed", name);
|
||||
}
|
||||
read_manifest_file(&manifest_path)
|
||||
}
|
||||
|
||||
/// Read and parse a `bread.module.toml` file.
|
||||
pub fn read_manifest_file(path: &Path) -> Result<ModuleManifest> {
|
||||
let raw =
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
toml::from_str(&raw).context("failed to parse module manifest")
|
||||
}
|
||||
|
||||
/// Returns the default modules directory.
|
||||
pub fn modules_dir() -> PathBuf {
|
||||
if let Some(cfg) = dirs::config_dir() {
|
||||
return cfg.join("bread").join("modules");
|
||||
}
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(xdg).join("bread").join("modules");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("bread")
|
||||
.join("modules");
|
||||
}
|
||||
PathBuf::from(".config/bread/modules")
|
||||
}
|
||||
|
||||
fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
if src_path.is_dir() {
|
||||
copy_dir(&src_path, &dst_path)?;
|
||||
} else {
|
||||
fs::copy(&src_path, &dst_path).with_context(|| {
|
||||
format!(
|
||||
"failed to copy {} to {}",
|
||||
src_path.display(),
|
||||
dst_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
139
bread-cli/tests/modules.rs
Normal file
139
bread-cli/tests/modules.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use bread_cli::modules_mgmt;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper: create a minimal valid module directory in `dir` with given name.
|
||||
fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf {
|
||||
let module_dir = dir.join(name);
|
||||
fs::create_dir_all(&module_dir).unwrap();
|
||||
let manifest = format!(
|
||||
r#"name = "{name}"
|
||||
version = "{version}"
|
||||
description = "Test module"
|
||||
author = "test"
|
||||
source = "/tmp/test"
|
||||
installed_at = ""
|
||||
"#
|
||||
);
|
||||
fs::write(module_dir.join("bread.module.toml"), manifest).unwrap();
|
||||
fs::write(module_dir.join("init.lua"), "-- test\n").unwrap();
|
||||
module_dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_from_local_succeeds_with_manifest() {
|
||||
let src_tmp = TempDir::new().unwrap();
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
|
||||
make_module_dir(src_tmp.path(), "mymod", "1.2.3");
|
||||
let src = src_tmp.path().join("mymod");
|
||||
|
||||
let result = modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path());
|
||||
|
||||
assert!(result.is_ok(), "install failed: {:?}", result.err());
|
||||
let manifest = result.unwrap();
|
||||
assert_eq!(manifest.name, "mymod");
|
||||
assert_eq!(manifest.version, "1.2.3");
|
||||
|
||||
// Module directory must exist in modules dir
|
||||
assert!(modules_tmp.path().join("mymod").exists());
|
||||
assert!(modules_tmp
|
||||
.path()
|
||||
.join("mymod")
|
||||
.join("bread.module.toml")
|
||||
.exists());
|
||||
assert!(modules_tmp.path().join("mymod").join("init.lua").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_from_local_fails_without_manifest() {
|
||||
let src_tmp = TempDir::new().unwrap();
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
|
||||
// No bread.module.toml in src
|
||||
let src = src_tmp.path();
|
||||
fs::write(src.join("init.lua"), "-- no manifest\n").unwrap();
|
||||
|
||||
let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path());
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("bread.module.toml"),
|
||||
"expected error about bread.module.toml, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_module_directory() {
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
make_module_dir(modules_tmp.path(), "delme", "0.1.0");
|
||||
|
||||
// Verify it exists before removal
|
||||
assert!(modules_tmp.path().join("delme").exists());
|
||||
|
||||
let result = modules_mgmt::remove_module("delme", modules_tmp.path());
|
||||
assert!(result.is_ok(), "remove failed: {:?}", result.err());
|
||||
assert!(!modules_tmp.path().join("delme").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_errors() {
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
let result = modules_mgmt::remove_module("ghost", modules_tmp.path());
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("ghost"),
|
||||
"expected error mentioning module name, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reads_manifests_from_disk() {
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
make_module_dir(modules_tmp.path(), "alpha", "1.0.0");
|
||||
make_module_dir(modules_tmp.path(), "beta", "2.0.0");
|
||||
|
||||
// Add a non-module dir (no manifest) — should be ignored
|
||||
fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap();
|
||||
|
||||
let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap();
|
||||
assert_eq!(modules.len(), 2);
|
||||
assert_eq!(modules[0].name, "alpha");
|
||||
assert_eq!(modules[1].name, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_written_correctly_on_install() {
|
||||
let src_tmp = TempDir::new().unwrap();
|
||||
let modules_tmp = TempDir::new().unwrap();
|
||||
|
||||
make_module_dir(src_tmp.path(), "installtest", "3.0.0");
|
||||
let src = src_tmp.path().join("installtest");
|
||||
|
||||
let manifest =
|
||||
modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path())
|
||||
.unwrap();
|
||||
|
||||
// All required fields must be present and non-empty
|
||||
assert_eq!(manifest.name, "installtest");
|
||||
assert_eq!(manifest.version, "3.0.0");
|
||||
assert!(!manifest.description.is_empty());
|
||||
assert!(!manifest.author.is_empty());
|
||||
assert_eq!(manifest.source, "github:test/installtest");
|
||||
assert!(!manifest.installed_at.is_empty());
|
||||
|
||||
// installed_at must be valid RFC 3339
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"installed_at '{}' is not valid RFC 3339",
|
||||
manifest.installed_at
|
||||
);
|
||||
|
||||
// Verify the on-disk manifest also has all fields
|
||||
let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap();
|
||||
assert_eq!(on_disk.name, manifest.name);
|
||||
assert_eq!(on_disk.installed_at, manifest.installed_at);
|
||||
assert_eq!(on_disk.source, "github:test/installtest");
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bread-shared"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -1,32 +1,73 @@
|
|||
//! Shared types for the Bread automation fabric.
|
||||
//!
|
||||
//! This crate defines the canonical event types ([`RawEvent`], [`BreadEvent`])
|
||||
//! and the [`AdapterSource`] enum that both the daemon (`breadd`) and CLI
|
||||
//! (`bread-cli`) depend on. Keeping these types in a separate crate guarantees
|
||||
//! that adapters, the state engine, IPC clients, and the Lua bindings all
|
||||
//! agree on a single wire format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Identifies which adapter produced an event.
|
||||
///
|
||||
/// The state engine uses this to choose a normalization strategy and the
|
||||
/// IPC layer surfaces it so subscribers can filter by origin.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdapterSource {
|
||||
/// The Hyprland compositor IPC socket.
|
||||
Hyprland,
|
||||
/// The Linux udev / netlink subsystem.
|
||||
Udev,
|
||||
/// Power management (sysfs / UPower).
|
||||
Power,
|
||||
/// Network state (rtnetlink / NetworkManager).
|
||||
Network,
|
||||
/// Internal events synthesized by the daemon itself
|
||||
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
|
||||
System,
|
||||
/// BlueZ Bluetooth stack via D-Bus.
|
||||
Bluetooth,
|
||||
}
|
||||
|
||||
/// An unnormalized event as emitted by an adapter.
|
||||
///
|
||||
/// Raw events carry the adapter's native payload verbatim. The
|
||||
/// [`EventNormalizer`](../breadd/core/normalizer/struct.EventNormalizer.html)
|
||||
/// in `breadd` transforms `RawEvent` into one or more [`BreadEvent`]s with
|
||||
/// a semantic name and structured data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RawEvent {
|
||||
/// Which adapter produced this event.
|
||||
pub source: AdapterSource,
|
||||
/// Adapter-specific event kind (e.g. `"workspace"`, `"add"`, `"battery"`).
|
||||
pub kind: String,
|
||||
/// Adapter-specific JSON payload — not stable across versions.
|
||||
pub payload: serde_json::Value,
|
||||
/// Unix epoch milliseconds when the event was observed.
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// A normalized event ready for dispatch to Lua subscribers and IPC consumers.
|
||||
///
|
||||
/// `BreadEvent` is the public, stable contract: event names use a dotted
|
||||
/// namespace (e.g. `bread.device.dock.connected`) and the `data` payload
|
||||
/// follows a documented shape per event family. See `Documentation.md` for
|
||||
/// the full event catalogue.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BreadEvent {
|
||||
/// Dotted event name, e.g. `bread.workspace.changed`.
|
||||
pub event: String,
|
||||
/// Unix epoch milliseconds when the originating signal was observed.
|
||||
pub timestamp: u64,
|
||||
/// The adapter that produced the underlying raw event.
|
||||
pub source: AdapterSource,
|
||||
/// Structured event data. The shape depends on the event family.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl BreadEvent {
|
||||
/// Construct a new event with `timestamp` set to the current wall-clock.
|
||||
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
|
||||
Self {
|
||||
event: event.into(),
|
||||
|
|
@ -37,9 +78,142 @@ impl BreadEvent {
|
|||
}
|
||||
}
|
||||
|
||||
/// Current Unix epoch in milliseconds.
|
||||
///
|
||||
/// Falls back to `0` if the system clock is before the epoch, which keeps
|
||||
/// callers infallible. Used for `BreadEvent::timestamp` and replay cutoffs.
|
||||
pub fn now_unix_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn adapter_source_serializes_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Hyprland).unwrap(),
|
||||
"\"hyprland\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Udev).unwrap(),
|
||||
"\"udev\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Power).unwrap(),
|
||||
"\"power\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Network).unwrap(),
|
||||
"\"network\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::System).unwrap(),
|
||||
"\"system\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
|
||||
"\"bluetooth\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_source_round_trips_through_json() {
|
||||
for source in [
|
||||
AdapterSource::Hyprland,
|
||||
AdapterSource::Udev,
|
||||
AdapterSource::Power,
|
||||
AdapterSource::Network,
|
||||
AdapterSource::System,
|
||||
AdapterSource::Bluetooth,
|
||||
] {
|
||||
let s = serde_json::to_string(&source).unwrap();
|
||||
let back: AdapterSource = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(source, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_source_rejects_unknown_variant() {
|
||||
let result: Result<AdapterSource, _> = serde_json::from_str("\"floppy\"");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_event_new_sets_current_timestamp() {
|
||||
let before = now_unix_ms();
|
||||
let event = BreadEvent::new("bread.test", AdapterSource::System, json!({}));
|
||||
let after = now_unix_ms();
|
||||
|
||||
assert!(event.timestamp >= before);
|
||||
assert!(event.timestamp <= after);
|
||||
assert_eq!(event.event, "bread.test");
|
||||
assert_eq!(event.source, AdapterSource::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_event_new_accepts_owned_and_borrowed_names() {
|
||||
let owned = BreadEvent::new(String::from("bread.a"), AdapterSource::System, json!(null));
|
||||
let borrowed = BreadEvent::new("bread.b", AdapterSource::System, json!(null));
|
||||
assert_eq!(owned.event, "bread.a");
|
||||
assert_eq!(borrowed.event, "bread.b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_event_round_trips_through_json() {
|
||||
let original = BreadEvent {
|
||||
event: "bread.device.connected".to_string(),
|
||||
timestamp: 1_700_000_000_000,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({ "id": "usb-1-1.4", "name": "Logitech" }),
|
||||
};
|
||||
let raw = serde_json::to_string(&original).unwrap();
|
||||
let decoded: BreadEvent = serde_json::from_str(&raw).unwrap();
|
||||
|
||||
assert_eq!(decoded.event, original.event);
|
||||
assert_eq!(decoded.timestamp, original.timestamp);
|
||||
assert_eq!(decoded.source, original.source);
|
||||
assert_eq!(decoded.data, original.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_event_round_trips_through_json() {
|
||||
let original = RawEvent {
|
||||
source: AdapterSource::Hyprland,
|
||||
kind: "workspace".to_string(),
|
||||
payload: json!({ "data": "2" }),
|
||||
timestamp: 42,
|
||||
};
|
||||
let raw = serde_json::to_string(&original).unwrap();
|
||||
let decoded: RawEvent = serde_json::from_str(&raw).unwrap();
|
||||
|
||||
assert_eq!(decoded.kind, original.kind);
|
||||
assert_eq!(decoded.timestamp, original.timestamp);
|
||||
assert_eq!(decoded.source, original.source);
|
||||
assert_eq!(decoded.payload, original.payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_unix_ms_is_monotonically_non_decreasing_across_calls() {
|
||||
let a = now_unix_ms();
|
||||
let b = now_unix_ms();
|
||||
assert!(b >= a, "now_unix_ms went backwards: {a} -> {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_source_is_hashable_and_eq() {
|
||||
use std::collections::HashSet;
|
||||
let mut set = HashSet::new();
|
||||
set.insert(AdapterSource::Hyprland);
|
||||
set.insert(AdapterSource::Hyprland);
|
||||
set.insert(AdapterSource::Udev);
|
||||
set.insert(AdapterSource::Bluetooth);
|
||||
assert_eq!(set.len(), 3);
|
||||
assert!(set.contains(&AdapterSource::Hyprland));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
bread-sync/Cargo.toml
Normal file
18
bread-sync/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "bread-sync"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
git2.workspace = true
|
||||
dirs.workspace = true
|
||||
chrono.workspace = true
|
||||
glob.workspace = true
|
||||
toml = "0.8"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
88
bread-sync/README.md
Normal file
88
bread-sync/README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# bread-sync
|
||||
|
||||
Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote.
|
||||
|
||||
## Purpose
|
||||
|
||||
`bread-sync` provides the library backing `bread sync` commands. It handles:
|
||||
|
||||
- **Git operations** — clone, commit, push, pull, fetch, diff via `git2`
|
||||
- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages)
|
||||
- **Delegate file sync** — rsync-style directory copy with glob excludes
|
||||
- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo
|
||||
- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp
|
||||
|
||||
## Public API
|
||||
|
||||
### `config`
|
||||
|
||||
```rust
|
||||
SyncConfig::load(config_dir: &Path) -> Result<SyncConfig>
|
||||
SyncConfig::save(&self, config_dir: &Path) -> Result<()>
|
||||
SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/
|
||||
bread_config_dir() -> PathBuf // ~/.config/bread/
|
||||
expand_path(path: &str) -> PathBuf // expands ~/
|
||||
```
|
||||
|
||||
### `git`
|
||||
|
||||
```rust
|
||||
SyncRepo::init(path: &Path) -> Result<SyncRepo>
|
||||
SyncRepo::open(path: &Path) -> Result<SyncRepo>
|
||||
SyncRepo::clone_from(url: &str, path: &Path) -> Result<SyncRepo>
|
||||
SyncRepo::open_or_clone(url: &str, path: &Path) -> Result<SyncRepo>
|
||||
SyncRepo::commit(&self, message: &str) -> Result<Option<git2::Oid>> // None = nothing to commit
|
||||
SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()>
|
||||
SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only
|
||||
SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()>
|
||||
SyncRepo::is_clean(&self) -> Result<bool>
|
||||
SyncRepo::local_changes(&self) -> Result<Vec<(char, String)>>
|
||||
SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result<Vec<(char, String)>>
|
||||
SyncRepo::working_diff(&self) -> Result<String>
|
||||
SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result<String>
|
||||
SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()>
|
||||
SyncRepo::last_commit_time(&self) -> Option<DateTime<Local>>
|
||||
```
|
||||
|
||||
### `delegates`
|
||||
|
||||
```rust
|
||||
sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()>
|
||||
resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)>
|
||||
```
|
||||
|
||||
### `machine`
|
||||
|
||||
```rust
|
||||
MachineProfile::new(name: String, tags: Vec<String>) -> MachineProfile
|
||||
MachineProfile::write(&self, machines_dir: &Path) -> Result<()>
|
||||
MachineProfile::read(machines_dir: &Path, name: &str) -> Result<MachineProfile>
|
||||
MachineProfile::list(machines_dir: &Path) -> Result<Vec<MachineProfile>>
|
||||
hostname() -> String
|
||||
```
|
||||
|
||||
### `packages`
|
||||
|
||||
```rust
|
||||
snapshot(manager: &str, dest: &Path) -> Result<bool> // false = manager not found (non-fatal)
|
||||
parse_pacman(content: &str) -> Vec<String>
|
||||
parse_pip(content: &str) -> Vec<String>
|
||||
parse_npm(content: &str) -> Vec<String>
|
||||
parse_cargo(content: &str) -> Vec<String>
|
||||
```
|
||||
|
||||
## Sync repo layout
|
||||
|
||||
```
|
||||
~/.local/share/bread/sync-repo/
|
||||
├── bread/ ← snapshot of ~/.config/bread/
|
||||
├── configs/
|
||||
│ └── <basename>/ ← delegate paths
|
||||
├── machines/
|
||||
│ └── <name>.toml ← per-machine profiles
|
||||
└── packages/
|
||||
├── pacman.txt
|
||||
├── pip.txt
|
||||
├── npm.txt
|
||||
└── cargo.txt
|
||||
```
|
||||
259
bread-sync/src/config.rs
Normal file
259
bread-sync/src/config.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Configuration stored in `~/.config/bread/sync.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncConfig {
|
||||
pub remote: RemoteConfig,
|
||||
pub machine: MachineConfig,
|
||||
#[serde(default)]
|
||||
pub packages: PackagesConfig,
|
||||
#[serde(default)]
|
||||
pub delegates: DelegatesConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteConfig {
|
||||
pub url: String,
|
||||
#[serde(default = "default_branch")]
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
fn default_branch() -> String {
|
||||
"main".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MachineConfig {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackagesConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub managers: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for PackagesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
managers: vec![
|
||||
"pacman".to_string(),
|
||||
"aur".to_string(),
|
||||
"pip".to_string(),
|
||||
"npm".to_string(),
|
||||
"cargo".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DelegatesConfig {
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
impl SyncConfig {
|
||||
/// Load sync config from the given bread config directory.
|
||||
pub fn load(config_dir: &Path) -> Result<Self> {
|
||||
let path = config_dir.join("sync.toml");
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?;
|
||||
toml::from_str(&raw).context("failed to parse sync.toml")
|
||||
}
|
||||
|
||||
/// Save sync config to the given bread config directory.
|
||||
pub fn save(&self, config_dir: &Path) -> Result<()> {
|
||||
let path = config_dir.join("sync.toml");
|
||||
fs::create_dir_all(config_dir)?;
|
||||
let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?;
|
||||
fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
/// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`).
|
||||
pub fn local_repo_path() -> PathBuf {
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
return data_dir.join("bread").join("sync-repo");
|
||||
}
|
||||
// Fallback using $HOME
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home)
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("bread")
|
||||
.join("sync-repo");
|
||||
}
|
||||
PathBuf::from(".local/share/bread/sync-repo")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bread config directory (`~/.config/bread/`).
|
||||
pub fn bread_config_dir() -> PathBuf {
|
||||
if let Some(cfg) = dirs::config_dir() {
|
||||
return cfg.join("bread");
|
||||
}
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(xdg).join("bread");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(".config").join("bread");
|
||||
}
|
||||
PathBuf::from(".config/bread")
|
||||
}
|
||||
|
||||
/// Expand `~` to the home directory in a path string.
|
||||
pub fn expand_path(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample_config() -> SyncConfig {
|
||||
SyncConfig {
|
||||
remote: RemoteConfig {
|
||||
url: "git@github.com:user/repo.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: MachineConfig {
|
||||
name: "host".to_string(),
|
||||
tags: vec!["mobile".to_string()],
|
||||
},
|
||||
packages: PackagesConfig::default(),
|
||||
delegates: DelegatesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = sample_config();
|
||||
cfg.save(tmp.path()).unwrap();
|
||||
|
||||
assert!(tmp.path().join("sync.toml").exists());
|
||||
|
||||
let loaded = SyncConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(loaded.remote.url, cfg.remote.url);
|
||||
assert_eq!(loaded.remote.branch, cfg.remote.branch);
|
||||
assert_eq!(loaded.machine.name, cfg.machine.name);
|
||||
assert_eq!(loaded.machine.tags, cfg.machine.tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_config_returns_helpful_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("sync not initialized") || msg.contains("bread sync init"),
|
||||
"expected init hint, got: {msg}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_toml_returns_parse_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap();
|
||||
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.to_lowercase().contains("parse"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_config_default_includes_all_managers() {
|
||||
let cfg = PackagesConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert!(cfg.managers.contains(&"pacman".to_string()));
|
||||
assert!(cfg.managers.contains(&"aur".to_string()));
|
||||
assert!(cfg.managers.contains(&"pip".to_string()));
|
||||
assert!(cfg.managers.contains(&"npm".to_string()));
|
||||
assert!(cfg.managers.contains(&"cargo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_branch_defaults_to_main_when_omitted() {
|
||||
let raw = r#"
|
||||
[remote]
|
||||
url = "git@example.com:r.git"
|
||||
|
||||
[machine]
|
||||
name = "host"
|
||||
"#;
|
||||
let cfg: SyncConfig = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.remote.branch, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegates_default_is_empty() {
|
||||
let cfg = DelegatesConfig::default();
|
||||
assert!(cfg.include.is_empty());
|
||||
assert!(cfg.exclude.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_repo_path_resolves_to_data_dir() {
|
||||
let path = SyncConfig::local_repo_path();
|
||||
// Must include the bread sync-repo segment at the end.
|
||||
let suffix = path.iter().rev().take(2).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
suffix,
|
||||
vec![
|
||||
std::ffi::OsStr::new("sync-repo"),
|
||||
std::ffi::OsStr::new("bread")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_passes_through_absolute_paths() {
|
||||
assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread"));
|
||||
assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_expands_tilde_alone_to_home() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
assert_eq!(expand_path("~"), home);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_expands_tilde_prefix() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
assert_eq!(expand_path("~/.config"), home.join(".config"));
|
||||
}
|
||||
}
|
||||
}
|
||||
247
bread-sync/src/delegates.rs
Normal file
247
bread-sync/src/delegates.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
use anyhow::Result;
|
||||
use glob::Pattern;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::expand_path;
|
||||
|
||||
/// Copy all files from `src` into `dst`, mirroring the directory tree.
|
||||
/// Files present in `dst` but not in `src` are deleted (rsync-style).
|
||||
/// Files matching any `exclude` glob are skipped.
|
||||
pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> {
|
||||
let patterns: Vec<Pattern> = exclude
|
||||
.iter()
|
||||
.filter_map(|g| Pattern::new(g).ok())
|
||||
.collect();
|
||||
|
||||
fs::create_dir_all(dst)?;
|
||||
sync_dir_inner(src, dst, src, &patterns)
|
||||
}
|
||||
|
||||
fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> {
|
||||
// Remove files in dst that don't exist in src.
|
||||
if dst.exists() {
|
||||
for entry in fs::read_dir(dst)? {
|
||||
let entry = entry?;
|
||||
let rel = entry
|
||||
.path()
|
||||
.strip_prefix(dst)
|
||||
.unwrap_or(&entry.path())
|
||||
.to_path_buf();
|
||||
let src_counterpart = src.join(&rel);
|
||||
if !src_counterpart.exists() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
} else {
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let rel = src_path.strip_prefix(root).unwrap_or(&src_path);
|
||||
|
||||
if is_excluded(rel, root, patterns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path));
|
||||
|
||||
if src_path.is_dir() {
|
||||
fs::create_dir_all(&dst_path)?;
|
||||
sync_dir_inner(&src_path, &dst_path, root, patterns)?;
|
||||
} else {
|
||||
if let Some(parent) = dst_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool {
|
||||
let rel_str = rel.to_string_lossy();
|
||||
let file_name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy())
|
||||
.unwrap_or_default();
|
||||
|
||||
for pat in patterns {
|
||||
// Match against full relative path or just filename
|
||||
if pat.matches(&rel_str) || pat.matches(&file_name) {
|
||||
return true;
|
||||
}
|
||||
// For directory-name patterns (e.g. "**/.git"), also check component names
|
||||
if let Some(pat_str) = pat.as_str().strip_prefix("**/") {
|
||||
for component in rel.components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
if Pattern::new(pat_str)
|
||||
.map(|p| p.matches(&name.to_string_lossy()))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Resolve delegate paths from the config (expanding `~`).
|
||||
pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> {
|
||||
includes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let expanded = expand_path(s);
|
||||
let basename = expanded
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| s.clone());
|
||||
(basename, expanded)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn sync_dir_copies_nested_tree() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
|
||||
fs::create_dir_all(src.path().join("a/b/c")).unwrap();
|
||||
fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap();
|
||||
fs::write(src.path().join("root.txt"), "root").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(),
|
||||
"hello"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.path().join("root.txt")).unwrap(),
|
||||
"root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_overwrites_existing_files() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(src.path().join("f"), "new").unwrap();
|
||||
fs::write(dst.path().join("f"), "old").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_removes_files_no_longer_in_src() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(dst.path().join("orphan.txt"), "to remove").unwrap();
|
||||
fs::write(src.path().join("keeper.txt"), "stay").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
|
||||
assert!(!dst.path().join("orphan.txt").exists());
|
||||
assert!(dst.path().join("keeper.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_removes_directories_no_longer_in_src() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dst.path().join("ghost-dir")).unwrap();
|
||||
fs::write(dst.path().join("ghost-dir/x"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
assert!(!dst.path().join("ghost-dir").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_exclude_filters_by_basename_pattern() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(src.path().join("keep.lua"), "lua").unwrap();
|
||||
fs::write(src.path().join("trash.cache"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap();
|
||||
assert!(dst.path().join("keep.lua").exists());
|
||||
assert!(!dst.path().join("trash.cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_exclude_filters_nested_directory_by_name() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::create_dir_all(src.path().join(".git/objects")).unwrap();
|
||||
fs::write(src.path().join(".git/objects/abc"), "").unwrap();
|
||||
fs::write(src.path().join("init.lua"), "lua").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap();
|
||||
assert!(dst.path().join("init.lua").exists());
|
||||
assert!(!dst.path().join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_creates_destination_if_missing() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst_parent = TempDir::new().unwrap();
|
||||
let dst = dst_parent.path().join("brand-new");
|
||||
fs::write(src.path().join("hi"), "hi").unwrap();
|
||||
|
||||
sync_dir(src.path(), &dst, &[]).unwrap();
|
||||
assert!(dst.join("hi").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_empty_src_clears_dst() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(dst.path().join("a"), "").unwrap();
|
||||
fs::write(dst.path().join("b"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect();
|
||||
assert!(remaining.is_empty());
|
||||
}
|
||||
|
||||
// ─── resolve_include_paths ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_include_paths_uses_basename_as_key() {
|
||||
let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()];
|
||||
let resolved = resolve_include_paths(&includes);
|
||||
assert_eq!(resolved.len(), 2);
|
||||
assert_eq!(resolved[0].0, "bar");
|
||||
assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar"));
|
||||
assert_eq!(resolved[1].0, "quux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_include_paths_expands_tilde_in_source() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
let resolved = resolve_include_paths(&["~/Documents".to_string()]);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].1, home.join("Documents"));
|
||||
assert_eq!(resolved[0].0, "Documents");
|
||||
}
|
||||
}
|
||||
}
|
||||
850
bread-sync/src/export.rs
Normal file
850
bread-sync/src/export.rs
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use git2::Repository;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{expand_path, SyncConfig};
|
||||
use crate::delegates::sync_dir;
|
||||
use crate::machine::{hostname, MachineProfile};
|
||||
use crate::packages;
|
||||
|
||||
/// Maps a staged path back to the original absolute path on the source machine.
|
||||
/// Drives the import — no hardcoded paths needed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathRecord {
|
||||
/// Relative path within the export (e.g. "configs/hypr").
|
||||
pub staging: String,
|
||||
/// Original path with `~` (e.g. "~/.config/hypr").
|
||||
pub original: String,
|
||||
/// Whether this is a single file (false = directory).
|
||||
#[serde(default)]
|
||||
pub is_file: bool,
|
||||
}
|
||||
|
||||
/// A git repository found on the machine, keyed by its remote URL.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitRepoRecord {
|
||||
/// Path relative to $HOME (e.g. "Projects/bread").
|
||||
pub path: String,
|
||||
/// Remote URL (e.g. "https://github.com/Breadway/bread.git").
|
||||
pub remote: String,
|
||||
/// Branch that was checked out at export time.
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
/// Manifest stored in the export root as `manifest.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportManifest {
|
||||
pub version: u32,
|
||||
pub machine: String,
|
||||
pub hostname: String,
|
||||
pub exported_at: String,
|
||||
/// Explicit staging→original path map for all captured items.
|
||||
#[serde(default)]
|
||||
pub path_map: Vec<PathRecord>,
|
||||
/// High-level list of config dir names (for display).
|
||||
pub configs: Vec<String>,
|
||||
/// Git repos found on the source machine.
|
||||
#[serde(default)]
|
||||
pub repos: Vec<GitRepoRecord>,
|
||||
pub system: bool,
|
||||
pub packages: Vec<String>,
|
||||
// Legacy fields kept for forward compat (ignored on import)
|
||||
#[serde(default)]
|
||||
pub bread: bool,
|
||||
#[serde(default)]
|
||||
pub dotfiles: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub local_bin: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub systemd_units: Vec<String>,
|
||||
}
|
||||
|
||||
/// Config directories always included in the export (if they exist on disk).
|
||||
static BUILTIN_CONFIGS: &[(&str, &str)] = &[
|
||||
("hypr", "~/.config/hypr"),
|
||||
("fish", "~/.config/fish"),
|
||||
("kitty", "~/.config/kitty"),
|
||||
("nvim", "~/.config/nvim"),
|
||||
("ags", "~/.config/ags"),
|
||||
("wofi", "~/.config/wofi"),
|
||||
("waybar", "~/.config/waybar"),
|
||||
("dunst", "~/.config/dunst"),
|
||||
("mako", "~/.config/mako"),
|
||||
("hyprlock", "~/.config/hyprlock"),
|
||||
("hyprpaper", "~/.config/hyprpaper"),
|
||||
("swaylock", "~/.config/swaylock"),
|
||||
("wlogout", "~/.config/wlogout"),
|
||||
("swappy", "~/.config/swappy"),
|
||||
("btop", "~/.config/btop"),
|
||||
("waypaper", "~/.config/waypaper"),
|
||||
("wal", "~/.config/wal"),
|
||||
("gtk-3.0", "~/.config/gtk-3.0"),
|
||||
("gtk-4.0", "~/.config/gtk-4.0"),
|
||||
("keyd", "~/.config/keyd"),
|
||||
("autostart", "~/.config/autostart"),
|
||||
];
|
||||
|
||||
/// Standalone dotfiles captured as individual files: (staging-name, source-path).
|
||||
static BUILTIN_DOTFILES: &[(&str, &str)] = &[
|
||||
(".gitconfig", "~/.gitconfig"),
|
||||
("user-dirs.dirs", "~/.config/user-dirs.dirs"),
|
||||
("mimeapps.list", "~/.config/mimeapps.list"),
|
||||
("ssh_config", "~/.ssh/config"),
|
||||
(".zshrc", "~/.zshrc"),
|
||||
(".zprofile", "~/.zprofile"),
|
||||
(".zshenv", "~/.zshenv"),
|
||||
];
|
||||
|
||||
/// System-level directories. World-readable ones are copied directly;
|
||||
/// root-only ones (networkmanager, bluetooth) require running with sudo.
|
||||
static SYSTEM_PATHS: &[(&str, &str)] = &[
|
||||
("udev", "/etc/udev/rules.d"),
|
||||
("modprobe", "/etc/modprobe.d"),
|
||||
("sysctl", "/etc/sysctl.d"),
|
||||
("networkmanager", "/etc/NetworkManager/system-connections"),
|
||||
("bluetooth", "/var/lib/bluetooth"),
|
||||
];
|
||||
|
||||
/// Directories excluded from every recursive copy.
|
||||
static DEFAULT_EXCLUDES: &[&str] = &[
|
||||
"**/.git",
|
||||
"**/*.cache",
|
||||
"**/node_modules",
|
||||
"**/@girs",
|
||||
"**/__pycache__",
|
||||
"fish_variables?*",
|
||||
];
|
||||
|
||||
/// Directories skipped when searching for git repos.
|
||||
static GIT_SKIP_DIRS: &[&str] = &[
|
||||
".local", "Nextcloud", "target", "node_modules", "__pycache__",
|
||||
".cache", "snap", "flatpak", "@girs", "Steam",
|
||||
];
|
||||
|
||||
// ── stage_export ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a self-contained snapshot directory at `staging`.
|
||||
pub fn stage_export(
|
||||
cfg_dir: &Path,
|
||||
config: &SyncConfig,
|
||||
staging: &Path,
|
||||
) -> Result<ExportManifest> {
|
||||
fs::create_dir_all(staging)?;
|
||||
|
||||
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
|
||||
let mut path_map: Vec<PathRecord> = Vec::new();
|
||||
let mut included_configs: Vec<String> = Vec::new();
|
||||
|
||||
// Helper: tilde-ify an absolute path for storage in the manifest.
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
|
||||
let tilde = |p: &Path| -> String {
|
||||
p.strip_prefix(&home)
|
||||
.map(|rel| format!("~/{}", rel.display()))
|
||||
.unwrap_or_else(|_| p.display().to_string())
|
||||
};
|
||||
|
||||
// 1. Bread config → bread/
|
||||
let bread_dest = staging.join("bread");
|
||||
sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?;
|
||||
path_map.push(PathRecord {
|
||||
staging: "bread".to_string(),
|
||||
original: tilde(cfg_dir),
|
||||
is_file: false,
|
||||
});
|
||||
|
||||
// 2. Built-in + delegate configs → configs/<name>/
|
||||
let configs_dir = staging.join("configs");
|
||||
|
||||
for (name, raw_path) in BUILTIN_CONFIGS {
|
||||
let src = expand_path(raw_path);
|
||||
if src.exists() {
|
||||
let dst = configs_dir.join(name);
|
||||
sync_dir(&src, &dst, &excludes)
|
||||
.with_context(|| format!("failed to snapshot {raw_path}"))?;
|
||||
path_map.push(PathRecord {
|
||||
staging: format!("configs/{name}"),
|
||||
original: raw_path.to_string(),
|
||||
is_file: false,
|
||||
});
|
||||
included_configs.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include);
|
||||
for (basename, src_path) in &delegate_paths {
|
||||
if src_path.exists() && !included_configs.contains(basename) {
|
||||
let dst = configs_dir.join(basename);
|
||||
sync_dir(src_path, &dst, &config.delegates.exclude)
|
||||
.with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?;
|
||||
path_map.push(PathRecord {
|
||||
staging: format!("configs/{basename}"),
|
||||
original: tilde(src_path),
|
||||
is_file: false,
|
||||
});
|
||||
included_configs.push(basename.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dotfiles → dotfiles/
|
||||
let dotfiles_dir = staging.join("dotfiles");
|
||||
fs::create_dir_all(&dotfiles_dir)?;
|
||||
|
||||
for (dest_name, raw_path) in BUILTIN_DOTFILES {
|
||||
let src = expand_path(raw_path);
|
||||
if src.exists() {
|
||||
fs::copy(&src, dotfiles_dir.join(dest_name))
|
||||
.with_context(|| format!("failed to copy {raw_path}"))?;
|
||||
path_map.push(PathRecord {
|
||||
staging: format!("dotfiles/{dest_name}"),
|
||||
original: raw_path.to_string(),
|
||||
is_file: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. ~/.local/bin custom scripts → local-bin/
|
||||
// Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts).
|
||||
let local_bin_src = expand_path("~/.local/bin");
|
||||
let local_bin_dst = staging.join("local-bin");
|
||||
if local_bin_src.exists() {
|
||||
fs::create_dir_all(&local_bin_dst)?;
|
||||
let mut any = false;
|
||||
for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? {
|
||||
let entry = entry?;
|
||||
let meta = entry.metadata()?;
|
||||
if meta.file_type().is_symlink() || meta.len() > 512 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
fs::copy(&path, local_bin_dst.join(&name))?;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
path_map.push(PathRecord {
|
||||
staging: "local-bin".to_string(),
|
||||
original: "~/.local/bin".to_string(),
|
||||
is_file: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. ~/.local/share/fonts → local-fonts/
|
||||
let fonts_src = expand_path("~/.local/share/fonts");
|
||||
let fonts_dst = staging.join("local-fonts");
|
||||
if fonts_src.exists() {
|
||||
sync_dir(&fonts_src, &fonts_dst, &excludes)
|
||||
.context("failed to snapshot fonts")?;
|
||||
path_map.push(PathRecord {
|
||||
staging: "local-fonts".to_string(),
|
||||
original: "~/.local/share/fonts".to_string(),
|
||||
is_file: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. ~/.config/systemd/user → systemd/
|
||||
let systemd_src = expand_path("~/.config/systemd/user");
|
||||
let systemd_dst = staging.join("systemd");
|
||||
if systemd_src.exists() {
|
||||
sync_dir(&systemd_src, &systemd_dst, &excludes)
|
||||
.context("failed to snapshot systemd user units")?;
|
||||
path_map.push(PathRecord {
|
||||
staging: "systemd".to_string(),
|
||||
original: "~/.config/systemd/user".to_string(),
|
||||
is_file: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. System configs → system/ (read-only; restore needs sudo)
|
||||
let system_dst = staging.join("system");
|
||||
let mut has_system = false;
|
||||
for (name, raw_path) in SYSTEM_PATHS {
|
||||
let src = PathBuf::from(raw_path);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
match sync_dir(&src, &system_dst.join(name), &excludes) {
|
||||
Ok(_) => has_system = true,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("Permission denied") || msg.contains("permission denied") {
|
||||
eprintln!(
|
||||
"bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)"
|
||||
);
|
||||
} else {
|
||||
eprintln!("bread: warning: failed to snapshot {raw_path}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Package snapshots → packages/
|
||||
let packages_dir = staging.join("packages");
|
||||
let mut included_managers: Vec<String> = Vec::new();
|
||||
if config.packages.enabled {
|
||||
for manager in &config.packages.managers {
|
||||
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
||||
match packages::snapshot(manager, &dest_file) {
|
||||
Ok(true) => included_managers.push(manager.clone()),
|
||||
Ok(false) => {}
|
||||
Err(e) => eprintln!(
|
||||
"bread: warning: package snapshot for {manager} failed: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Machine profile → machines/
|
||||
let machines_dir = staging.join("machines");
|
||||
MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
|
||||
.write(&machines_dir)?;
|
||||
|
||||
// 11. Git repositories — find all repos with a remote, commit+push each
|
||||
let nc_dirs = nextcloud_sync_dirs(&home);
|
||||
if !nc_dirs.is_empty() {
|
||||
let labels: Vec<_> = nc_dirs.iter()
|
||||
.map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string()))
|
||||
.collect();
|
||||
eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", "));
|
||||
}
|
||||
let repos = find_git_repos(&home);
|
||||
commit_and_push_repos(&repos, &home);
|
||||
|
||||
// 12. Manifest
|
||||
let manifest = ExportManifest {
|
||||
version: 2,
|
||||
machine: config.machine.name.clone(),
|
||||
hostname: hostname(),
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
path_map,
|
||||
configs: included_configs,
|
||||
repos,
|
||||
system: has_system,
|
||||
packages: included_managers,
|
||||
bread: true,
|
||||
dotfiles: vec![],
|
||||
local_bin: vec![],
|
||||
systemd_units: vec![],
|
||||
};
|
||||
fs::write(
|
||||
staging.join("manifest.toml"),
|
||||
toml::to_string_pretty(&manifest).context("failed to serialize manifest")?,
|
||||
)?;
|
||||
|
||||
// 11. restore.sh
|
||||
let restore_path = staging.join("restore.sh");
|
||||
fs::write(&restore_path, generate_restore_sh(&manifest))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&restore_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&restore_path, perms)?;
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
// ── apply_import ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a staged snapshot directory to this machine.
|
||||
/// Returns a list of human-readable descriptions of what was applied.
|
||||
pub fn apply_import(
|
||||
staging: &Path,
|
||||
cfg_dir: &Path,
|
||||
install_packages: bool,
|
||||
clone_repos: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut applied: Vec<String> = Vec::new();
|
||||
|
||||
// Read manifest to get the path map
|
||||
let manifest_path = staging.join("manifest.toml");
|
||||
let path_map: Vec<PathRecord> = if manifest_path.exists() {
|
||||
let raw = fs::read_to_string(&manifest_path)?;
|
||||
toml::from_str::<ExportManifest>(&raw)
|
||||
.map(|m| m.path_map)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
if !path_map.is_empty() {
|
||||
// Manifest-driven restore: use path_map for exact original locations
|
||||
for record in &path_map {
|
||||
let src = staging.join(&record.staging);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
let dst = expand_path(&record.original);
|
||||
|
||||
if record.is_file {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Secure directory permissions for SSH
|
||||
if record.staging.contains("ssh_config") {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(p) = dst.parent() {
|
||||
if let Ok(m) = fs::metadata(p) {
|
||||
let mut perms = m.permissions();
|
||||
perms.set_mode(0o700);
|
||||
let _ = fs::set_permissions(p, perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::copy(&src, &dst)
|
||||
.with_context(|| format!("failed to restore {}", record.original))?;
|
||||
applied.push(record.original.clone());
|
||||
} else {
|
||||
sync_dir(&src, &dst, &[])
|
||||
.with_context(|| format!("failed to restore {}", record.original))?;
|
||||
applied.push(record.original.clone());
|
||||
|
||||
// Reload systemd if this was the systemd dir
|
||||
if record.staging == "systemd" {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
}
|
||||
|
||||
// Rebuild font cache after restoring fonts
|
||||
if record.staging == "local-fonts" {
|
||||
let _ = std::process::Command::new("fc-cache").arg("-f").status();
|
||||
}
|
||||
|
||||
// Make local-bin scripts executable
|
||||
if record.staging == "local-bin" {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(entries) = fs::read_dir(&dst) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
if entry.path().is_file() {
|
||||
if let Ok(m) = fs::metadata(entry.path()) {
|
||||
let mut perms = m.permissions();
|
||||
perms.set_mode(perms.mode() | 0o111);
|
||||
let _ = fs::set_permissions(entry.path(), perms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback for v1 exports without path_map
|
||||
let bread_src = staging.join("bread");
|
||||
if bread_src.exists() {
|
||||
sync_dir(&bread_src, cfg_dir, &[])?;
|
||||
applied.push("~/.config/bread".to_string());
|
||||
}
|
||||
let configs_dir = staging.join("configs");
|
||||
if configs_dir.exists() {
|
||||
let config_home = expand_path("~/.config");
|
||||
for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) {
|
||||
let src = entry.path();
|
||||
if src.is_dir() {
|
||||
let name = src.file_name().unwrap().to_string_lossy().to_string();
|
||||
sync_dir(&src, &config_home.join(&name), &[])?;
|
||||
applied.push(format!("~/.config/{name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package installs
|
||||
if install_packages {
|
||||
let packages_dir = staging.join("packages");
|
||||
if packages_dir.exists() {
|
||||
install_packages_from(&packages_dir)?;
|
||||
applied.push("packages installed".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Clone git repos
|
||||
if clone_repos {
|
||||
let manifest_path = staging.join("manifest.toml");
|
||||
if manifest_path.exists() {
|
||||
let raw = fs::read_to_string(&manifest_path)?;
|
||||
if let Ok(manifest) = toml::from_str::<ExportManifest>(&raw) {
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()));
|
||||
for repo in &manifest.repos {
|
||||
let dest = home.join(&repo.path);
|
||||
if dest.exists() {
|
||||
applied.push(format!("skip (exists): ~/{}", repo.path));
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
eprint!(" cloning ~/{} ... ", repo.path);
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["clone", "--branch", &repo.branch, &repo.remote])
|
||||
.arg(&dest)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
eprintln!("done");
|
||||
applied.push(format!("cloned ~/{}", repo.path));
|
||||
}
|
||||
_ => {
|
||||
eprintln!("failed");
|
||||
applied.push(format!("clone failed: ~/{}", repo.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
// ── commit_and_push_repos ───────────────────────────────────────────────────
|
||||
|
||||
fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) {
|
||||
if repos.is_empty() {
|
||||
return;
|
||||
}
|
||||
eprintln!("bread: committing and pushing {} repo(s)...", repos.len());
|
||||
for repo in repos {
|
||||
let dir = home.join(&repo.path);
|
||||
let dir_str = dir.to_string_lossy();
|
||||
|
||||
// Stage all changes
|
||||
let add = std::process::Command::new("git")
|
||||
.args(["-C", &dir_str, "add", "-A"])
|
||||
.output();
|
||||
if add.map(|o| !o.status.success()).unwrap_or(true) {
|
||||
eprintln!(" ~/{}: git add failed, skipping", repo.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if there's anything staged
|
||||
let has_changes = std::process::Command::new("git")
|
||||
.args(["-C", &dir_str, "diff", "--cached", "--quiet"])
|
||||
.status()
|
||||
.map(|s| !s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_changes {
|
||||
let commit = std::process::Command::new("git")
|
||||
.args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"])
|
||||
.output();
|
||||
match commit {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
eprintln!(
|
||||
" ~/{}: commit failed: {}",
|
||||
repo.path,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ~/{}: commit failed: {}", repo.path, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push
|
||||
eprint!(" ~/{}: pushing... ", repo.path);
|
||||
let push = std::process::Command::new("git")
|
||||
.args(["-C", &dir_str, "push"])
|
||||
.output();
|
||||
match push {
|
||||
Ok(o) if o.status.success() => eprintln!("ok"),
|
||||
Ok(o) => eprintln!(
|
||||
"failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => eprintln!("failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── find_git_repos ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots.
|
||||
/// Always includes ~/Nextcloud if it exists, even without a config file.
|
||||
fn nextcloud_sync_dirs(home: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let cfg = home.join(".config/Nextcloud/nextcloud.cfg");
|
||||
if let Ok(content) = fs::read_to_string(&cfg) {
|
||||
for line in content.lines() {
|
||||
if let Some(raw) = line.trim().strip_prefix("localPath=") {
|
||||
let p = PathBuf::from(raw);
|
||||
let p = if p.is_absolute() { p } else { home.join(p) };
|
||||
if !dirs.contains(&p) {
|
||||
dirs.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always treat ~/Nextcloud as off-limits if it exists
|
||||
let default_nc = home.join("Nextcloud");
|
||||
if default_nc.exists() && !dirs.contains(&default_nc) {
|
||||
dirs.push(default_nc);
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
|
||||
let nc_dirs = nextcloud_sync_dirs(home);
|
||||
let mut repos: Vec<GitRepoRecord> = Vec::new();
|
||||
|
||||
// Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell)
|
||||
walk_repos(home, home, 0, 1, &mut repos, &nc_dirs);
|
||||
|
||||
// Deeper search in common project directories
|
||||
for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] {
|
||||
let p = home.join(subdir);
|
||||
if p.exists() {
|
||||
walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs);
|
||||
}
|
||||
}
|
||||
|
||||
// .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf)
|
||||
let config_dir = home.join(".config");
|
||||
if config_dir.exists() {
|
||||
walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs);
|
||||
}
|
||||
|
||||
// Deduplicate by path, sort for determinism
|
||||
repos.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
repos.dedup_by(|a, b| a.path == b.path);
|
||||
repos
|
||||
}
|
||||
|
||||
fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec<GitRepoRecord>, nc_dirs: &[PathBuf]) {
|
||||
// Skip anything inside a Nextcloud sync root
|
||||
if nc_dirs.iter().any(|nc| dir.starts_with(nc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if dir.join(".git").exists() {
|
||||
if let Ok(repo) = Repository::open(dir) {
|
||||
let remote_url = repo
|
||||
.find_remote("origin")
|
||||
.ok()
|
||||
.and_then(|r| r.url().map(str::to_string));
|
||||
|
||||
if let Some(remote) = remote_url {
|
||||
let branch = repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.shorthand().map(str::to_string))
|
||||
.unwrap_or_else(|| "main".to_string());
|
||||
|
||||
let rel = dir
|
||||
.strip_prefix(home)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| dir.to_string_lossy().to_string());
|
||||
|
||||
repos.push(GitRepoRecord { path: rel, remote, branch });
|
||||
}
|
||||
}
|
||||
return; // don't recurse into git repos (skip submodules)
|
||||
}
|
||||
|
||||
if depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
if GIT_SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── package install ─────────────────────────────────────────────────────────
|
||||
|
||||
fn install_packages_from(packages_dir: &Path) -> Result<()> {
|
||||
let pacman_file = packages_dir.join("pacman.txt");
|
||||
if pacman_file.exists() {
|
||||
let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?);
|
||||
if !pkgs.is_empty() {
|
||||
eprintln!("bread: installing {} pacman packages...", pkgs.len());
|
||||
let _ = std::process::Command::new("sudo")
|
||||
.args(["pacman", "-S", "--needed"])
|
||||
.args(&pkgs)
|
||||
.status();
|
||||
}
|
||||
}
|
||||
let cargo_file = packages_dir.join("cargo.txt");
|
||||
if cargo_file.exists() {
|
||||
for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) {
|
||||
let _ = std::process::Command::new("cargo").args(["install", &pkg]).status();
|
||||
}
|
||||
}
|
||||
let pip_file = packages_dir.join("pip.txt");
|
||||
if pip_file.exists() {
|
||||
let _ = std::process::Command::new("pip")
|
||||
.args(["install", "--user", "-r"])
|
||||
.arg(&pip_file)
|
||||
.status();
|
||||
}
|
||||
let npm_file = packages_dir.join("npm.txt");
|
||||
if npm_file.exists() {
|
||||
for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) {
|
||||
let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── restore.sh ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_restore_sh(manifest: &ExportManifest) -> String {
|
||||
let ts = &manifest.exported_at[..16];
|
||||
let mut s = String::new();
|
||||
|
||||
s.push_str("#!/bin/bash\n");
|
||||
s.push_str("set -e\n");
|
||||
s.push_str("cd \"$(dirname \"$0\")\"\n");
|
||||
s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n");
|
||||
s.push_str(&format!(
|
||||
"echo \"Restoring bread snapshot for {} ({})\"\n\n",
|
||||
manifest.machine, ts
|
||||
));
|
||||
|
||||
// Config dirs and dotfiles from path_map
|
||||
let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect();
|
||||
let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect();
|
||||
|
||||
if !dirs.is_empty() {
|
||||
s.push_str("# configs and directories\n");
|
||||
for r in &dirs {
|
||||
let dst = &r.original;
|
||||
let src = &r.staging;
|
||||
s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n"));
|
||||
s.push_str(&format!(" mkdir -p \"{dst}\"\n"));
|
||||
s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n"));
|
||||
if r.staging == "systemd" {
|
||||
s.push_str(" systemctl --user daemon-reload\n");
|
||||
}
|
||||
if r.staging == "local-bin" {
|
||||
s.push_str(" chmod +x \"${dst}\"/*\n");
|
||||
}
|
||||
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
|
||||
s.push_str("fi\n");
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
if !files.is_empty() {
|
||||
s.push_str("# dotfiles\n");
|
||||
for r in &files {
|
||||
let dst = &r.original;
|
||||
let src = &r.staging;
|
||||
s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n"));
|
||||
if r.staging.contains("ssh_config") {
|
||||
s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n");
|
||||
}
|
||||
// Expand ~ in destination for shell
|
||||
let dst_shell = dst.replace('~', "$HOME");
|
||||
s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n"));
|
||||
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
|
||||
s.push_str("fi\n");
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
// Packages
|
||||
if !manifest.packages.is_empty() {
|
||||
s.push_str("echo \"\"\n");
|
||||
s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n");
|
||||
if manifest.packages.contains(&"pacman".to_string()) {
|
||||
s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n");
|
||||
}
|
||||
if manifest.packages.contains(&"cargo".to_string()) {
|
||||
s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n");
|
||||
}
|
||||
if manifest.packages.contains(&"pip".to_string()) {
|
||||
s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n");
|
||||
}
|
||||
if manifest.packages.contains(&"npm".to_string()) {
|
||||
s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n");
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
// System files
|
||||
if manifest.system {
|
||||
s.push_str("echo \"\"\n");
|
||||
s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n");
|
||||
s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n");
|
||||
s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n");
|
||||
s.push_str("fi\n");
|
||||
s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n");
|
||||
s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n");
|
||||
s.push_str("fi\n");
|
||||
s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n");
|
||||
s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n");
|
||||
s.push_str("fi\n");
|
||||
s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n");
|
||||
s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n");
|
||||
s.push_str("fi\n");
|
||||
s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n");
|
||||
s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n");
|
||||
s.push_str("fi\n\n");
|
||||
}
|
||||
|
||||
// Git repos
|
||||
if !manifest.repos.is_empty() {
|
||||
s.push_str("echo \"\"\n");
|
||||
s.push_str("echo \"--- Git repositories ---\"\n");
|
||||
for repo in &manifest.repos {
|
||||
let dest = format!("$HOME/{}", repo.path);
|
||||
let branch = &repo.branch;
|
||||
let remote = &repo.remote;
|
||||
// Create parent dir and clone; skip if already present
|
||||
let parent = std::path::Path::new(&repo.path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if !parent.is_empty() {
|
||||
s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n"));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"if [ ! -d \"{dest}/.git\" ]; then\n"
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n",
|
||||
repo.path
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"else\n echo \"[skip] ~/{} (already exists)\"\nfi\n",
|
||||
repo.path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
364
bread-sync/src/git.rs
Normal file
364
bread-sync/src/git.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
use anyhow::{Context, Result};
|
||||
use git2::{
|
||||
build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks,
|
||||
Repository, Signature, StatusOptions,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Wraps a git2 repository with sync-specific operations.
|
||||
pub struct SyncRepo {
|
||||
repo: Repository,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl SyncRepo {
|
||||
/// Open an existing repository at `path`.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let repo = Repository::open(path)
|
||||
.with_context(|| format!("failed to open git repo at {}", path.display()))?;
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone `url` into `path`.
|
||||
pub fn clone_from(url: &str, path: &Path) -> Result<Self> {
|
||||
let fetch_opts = make_fetch_options();
|
||||
let mut builder = git2::build::RepoBuilder::new();
|
||||
builder.fetch_options(fetch_opts);
|
||||
let repo = builder
|
||||
.clone(url, path)
|
||||
.with_context(|| format!("failed to clone {} into {}", url, path.display()))?;
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the repo at `path` if it exists; otherwise clone from `url`.
|
||||
pub fn open_or_clone(url: &str, path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
Self::open(path)
|
||||
} else {
|
||||
std::fs::create_dir_all(path)?;
|
||||
Self::clone_from(url, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new empty repository at `path` with `main` as the initial branch.
|
||||
pub fn init(path: &Path) -> Result<Self> {
|
||||
std::fs::create_dir_all(path)?;
|
||||
let mut opts = git2::RepositoryInitOptions::new();
|
||||
opts.initial_head("main");
|
||||
let repo = Repository::init_opts(path, &opts)
|
||||
.with_context(|| format!("failed to init git repo at {}", path.display()))?;
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stage all changes (equivalent to `git add -A`).
|
||||
pub fn stage_all(&self) -> Result<()> {
|
||||
let mut index = self.repo.index().context("failed to get git index")?;
|
||||
index
|
||||
.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
|
||||
.context("failed to stage changes")?;
|
||||
index.write().context("failed to write git index")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a commit. Returns `None` if there are no staged changes.
|
||||
pub fn commit(&self, message: &str) -> Result<Option<git2::Oid>> {
|
||||
self.stage_all()?;
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
let tree_id = index.write_tree()?;
|
||||
|
||||
// Check if tree matches current HEAD (nothing to commit)
|
||||
if let Ok(head) = self.repo.head() {
|
||||
if let Ok(head_commit) = head.peel_to_commit() {
|
||||
if head_commit.tree_id() == tree_id {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tree = self.repo.find_tree(tree_id)?;
|
||||
let sig = Signature::now("Bread Sync", "bread@localhost")?;
|
||||
|
||||
let oid = match self.repo.head() {
|
||||
Ok(head) => {
|
||||
let parent = head.peel_to_commit()?;
|
||||
self.repo
|
||||
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
|
||||
}
|
||||
Err(_) => {
|
||||
// First commit — no parents
|
||||
self.repo
|
||||
.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(oid))
|
||||
}
|
||||
|
||||
/// Push `branch` to `remote_name`.
|
||||
pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> {
|
||||
let mut remote = self
|
||||
.repo
|
||||
.find_remote(remote_name)
|
||||
.with_context(|| format!("remote '{}' not found", remote_name))?;
|
||||
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
let mut push_opts = PushOptions::new();
|
||||
let callbacks = make_callbacks();
|
||||
push_opts.remote_callbacks(callbacks);
|
||||
remote
|
||||
.push(&[refspec.as_str()], Some(&mut push_opts))
|
||||
.context("git push failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch `branch` from `remote_name` without merging.
|
||||
pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> {
|
||||
let mut remote = self
|
||||
.repo
|
||||
.find_remote(remote_name)
|
||||
.with_context(|| format!("remote '{}' not found", remote_name))?;
|
||||
let mut fetch_opts = make_fetch_options();
|
||||
remote
|
||||
.fetch(&[branch], Some(&mut fetch_opts), None)
|
||||
.context("git fetch failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch and fast-forward merge. Errors on non-fast-forward.
|
||||
pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> {
|
||||
self.fetch(remote_name, branch)?;
|
||||
|
||||
let fetch_head = self
|
||||
.repo
|
||||
.find_reference("FETCH_HEAD")
|
||||
.context("FETCH_HEAD not found after fetch")?;
|
||||
let fetch_commit = self
|
||||
.repo
|
||||
.reference_to_annotated_commit(&fetch_head)
|
||||
.context("failed to get annotated commit from FETCH_HEAD")?;
|
||||
|
||||
let (analysis, _) = self
|
||||
.repo
|
||||
.merge_analysis(&[&fetch_commit])
|
||||
.context("merge analysis failed")?;
|
||||
|
||||
if analysis.is_up_to_date() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if analysis.is_fast_forward() {
|
||||
let target_id = fetch_commit.id();
|
||||
let ref_name = format!("refs/heads/{branch}");
|
||||
match self.repo.find_reference(&ref_name) {
|
||||
Ok(mut r) => {
|
||||
r.set_target(target_id, "fast-forward pull")?;
|
||||
}
|
||||
Err(_) => {
|
||||
self.repo
|
||||
.reference(&ref_name, target_id, true, "fast-forward pull")?;
|
||||
}
|
||||
}
|
||||
self.repo.set_head(&ref_name)?;
|
||||
self.repo
|
||||
.checkout_head(Some(CheckoutBuilder::default().force()))
|
||||
.context("checkout failed during pull")?;
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"bread: sync conflict — resolve manually in {}",
|
||||
self.path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if working tree has no uncommitted changes.
|
||||
pub fn is_clean(&self) -> Result<bool> {
|
||||
Ok(self.local_changes()?.is_empty())
|
||||
}
|
||||
|
||||
/// Returns list of (status_char, path) for working-tree changes vs HEAD.
|
||||
pub fn local_changes(&self) -> Result<Vec<(char, String)>> {
|
||||
let mut status_opts = StatusOptions::new();
|
||||
status_opts
|
||||
.include_untracked(true)
|
||||
.recurse_untracked_dirs(true);
|
||||
|
||||
let statuses = self
|
||||
.repo
|
||||
.statuses(Some(&mut status_opts))
|
||||
.context("failed to get git status")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in statuses.iter() {
|
||||
let s = entry.status();
|
||||
let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) {
|
||||
'A'
|
||||
} else if s.contains(git2::Status::INDEX_DELETED)
|
||||
|| s.contains(git2::Status::WT_DELETED)
|
||||
{
|
||||
'D'
|
||||
} else {
|
||||
'M'
|
||||
};
|
||||
if let Some(path) = entry.path() {
|
||||
out.push((ch, path.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Returns list of (status_char, path) for changes on remote not yet pulled.
|
||||
pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result<Vec<(char, String)>> {
|
||||
// We compare HEAD to remote/branch
|
||||
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
|
||||
let remote_oid = match self.repo.find_reference(&remote_ref) {
|
||||
Ok(r) => r.peel_to_commit()?.id(),
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let head_commit = match self.repo.head() {
|
||||
Ok(h) => h.peel_to_commit()?.id(),
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
|
||||
if head_commit == remote_oid {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let head_tree = self.repo.find_commit(head_commit)?.tree()?;
|
||||
let remote_tree = self.repo.find_commit(remote_oid)?.tree()?;
|
||||
|
||||
let diff = self
|
||||
.repo
|
||||
.diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None)
|
||||
.context("failed to compute remote diff")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for delta in diff.deltas() {
|
||||
let ch = match delta.status() {
|
||||
git2::Delta::Added => 'A',
|
||||
git2::Delta::Deleted => 'D',
|
||||
_ => 'M',
|
||||
};
|
||||
if let Some(path) = delta.new_file().path() {
|
||||
out.push((ch, path.to_string_lossy().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Return a unified diff string of working tree vs HEAD.
|
||||
pub fn working_diff(&self) -> Result<String> {
|
||||
let head_tree = match self.repo.head() {
|
||||
Ok(h) => Some(h.peel_to_tree()?),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let diff = self
|
||||
.repo
|
||||
.diff_tree_to_workdir_with_index(head_tree.as_ref(), None)
|
||||
.context("failed to compute working diff")?;
|
||||
|
||||
let mut out = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
let prefix = match line.origin() {
|
||||
'+' | '-' | ' ' => line.origin().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push_str(&prefix);
|
||||
if let Ok(s) = std::str::from_utf8(line.content()) {
|
||||
out.push_str(s);
|
||||
}
|
||||
true
|
||||
})
|
||||
.context("failed to format diff")?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Return a unified diff string between HEAD and remote branch HEAD.
|
||||
pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result<String> {
|
||||
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
|
||||
let remote_oid = self
|
||||
.repo
|
||||
.find_reference(&remote_ref)
|
||||
.and_then(|r| r.peel_to_commit())
|
||||
.map(|c| c.id())
|
||||
.ok();
|
||||
|
||||
let head_tree = match self.repo.head() {
|
||||
Ok(h) => Some(h.peel_to_tree()?),
|
||||
Err(_) => None,
|
||||
};
|
||||
let remote_tree = remote_oid
|
||||
.and_then(|id| self.repo.find_commit(id).ok())
|
||||
.and_then(|c| c.tree().ok());
|
||||
|
||||
let diff = self
|
||||
.repo
|
||||
.diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None)
|
||||
.context("failed to compute remote diff")?;
|
||||
|
||||
let mut out = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
let prefix = match line.origin() {
|
||||
'+' | '-' | ' ' => line.origin().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push_str(&prefix);
|
||||
if let Ok(s) = std::str::from_utf8(line.content()) {
|
||||
out.push_str(s);
|
||||
}
|
||||
true
|
||||
})
|
||||
.context("failed to format remote diff")?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set a named remote.
|
||||
pub fn set_remote(&self, name: &str, url: &str) -> Result<()> {
|
||||
let _ = self.repo.remote_delete(name);
|
||||
self.repo
|
||||
.remote(name, url)
|
||||
.with_context(|| format!("failed to set remote {name}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the timestamp of the last commit, or None if no commits.
|
||||
pub fn last_commit_time(&self) -> Option<chrono::DateTime<chrono::Local>> {
|
||||
let head = self.repo.head().ok()?;
|
||||
let commit = head.peel_to_commit().ok()?;
|
||||
let t = commit.time();
|
||||
// git2::Time uses seconds-from-epoch and offset-in-minutes
|
||||
let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?;
|
||||
Some(naive.with_timezone(&chrono::Local))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_callbacks<'a>() -> RemoteCallbacks<'a> {
|
||||
let mut cb = RemoteCallbacks::new();
|
||||
cb.credentials(|_url, username_from_url, allowed_types| {
|
||||
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
|
||||
return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"));
|
||||
}
|
||||
Cred::default()
|
||||
});
|
||||
cb
|
||||
}
|
||||
|
||||
fn make_fetch_options<'a>() -> FetchOptions<'a> {
|
||||
let mut opts = FetchOptions::new();
|
||||
opts.remote_callbacks(make_callbacks());
|
||||
opts
|
||||
}
|
||||
11
bread-sync/src/lib.rs
Normal file
11
bread-sync/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/// Bread sync: snapshot and restore system state via a Git remote.
|
||||
pub mod config;
|
||||
pub mod delegates;
|
||||
pub mod export;
|
||||
pub mod git;
|
||||
pub mod machine;
|
||||
pub mod packages;
|
||||
|
||||
pub use config::SyncConfig;
|
||||
pub use export::{apply_import, stage_export, ExportManifest};
|
||||
pub use git::SyncRepo;
|
||||
167
bread-sync/src/machine.rs
Normal file
167
bread-sync/src/machine.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Machine profile stored in `machines/<name>.toml` in the sync repo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MachineProfile {
|
||||
pub name: String,
|
||||
pub hostname: String,
|
||||
pub tags: Vec<String>,
|
||||
pub last_sync: String, // RFC 3339
|
||||
}
|
||||
|
||||
impl MachineProfile {
|
||||
/// Create a new profile for this machine.
|
||||
pub fn new(name: String, tags: Vec<String>) -> Self {
|
||||
Self {
|
||||
hostname: hostname(),
|
||||
name,
|
||||
tags,
|
||||
last_sync: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write this profile to `<machines_dir>/<name>.toml`.
|
||||
pub fn write(&self, machines_dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(machines_dir)?;
|
||||
let path = machines_dir.join(format!("{}.toml", self.name));
|
||||
let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?;
|
||||
fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
/// Read a machine profile from `<machines_dir>/<name>.toml`.
|
||||
pub fn read(machines_dir: &Path, name: &str) -> Result<Self> {
|
||||
let path = machines_dir.join(format!("{name}.toml"));
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
toml::from_str(&raw).context("failed to parse machine profile")
|
||||
}
|
||||
|
||||
/// List all machine profiles in `machines_dir`.
|
||||
pub fn list(machines_dir: &Path) -> Result<Vec<Self>> {
|
||||
if !machines_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(machines_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
|
||||
if let Ok(raw) = fs::read_to_string(&path) {
|
||||
if let Ok(profile) = toml::from_str::<Self>(&raw) {
|
||||
out.push(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the system hostname.
|
||||
pub fn hostname() -> String {
|
||||
// Try gethostname via libc, fall back to environment variable.
|
||||
let mut buf = [0u8; 256];
|
||||
unsafe {
|
||||
if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 {
|
||||
if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("HOST"))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn write_creates_machines_dir_if_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let machines = tmp.path().join("does/not/exist/yet");
|
||||
let profile = MachineProfile::new("host".to_string(), vec![]);
|
||||
profile.write(&machines).unwrap();
|
||||
assert!(machines.join("host.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_overwrites_existing_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]);
|
||||
p1.write(tmp.path()).unwrap();
|
||||
|
||||
let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]);
|
||||
p2.write(tmp.path()).unwrap();
|
||||
|
||||
let loaded = MachineProfile::read(tmp.path(), "host").unwrap();
|
||||
assert_eq!(loaded.tags, vec!["b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_empty_when_dir_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("nope");
|
||||
assert!(MachineProfile::list(&missing).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_sorted_profiles_only_for_toml_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
MachineProfile::new("zebra".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
MachineProfile::new("alpha".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
MachineProfile::new("middle".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
// Non-toml file should be ignored.
|
||||
std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap();
|
||||
|
||||
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_skips_invalid_toml_files_without_failing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
MachineProfile::new("valid".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap();
|
||||
|
||||
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].name, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_helpful_error_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err();
|
||||
assert!(err.to_string().contains("failed to read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_assigns_current_hostname_and_timestamp() {
|
||||
let p = MachineProfile::new("h".to_string(), vec![]);
|
||||
assert!(!p.hostname.is_empty());
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_returns_non_empty_string() {
|
||||
// Whether libc or env fallback fires, the result must be non-empty.
|
||||
assert!(!hostname().is_empty());
|
||||
}
|
||||
}
|
||||
257
bread-sync/src/packages.rs
Normal file
257
bread-sync/src/packages.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Snapshot a package manager's installed packages and write to `dest`.
|
||||
/// Returns true if the snapshot was written, false if the package manager
|
||||
/// is not installed (warns instead of failing).
|
||||
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
|
||||
let content = match manager {
|
||||
"pacman" => run_pacman()?,
|
||||
"aur" => run_aur()?,
|
||||
"pip" => run_pip()?,
|
||||
"npm" => run_npm()?,
|
||||
"cargo" => run_cargo()?,
|
||||
other => {
|
||||
eprintln!("bread: unknown package manager '{}', skipping", other);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let Some(content) = content else {
|
||||
eprintln!("bread: package manager '{}' not found, skipping", manager);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(dest, content)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Parse a pacman snapshot (one "name version" per line, space-separated) and
|
||||
/// return a list of package names.
|
||||
pub fn parse_pacman(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a pip freeze snapshot and return package names.
|
||||
pub fn parse_pip(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
|
||||
.map(|l| {
|
||||
l.split("==")
|
||||
.next()
|
||||
.unwrap_or(l)
|
||||
.split(">=")
|
||||
.next()
|
||||
.unwrap_or(l)
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse npm global packages list (parseable format, one path per line).
|
||||
pub fn parse_npm(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| {
|
||||
// `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg
|
||||
let name = Path::new(l)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())?;
|
||||
// Skip npm itself and the root node_modules
|
||||
if name == "node_modules" {
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse cargo install list.
|
||||
/// Format: "crate v1.2.3 (some-path):\n binary\n..."
|
||||
pub fn parse_cargo(content: &str) -> Vec<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
|
||||
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_aur() -> Result<Option<String>> {
|
||||
match Command::new("pacman").arg("-Qm").output() {
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pacman() -> Result<Option<String>> {
|
||||
match Command::new("pacman").arg("-Qe").output() {
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pip() -> Result<Option<String>> {
|
||||
// Try pip3 first, then pip
|
||||
for cmd in ["pip3", "pip"] {
|
||||
match Command::new(cmd)
|
||||
.args(["list", "--user", "--format=freeze"])
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn run_npm() -> Result<Option<String>> {
|
||||
match Command::new("npm")
|
||||
.args(["list", "-g", "--depth=0", "--parseable"])
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cargo() -> Result<Option<String>> {
|
||||
match Command::new("cargo").args(["install", "--list"]).output() {
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ─── parse_pacman ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pacman_parses_each_line_to_first_field() {
|
||||
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
|
||||
assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_skips_blank_lines() {
|
||||
let input = "firefox 1\n\n \ncurl 2\n";
|
||||
assert_eq!(parse_pacman(input), vec!["firefox", "curl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_handles_empty_input() {
|
||||
assert!(parse_pacman("").is_empty());
|
||||
assert!(parse_pacman("\n\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_handles_single_token_lines() {
|
||||
// A line with no version still yields the package name.
|
||||
assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]);
|
||||
}
|
||||
|
||||
// ─── parse_pip ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pip_strips_eq_and_ge_specifiers() {
|
||||
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
|
||||
assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pip_skips_comments_and_blank_lines() {
|
||||
let input = "# editable install\n\nflake8==1.0\n# trailing\n";
|
||||
assert_eq!(parse_pip(input), vec!["flake8"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pip_handles_package_without_specifier() {
|
||||
assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]);
|
||||
}
|
||||
|
||||
// ─── parse_npm ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn npm_extracts_basename_from_paths() {
|
||||
let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n";
|
||||
let pkgs = parse_npm(input);
|
||||
assert!(pkgs.contains(&"npm".to_string()));
|
||||
assert!(pkgs.contains(&"typescript".to_string()));
|
||||
assert!(pkgs.contains(&"yarn".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_skips_root_node_modules_entry() {
|
||||
let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n";
|
||||
assert_eq!(parse_npm(input), vec!["typescript"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_handles_empty_input() {
|
||||
assert!(parse_npm("").is_empty());
|
||||
}
|
||||
|
||||
// ─── parse_cargo ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cargo_extracts_crate_names_from_install_list_output() {
|
||||
let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n";
|
||||
assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_skips_binary_lines() {
|
||||
// Indented lines are binaries inside a crate.
|
||||
let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n";
|
||||
assert_eq!(parse_cargo(input), vec!["alpha", "beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_handles_empty_input() {
|
||||
assert!(parse_cargo("").is_empty());
|
||||
}
|
||||
|
||||
// ─── snapshot dispatch ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn snapshot_unknown_manager_returns_false_without_writing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dest = tmp.path().join("out.txt");
|
||||
let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap();
|
||||
assert!(!wrote);
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
482
bread-sync/tests/sync.rs
Normal file
482
bread-sync/tests/sync.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
use bread_sync::{
|
||||
config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig},
|
||||
delegates, machine, packages, SyncRepo,
|
||||
};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_bare_repo(path: &std::path::Path) -> git2::Repository {
|
||||
let mut opts = git2::RepositoryInitOptions::new();
|
||||
opts.bare(true);
|
||||
opts.initial_head("main");
|
||||
git2::Repository::init_opts(path, &opts).unwrap()
|
||||
}
|
||||
|
||||
// Helper to create a git commit in a non-bare repo so we have initial state
|
||||
fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo {
|
||||
let repo = SyncRepo::init(path).unwrap();
|
||||
fs::write(path.join(".gitkeep"), "").unwrap();
|
||||
repo.stage_all().unwrap();
|
||||
repo.commit("initial commit").unwrap();
|
||||
repo
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_init_creates_toml_with_required_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = SyncConfig {
|
||||
remote: RemoteConfig {
|
||||
url: "git@github.com:test/sync.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: MachineConfig {
|
||||
name: "testbox".to_string(),
|
||||
tags: vec!["mobile".to_string()],
|
||||
},
|
||||
packages: PackagesConfig::default(),
|
||||
delegates: DelegatesConfig::default(),
|
||||
};
|
||||
config.save(tmp.path()).unwrap();
|
||||
|
||||
let loaded = SyncConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(loaded.remote.url, "git@github.com:test/sync.git");
|
||||
assert_eq!(loaded.remote.branch, "main");
|
||||
assert_eq!(loaded.machine.name, "testbox");
|
||||
assert_eq!(loaded.machine.tags, vec!["mobile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_init_errors_if_already_initialized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = SyncConfig {
|
||||
remote: RemoteConfig {
|
||||
url: "git@github.com:test/sync.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: MachineConfig {
|
||||
name: "box".to_string(),
|
||||
tags: vec![],
|
||||
},
|
||||
packages: PackagesConfig::default(),
|
||||
delegates: DelegatesConfig::default(),
|
||||
};
|
||||
config.save(tmp.path()).unwrap();
|
||||
|
||||
// Second load should succeed (init itself must check for existence externally)
|
||||
// We test that load works
|
||||
let result = SyncConfig::load(tmp.path());
|
||||
assert!(result.is_ok());
|
||||
// sync.toml now exists — the CLI checks this before calling save
|
||||
assert!(tmp.path().join("sync.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_push_creates_correct_directory_structure() {
|
||||
let repo_tmp = TempDir::new().unwrap();
|
||||
let bare_tmp = TempDir::new().unwrap();
|
||||
let bread_cfg_tmp = TempDir::new().unwrap();
|
||||
|
||||
// Create initial bare remote
|
||||
let _bare = make_bare_repo(bare_tmp.path());
|
||||
|
||||
// Create local bread config
|
||||
fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap();
|
||||
|
||||
// Init local sync repo
|
||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
// Snapshot bread dir
|
||||
let bread_dest = repo_tmp.path().join("bread");
|
||||
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
|
||||
|
||||
// Write machine profile
|
||||
let machines_dir = repo_tmp.path().join("machines");
|
||||
let profile = machine::MachineProfile::new("testbox".to_string(), vec![]);
|
||||
profile.write(&machines_dir).unwrap();
|
||||
|
||||
// Commit and push
|
||||
repo.commit("sync: testbox").unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
|
||||
// Verify structure in local repo
|
||||
assert!(repo_tmp.path().join("bread").exists());
|
||||
assert!(repo_tmp.path().join("bread").join("init.lua").exists());
|
||||
assert!(repo_tmp
|
||||
.path()
|
||||
.join("machines")
|
||||
.join("testbox.toml")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_push_snapshots_bread_config() {
|
||||
let repo_tmp = TempDir::new().unwrap();
|
||||
let bare_tmp = TempDir::new().unwrap();
|
||||
let bread_cfg_tmp = TempDir::new().unwrap();
|
||||
|
||||
make_bare_repo(bare_tmp.path());
|
||||
|
||||
// Create a more complex bread config
|
||||
fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap();
|
||||
fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap();
|
||||
fs::write(
|
||||
bread_cfg_tmp.path().join("modules/mymod/init.lua"),
|
||||
"-- mymod",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let bread_dest = repo_tmp.path().join("bread");
|
||||
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
|
||||
|
||||
repo.commit("sync: testbox").unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
|
||||
// Verify files were copied
|
||||
assert!(bread_dest.join("init.lua").exists());
|
||||
assert!(bread_dest.join("modules/mymod/init.lua").exists());
|
||||
|
||||
let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap();
|
||||
assert_eq!(content, "-- init");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_pull_copies_files_from_repo() {
|
||||
let bare_tmp = TempDir::new().unwrap();
|
||||
let local_tmp = TempDir::new().unwrap();
|
||||
let apply_tmp = TempDir::new().unwrap();
|
||||
|
||||
make_bare_repo(bare_tmp.path());
|
||||
|
||||
// Create a local repo, add some files, push to bare
|
||||
let repo = SyncRepo::init(local_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let bread_dest = local_tmp.path().join("bread");
|
||||
fs::create_dir_all(&bread_dest).unwrap();
|
||||
fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap();
|
||||
|
||||
repo.commit("sync: first push").unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
|
||||
// Now clone the bare repo and pull
|
||||
let clone_tmp = TempDir::new().unwrap();
|
||||
let _cloned =
|
||||
SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
|
||||
|
||||
// Apply bread/ to apply_tmp
|
||||
let src = clone_tmp.path().join("bread");
|
||||
if src.exists() {
|
||||
delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap();
|
||||
}
|
||||
|
||||
assert!(apply_tmp.path().join("init.lua").exists());
|
||||
let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap();
|
||||
assert_eq!(content, "-- from sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_manifest_pacman_parses_output_correctly() {
|
||||
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
|
||||
let pkgs = packages::parse_pacman(input);
|
||||
assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_manifest_pip_parses_output_correctly() {
|
||||
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
|
||||
let pkgs = packages::parse_pip(input);
|
||||
assert_eq!(pkgs, vec!["requests", "numpy", "black"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegates_exclude_globs_filter_correctly() {
|
||||
let src_tmp = TempDir::new().unwrap();
|
||||
let dst_tmp = TempDir::new().unwrap();
|
||||
|
||||
// Create files that should and shouldn't be copied
|
||||
fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap();
|
||||
fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap();
|
||||
fs::create_dir_all(src_tmp.path().join("lua")).unwrap();
|
||||
fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap();
|
||||
fs::write(src_tmp.path().join("log.cache"), "cached").unwrap();
|
||||
|
||||
let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()];
|
||||
delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap();
|
||||
|
||||
assert!(dst_tmp.path().join("lua/init.lua").exists());
|
||||
assert!(!dst_tmp.path().join(".git").exists());
|
||||
assert!(!dst_tmp.path().join("log.cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_profile_written_with_correct_fields() {
|
||||
let machines_tmp = TempDir::new().unwrap();
|
||||
let profile = machine::MachineProfile::new(
|
||||
"myhost".to_string(),
|
||||
vec!["mobile".to_string(), "battery".to_string()],
|
||||
);
|
||||
profile.write(machines_tmp.path()).unwrap();
|
||||
|
||||
let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap();
|
||||
assert_eq!(loaded.name, "myhost");
|
||||
assert_eq!(loaded.tags, vec!["mobile", "battery"]);
|
||||
assert!(!loaded.hostname.is_empty());
|
||||
// last_sync must be valid RFC 3339
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"last_sync '{}' is not valid RFC 3339",
|
||||
loaded.last_sync
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_shows_no_changes_when_clean() {
|
||||
let repo_tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(repo_tmp.path());
|
||||
let changes = repo.local_changes().unwrap();
|
||||
assert!(
|
||||
changes.is_empty(),
|
||||
"expected no local changes, got: {:?}",
|
||||
changes
|
||||
);
|
||||
assert!(repo.is_clean().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_with_no_changes_returns_none() {
|
||||
let repo_tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(repo_tmp.path());
|
||||
|
||||
// No new changes — commit should return None
|
||||
let result = repo.commit("second commit").unwrap();
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"expected None (nothing to commit), got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// ─── git.rs additional coverage ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn init_creates_repo_with_main_branch() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
fs::write(tmp.path().join("x"), "").unwrap();
|
||||
repo.stage_all().unwrap();
|
||||
let oid = repo.commit("initial").unwrap();
|
||||
assert!(oid.is_some(), "first commit should succeed");
|
||||
|
||||
// Verify HEAD is on refs/heads/main.
|
||||
let head_ref = std::process::Command::new("git")
|
||||
.args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let head_name = String::from_utf8_lossy(&head_ref.stdout);
|
||||
assert!(
|
||||
head_name.trim() == "refs/heads/main",
|
||||
"expected refs/heads/main, got {head_name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_or_clone_opens_existing_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
SyncRepo::init(tmp.path()).unwrap();
|
||||
|
||||
// Calling open_or_clone on an existing path must not attempt to clone.
|
||||
let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path());
|
||||
assert!(again.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_or_clone_clones_into_missing_path() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
let bare_repo = make_bare_repo(bare.path());
|
||||
// Seed the bare repo with at least one commit so a clone is meaningful.
|
||||
let local = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(local.path()).unwrap();
|
||||
fs::write(local.path().join("seed"), "x").unwrap();
|
||||
repo.commit("seed").unwrap();
|
||||
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
drop(bare_repo);
|
||||
|
||||
let dest_parent = TempDir::new().unwrap();
|
||||
let dest = dest_parent.path().join("clone-target");
|
||||
let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap();
|
||||
assert_eq!(cloned.path, dest);
|
||||
assert!(dest.join("seed").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_changes_reports_new_modified_and_deleted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
|
||||
fs::write(tmp.path().join("added.txt"), "new").unwrap();
|
||||
fs::write(tmp.path().join(".gitkeep"), "modified").unwrap();
|
||||
|
||||
let changes = repo.local_changes().unwrap();
|
||||
assert!(!changes.is_empty());
|
||||
let kinds: Vec<char> = changes.iter().map(|(c, _)| *c).collect();
|
||||
assert!(kinds.contains(&'A'));
|
||||
assert!(kinds.contains(&'M'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_clean_after_commit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
assert!(repo.is_clean().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_includes_modified_tracked_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
// Modify an already-tracked file so it appears in `git diff HEAD`.
|
||||
fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap();
|
||||
|
||||
let diff = repo.working_diff().unwrap();
|
||||
assert!(
|
||||
diff.contains("tracked change"),
|
||||
"diff did not include tracked change, diff was: {diff:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_empty_when_only_untracked_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap();
|
||||
|
||||
// working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED,
|
||||
// so untracked files don't appear — local_changes is the right tool for that.
|
||||
let diff = repo.working_diff().unwrap();
|
||||
assert!(
|
||||
diff.is_empty() || !diff.contains("new-untracked"),
|
||||
"expected untracked file to be excluded, diff was: {diff:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_remote_overwrites_existing_remote() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
repo.set_remote("origin", "https://example.com/a.git")
|
||||
.unwrap();
|
||||
// A second call must not error out — it should replace the previous URL.
|
||||
repo.set_remote("origin", "https://example.com/b.git")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_time_returns_none_for_empty_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
assert!(repo.last_commit_time().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_time_present_after_commit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
assert!(repo.last_commit_time().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_pull_round_trip_through_bare_remote() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
make_bare_repo(bare.path());
|
||||
|
||||
// Push from author repo.
|
||||
let author = TempDir::new().unwrap();
|
||||
let r1 = SyncRepo::init(author.path()).unwrap();
|
||||
r1.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
fs::write(author.path().join("note.txt"), "v1").unwrap();
|
||||
r1.commit("v1").unwrap();
|
||||
r1.push("origin", "main").unwrap();
|
||||
|
||||
// Clone into reader repo and confirm contents.
|
||||
let reader_tmp = TempDir::new().unwrap();
|
||||
let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||
"v1"
|
||||
);
|
||||
|
||||
// Author writes a second version and pushes.
|
||||
fs::write(author.path().join("note.txt"), "v2").unwrap();
|
||||
r1.commit("v2").unwrap();
|
||||
r1.push("origin", "main").unwrap();
|
||||
|
||||
// Reader pulls and sees the new content.
|
||||
r2.pull("origin", "main").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||
"v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_with_no_remote_changes_is_noop() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
make_bare_repo(bare.path());
|
||||
|
||||
let local = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(local.path()).unwrap();
|
||||
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
fs::write(local.path().join("a"), "1").unwrap();
|
||||
repo.commit("c1").unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
|
||||
// Calling pull immediately after push must be up-to-date and succeed.
|
||||
repo.pull("origin", "main").unwrap();
|
||||
assert!(repo.is_clean().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_changes_returns_empty_when_remote_unknown() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
let changes = repo.remote_changes("origin", "main").unwrap();
|
||||
assert!(changes.is_empty());
|
||||
}
|
||||
|
||||
// ─── machine list ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn machine_list_returns_all_profiles_sorted() {
|
||||
let machines_tmp = TempDir::new().unwrap();
|
||||
for name in ["delta", "alpha", "charlie", "bravo"] {
|
||||
machine::MachineProfile::new(name.to_string(), vec![])
|
||||
.write(machines_tmp.path())
|
||||
.unwrap();
|
||||
}
|
||||
let list = machine::MachineProfile::list(machines_tmp.path()).unwrap();
|
||||
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]);
|
||||
}
|
||||
|
||||
// ─── packages snapshot ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn snapshot_writes_destination_when_manager_unknown_is_skipped() {
|
||||
let dest_tmp = TempDir::new().unwrap();
|
||||
let dest = dest_tmp.path().join("nested/dir/file.txt");
|
||||
let wrote = packages::snapshot("does-not-exist", &dest).unwrap();
|
||||
assert!(!wrote);
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
[package]
|
||||
name = "breadd"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bread-shared = { path = "../bread-shared" }
|
||||
bread-sync = { path = "../bread-sync" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
@ -14,10 +15,9 @@ 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"
|
||||
futures-util = "0.3"
|
||||
netlink-packet-route = "0.11"
|
||||
netlink-packet-core = "0.4"
|
||||
|
|
|
|||
255
breadd/src/adapters/bluetooth.rs
Normal file
255
breadd/src/adapters/bluetooth.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
use zbus::{Message, MessageStream};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BluetoothAdapter;
|
||||
|
||||
impl BluetoothAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Emit `bluetooth.enumerate` events for every device that is currently connected.
|
||||
/// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure.
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
|
||||
match try_enumerate(tx).await {
|
||||
Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"),
|
||||
Err(e) => debug!("bluetooth enumeration skipped: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for BluetoothAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"bluetooth"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("bluetooth adapter starting");
|
||||
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?;
|
||||
|
||||
let mut stream = MessageStream::from(&conn);
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(message) => {
|
||||
if let Some(event) = parse_bluetooth_message(&message) {
|
||||
if tx.send(event).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => debug!("bluetooth stream error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_enumerate(tx: &mpsc::Sender<RawEvent>) -> Result<usize> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some("org.bluez"),
|
||||
"/",
|
||||
Some("org.freedesktop.DBus.ObjectManager"),
|
||||
"GetManagedObjects",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let objects: HashMap<OwnedObjectPath, HashMap<String, HashMap<String, OwnedValue>>> =
|
||||
msg.body()?;
|
||||
|
||||
let mut count = 0;
|
||||
for (path, interfaces) in objects {
|
||||
let Some(props) = interfaces.get("org.bluez.Device1") else {
|
||||
continue;
|
||||
};
|
||||
let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({}));
|
||||
if !props_json
|
||||
.get("Connected")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = props_json
|
||||
.get("Name")
|
||||
.or_else(|| props_json.get("Alias"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let address = props_json
|
||||
.get("Address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.enumerate".to_string(),
|
||||
payload: json!({
|
||||
"path": path.as_str(),
|
||||
"address": address,
|
||||
"name": name,
|
||||
"properties": props_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn parse_bluetooth_message(message: &Message) -> Option<RawEvent> {
|
||||
let header = message.header().ok()?;
|
||||
let interface = header.interface().ok()??.as_str().to_string();
|
||||
let member = header.member().ok()??.as_str().to_string();
|
||||
let path = header
|
||||
.path()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Connected / disconnected — PropertiesChanged on a BlueZ device object
|
||||
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
|
||||
if !path.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
let (iface, changed, _): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||
message.body().ok()?;
|
||||
if iface != "org.bluez.Device1" {
|
||||
return None;
|
||||
}
|
||||
let changed_json = serde_json::to_value(&changed).ok()?;
|
||||
let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?;
|
||||
let address = address_from_path(&path);
|
||||
let kind = if connected {
|
||||
"bluetooth.device.connected"
|
||||
} else {
|
||||
"bluetooth.device.disconnected"
|
||||
};
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: kind.to_string(),
|
||||
payload: json!({
|
||||
"path": path,
|
||||
"address": address,
|
||||
"properties": changed_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// Device paired / discovered — InterfacesAdded from BlueZ ObjectManager
|
||||
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" {
|
||||
let (obj_path, interfaces): (
|
||||
OwnedObjectPath,
|
||||
HashMap<String, HashMap<String, OwnedValue>>,
|
||||
) = message.body().ok()?;
|
||||
let obj_str = obj_path.as_str();
|
||||
if !obj_str.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
let props = interfaces.get("org.bluez.Device1")?;
|
||||
let props_json = serde_json::to_value(props).ok()?;
|
||||
let name = props_json
|
||||
.get("Name")
|
||||
.or_else(|| props_json.get("Alias"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let address = props_json
|
||||
.get("Address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| address_from_path(obj_str));
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.device.added".to_string(),
|
||||
payload: json!({
|
||||
"path": obj_str,
|
||||
"address": address,
|
||||
"name": name,
|
||||
"properties": props_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// Device unpaired — InterfacesRemoved from BlueZ ObjectManager
|
||||
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" {
|
||||
let (obj_path, interfaces): (OwnedObjectPath, Vec<String>) = message.body().ok()?;
|
||||
let obj_str = obj_path.as_str();
|
||||
if !obj_str.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
if !interfaces.iter().any(|i| i == "org.bluez.Device1") {
|
||||
return None;
|
||||
}
|
||||
let address = address_from_path(obj_str);
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.device.removed".to_string(),
|
||||
payload: json!({
|
||||
"path": obj_str,
|
||||
"address": address,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"`
|
||||
fn address_from_path(path: &str) -> String {
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("dev_"))
|
||||
.map(|s| s.replace('_', ":"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn address_from_path_parses_standard_bluez_path() {
|
||||
assert_eq!(
|
||||
address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"),
|
||||
"AA:BB:CC:DD:EE:FF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_from_path_returns_empty_for_adapter_path() {
|
||||
assert_eq!(address_from_path("/org/bluez/hci0"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_from_path_returns_empty_for_root() {
|
||||
assert_eq!(address_from_path("/"), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -48,13 +48,39 @@ impl Adapter for HyprlandAdapter {
|
|||
}
|
||||
|
||||
fn hyprland_event_socket() -> Result<PathBuf> {
|
||||
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
||||
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
Ok(PathBuf::from(runtime)
|
||||
|
||||
// If the env var is set, use it directly.
|
||||
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||
return Ok(PathBuf::from(runtime)
|
||||
.join("hypr")
|
||||
.join(instance)
|
||||
.join(".socket2.sock"))
|
||||
.join(".socket2.sock"));
|
||||
}
|
||||
|
||||
// Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance.
|
||||
// Hyprland creates a per-instance directory there containing .socket2.sock.
|
||||
// This handles the case where breadd starts as a systemd user service before
|
||||
// Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment.
|
||||
let hypr_dir = PathBuf::from(&runtime).join("hypr");
|
||||
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
|
||||
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path().join(".socket2.sock"))
|
||||
.filter(|p| p.exists())
|
||||
.collect();
|
||||
|
||||
match sockets.len() {
|
||||
0 => Err(anyhow!(
|
||||
"no Hyprland instance found in {}",
|
||||
hypr_dir.display()
|
||||
)),
|
||||
1 => Ok(sockets.remove(0)),
|
||||
n => {
|
||||
warn!("found {n} Hyprland instances, using first");
|
||||
Ok(sockets.remove(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hyprland_line(line: &str) -> (String, String) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,29 @@
|
|||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::RawEvent;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::info;
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::core::supervisor::spawn_supervised;
|
||||
|
||||
pub mod bluetooth;
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod power;
|
||||
pub mod udev;
|
||||
pub mod network_rtnetlink;
|
||||
pub mod power;
|
||||
pub mod power_upower;
|
||||
pub mod udev;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdapterStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Adapter: Send + Sync {
|
||||
|
|
@ -30,6 +41,7 @@ pub struct Manager {
|
|||
raw_tx: mpsc::Sender<RawEvent>,
|
||||
config: Config,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
|
|
@ -42,9 +54,14 @@ impl Manager {
|
|||
raw_tx,
|
||||
config,
|
||||
shutdown_rx,
|
||||
status: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_handle(&self) -> Arc<RwLock<HashMap<String, AdapterStatus>>> {
|
||||
self.status.clone()
|
||||
}
|
||||
|
||||
pub async fn start_all(&self) -> Result<()> {
|
||||
info!("starting adapters");
|
||||
|
||||
|
|
@ -55,7 +72,7 @@ impl Manager {
|
|||
}
|
||||
|
||||
if self.config.adapters.hyprland.enabled {
|
||||
self.spawn_adapter(hyprland::HyprlandAdapter::default());
|
||||
self.spawn_adapter(hyprland::HyprlandAdapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.power.enabled {
|
||||
|
|
@ -70,13 +87,19 @@ impl Manager {
|
|||
}
|
||||
}
|
||||
|
||||
if self.config.adapters.bluetooth.enabled {
|
||||
let adapter = bluetooth::BluetoothAdapter::new();
|
||||
adapter.enumerate_existing(&self.raw_tx).await;
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.network.enabled {
|
||||
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
|
||||
let rt = network_rtnetlink::RtnetlinkAdapter::new();
|
||||
if let Ok(adapter) = rt {
|
||||
self.spawn_adapter(adapter);
|
||||
} else {
|
||||
self.spawn_adapter(network::NetworkAdapter::default());
|
||||
self.spawn_adapter(network::NetworkAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,17 +114,27 @@ impl Manager {
|
|||
let tx = self.raw_tx.clone();
|
||||
let shutdown_rx = self.shutdown_rx.clone();
|
||||
let shutdown_for_task = shutdown_rx.clone();
|
||||
let status = self.status.clone();
|
||||
spawn_supervised(name, shutdown_rx, move || {
|
||||
let adapter = adapter.clone();
|
||||
let tx = tx.clone();
|
||||
let mut shutdown_rx = shutdown_for_task.clone();
|
||||
let status = status.clone();
|
||||
async move {
|
||||
adapter.on_connect().await?;
|
||||
{
|
||||
let mut guard = status.write().await;
|
||||
guard.insert(adapter.name().to_string(), AdapterStatus::Connected);
|
||||
}
|
||||
let result = tokio::select! {
|
||||
result = adapter.run(tx) => result,
|
||||
_ = shutdown_rx.changed() => Ok(()),
|
||||
};
|
||||
adapter.on_disconnect().await?;
|
||||
{
|
||||
let mut guard = status.write().await;
|
||||
guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected);
|
||||
}
|
||||
result
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ impl Adapter for RtnetlinkAdapter {
|
|||
"netns_id": netns_id,
|
||||
"netns_fd": netns_fd
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Network,
|
||||
kind: kind.to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
|
||||
|
|
@ -86,17 +93,32 @@ impl Adapter for RtnetlinkAdapter {
|
|||
"gateway": gateway_ip,
|
||||
"table": route.header.table
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Network,
|
||||
kind: "route.default.changed".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => {
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(
|
||||
addr,
|
||||
)) => {
|
||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => {
|
||||
Some(bytes.clone())
|
||||
}
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
|
||||
Some(bytes.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||
Some(label.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||
|
|
@ -107,16 +129,31 @@ impl Adapter for RtnetlinkAdapter {
|
|||
"address": ip,
|
||||
"label": label
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Network,
|
||||
kind: "address.added".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => {
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(
|
||||
addr,
|
||||
)) => {
|
||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => {
|
||||
Some(bytes.clone())
|
||||
}
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => {
|
||||
Some(bytes.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||
Some(label.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||
|
|
@ -127,7 +164,14 @@ impl Adapter for RtnetlinkAdapter {
|
|||
"address": ip,
|
||||
"label": label
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Network,
|
||||
kind: "address.removed".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
debug!("unhandled netlink message");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ use serde_json::json;
|
|||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use zbus::{Message, MessageStream};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
use zbus::{Message, MessageStream};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
|
@ -22,10 +19,7 @@ impl UdevAdapter {
|
|||
}
|
||||
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
|
||||
scan_devices(&self.subsystems).unwrap_or_default()
|
||||
});
|
||||
|
||||
let devices = enumerate_with_udev(&self.subsystems)?;
|
||||
for device in devices {
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
|
|
@ -52,57 +46,66 @@ impl Adapter for UdevAdapter {
|
|||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("udev adapter started");
|
||||
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fallback for environments where monitor sockets are unavailable.
|
||||
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
|
||||
.into_iter()
|
||||
.map(|d| (d.id.clone(), d))
|
||||
.collect();
|
||||
|
||||
loop {
|
||||
let current = scan_devices(&self.subsystems)?;
|
||||
let current_map: HashMap<String, ScannedDevice> = current
|
||||
.into_iter()
|
||||
.map(|d| (d.id.clone(), d))
|
||||
.collect();
|
||||
|
||||
for (id, dev) in ¤t_map {
|
||||
if !known.contains_key(id) {
|
||||
tx.send(raw_change_event("add", dev)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
for (id, dev) in &known {
|
||||
if !current_map.contains_key(id) {
|
||||
tx.send(raw_change_event("remove", dev)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
||||
// first polling the fd returns None immediately and exits the loop — which is
|
||||
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
||||
// inside spawn_blocking so the thread truly blocks until events are available.
|
||||
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut builder = udev::MonitorBuilder::new()?;
|
||||
for subsystem in &subsystems {
|
||||
builder = builder.match_subsystem(subsystem)?;
|
||||
}
|
||||
let monitor = builder.listen()?;
|
||||
let socket = builder.listen()?;
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
for event in monitor.iter() {
|
||||
loop {
|
||||
let mut pfd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
|
||||
let ret = unsafe { libc::poll(&mut pfd, 1, 1000) };
|
||||
if ret < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
if ret == 0 {
|
||||
// Timeout: bail if the downstream channel has been dropped.
|
||||
if tx.is_closed() {
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if pfd.revents & libc::POLLIN != 0 {
|
||||
while let Some(event) = socket.iter().next() {
|
||||
if tx.blocking_send(build_event(&event)).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_event(event: &udev::Event) -> RawEvent {
|
||||
let action = event
|
||||
.action()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
|
|
@ -117,12 +120,9 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
|
|||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = event
|
||||
.syspath()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let id = event.syspath().to_string_lossy().to_string();
|
||||
|
||||
let msg = RawEvent {
|
||||
RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.change".to_string(),
|
||||
payload: json!({
|
||||
|
|
@ -130,20 +130,20 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
|
|||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
||||
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
||||
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
||||
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
||||
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
||||
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
||||
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
||||
"id_vendor": prop_str(event, "ID_VENDOR"),
|
||||
"id_model": prop_str(event, "ID_MODEL"),
|
||||
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
||||
"product_id": prop_str(event, "ID_MODEL_ID"),
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
};
|
||||
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
|
|
@ -165,7 +165,6 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
|||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = dev.syspath().to_string_lossy().to_string();
|
||||
|
||||
out.push(ScannedDevice {
|
||||
id,
|
||||
name,
|
||||
|
|
@ -176,90 +175,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
|||
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,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
fn prop_bool(event: &udev::Event, key: &str) -> bool {
|
||||
event
|
||||
.property_value(key)
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
if subsystems.iter().any(|s| s == "drm") {
|
||||
let drm_dir = Path::new("/sys/class/drm");
|
||||
if drm_dir.exists() {
|
||||
for entry in fs::read_dir(drm_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
|
||||
if status.trim() == "connected" {
|
||||
out.push(ScannedDevice {
|
||||
id: format!("drm:{name}"),
|
||||
name,
|
||||
subsystem: "drm".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
out.push(ScannedDevice {
|
||||
id: format!("usb:{name}"),
|
||||
name,
|
||||
subsystem: "usb".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
|
||||
event
|
||||
.property_value(key)
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonConfig,
|
||||
#[serde(default)]
|
||||
pub lua: LuaConfig,
|
||||
#[serde(default)]
|
||||
pub modules: ModulesConfig,
|
||||
#[serde(default)]
|
||||
pub adapters: AdaptersConfig,
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
#[serde(default)]
|
||||
pub events: EventsConfig,
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +38,14 @@ pub struct LuaConfig {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ModulesConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub builtin: bool,
|
||||
#[serde(default)]
|
||||
pub disable: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct AdaptersConfig {
|
||||
#[serde(default)]
|
||||
pub hyprland: AdapterToggle,
|
||||
|
|
@ -43,6 +55,8 @@ pub struct AdaptersConfig {
|
|||
pub power: PowerConfig,
|
||||
#[serde(default)]
|
||||
pub network: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub bluetooth: AdapterToggle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -73,15 +87,14 @@ pub struct EventsConfig {
|
|||
pub dedup_window_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daemon: DaemonConfig::default(),
|
||||
lua: LuaConfig::default(),
|
||||
adapters: AdaptersConfig::default(),
|
||||
events: EventsConfig::default(),
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NotificationsConfig {
|
||||
#[serde(default = "default_notify_timeout")]
|
||||
pub default_timeout_ms: i64,
|
||||
#[serde(default = "default_notify_urgency")]
|
||||
pub default_urgency: String,
|
||||
#[serde(default = "default_notify_path")]
|
||||
pub notify_send_path: String,
|
||||
}
|
||||
|
||||
impl Default for DaemonConfig {
|
||||
|
|
@ -102,13 +115,11 @@ impl Default for LuaConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for AdaptersConfig {
|
||||
impl Default for ModulesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hyprland: AdapterToggle::default(),
|
||||
udev: UdevConfig::default(),
|
||||
power: PowerConfig::default(),
|
||||
network: AdapterToggle::default(),
|
||||
builtin: default_true(),
|
||||
disable: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +158,16 @@ impl Default for EventsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_timeout_ms: default_notify_timeout(),
|
||||
default_urgency: default_notify_urgency(),
|
||||
notify_send_path: default_notify_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
|
|
@ -218,6 +239,18 @@ fn default_dedup_window() -> u64 {
|
|||
100
|
||||
}
|
||||
|
||||
fn default_notify_timeout() -> i64 {
|
||||
3000
|
||||
}
|
||||
|
||||
fn default_notify_urgency() -> String {
|
||||
"normal".to_string()
|
||||
}
|
||||
|
||||
fn default_notify_path() -> String {
|
||||
"notify-send".to_string()
|
||||
}
|
||||
|
||||
fn default_udev_subsystems() -> Vec<String> {
|
||||
vec![
|
||||
"usb".to_string(),
|
||||
|
|
@ -226,3 +259,246 @@ fn default_udev_subsystems() -> Vec<String> {
|
|||
"power_supply".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Tests that mutate process env vars must serialize against each other
|
||||
// — cargo runs tests in parallel by default and HOME/XDG_RUNTIME_DIR are
|
||||
// process-global. Tests that don't touch env are free to run unguarded.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard {
|
||||
saved: Vec<(&'static str, Option<String>)>,
|
||||
_guard: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(vars: &[&'static str]) -> Self {
|
||||
let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let saved = vars.iter().map(|k| (*k, std::env::var(k).ok())).collect();
|
||||
Self {
|
||||
saved,
|
||||
_guard: guard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in &self.saved {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(key, v),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_uses_documented_defaults() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.daemon.log_level, "info");
|
||||
assert!(cfg.daemon.socket_path.is_empty());
|
||||
assert_eq!(cfg.lua.entry_point, "~/.config/bread/init.lua");
|
||||
assert_eq!(cfg.lua.module_path, "~/.config/bread/modules");
|
||||
assert!(cfg.adapters.hyprland.enabled);
|
||||
assert!(cfg.adapters.udev.enabled);
|
||||
assert!(cfg.adapters.power.enabled);
|
||||
assert!(cfg.adapters.network.enabled);
|
||||
assert!(cfg.adapters.bluetooth.enabled);
|
||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
|
||||
assert_eq!(cfg.notifications.default_urgency, "normal");
|
||||
assert_eq!(cfg.notifications.notify_send_path, "notify-send");
|
||||
assert!(cfg.modules.builtin);
|
||||
assert!(cfg.modules.disable.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_udev_subsystems_match_documented_list() {
|
||||
assert_eq!(
|
||||
default_udev_subsystems(),
|
||||
vec!["usb", "input", "drm", "power_supply"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_toml_yields_defaults() {
|
||||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "info");
|
||||
assert!(cfg.adapters.hyprland.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_toml_overrides_all_values() {
|
||||
let raw = r#"
|
||||
[daemon]
|
||||
log_level = "debug"
|
||||
socket_path = "/tmp/custom.sock"
|
||||
|
||||
[lua]
|
||||
entry_point = "/abs/init.lua"
|
||||
module_path = "/abs/mods"
|
||||
|
||||
[modules]
|
||||
builtin = false
|
||||
disable = ["foo", "bar"]
|
||||
|
||||
[adapters.hyprland]
|
||||
enabled = false
|
||||
|
||||
[adapters.udev]
|
||||
enabled = true
|
||||
subsystems = ["usb"]
|
||||
|
||||
[adapters.power]
|
||||
enabled = false
|
||||
poll_interval_secs = 5
|
||||
|
||||
[adapters.network]
|
||||
enabled = false
|
||||
|
||||
[adapters.bluetooth]
|
||||
enabled = false
|
||||
|
||||
[events]
|
||||
dedup_window_ms = 250
|
||||
|
||||
[notifications]
|
||||
default_timeout_ms = 1000
|
||||
default_urgency = "critical"
|
||||
notify_send_path = "/usr/local/bin/notify-send"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "debug");
|
||||
assert_eq!(cfg.daemon.socket_path, "/tmp/custom.sock");
|
||||
assert_eq!(cfg.lua.entry_point, "/abs/init.lua");
|
||||
assert_eq!(cfg.lua.module_path, "/abs/mods");
|
||||
assert!(!cfg.modules.builtin);
|
||||
assert_eq!(cfg.modules.disable, vec!["foo", "bar"]);
|
||||
assert!(!cfg.adapters.hyprland.enabled);
|
||||
assert!(cfg.adapters.udev.enabled);
|
||||
assert_eq!(cfg.adapters.udev.subsystems, vec!["usb"]);
|
||||
assert!(!cfg.adapters.power.enabled);
|
||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
|
||||
assert!(!cfg.adapters.network.enabled);
|
||||
assert!(!cfg.adapters.bluetooth.enabled);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 250);
|
||||
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
||||
assert_eq!(cfg.notifications.default_urgency, "critical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_partial_toml_fills_missing_with_defaults() {
|
||||
let raw = r#"
|
||||
[daemon]
|
||||
log_level = "trace"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "trace");
|
||||
// Untouched sections still get their defaults.
|
||||
assert!(cfg.adapters.hyprland.enabled);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_toml_returns_error() {
|
||||
let result: Result<Config, _> = toml::from_str("[daemon\nbroken");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_uses_explicit_path_verbatim() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.daemon.socket_path = "/run/bread.sock".to_string();
|
||||
assert_eq!(cfg.socket_path(), PathBuf::from("/run/bread.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_expands_tilde_when_explicit() {
|
||||
let _g = EnvGuard::new(&["HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
let mut cfg = Config::default();
|
||||
cfg.daemon.socket_path = "~/sockets/bread.sock".to_string();
|
||||
assert_eq!(
|
||||
cfg.socket_path(),
|
||||
PathBuf::from("/synthetic/home/sockets/bread.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_falls_back_to_xdg_runtime_dir() {
|
||||
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.socket_path(),
|
||||
PathBuf::from("/tmp/xdg/bread/breadd.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_uses_tmp_when_no_xdg_runtime_dir() {
|
||||
let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]);
|
||||
std::env::remove_var("XDG_RUNTIME_DIR");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.socket_path(), PathBuf::from("/tmp/bread/breadd.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_and_module_path_expand_tilde() {
|
||||
let _g = EnvGuard::new(&["HOME"]);
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
let cfg = Config::default();
|
||||
assert_eq!(
|
||||
cfg.lua_entry_point(),
|
||||
PathBuf::from("/synthetic/home/.config/bread/init.lua")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.lua_module_path(),
|
||||
PathBuf::from("/synthetic/home/.config/bread/modules")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_entry_point_returns_absolute_path_unchanged() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.lua.entry_point = "/etc/bread/init.lua".to_string();
|
||||
assert_eq!(cfg.lua_entry_point(), PathBuf::from("/etc/bread/init.lua"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_home_handles_missing_home_env() {
|
||||
let _g = EnvGuard::new(&["HOME"]);
|
||||
std::env::remove_var("HOME");
|
||||
// Without HOME, ~/-prefixed paths fall back to the literal string.
|
||||
assert_eq!(expand_home("~/foo"), PathBuf::from("~/foo"));
|
||||
// Non-tilde paths are unchanged regardless.
|
||||
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_respects_xdg_config_home() {
|
||||
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
|
||||
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
|
||||
assert_eq!(
|
||||
config_path(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/breadd.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_falls_back_to_home_when_no_xdg() {
|
||||
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
assert_eq!(
|
||||
config_path(),
|
||||
PathBuf::from("/synthetic/home/.config/bread/breadd.toml")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ use std::sync::RwLock;
|
|||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::core::types::DeviceClass;
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
const EVICT_MULTIPLIER: u64 = 60;
|
||||
|
||||
pub struct EventNormalizer {
|
||||
dedup_window_ms: u64,
|
||||
recent: RwLock<HashMap<String, u64>>,
|
||||
/// Tracks the first time a physical device (keyed by verb+vendor_id+product_id)
|
||||
/// fired within the current window, so subsequent child-node events from the
|
||||
/// same plug-in are suppressed at the normalizer level.
|
||||
seen_devices: RwLock<HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
impl EventNormalizer {
|
||||
|
|
@ -19,6 +21,7 @@ impl EventNormalizer {
|
|||
Self {
|
||||
dedup_window_ms,
|
||||
recent: RwLock::new(HashMap::new()),
|
||||
seen_devices: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +31,7 @@ impl EventNormalizer {
|
|||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||
AdapterSource::Power => self.normalize_power(raw),
|
||||
AdapterSource::Network => self.normalize_network(raw),
|
||||
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
|
||||
AdapterSource::System => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
|
|
@ -41,62 +45,213 @@ impl EventNormalizer {
|
|||
}
|
||||
|
||||
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
|
||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let class = classify_device(&raw.payload);
|
||||
let class_str = serde_json::to_string(&class)
|
||||
.unwrap_or_else(|_| "\"unknown\"".to_string())
|
||||
.replace('"', "");
|
||||
let action = raw
|
||||
.payload
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("change");
|
||||
|
||||
// "bind" is the kernel attaching a driver to an interface — not a meaningful
|
||||
// device state change for automation purposes.
|
||||
if action == "bind" {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let name = raw
|
||||
.payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let vendor = raw
|
||||
.payload
|
||||
.get("id_vendor")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let vendor_id = raw
|
||||
.payload
|
||||
.get("vendor_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let product_id = raw
|
||||
.payload
|
||||
.get("product_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let subsystem = raw
|
||||
.payload
|
||||
.get("subsystem")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
|
||||
// no identity information — they are USB protocol artefacts, not devices.
|
||||
if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// For connected/disconnected, suppress duplicate events from child nodes of
|
||||
// the same physical device (e.g. input66, mouse0, event17 all from one plug-in).
|
||||
// Key by verb+vendor_id+product_id so a second distinct device of the same
|
||||
// model plugged in after the window still fires correctly.
|
||||
let verb = match action {
|
||||
"add" => "connected",
|
||||
"remove" => "disconnected",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
let mut events = vec![BreadEvent {
|
||||
if (verb == "connected" || verb == "disconnected")
|
||||
&& !vendor_id.is_empty()
|
||||
&& !product_id.is_empty()
|
||||
{
|
||||
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
|
||||
let now = raw.timestamp;
|
||||
let already_seen = {
|
||||
let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner());
|
||||
seen.get(&device_key)
|
||||
.map(|&last| now.saturating_sub(last) < self.dedup_window_ms)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if already_seen {
|
||||
return vec![];
|
||||
}
|
||||
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
|
||||
seen.insert(device_key, now);
|
||||
// Evict stale entries
|
||||
let evict_before =
|
||||
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||
if evict_before > 0 {
|
||||
seen.retain(|_, &mut last| last >= evict_before);
|
||||
}
|
||||
}
|
||||
|
||||
let id = raw
|
||||
.payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// Device name is always "unknown" here; the state engine applies user-defined
|
||||
// classification rules from devices.lua before dispatching to subscribers.
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.device.{}", verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"vendor": vendor,
|
||||
"vendor_id": vendor_id,
|
||||
"product_id": product_id,
|
||||
"subsystem": subsystem,
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}];
|
||||
|
||||
events.push(BreadEvent {
|
||||
event: format!("bread.device.{}.{}", class_str, verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
}),
|
||||
});
|
||||
|
||||
events
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let mapped = match kind {
|
||||
"workspace" | "workspacev2" => "bread.workspace.changed",
|
||||
"monitoradded" => "bread.monitor.connected",
|
||||
"monitorremoved" => "bread.monitor.disconnected",
|
||||
"activewindow" | "activewindowv2" => "bread.window.focus.changed",
|
||||
"openwindow" => "bread.window.opened",
|
||||
"closewindow" => "bread.window.closed",
|
||||
_ => "bread.hyprland.event",
|
||||
};
|
||||
let kind = raw
|
||||
.payload
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let data = raw
|
||||
.payload
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
|
||||
vec![BreadEvent {
|
||||
event: mapped.to_string(),
|
||||
match kind {
|
||||
"workspace" | "workspacev2" => vec![BreadEvent {
|
||||
event: "bread.workspace.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
"createworkspace" => vec![BreadEvent {
|
||||
event: "bread.workspace.created".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "workspace": data }),
|
||||
}],
|
||||
"destroyworkspace" => vec![BreadEvent {
|
||||
event: "bread.workspace.destroyed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "workspace": data }),
|
||||
}],
|
||||
"monitoradded" => vec![BreadEvent {
|
||||
event: "bread.monitor.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"monitorremoved" => vec![BreadEvent {
|
||||
event: "bread.monitor.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"activewindow" => vec![BreadEvent {
|
||||
event: "bread.window.focus.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
"activewindowv2" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.focused".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
"address": fields.first().unwrap_or(&"")
|
||||
}),
|
||||
}]
|
||||
}
|
||||
"openwindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.opened".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
"address": fields.first().unwrap_or(&""),
|
||||
"workspace": fields.get(1).unwrap_or(&""),
|
||||
"class": fields.get(2).unwrap_or(&""),
|
||||
"title": fields.get(3).unwrap_or(&""),
|
||||
}),
|
||||
}]
|
||||
}
|
||||
"closewindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.closed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "address": fields.first().unwrap_or(&"") }),
|
||||
}]
|
||||
}
|
||||
"movewindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.moved".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
"address": fields.first().unwrap_or(&""),
|
||||
"workspace": fields.get(1).unwrap_or(&""),
|
||||
}),
|
||||
}]
|
||||
}
|
||||
_ => vec![BreadEvent {
|
||||
event: "bread.hyprland.event".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
|
@ -149,8 +304,89 @@ impl EventNormalizer {
|
|||
events
|
||||
}
|
||||
|
||||
fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let path = raw
|
||||
.payload
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let address = raw
|
||||
.payload
|
||||
.get("address")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let name = raw
|
||||
.payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
raw.payload
|
||||
.pointer("/properties/Name")
|
||||
.or_else(|| raw.payload.pointer("/properties/Alias"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("unknown");
|
||||
|
||||
match raw.kind.as_str() {
|
||||
"bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent {
|
||||
event: "bread.device.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.disconnected" => vec![BreadEvent {
|
||||
event: "bread.device.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.added" => vec![BreadEvent {
|
||||
event: "bread.bluetooth.device.paired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.removed" => vec![BreadEvent {
|
||||
event: "bread.bluetooth.device.unpaired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
|
||||
let online = raw
|
||||
.payload
|
||||
.get("online")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let name = if online {
|
||||
"bread.network.connected"
|
||||
} else {
|
||||
|
|
@ -192,7 +428,8 @@ impl EventNormalizer {
|
|||
recent.insert(key.clone(), now);
|
||||
|
||||
// Evict stale entries to prevent unbounded growth.
|
||||
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||
let evict_before =
|
||||
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||
if evict_before > 0 {
|
||||
recent.retain(|_, &mut last| last >= evict_before);
|
||||
}
|
||||
|
|
@ -201,36 +438,527 @@ impl EventNormalizer {
|
|||
}
|
||||
}
|
||||
|
||||
fn classify_device(payload: &Value) -> DeviceClass {
|
||||
let name = payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
let subsystem = payload
|
||||
.get("subsystem")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
if name.contains("dock") {
|
||||
return DeviceClass::Dock;
|
||||
fn split_hyprland_fields(data: &str) -> Vec<&str> {
|
||||
if data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
data.split(">>").collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent {
|
||||
RawEvent {
|
||||
source,
|
||||
kind: kind.to_string(),
|
||||
payload,
|
||||
timestamp: ts,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Udev ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn udev_add_emits_connected_with_identity_fields() {
|
||||
let n = EventNormalizer::new(100);
|
||||
let ev = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({
|
||||
"action": "add",
|
||||
"name": "Logitech Mouse",
|
||||
"id_vendor": "Logitech",
|
||||
"vendor_id": "046d",
|
||||
"product_id": "c52b",
|
||||
"subsystem": "usb",
|
||||
"id": "1-1.4",
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.connected");
|
||||
assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d");
|
||||
assert_eq!(out[0].data.get("product_id").unwrap(), "c52b");
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse");
|
||||
assert_eq!(out[0].data.get("subsystem").unwrap(), "usb");
|
||||
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_remove_emits_disconnected() {
|
||||
let n = EventNormalizer::new(100);
|
||||
let ev = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({
|
||||
"action": "remove",
|
||||
"name": "Logitech",
|
||||
"vendor_id": "046d",
|
||||
"product_id": "c52b",
|
||||
"subsystem": "usb",
|
||||
"id": "1-1.4",
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.disconnected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_bind_action_is_suppressed() {
|
||||
let n = EventNormalizer::new(100);
|
||||
let ev = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({
|
||||
"action": "bind",
|
||||
"name": "x",
|
||||
"vendor_id": "046d",
|
||||
"product_id": "c52b",
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
assert!(n.normalize(&ev).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_anonymous_child_interface_is_dropped() {
|
||||
let n = EventNormalizer::new(100);
|
||||
// No name, no vendor — pure USB protocol artefact.
|
||||
let ev = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({
|
||||
"action": "add",
|
||||
"id": "3-5:1.0",
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
assert!(n.normalize(&ev).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_dedupes_child_nodes_of_same_physical_device() {
|
||||
let n = EventNormalizer::new(1000);
|
||||
let mk = |id: &str, ts: u64| {
|
||||
raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({
|
||||
"action": "add",
|
||||
"name": "Hub Device",
|
||||
"vendor_id": "1d6b",
|
||||
"product_id": "0002",
|
||||
"subsystem": "usb",
|
||||
"id": id,
|
||||
}),
|
||||
ts,
|
||||
)
|
||||
};
|
||||
// First child fires
|
||||
assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1);
|
||||
// Sibling within window is suppressed
|
||||
assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0);
|
||||
// After the dedup window, a sibling fires again
|
||||
assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udev_disconnect_does_not_share_dedup_with_connect() {
|
||||
let n = EventNormalizer::new(1000);
|
||||
let connect = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
|
||||
1000,
|
||||
);
|
||||
let disconnect = raw(
|
||||
AdapterSource::Udev,
|
||||
"udev",
|
||||
json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
|
||||
1100,
|
||||
);
|
||||
assert_eq!(n.normalize(&connect).len(), 1);
|
||||
// Disconnect uses a different verb in the dedup key, so it fires.
|
||||
assert_eq!(n.normalize(&disconnect).len(), 1);
|
||||
}
|
||||
|
||||
// ─── Hyprland ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn hyprland_workspace_change() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "workspace", "data": "2"}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.workspace.changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_active_window_v2_parses_address_from_fields() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "activewindowv2", "data": "0xdeadbeef"}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.window.focused");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_openwindow_splits_all_fields() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.window.opened");
|
||||
let d = &out[0].data;
|
||||
assert_eq!(d.get("address").unwrap(), "0xabc");
|
||||
assert_eq!(d.get("workspace").unwrap(), "2");
|
||||
assert_eq!(d.get("class").unwrap(), "firefox");
|
||||
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_unknown_kind_falls_through_to_generic_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "submap", "data": "resize"}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.hyprland.event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_monitor_lifecycle() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let added = n.normalize(&raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "monitoradded", "data": "HDMI-A-1"}),
|
||||
1,
|
||||
));
|
||||
let removed = n.normalize(&raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
|
||||
2,
|
||||
));
|
||||
assert_eq!(added[0].event, "bread.monitor.connected");
|
||||
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1");
|
||||
assert_eq!(removed[0].event, "bread.monitor.disconnected");
|
||||
}
|
||||
|
||||
// ─── Power ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn power_ac_connected_emits_named_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Power,
|
||||
"power",
|
||||
json!({"ac_connected": true}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.power.ac.connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_battery_thresholds_select_correct_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let cases = [
|
||||
(3, "bread.power.battery.critical"),
|
||||
(5, "bread.power.battery.critical"),
|
||||
(8, "bread.power.battery.very_low"),
|
||||
(10, "bread.power.battery.very_low"),
|
||||
(15, "bread.power.battery.low"),
|
||||
(20, "bread.power.battery.low"),
|
||||
(100, "bread.power.battery.full"),
|
||||
];
|
||||
for (level, expected) in cases {
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Power,
|
||||
"power",
|
||||
json!({"battery_percent": level}),
|
||||
level * 1000,
|
||||
));
|
||||
assert_eq!(
|
||||
out[0].event, expected,
|
||||
"level {level} should map to {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_mid_range_battery_emits_generic_changed() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Power,
|
||||
"power",
|
||||
json!({"battery_percent": 50}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.power.changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_ac_and_battery_can_both_fire() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Power,
|
||||
"power",
|
||||
json!({"ac_connected": false, "battery_percent": 4}),
|
||||
1,
|
||||
));
|
||||
let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect();
|
||||
assert!(names.contains(&"bread.power.ac.disconnected"));
|
||||
assert!(names.contains(&"bread.power.battery.critical"));
|
||||
}
|
||||
|
||||
// ─── Bluetooth ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bluetooth_connected_emits_device_connected() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": true },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.connected",
|
||||
ev.payload.clone(),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.connected");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||
assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth");
|
||||
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_disconnected_emits_device_disconnected() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.disconnected",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": false },
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.disconnected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_enumerate_includes_name() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.enumerate",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"name": "WH-1000XM4",
|
||||
"properties": {},
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.connected");
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_paired_emits_bluetooth_specific_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.added",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"name": "My Headphones",
|
||||
"properties": {},
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.bluetooth.device.paired");
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "My Headphones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_unpaired_emits_bluetooth_specific_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.removed",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.bluetooth.device.unpaired");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_name_falls_back_to_properties() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.connected",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": true, "Name": "Fallback Name" },
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name");
|
||||
}
|
||||
|
||||
// ─── Network ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn network_online_and_offline() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let online = n.normalize(&raw(
|
||||
AdapterSource::Network,
|
||||
"net",
|
||||
json!({"online": true}),
|
||||
1,
|
||||
));
|
||||
let offline = n.normalize(&raw(
|
||||
AdapterSource::Network,
|
||||
"net",
|
||||
json!({"online": false}),
|
||||
2,
|
||||
));
|
||||
assert_eq!(online[0].event, "bread.network.connected");
|
||||
assert_eq!(offline[0].event, "bread.network.disconnected");
|
||||
}
|
||||
|
||||
// ─── System pass-through ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn system_events_pass_through_unchanged() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::System,
|
||||
"bread.custom.event",
|
||||
json!({"foo": "bar"}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.custom.event");
|
||||
assert_eq!(out[0].source, AdapterSource::System);
|
||||
assert_eq!(out[0].data.get("foo").unwrap(), "bar");
|
||||
}
|
||||
|
||||
// ─── Dedup ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dedup_drops_duplicate_within_window() {
|
||||
let n = EventNormalizer::new(500);
|
||||
let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
|
||||
assert_eq!(n.normalize(&ev).len(), 1);
|
||||
|
||||
let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200);
|
||||
assert_eq!(n.normalize(&dup).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_allows_after_window_elapses() {
|
||||
let n = EventNormalizer::new(500);
|
||||
let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
|
||||
assert_eq!(n.normalize(&first).len(), 1);
|
||||
|
||||
let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000);
|
||||
assert_eq!(n.normalize(&later).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_distinguishes_different_payloads() {
|
||||
let n = EventNormalizer::new(10_000);
|
||||
let a = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "workspace", "data": "1"}),
|
||||
1000,
|
||||
);
|
||||
let b = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "workspace", "data": "2"}),
|
||||
1100,
|
||||
);
|
||||
assert_eq!(n.normalize(&a).len(), 1);
|
||||
// Different payloads = different dedup key
|
||||
assert_eq!(n.normalize(&b).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_window_of_zero_allows_everything() {
|
||||
let n = EventNormalizer::new(0);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
n.normalize(&raw(
|
||||
AdapterSource::Network,
|
||||
"net",
|
||||
json!({"online": true}),
|
||||
1000,
|
||||
))
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn split_fields_handles_empty_and_single() {
|
||||
assert!(split_hyprland_fields("").is_empty());
|
||||
assert_eq!(split_hyprland_fields("only"), vec!["only"]);
|
||||
assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]);
|
||||
}
|
||||
if subsystem == "input" && name.contains("keyboard") {
|
||||
return DeviceClass::Keyboard;
|
||||
}
|
||||
if subsystem == "input" && name.contains("mouse") {
|
||||
return DeviceClass::Mouse;
|
||||
}
|
||||
if subsystem == "drm" {
|
||||
return DeviceClass::Display;
|
||||
}
|
||||
if subsystem == "sound" || name.contains("audio") {
|
||||
return DeviceClass::Audio;
|
||||
}
|
||||
if subsystem == "block" || name.contains("storage") {
|
||||
return DeviceClass::Storage;
|
||||
}
|
||||
|
||||
DeviceClass::Unknown
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,12 @@ pub struct SubscriptionTable {
|
|||
}
|
||||
|
||||
impl SubscriptionTable {
|
||||
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId {
|
||||
pub fn add_with_id(
|
||||
&mut self,
|
||||
id: SubscriptionId,
|
||||
pattern: String,
|
||||
once: bool,
|
||||
) -> SubscriptionId {
|
||||
self.next_id = self.next_id.max(id.0.saturating_add(1));
|
||||
|
||||
let sub = Subscription { id, pattern, once };
|
||||
|
|
@ -35,7 +40,6 @@ impl SubscriptionTable {
|
|||
// swap_remove moves the last element into `idx`. We need to update by_id
|
||||
// for that element. But first, remove its stale entry (it was at the last
|
||||
// position before the swap); then re-insert it at the new position.
|
||||
let last_idx = self.entries.len() - 1;
|
||||
self.entries.swap_remove(idx);
|
||||
|
||||
if idx < self.entries.len() {
|
||||
|
|
@ -68,5 +72,222 @@ fn matches_pattern(pattern: &str, event_name: &str) -> bool {
|
|||
return event_name.starts_with(prefix);
|
||||
}
|
||||
|
||||
pattern == event_name
|
||||
if let Some(prefix) = pattern.strip_suffix(".**") {
|
||||
if event_name == prefix {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
matches_glob(pattern.as_bytes(), event_name.as_bytes())
|
||||
}
|
||||
|
||||
fn matches_glob(pattern: &[u8], text: &[u8]) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return text.is_empty();
|
||||
}
|
||||
|
||||
if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' {
|
||||
let mut idx = 2;
|
||||
while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' {
|
||||
idx += 2;
|
||||
}
|
||||
let rest = &pattern[idx..];
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for offset in 0..=text.len() {
|
||||
if matches_glob(rest, &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
match pattern[0] {
|
||||
b'*' => {
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if matches_glob(&pattern[1..], &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
if offset == text.len() || text[offset] == b'.' {
|
||||
break;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
b'?' => {
|
||||
if text.is_empty() || text[0] == b'.' {
|
||||
return false;
|
||||
}
|
||||
matches_glob(&pattern[1..], &text[1..])
|
||||
}
|
||||
ch => {
|
||||
if text.first().copied() != Some(ch) {
|
||||
return false;
|
||||
}
|
||||
matches_glob(&pattern[1..], &text[1..])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_match() {
|
||||
assert!(matches_pattern(
|
||||
"bread.device.dock.connected",
|
||||
"bread.device.dock.connected"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
"bread.device.dock.connected",
|
||||
"bread.device.dock.disconnected"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_segment_wildcard() {
|
||||
assert!(matches_pattern(
|
||||
"bread.device.*",
|
||||
"bread.device.dock.connected"
|
||||
));
|
||||
assert!(matches_pattern("bread.device.*", "bread.device.foo"));
|
||||
assert!(!matches_pattern("bread.device.*", "bread.device"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_wildcard() {
|
||||
assert!(matches_pattern(
|
||||
"bread.device.**",
|
||||
"bread.device.dock.connected"
|
||||
));
|
||||
assert!(matches_pattern("bread.**", "bread.device.dock.connected"));
|
||||
assert!(matches_pattern("bread.**", "bread"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_char_wildcard() {
|
||||
assert!(matches_pattern("bread.monitor.?", "bread.monitor.1"));
|
||||
assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10"));
|
||||
assert!(!matches_pattern("bread.monitor.?", "bread.monitor."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_does_not_cross_dot_segments() {
|
||||
// `*` matches within a segment only.
|
||||
assert!(matches_pattern(
|
||||
"bread.*.connected",
|
||||
"bread.device.connected"
|
||||
));
|
||||
assert!(!matches_pattern(
|
||||
"bread.*.connected",
|
||||
"bread.device.dock.connected"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_star_matches_zero_or_more_segments() {
|
||||
assert!(matches_pattern("bread.**", "bread.a"));
|
||||
assert!(matches_pattern("bread.**", "bread.a.b.c.d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pattern_matches_only_empty_text() {
|
||||
assert!(matches_pattern("", ""));
|
||||
assert!(!matches_pattern("", "bread"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_only_matches_wildcards() {
|
||||
assert!(matches_pattern("**", ""));
|
||||
assert!(!matches_pattern("bread.*", ""));
|
||||
}
|
||||
|
||||
// ─── SubscriptionTable ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn table_add_assigns_provided_id_and_finds_match() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
let id = t.add_with_id(SubscriptionId(7), "bread.window.*".into(), false);
|
||||
assert_eq!(id, SubscriptionId(7));
|
||||
|
||||
let matches = t.match_event("bread.window.opened");
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].id, SubscriptionId(7));
|
||||
assert_eq!(matches[0].pattern, "bread.window.*");
|
||||
assert!(!matches[0].once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_match_returns_all_matching_subscriptions() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "bread.window.opened".into(), false);
|
||||
t.add_with_id(SubscriptionId(2), "bread.window.*".into(), false);
|
||||
t.add_with_id(SubscriptionId(3), "bread.**".into(), true);
|
||||
t.add_with_id(SubscriptionId(4), "bread.device.*".into(), false);
|
||||
|
||||
let matches = t.match_event("bread.window.opened");
|
||||
let ids: Vec<u64> = matches.iter().map(|s| s.id.0).collect();
|
||||
assert!(ids.contains(&1));
|
||||
assert!(ids.contains(&2));
|
||||
assert!(ids.contains(&3));
|
||||
assert!(!ids.contains(&4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_remove_returns_true_only_for_known_ids() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||
assert!(t.remove(SubscriptionId(1)));
|
||||
// Second remove of the same id is false.
|
||||
assert!(!t.remove(SubscriptionId(1)));
|
||||
// Removing a never-known id is false.
|
||||
assert!(!t.remove(SubscriptionId(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_remove_preserves_other_entries_after_swap_remove() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||
t.add_with_id(SubscriptionId(2), "b".into(), false);
|
||||
t.add_with_id(SubscriptionId(3), "c".into(), false);
|
||||
|
||||
// Remove the middle entry — swap_remove will move entry 3 into the slot.
|
||||
assert!(t.remove(SubscriptionId(2)));
|
||||
|
||||
// Subsequent removes still work, proving the by_id index was kept consistent.
|
||||
assert!(t.remove(SubscriptionId(3)));
|
||||
assert!(t.remove(SubscriptionId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_clear_removes_all() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "a".into(), false);
|
||||
t.add_with_id(SubscriptionId(2), "b".into(), false);
|
||||
t.clear();
|
||||
assert!(t.match_event("a").is_empty());
|
||||
assert!(t.match_event("b").is_empty());
|
||||
// After clear, the ids are reusable.
|
||||
assert!(!t.remove(SubscriptionId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_match_returns_empty_for_unmatched_event() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "bread.device.*".into(), false);
|
||||
assert!(t.match_event("bread.window.opened").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_once_flag_is_preserved_in_match_result() {
|
||||
let mut t = SubscriptionTable::default();
|
||||
t.add_with_id(SubscriptionId(1), "bread.test".into(), true);
|
||||
let matches = t.match_event("bread.test");
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert!(matches[0].once);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ pub fn spawn_supervised<F, Fut>(
|
|||
name: &'static str,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
mut task_factory: F,
|
||||
)
|
||||
where
|
||||
) where
|
||||
F: FnMut() -> Fut + Send + 'static,
|
||||
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||
{
|
||||
|
|
@ -50,7 +49,11 @@ where
|
|||
}
|
||||
|
||||
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
|
||||
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure");
|
||||
warn!(
|
||||
adapter = name,
|
||||
delay_ms = wait_ms,
|
||||
"restarting adapter after failure"
|
||||
);
|
||||
tokio::select! {
|
||||
_ = sleep(Duration::from_millis(wait_ms)) => {},
|
||||
_ = shutdown_rx.changed() => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RuntimeState {
|
||||
pub monitors: Vec<Monitor>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
|
|
@ -15,22 +16,6 @@ pub struct RuntimeState {
|
|||
pub modules: Vec<ModuleStatus>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitors: Vec::new(),
|
||||
workspaces: Vec::new(),
|
||||
active_workspace: None,
|
||||
active_window: None,
|
||||
devices: DeviceTopology::default(),
|
||||
network: NetworkState::default(),
|
||||
power: PowerState::default(),
|
||||
profile: ProfileState::default(),
|
||||
modules: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Monitor {
|
||||
pub name: String,
|
||||
|
|
@ -54,21 +39,38 @@ pub struct DeviceTopology {
|
|||
pub struct Device {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub class: DeviceClass,
|
||||
pub device: String,
|
||||
pub subsystem: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vendor_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub product_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceClass {
|
||||
Dock,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
Tablet,
|
||||
Display,
|
||||
Storage,
|
||||
Audio,
|
||||
Unknown,
|
||||
/// One set of match conditions. All provided fields must match.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MatchCondition {
|
||||
pub vendor_id: Option<String>,
|
||||
pub product_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub name_contains: Option<String>,
|
||||
pub id_input_keyboard: Option<bool>,
|
||||
pub id_input_mouse: Option<bool>,
|
||||
pub id_input_tablet: Option<bool>,
|
||||
/// True triggers the compound USB hub + secondary-interface check.
|
||||
pub usb_hub: Option<bool>,
|
||||
pub id_usb_class: Option<String>,
|
||||
pub subsystem: Option<String>,
|
||||
}
|
||||
|
||||
/// A device rule from `devices.lua`. The device name is assigned if ANY
|
||||
/// condition in `conditions` matches (OR semantics across conditions,
|
||||
/// AND semantics within a condition).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceRule {
|
||||
pub device: String,
|
||||
pub conditions: Vec<MatchCondition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -82,23 +84,13 @@ pub struct InterfaceState {
|
|||
pub up: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PowerState {
|
||||
pub ac_connected: bool,
|
||||
pub battery_percent: Option<u8>,
|
||||
pub battery_low: bool,
|
||||
}
|
||||
|
||||
impl Default for PowerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ac_connected: false,
|
||||
battery_percent: None,
|
||||
battery_low: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileState {
|
||||
pub active: String,
|
||||
|
|
@ -121,6 +113,10 @@ pub struct ModuleStatus {
|
|||
pub name: String,
|
||||
pub status: ModuleLoadState,
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
#[serde(default)]
|
||||
pub store: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -129,4 +125,6 @@ pub enum ModuleLoadState {
|
|||
Loaded,
|
||||
LoadError,
|
||||
NotFound,
|
||||
Degraded,
|
||||
Disabled,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{AdapterSource, BreadEvent};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::adapters::AdapterStatus;
|
||||
use crate::core::state_engine::StateHandle;
|
||||
use crate::lua::RuntimeHandle;
|
||||
|
||||
|
|
@ -23,6 +27,9 @@ pub struct Server {
|
|||
event_tx: broadcast::Sender<BreadEvent>,
|
||||
lua_runtime: RuntimeHandle,
|
||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
subscription_count: Arc<AtomicU64>,
|
||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
started_at: Instant,
|
||||
pid: u32,
|
||||
}
|
||||
|
|
@ -45,12 +52,18 @@ struct IpcResponse {
|
|||
}
|
||||
|
||||
impl Server {
|
||||
// Server::new legitimately requires all 8 fields; a builder pattern here would be
|
||||
// over-engineering for a single-call-site constructor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
socket_path: PathBuf,
|
||||
state_handle: StateHandle,
|
||||
event_tx: broadcast::Sender<BreadEvent>,
|
||||
lua_runtime: RuntimeHandle,
|
||||
emit_tx: mpsc::UnboundedSender<BreadEvent>,
|
||||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
subscription_count: Arc<AtomicU64>,
|
||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_path,
|
||||
|
|
@ -58,6 +71,9 @@ impl Server {
|
|||
event_tx,
|
||||
lua_runtime,
|
||||
emit_tx,
|
||||
adapter_status,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
started_at: Instant::now(),
|
||||
pid: process::id(),
|
||||
}
|
||||
|
|
@ -148,7 +164,10 @@ impl Server {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> {
|
||||
async fn handle_request(
|
||||
&self,
|
||||
req: IpcRequest,
|
||||
) -> std::result::Result<(String, Value), (String, String)> {
|
||||
let id = req.id.clone();
|
||||
let result = match req.method.as_str() {
|
||||
"ping" => Ok(json!({ "ok": true })),
|
||||
|
|
@ -166,12 +185,25 @@ impl Server {
|
|||
let full = self.state_handle.state_dump().await;
|
||||
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
|
||||
}
|
||||
"modules.reload" => self
|
||||
.lua_runtime
|
||||
.reload()
|
||||
"modules.reload" => {
|
||||
let started = Instant::now();
|
||||
if let Err(err) = self.lua_runtime.reload().await {
|
||||
return Err((id, err.to_string()));
|
||||
}
|
||||
let duration_ms = started.elapsed().as_millis();
|
||||
let modules = self
|
||||
.state_handle
|
||||
.state_dump()
|
||||
.await
|
||||
.map(|_| json!({ "reloaded": true }))
|
||||
.map_err(|e| e.to_string()),
|
||||
.get("modules")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"duration_ms": duration_ms,
|
||||
"modules": modules,
|
||||
}))
|
||||
}
|
||||
"profile.list" => {
|
||||
let full = self.state_handle.state_dump().await;
|
||||
let profiles = full
|
||||
|
|
@ -182,11 +214,7 @@ impl Server {
|
|||
Ok(profiles)
|
||||
}
|
||||
"profile.activate" => {
|
||||
let Some(name) = req
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
let Some(name) = req.params.get("name").and_then(Value::as_str) else {
|
||||
return Err((id, "missing profile name".to_string()));
|
||||
};
|
||||
|
||||
|
|
@ -205,11 +233,7 @@ impl Server {
|
|||
Ok(json!({ "active": name }))
|
||||
}
|
||||
"emit" => {
|
||||
let Some(event) = req
|
||||
.params
|
||||
.get("event")
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||
|
|
@ -224,13 +248,70 @@ impl Server {
|
|||
}
|
||||
"health" => {
|
||||
let uptime_ms = self.started_at.elapsed().as_millis();
|
||||
let state = self.state_handle.state_dump().await;
|
||||
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
|
||||
let adapters = self.adapter_status.read().await.clone();
|
||||
let subscription_count = self
|
||||
.subscription_count
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let recent_errors = self.lua_runtime.recent_errors();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"pid": self.pid,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_ms": uptime_ms,
|
||||
"socket": self.socket_path.to_string_lossy(),
|
||||
"adapters": adapters,
|
||||
"modules": modules,
|
||||
"subscriptions": subscription_count,
|
||||
"recent_errors": recent_errors,
|
||||
}))
|
||||
}
|
||||
"sync.status" => {
|
||||
let sync_path = bread_sync::config::bread_config_dir().join("sync.toml");
|
||||
match std::fs::read_to_string(&sync_path)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<toml::Value>().ok())
|
||||
{
|
||||
Some(toml) => {
|
||||
let machine = toml
|
||||
.get("machine")
|
||||
.and_then(|m| m.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let remote = toml
|
||||
.get("remote")
|
||||
.and_then(|r| r.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
Ok(json!({
|
||||
"initialized": true,
|
||||
"machine": machine,
|
||||
"remote": remote,
|
||||
}))
|
||||
}
|
||||
None => Ok(json!({ "initialized": false })),
|
||||
}
|
||||
}
|
||||
"events.replay" => {
|
||||
let since_ms = req
|
||||
.params
|
||||
.get("since_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cutoff = now_unix_ms().saturating_sub(since_ms);
|
||||
let replay: Vec<BreadEvent> = self
|
||||
.event_buffer
|
||||
.lock()
|
||||
.map(|buf| {
|
||||
buf.iter()
|
||||
.filter(|e| e.timestamp >= cutoff)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
|
||||
}
|
||||
_ => Err("unknown method".to_string()),
|
||||
};
|
||||
|
||||
|
|
@ -264,9 +345,134 @@ impl Server {
|
|||
}
|
||||
|
||||
fn matches_filter(event_name: &str, pattern: &str) -> bool {
|
||||
// Delegate to the same glob logic used by the subscription table so that
|
||||
// `bread events --filter "bread.device.**"` behaves identically to
|
||||
// `bread.on("bread.device.**", ...)` in Lua.
|
||||
if pattern.ends_with(".*") {
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
return event_name.starts_with(prefix);
|
||||
}
|
||||
event_name == pattern
|
||||
|
||||
if let Some(prefix) = pattern.strip_suffix(".**") {
|
||||
if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
matches_glob_filter(pattern.as_bytes(), event_name.as_bytes())
|
||||
}
|
||||
|
||||
fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return text.is_empty();
|
||||
}
|
||||
|
||||
if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' {
|
||||
let rest = &pattern[2..];
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for offset in 0..=text.len() {
|
||||
if matches_glob_filter(rest, &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
match pattern[0] {
|
||||
b'*' => {
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if matches_glob_filter(&pattern[1..], &text[offset..]) {
|
||||
return true;
|
||||
}
|
||||
if offset == text.len() || text[offset] == b'.' {
|
||||
break;
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
b'?' => {
|
||||
if text.is_empty() || text[0] == b'.' {
|
||||
return false;
|
||||
}
|
||||
matches_glob_filter(&pattern[1..], &text[1..])
|
||||
}
|
||||
ch => {
|
||||
if text.first().copied() != Some(ch) {
|
||||
return false;
|
||||
}
|
||||
matches_glob_filter(&pattern[1..], &text[1..])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::matches_filter;
|
||||
|
||||
#[test]
|
||||
fn filter_exact_match() {
|
||||
assert!(matches_filter("bread.window.opened", "bread.window.opened"));
|
||||
assert!(!matches_filter(
|
||||
"bread.window.opened",
|
||||
"bread.window.closed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dot_star_matches_one_segment_only() {
|
||||
assert!(matches_filter("bread.device.connected", "bread.device.*"));
|
||||
assert!(matches_filter(
|
||||
"bread.device.dock.connected",
|
||||
"bread.device.*"
|
||||
));
|
||||
assert!(!matches_filter("bread.device", "bread.device.*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dot_double_star_matches_zero_or_more_segments() {
|
||||
// Matches the exact prefix (zero segments after).
|
||||
assert!(matches_filter("bread.device", "bread.device.**"));
|
||||
// And matches deeper paths.
|
||||
assert!(matches_filter(
|
||||
"bread.device.dock.connected",
|
||||
"bread.device.**"
|
||||
));
|
||||
// But not a sibling at the same depth.
|
||||
assert!(!matches_filter(
|
||||
"bread.network.connected",
|
||||
"bread.device.**"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_question_mark_matches_single_char_not_dot() {
|
||||
assert!(matches_filter("bread.x", "bread.?"));
|
||||
assert!(!matches_filter("bread.xy", "bread.?"));
|
||||
assert!(!matches_filter("bread.", "bread.?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_mid_pattern_star_does_not_cross_dots() {
|
||||
// A `*` in the middle of the pattern (not the `.*` suffix shortcut)
|
||||
// matches within a single segment only.
|
||||
assert!(matches_filter("bread.alpha.connected", "bread.*.connected"));
|
||||
assert!(!matches_filter(
|
||||
"bread.alpha.beta.connected",
|
||||
"bread.*.connected"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dot_star_at_end_acts_as_prefix_match() {
|
||||
// `bread.*` ending the pattern is treated as a prefix match, so
|
||||
// matches everything under `bread.` regardless of depth. This is
|
||||
// consistent with the subscription table's pattern matcher.
|
||||
assert!(matches_filter("bread.alpha", "bread.*"));
|
||||
assert!(matches_filter("bread.alpha.beta", "bread.*"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,8 @@ mod core;
|
|||
mod ipc;
|
||||
mod lua;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
@ -33,9 +35,11 @@ async fn main() -> Result<()> {
|
|||
let (event_stream_tx, _) = broadcast::channel(2048);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let subscription_count = Arc::new(AtomicU64::new(0));
|
||||
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
|
||||
|
||||
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
||||
let lua_runtime =
|
||||
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
||||
let lua_tx = lua_runtime.sender();
|
||||
|
||||
tokio::spawn(run_state_engine(
|
||||
|
|
@ -44,6 +48,7 @@ async fn main() -> Result<()> {
|
|||
state.clone(),
|
||||
lua_tx,
|
||||
event_stream_tx.clone(),
|
||||
subscription_count.clone(),
|
||||
shutdown_rx.clone(),
|
||||
));
|
||||
|
||||
|
|
@ -78,6 +83,28 @@ async fn main() -> Result<()> {
|
|||
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
|
||||
adapter_manager.start_all().await?;
|
||||
|
||||
let adapter_status = adapter_manager.status_handle();
|
||||
|
||||
let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000)));
|
||||
{
|
||||
let mut rx = event_stream_tx.subscribe();
|
||||
let event_buffer = event_buffer.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let evt = match rx.recv().await {
|
||||
Ok(evt) => evt,
|
||||
Err(_) => break,
|
||||
};
|
||||
if let Ok(mut buf) = event_buffer.lock() {
|
||||
if buf.len() >= 1000 {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(evt);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _ = normalized_tx.send(BreadEvent::new(
|
||||
"bread.system.startup",
|
||||
AdapterSource::System,
|
||||
|
|
@ -90,6 +117,9 @@ async fn main() -> Result<()> {
|
|||
event_stream_tx,
|
||||
lua_runtime.clone(),
|
||||
normalized_tx,
|
||||
adapter_status,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
);
|
||||
|
||||
info!("breadd fully started");
|
||||
|
|
@ -115,7 +145,8 @@ async fn wait_for_shutdown() {
|
|||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
||||
let mut sigterm =
|
||||
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = sigterm.recv() => {},
|
||||
|
|
|
|||
|
|
@ -31,6 +31,291 @@ async fn ping_and_state_dump_work() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_method_returns_error() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("not.a.real.method", json!({})).await;
|
||||
assert!(result.is_err(), "expected error for unknown method");
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(
|
||||
msg.contains("unknown method"),
|
||||
"expected 'unknown method', got: {msg}"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_activate_updates_state() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness
|
||||
.send_request("profile.activate", json!({"name": "battery"}))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
result.get("active").and_then(Value::as_str),
|
||||
Some("battery")
|
||||
);
|
||||
|
||||
let dump = harness.send_request("state.dump", json!({})).await?;
|
||||
assert_eq!(
|
||||
dump.get("profile")
|
||||
.and_then(|v| v.get("active"))
|
||||
.and_then(Value::as_str),
|
||||
Some("battery")
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_activate_without_name_errors() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("profile.activate", json!({})).await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap().to_string();
|
||||
assert!(msg.contains("missing profile name"), "got: {msg}");
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_without_event_errors() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("emit", json!({})).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_get_returns_specific_subtree() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let modules = harness
|
||||
.send_request("state.get", json!({"key": "modules"}))
|
||||
.await?;
|
||||
assert!(modules.is_array(), "expected modules to be an array");
|
||||
|
||||
let active = harness
|
||||
.send_request("state.get", json!({"key": "profile.active"}))
|
||||
.await?;
|
||||
assert!(
|
||||
active.as_str().is_some(),
|
||||
"expected profile.active to be a string, got: {active:?}"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_get_missing_key_returns_error() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness
|
||||
.send_request("state.get", json!({"key": "does.not.exist"}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modules_list_returns_array() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("modules.list", json!({})).await?;
|
||||
assert!(result.is_array());
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modules_reload_succeeds() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("modules.reload", json!({})).await?;
|
||||
assert_eq!(result.get("ok").and_then(Value::as_bool), Some(true));
|
||||
assert!(result.get("duration_ms").is_some());
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_status_uninitialized_when_no_config() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("sync.status", json!({})).await?;
|
||||
assert_eq!(
|
||||
result.get("initialized").and_then(Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_status_reports_initialized_with_config() -> Result<()> {
|
||||
let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let result = harness.send_request("sync.status", json!({})).await?;
|
||||
assert_eq!(
|
||||
result.get("initialized").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("machine").and_then(Value::as_str),
|
||||
Some("myhost")
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("remote").and_then(Value::as_str),
|
||||
Some("git@example.com:user/repo.git")
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_replay_returns_buffered_events() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
// Emit a couple of events.
|
||||
harness
|
||||
.send_request("emit", json!({"event": "bread.replay.a", "data": {}}))
|
||||
.await?;
|
||||
harness
|
||||
.send_request("emit", json!({"event": "bread.replay.b", "data": {}}))
|
||||
.await?;
|
||||
|
||||
// Small delay so the events make it into the buffer.
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let result = harness
|
||||
.send_request("events.replay", json!({"since_ms": 10_000}))
|
||||
.await?;
|
||||
let arr = result.as_array().expect("replay result should be array");
|
||||
let names: Vec<&str> = arr
|
||||
.iter()
|
||||
.filter_map(|e| e.get("event").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(names.contains(&"bread.replay.a"));
|
||||
assert!(names.contains(&"bread.replay.b"));
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn event_stream_filter_excludes_non_matching_events() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
|
||||
let stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let subscribe = json!({
|
||||
"id": "sub-x",
|
||||
"method": "events.subscribe",
|
||||
"params": {
|
||||
"filter": "bread.match.*"
|
||||
}
|
||||
});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||
.await?;
|
||||
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
// Consume the ack line.
|
||||
reader.next_line().await?;
|
||||
|
||||
// Emit one matching and one non-matching event.
|
||||
harness
|
||||
.send_request("emit", json!({"event": "bread.nomatch.x", "data": {}}))
|
||||
.await?;
|
||||
harness
|
||||
.send_request("emit", json!({"event": "bread.match.yes", "data": {}}))
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut matched = false;
|
||||
while Instant::now() < deadline {
|
||||
let Some(line) = reader.next_line().await? else {
|
||||
break;
|
||||
};
|
||||
let event: Value = serde_json::from_str(&line)?;
|
||||
let name = event.get("event").and_then(Value::as_str).unwrap_or("");
|
||||
assert!(
|
||||
!name.starts_with("bread.nomatch"),
|
||||
"filter let through non-matching event: {name}"
|
||||
);
|
||||
if name == "bread.match.yes" {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(matched, "did not receive matching event through filter");
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_concurrent_clients_each_get_response() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
harness.wait_until_ready().await?;
|
||||
let socket = harness.socket_path().to_path_buf();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..8 {
|
||||
let socket = socket.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let stream = UnixStream::connect(&socket).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let req = json!({"id": i.to_string(), "method": "ping", "params": {}});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
|
||||
.await?;
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
let line = lines.next_line().await?.ok_or_else(|| anyhow!("eof"))?;
|
||||
let parsed: Value = serde_json::from_str(&line)?;
|
||||
assert_eq!(
|
||||
parsed.get("id").and_then(Value::as_str),
|
||||
Some(i.to_string().as_str())
|
||||
);
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await??;
|
||||
}
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_stream_receives_emitted_events() -> Result<()> {
|
||||
let harness = TestHarness::spawn()?;
|
||||
|
|
@ -100,6 +385,14 @@ struct TestHarness {
|
|||
|
||||
impl TestHarness {
|
||||
fn spawn() -> Result<Self> {
|
||||
Self::spawn_inner(None)
|
||||
}
|
||||
|
||||
fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result<Self> {
|
||||
Self::spawn_inner(Some((machine.to_string(), remote_url.to_string())))
|
||||
}
|
||||
|
||||
fn spawn_inner(sync_config: Option<(String, String)>) -> Result<Self> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let runtime_dir = temp.path().join("runtime");
|
||||
let config_home = temp.path().join("config");
|
||||
|
|
@ -140,6 +433,21 @@ enabled = false
|
|||
"#,
|
||||
)?;
|
||||
|
||||
if let Some((machine, remote_url)) = sync_config {
|
||||
let sync_toml = format!(
|
||||
r#"
|
||||
[remote]
|
||||
url = "{remote_url}"
|
||||
branch = "main"
|
||||
|
||||
[machine]
|
||||
name = "{machine}"
|
||||
tags = []
|
||||
"#
|
||||
);
|
||||
fs::write(bread_cfg.join("sync.toml"), sync_toml)?;
|
||||
}
|
||||
|
||||
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,47 @@
|
|||
Packaging notes
|
||||
================
|
||||
Packaging
|
||||
=========
|
||||
|
||||
This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under
|
||||
`packaging/arch/`.
|
||||
This directory contains distribution packaging for Bread.
|
||||
|
||||
```
|
||||
packaging/
|
||||
├── arch/
|
||||
│ └── PKGBUILD ← Arch Linux package build script
|
||||
└── systemd/
|
||||
└── breadd.service ← systemd user service unit
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
|
||||
```bash
|
||||
cd packaging/arch
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`.
|
||||
|
||||
Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball.
|
||||
|
||||
## systemd user service
|
||||
|
||||
The service unit starts `breadd` as a user service after the graphical session is available.
|
||||
|
||||
```bash
|
||||
# Install and enable manually (if not using the PKGBUILD)
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp systemd/breadd.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now breadd
|
||||
|
||||
# Check status
|
||||
systemctl --user status breadd
|
||||
journalctl --user -u breadd -f
|
||||
```
|
||||
|
||||
The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/breadd.service.d/debug.conf
|
||||
[Service]
|
||||
Environment=RUST_LOG=debug
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
# Maintainer: Your Name <you@example.com>
|
||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||
|
||||
pkgname=breadd
|
||||
pkgver=0.1.0
|
||||
pkgname=bread
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Bread daemon - event normalizer and automation runtime"
|
||||
pkgdesc="A reactive automation fabric for Linux desktops"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Breadway/bread"
|
||||
license=('MIT')
|
||||
depends=('glibc')
|
||||
makedepends=('rust')
|
||||
depends=('glibc' 'libgit2')
|
||||
optdepends=(
|
||||
'libnotify: desktop notifications via bread.notify()'
|
||||
'upower: D-Bus battery events (sysfs polling used otherwise)'
|
||||
'git: bread sync push/pull operations'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
|
|
@ -17,9 +22,15 @@ build() {
|
|||
cargo build --release --locked
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo test --release --locked --workspace
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
|
||||
install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli"
|
||||
install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
|
||||
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
Arch packaging
|
||||
==============
|
||||
|
||||
This is a minimal PKGBUILD skeleton.
|
||||
`PKGBUILD` builds and installs both `breadd` and `bread` from source.
|
||||
|
||||
Steps to use:
|
||||
- Update `pkgver`, `source`, `sha256sums`, and `url`.
|
||||
- Set the correct license and dependencies.
|
||||
- Ensure the release tarball includes `packaging/systemd/breadd.service`.
|
||||
## Local build
|
||||
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## Before publishing to AUR
|
||||
|
||||
1. Tag a release on GitHub.
|
||||
2. Update `pkgver` to match the tag.
|
||||
3. Update `source` to the release tarball URL.
|
||||
4. Run `updpkgsums` (or manually set `sha256sums`).
|
||||
5. Update `url` if the repository has moved.
|
||||
6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically.
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
| Package | Required | Notes |
|
||||
|---------|----------|-------|
|
||||
| `glibc` | yes | always |
|
||||
| `udev` | yes | device events |
|
||||
| `dbus` | optional | UPower battery events |
|
||||
| `libnotify` | optional | `bread.notify()` (uses `notify-send`) |
|
||||
| `git` | optional | `bread sync` push/pull |
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Wants=graphical-session.target
|
|||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.cargo/bin/breadd
|
||||
ExecStart=/usr/bin/breadd
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
|
|
|||
109
scripts/install.sh
Executable file
109
scripts/install.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
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 ""
|
||||
|
||||
# ── 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"
|
||||
# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin.
|
||||
sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \
|
||||
"$REPO_ROOT/packaging/systemd/breadd.service" \
|
||||
> "$SERVICE_DIR/breadd.service"
|
||||
echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
|
||||
if systemctl --user is-active --quiet breadd 2>/dev/null; then
|
||||
systemctl --user restart breadd
|
||||
echo " breadd restarted"
|
||||
else
|
||||
systemctl --user enable --now breadd
|
||||
echo " breadd enabled and started"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── verify ─────────────────────────────────────────────────────────────────────
|
||||
# Wait up to ~5s for the daemon to come up. Polling beats a fixed sleep
|
||||
# because a freshly enabled systemd unit can take a variable amount of time
|
||||
# to fork, bind the socket, and become ready.
|
||||
ready=0
|
||||
for _ in $(seq 1 25); do
|
||||
if "$BIN_DIR/bread" ping &>/dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if [[ "$ready" -eq 1 ]]; then
|
||||
"$BIN_DIR/bread" doctor
|
||||
else
|
||||
echo "warning: daemon did not respond to ping within 5s"
|
||||
echo " check: journalctl --user -u breadd -n 20"
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue