Compare commits
No commits in common. "de8b12f4d5daa15f65df32d4fa49e0ed5cdeabf8" and "fd1189dc8967f635e2f665071a8cf50db8faadee" have entirely different histories.
de8b12f4d5
...
fd1189dc89
50 changed files with 18213 additions and 12 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
|
||||||
45
.gitignore
vendored
45
.gitignore
vendored
|
|
@ -1,16 +1,39 @@
|
||||||
# ---> Rust
|
# Rust build artifacts
|
||||||
# Generated by Cargo
|
|
||||||
# will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# Editor and IDE files
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
.vscode/
|
||||||
Cargo.lock
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# OS artifacts
|
||||||
**/*.rs.bk
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# Environment and secrets
|
||||||
*.pdb
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.sock
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Internal project docs and spec files kept out of public history
|
||||||
|
Overview.md
|
||||||
|
DAEMON.md
|
||||||
|
LUA_RUNTIME.md
|
||||||
|
CLAUDE_SPEC.md
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
|
|
||||||
3580
Cargo.lock
generated
Normal file
3580
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"bread-shared",
|
||||||
|
"breadd",
|
||||||
|
"bread-cli",
|
||||||
|
"bread-sync",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Breadway Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
635
README.md
635
README.md
|
|
@ -1,2 +1,635 @@
|
||||||
# bread
|
# Bread
|
||||||
|
|
||||||
|
**A reactive automation fabric for Linux desktops.**
|
||||||
|
|
||||||
|
Bread is a modular desktop automation runtime built around a single idea: your desktop should behave like a programmable system, not a collection of disconnected config files.
|
||||||
|
|
||||||
|
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 active and feature-complete for daily use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Bread runs a long-lived daemon (`breadd`) that:
|
||||||
|
|
||||||
|
1. Ingests raw signals from your compositor, hardware, and OS
|
||||||
|
2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.)
|
||||||
|
3. Maintains a live model of your desktop state
|
||||||
|
4. Delivers those events to Lua modules that implement your automation
|
||||||
|
|
||||||
|
Your automation lives in Lua. You subscribe to events, read state, and call APIs:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
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(event)
|
||||||
|
bread.profile.activate("default")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
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, 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux (Arch recommended)
|
||||||
|
- Wayland compositor (Hyprland for full functionality)
|
||||||
|
- Rust toolchain (stable, 2021 edition)
|
||||||
|
- `udev` (standard on systemd systems)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Breadway/bread.git
|
||||||
|
cd bread
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/arch
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd user service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp packaging/systemd/breadd.service ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now breadd
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Bread reads from `~/.config/bread/breadd.toml`. All values are optional — the daemon runs with defaults if the file doesn't exist.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[daemon]
|
||||||
|
log_level = "info" # trace | debug | info | warn | error
|
||||||
|
|
||||||
|
[lua]
|
||||||
|
entry_point = "~/.config/bread/init.lua"
|
||||||
|
module_path = "~/.config/bread/modules"
|
||||||
|
|
||||||
|
[adapters.hyprland]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[adapters.udev]
|
||||||
|
enabled = true
|
||||||
|
subsystems = ["usb", "input", "drm", "power_supply"]
|
||||||
|
|
||||||
|
[adapters.power]
|
||||||
|
enabled = true
|
||||||
|
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`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- ~/.config/bread/init.lua
|
||||||
|
|
||||||
|
bread.on("bread.system.startup", function(event)
|
||||||
|
bread.profile.activate("default")
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event reference
|
||||||
|
|
||||||
|
Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|-------|---------|
|
||||||
|
| `bread.system.startup` | Daemon fully initialized |
|
||||||
|
| `bread.device.connected` | Any device attached |
|
||||||
|
| `bread.device.disconnected` | Any device removed |
|
||||||
|
| `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
|
||||||
|
| `bread.device.<device>.disconnected` | Named device removed |
|
||||||
|
| `bread.monitor.connected` | Display connected |
|
||||||
|
| `bread.monitor.disconnected` | Display disconnected |
|
||||||
|
| `bread.workspace.changed` | Active workspace changed |
|
||||||
|
| `bread.window.focus.changed` | Focused window changed |
|
||||||
|
| `bread.window.opened` | Window opened |
|
||||||
|
| `bread.window.closed` | Window closed |
|
||||||
|
| `bread.power.ac.connected` | AC adapter plugged in |
|
||||||
|
| `bread.power.ac.disconnected` | AC adapter unplugged |
|
||||||
|
| `bread.power.battery.low` | Battery ≤ 20% |
|
||||||
|
| `bread.power.battery.very_low` | Battery ≤ 10% |
|
||||||
|
| `bread.power.battery.critical` | Battery ≤ 5% |
|
||||||
|
| `bread.power.battery.full` | Battery at 100% |
|
||||||
|
| `bread.network.connected` | Network interface came online |
|
||||||
|
| `bread.network.disconnected` | Network interface went offline |
|
||||||
|
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
|
||||||
|
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
|
||||||
|
| `bread.profile.activated` | Profile switched |
|
||||||
|
| `bread.notify.sent` | Desktop notification dispatched |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lua API
|
||||||
|
|
||||||
|
### Modules
|
||||||
|
|
||||||
|
Every module file must declare itself. The declaration is used for dependency ordering and status tracking.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local M = bread.module({
|
||||||
|
name = "my-module",
|
||||||
|
version = "1.0.0",
|
||||||
|
after = { "bread.devices" }, -- load after this module
|
||||||
|
})
|
||||||
|
|
||||||
|
-- ... module body ...
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Subscribe to events; returns a subscription ID
|
||||||
|
local id = bread.on("bread.monitor.connected", function(event)
|
||||||
|
-- event.event → "bread.monitor.connected"
|
||||||
|
-- event.data → table of event-specific fields
|
||||||
|
-- event.source → adapter that produced it
|
||||||
|
bread.log(event.event)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Unsubscribe by ID
|
||||||
|
bread.off(id)
|
||||||
|
|
||||||
|
-- Subscribe once, auto-unsubscribe after first delivery
|
||||||
|
bread.once("bread.system.startup", function(event)
|
||||||
|
bread.profile.activate("default")
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Subscribe with a filter predicate. The predicate goes in an opts table.
|
||||||
|
bread.filter("bread.device.connected", function(event)
|
||||||
|
bread.exec("xset r rate 200 40")
|
||||||
|
end, {
|
||||||
|
filter = function(event)
|
||||||
|
return event.data.device == "keyboard"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Emit a custom event (for cross-module communication)
|
||||||
|
bread.emit("mymodule.something", { key = "value" })
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character):
|
||||||
|
```lua
|
||||||
|
bread.on("bread.device.*", handler) -- matches bread.device.dock.connected
|
||||||
|
bread.on("bread.device.**", handler) -- matches any depth under bread.device
|
||||||
|
```
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Read from runtime state by dot-separated path
|
||||||
|
local monitors = bread.state.get("monitors")
|
||||||
|
local online = bread.state.get("network.online")
|
||||||
|
|
||||||
|
-- Typed shorthands
|
||||||
|
local monitors = bread.state.monitors()
|
||||||
|
local workspace = bread.state.active_workspace()
|
||||||
|
local window = bread.state.active_window()
|
||||||
|
local devices = bread.state.devices()
|
||||||
|
local power = bread.state.power()
|
||||||
|
local network = bread.state.network()
|
||||||
|
local profile = bread.state.profile()
|
||||||
|
|
||||||
|
-- Watch a state path for changes
|
||||||
|
bread.state.watch("power.ac_connected", function(new_val, old_val)
|
||||||
|
if new_val then
|
||||||
|
bread.notify("AC connected")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.profile.activate("desk")
|
||||||
|
bread.profile.activate("default")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution and notifications
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Fire-and-forget shell command
|
||||||
|
bread.exec("kitty")
|
||||||
|
|
||||||
|
-- Desktop notification (uses notify-send)
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IPC protocol
|
||||||
|
|
||||||
|
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON — useful for scripting or building tooling outside the CLI.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Available methods:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ping` | Connectivity check |
|
||||||
|
| `health` | Version, uptime, PID, adapter status |
|
||||||
|
| `state.get` | Read a value from `RuntimeState` by dotted key path |
|
||||||
|
| `state.dump` | Return the full `RuntimeState` as JSON |
|
||||||
|
| `modules.list` | List all loaded modules and their status |
|
||||||
|
| `modules.reload` | Hot-reload the Lua runtime |
|
||||||
|
| `profile.list` | List defined profiles |
|
||||||
|
| `profile.activate` | Switch active profile |
|
||||||
|
| `events.subscribe` | Upgrade connection to streaming mode |
|
||||||
|
| `events.replay` | Replay buffered events from the last N ms |
|
||||||
|
| `emit` | Inject a synthetic event into the pipeline |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Bread is early-stage software. Contributions, issues, and feedback are welcome.
|
||||||
|
|
||||||
|
The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
30
bread-cli/Cargo.toml
Normal file
30
bread-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-cli"
|
||||||
|
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;
|
||||||
1356
bread-cli/src/main.rs
Normal file
1356
bread-cli/src/main.rs
Normal file
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");
|
||||||
|
}
|
||||||
8
bread-shared/Cargo.toml
Normal file
8
bread-shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "bread-shared"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
219
bread-shared/src/lib.rs
Normal file
219
bread-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
//! 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(),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
source,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
27
breadd/Cargo.toml
Normal file
27
breadd/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "breadd"
|
||||||
|
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
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
toml = "0.8"
|
||||||
|
udev = { version = "0.9", features = ["send"] }
|
||||||
|
rtnetlink = "0.9"
|
||||||
|
zbus = { version = "3.13", features = ["tokio"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
netlink-packet-route = "0.11"
|
||||||
|
netlink-packet-core = "0.4"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.13"
|
||||||
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("/"), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
92
breadd/src/adapters/hyprland.rs
Normal file
92
breadd/src/adapters/hyprland.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::adapters::Adapter;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct HyprlandAdapter;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Adapter for HyprlandAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"hyprland"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
debug!("hyprland adapter started");
|
||||||
|
let socket = hyprland_event_socket()?;
|
||||||
|
let stream = UnixStream::connect(&socket).await?;
|
||||||
|
let reader = BufReader::new(stream);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
|
||||||
|
while let Some(line) = lines.next_line().await? {
|
||||||
|
let (kind, data) = parse_hyprland_line(&line);
|
||||||
|
tx.send(RawEvent {
|
||||||
|
source: AdapterSource::Hyprland,
|
||||||
|
kind: "hyprland.event".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"kind": kind,
|
||||||
|
"raw": line,
|
||||||
|
"data": data,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("hyprland socket closed");
|
||||||
|
Err(anyhow!("hyprland socket closed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hyprland_event_socket() -> Result<PathBuf> {
|
||||||
|
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
|
||||||
|
// If the env var is set, use it directly.
|
||||||
|
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||||
|
return Ok(PathBuf::from(runtime)
|
||||||
|
.join("hypr")
|
||||||
|
.join(instance)
|
||||||
|
.join(".socket2.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance.
|
||||||
|
// Hyprland creates a per-instance directory there containing .socket2.sock.
|
||||||
|
// This handles the case where breadd starts as a systemd user service before
|
||||||
|
// Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment.
|
||||||
|
let hypr_dir = PathBuf::from(&runtime).join("hypr");
|
||||||
|
let mut sockets: Vec<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) {
|
||||||
|
if let Some((kind, data)) = line.split_once(">>") {
|
||||||
|
return (kind.to_string(), data.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
("unknown".to_string(), line.to_string())
|
||||||
|
}
|
||||||
142
breadd/src/adapters/mod.rs
Normal file
142
breadd/src/adapters/mod.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bread_shared::RawEvent;
|
||||||
|
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 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 {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()>;
|
||||||
|
async fn on_connect(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn on_disconnect(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Manager {
|
||||||
|
raw_tx: mpsc::Sender<RawEvent>,
|
||||||
|
config: Config,
|
||||||
|
shutdown_rx: watch::Receiver<bool>,
|
||||||
|
status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manager {
|
||||||
|
pub fn new(
|
||||||
|
raw_tx: mpsc::Sender<RawEvent>,
|
||||||
|
config: Config,
|
||||||
|
shutdown_rx: watch::Receiver<bool>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
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");
|
||||||
|
|
||||||
|
if self.config.adapters.udev.enabled {
|
||||||
|
let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone());
|
||||||
|
adapter.enumerate_existing(&self.raw_tx).await?;
|
||||||
|
self.spawn_adapter(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.adapters.hyprland.enabled {
|
||||||
|
self.spawn_adapter(hyprland::HyprlandAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.adapters.power.enabled {
|
||||||
|
// Prefer UPower DBus adapter; fall back to sysfs poller
|
||||||
|
let upower = power_upower::UPowerAdapter::new();
|
||||||
|
if let Ok(adapter) = upower {
|
||||||
|
self.spawn_adapter(adapter);
|
||||||
|
} else {
|
||||||
|
self.spawn_adapter(power::PowerAdapter::new(
|
||||||
|
self.config.adapters.power.poll_interval_secs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_adapter<A>(&self, adapter: A)
|
||||||
|
where
|
||||||
|
A: Adapter + Clone + 'static,
|
||||||
|
{
|
||||||
|
let name = adapter.name();
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
93
breadd/src/adapters/network.rs
Normal file
93
breadd/src/adapters/network.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct NetworkAdapter;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Adapter for NetworkAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"network"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
debug!("network adapter started");
|
||||||
|
let mut last = read_network_state();
|
||||||
|
tx.send(network_raw_event(&last)).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(5)).await;
|
||||||
|
let now = read_network_state();
|
||||||
|
if now != last {
|
||||||
|
tx.send(network_raw_event(&now)).await?;
|
||||||
|
last = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct NetworkSnapshot {
|
||||||
|
interfaces: BTreeMap<String, bool>,
|
||||||
|
online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent {
|
||||||
|
let interfaces = snapshot
|
||||||
|
.interfaces
|
||||||
|
.iter()
|
||||||
|
.map(|(name, up)| (name.clone(), json!({ "up": up })))
|
||||||
|
.collect::<serde_json::Map<String, serde_json::Value>>();
|
||||||
|
|
||||||
|
RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: "network.snapshot".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"online": snapshot.online,
|
||||||
|
"interfaces": interfaces,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_network_state() -> NetworkSnapshot {
|
||||||
|
let mut interfaces = BTreeMap::new();
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir("/sys/class/net") {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name == "lo" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default();
|
||||||
|
let up = oper.trim() == "up";
|
||||||
|
interfaces.insert(name, up);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let online = has_default_route();
|
||||||
|
|
||||||
|
NetworkSnapshot { interfaces, online }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_default_route() -> bool {
|
||||||
|
if let Ok(routes) = fs::read_to_string("/proc/net/route") {
|
||||||
|
for line in routes.lines().skip(1) {
|
||||||
|
let cols: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if cols.len() > 2 && cols[1] == "00000000" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
195
breadd/src/adapters/network_rtnetlink.rs
Normal file
195
breadd/src/adapters/network_rtnetlink.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bread_shared::{AdapterSource, RawEvent};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use netlink_packet_route::RtnlMessage;
|
||||||
|
use rtnetlink::new_connection;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use super::Adapter;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RtnetlinkAdapter;
|
||||||
|
|
||||||
|
impl RtnetlinkAdapter {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
// Try to create a connection to validate presence of rtnetlink
|
||||||
|
let conn = new_connection();
|
||||||
|
match conn {
|
||||||
|
Ok((connection, _handle, _messages)) => {
|
||||||
|
// Spawn and immediately drop the connection task; we just validated
|
||||||
|
tokio::spawn(connection);
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
Err(e) => Err(anyhow!(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Adapter for RtnetlinkAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"rtnetlink-network"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
info!("rtnetlink adapter starting");
|
||||||
|
let (connection, _handle, mut messages) = new_connection()?;
|
||||||
|
tokio::spawn(connection);
|
||||||
|
|
||||||
|
while let Some((message, _addr)) = messages.next().await {
|
||||||
|
match message.payload {
|
||||||
|
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
|
||||||
|
let ifname = link.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let mtu = link.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let netns_id = link.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let netns_fd = link.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let up = link.header.flags & (libc::IFF_UP as u32) != 0;
|
||||||
|
if let Some(name) = ifname {
|
||||||
|
let kind = if up { "link.up" } else { "link.down" };
|
||||||
|
let payload = json!({
|
||||||
|
"ifname": name,
|
||||||
|
"index": link.header.index,
|
||||||
|
"mtu": mtu,
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
|
||||||
|
// Heuristic: if destination is default (empty), treat as default-route change
|
||||||
|
let is_default = route.header.destination_prefix_length == 0;
|
||||||
|
if is_default {
|
||||||
|
let gateway = route.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let gateway_ip = gateway.as_deref().and_then(ip_from_bytes);
|
||||||
|
let payload = json!({
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||||
|
Some(label.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||||
|
let payload = json!({
|
||||||
|
"ifindex": addr.header.index,
|
||||||
|
"prefix_len": addr.header.prefix_len,
|
||||||
|
"family": addr.header.family,
|
||||||
|
"address": ip,
|
||||||
|
"label": label
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
)) => {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||||
|
netlink_packet_route::address::nlas::Nla::Label(label) => {
|
||||||
|
Some(label.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||||
|
let payload = json!({
|
||||||
|
"ifindex": addr.header.index,
|
||||||
|
"prefix_len": addr.header.prefix_len,
|
||||||
|
"family": addr.header.family,
|
||||||
|
"address": ip,
|
||||||
|
"label": label
|
||||||
|
});
|
||||||
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
kind: "address.removed".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
debug!("unhandled netlink message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip_from_bytes(bytes: &[u8]) -> Option<String> {
|
||||||
|
match bytes.len() {
|
||||||
|
4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()),
|
||||||
|
16 => {
|
||||||
|
let octets: [u8; 16] = bytes.try_into().ok()?;
|
||||||
|
Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
breadd/src/adapters/power.rs
Normal file
92
breadd/src/adapters/power.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PowerAdapter {
|
||||||
|
poll_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PowerAdapter {
|
||||||
|
pub fn new(poll_interval_secs: u64) -> Self {
|
||||||
|
Self { poll_interval_secs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Adapter for PowerAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"power"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
debug!("power adapter started");
|
||||||
|
|
||||||
|
let mut last = read_power_state();
|
||||||
|
tx.send(power_raw_event(&last)).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await;
|
||||||
|
let now = read_power_state();
|
||||||
|
if now != last {
|
||||||
|
tx.send(power_raw_event(&now)).await?;
|
||||||
|
last = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct PowerSnapshot {
|
||||||
|
ac_connected: bool,
|
||||||
|
battery_percent: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent {
|
||||||
|
RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.snapshot".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"ac_connected": snapshot.ac_connected,
|
||||||
|
"battery_percent": snapshot.battery_percent,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_power_state() -> PowerSnapshot {
|
||||||
|
let power_dir = Path::new("/sys/class/power_supply");
|
||||||
|
let mut ac_connected = false;
|
||||||
|
let mut battery_percent = None;
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(power_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let typ = fs::read_to_string(path.join("type")).unwrap_or_default();
|
||||||
|
if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") {
|
||||||
|
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
|
||||||
|
if online.trim() == "1" {
|
||||||
|
ac_connected = true;
|
||||||
|
}
|
||||||
|
} else if typ.trim().eq_ignore_ascii_case("Battery") {
|
||||||
|
let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default();
|
||||||
|
if let Ok(parsed) = cap.trim().parse::<u8>() {
|
||||||
|
battery_percent = Some(parsed.min(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PowerSnapshot {
|
||||||
|
ac_connected,
|
||||||
|
battery_percent,
|
||||||
|
}
|
||||||
|
}
|
||||||
147
breadd/src/adapters/power_upower.rs
Normal file
147
breadd/src/adapters/power_upower.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bread_shared::{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 UPowerAdapter;
|
||||||
|
|
||||||
|
impl UPowerAdapter {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
// Attempt to connect to system bus to validate availability
|
||||||
|
// We don't actually open the connection here because zbus::Connection::system() is async.
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Adapter for UPowerAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"upower"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
info!("UPower adapter starting (attempting DBus subscription)");
|
||||||
|
|
||||||
|
// Defer loading zbus until runtime to avoid build-time optional complexity
|
||||||
|
match zbus::Connection::system().await {
|
||||||
|
Ok(conn) => {
|
||||||
|
let payload = json!({"message": "upower:connected"});
|
||||||
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.upower.connected".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut stream = MessageStream::from(&conn);
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(message) => match parse_upower_message(&message) {
|
||||||
|
Ok(event) => {
|
||||||
|
let _ = tx.send(event).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!("upower parse error: {err:?}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
debug!("upower stream error: {err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// If DBus connection fails, fall back to periodic polling handled elsewhere
|
||||||
|
Err(anyhow!(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_upower_message(message: &Message) -> Result<RawEvent> {
|
||||||
|
let header = message.header()?;
|
||||||
|
let interface = header.interface()?.map(|v| v.as_str()).unwrap_or("");
|
||||||
|
let member = header.member()?.map(|v| v.as_str()).unwrap_or("");
|
||||||
|
let path = header.path()?.map(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
if interface == "org.freedesktop.UPower" {
|
||||||
|
match member {
|
||||||
|
"DeviceAdded" => {
|
||||||
|
let (device_path,): (OwnedObjectPath,) = message.body()?;
|
||||||
|
let payload = json!({"device_path": device_path.as_str()});
|
||||||
|
return Ok(RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.device.added".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"DeviceRemoved" => {
|
||||||
|
let (device_path,): (OwnedObjectPath,) = message.body()?;
|
||||||
|
let payload = json!({"device_path": device_path.as_str()});
|
||||||
|
return Ok(RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.device.removed".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
|
||||||
|
let (iface, changed, invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||||
|
message.body()?;
|
||||||
|
if iface == "org.freedesktop.UPower.Device" {
|
||||||
|
let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({}));
|
||||||
|
let normalized = json!({
|
||||||
|
"percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()),
|
||||||
|
"state": changed_json.get("State").and_then(|v| v.as_u64()),
|
||||||
|
"time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()),
|
||||||
|
"time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()),
|
||||||
|
"is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()),
|
||||||
|
"battery_type": changed_json.get("Type").and_then(|v| v.as_u64()),
|
||||||
|
"online": changed_json.get("Online").and_then(|v| v.as_bool()),
|
||||||
|
"native_path": changed_json.get("NativePath").and_then(|v| v.as_str()),
|
||||||
|
"model": changed_json.get("Model").and_then(|v| v.as_str()),
|
||||||
|
"vendor": changed_json.get("Vendor").and_then(|v| v.as_str()),
|
||||||
|
"serial": changed_json.get("Serial").and_then(|v| v.as_str()),
|
||||||
|
"update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()),
|
||||||
|
});
|
||||||
|
let payload = json!({
|
||||||
|
"path": path,
|
||||||
|
"properties": changed_json,
|
||||||
|
"invalidated": invalidated,
|
||||||
|
"normalized": normalized
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.device.changed".to_string(),
|
||||||
|
payload,
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RawEvent {
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
kind: "power.upower.signal".to_string(),
|
||||||
|
payload: json!({"interface": interface, "member": member, "path": path}),
|
||||||
|
timestamp: bread_shared::now_unix_ms(),
|
||||||
|
})
|
||||||
|
}
|
||||||
190
breadd/src/adapters/udev.rs
Normal file
190
breadd/src/adapters/udev.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
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 tracing::debug;
|
||||||
|
|
||||||
|
use crate::adapters::Adapter;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UdevAdapter {
|
||||||
|
subsystems: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdevAdapter {
|
||||||
|
pub fn new(subsystems: Vec<String>) -> Self {
|
||||||
|
Self { subsystems }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
let devices = enumerate_with_udev(&self.subsystems)?;
|
||||||
|
for device in devices {
|
||||||
|
tx.send(RawEvent {
|
||||||
|
source: AdapterSource::Udev,
|
||||||
|
kind: "udev.enumerate".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"action": "add",
|
||||||
|
"id": device.id,
|
||||||
|
"name": device.name,
|
||||||
|
"subsystem": device.subsystem,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Adapter for UdevAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"udev"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
debug!("udev adapter started");
|
||||||
|
run_udev_monitor(self.subsystems.clone(), tx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 socket = builder.listen()?;
|
||||||
|
let fd = socket.as_raw_fd();
|
||||||
|
|
||||||
|
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())
|
||||||
|
.unwrap_or_else(|| "change".to_string());
|
||||||
|
let subsystem = event
|
||||||
|
.subsystem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let name = event
|
||||||
|
.property_value("ID_MODEL")
|
||||||
|
.or_else(|| event.property_value("NAME"))
|
||||||
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
|
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let id = event.syspath().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
RawEvent {
|
||||||
|
source: AdapterSource::Udev,
|
||||||
|
kind: "udev.change".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"action": action,
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"subsystem": subsystem,
|
||||||
|
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
||||||
|
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
||||||
|
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
||||||
|
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
||||||
|
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
||||||
|
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
||||||
|
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
||||||
|
"id_vendor": prop_str(event, "ID_VENDOR"),
|
||||||
|
"id_model": prop_str(event, "ID_MODEL"),
|
||||||
|
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
||||||
|
"product_id": prop_str(event, "ID_MODEL_ID"),
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
|
let mut enumerator = udev::Enumerator::new()?;
|
||||||
|
for subsystem in subsystems {
|
||||||
|
enumerator.match_subsystem(subsystem)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for dev in enumerator.scan_devices()? {
|
||||||
|
let subsystem = dev
|
||||||
|
.subsystem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let name = dev
|
||||||
|
.property_value("ID_MODEL")
|
||||||
|
.or_else(|| dev.property_value("NAME"))
|
||||||
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
|
.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,
|
||||||
|
subsystem,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 prop_str(event: &udev::Event, key: &str) -> Option<String> {
|
||||||
|
event
|
||||||
|
.property_value(key)
|
||||||
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
504
breadd/src/core/config.rs
Normal file
504
breadd/src/core/config.rs
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DaemonConfig {
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub log_level: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub socket_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LuaConfig {
|
||||||
|
#[serde(default = "default_lua_entry")]
|
||||||
|
pub entry_point: String,
|
||||||
|
#[serde(default = "default_lua_modules")]
|
||||||
|
pub module_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
#[serde(default)]
|
||||||
|
pub udev: UdevConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub power: PowerConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub network: AdapterToggle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bluetooth: AdapterToggle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AdapterToggle {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UdevConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_udev_subsystems")]
|
||||||
|
pub subsystems: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct PowerConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_poll_interval")]
|
||||||
|
pub poll_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct EventsConfig {
|
||||||
|
#[serde(default = "default_dedup_window")]
|
||||||
|
pub dedup_window_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: default_log_level(),
|
||||||
|
socket_path: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LuaConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
entry_point: default_lua_entry(),
|
||||||
|
module_path: default_lua_modules(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModulesConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
builtin: default_true(),
|
||||||
|
disable: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AdapterToggle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_true(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UdevConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_true(),
|
||||||
|
subsystems: default_udev_subsystems(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PowerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_true(),
|
||||||
|
poll_interval_secs: default_poll_interval(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
dedup_window_ms: default_dedup_window(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(&path)?;
|
||||||
|
let cfg: Config = toml::from_str(&raw)?;
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_path(&self) -> PathBuf {
|
||||||
|
if !self.daemon.socket_path.is_empty() {
|
||||||
|
return expand_home(&self.daemon.socket_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
|
Path::new(&runtime_dir).join("bread").join("breadd.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lua_entry_point(&self) -> PathBuf {
|
||||||
|
expand_home(&self.lua.entry_point)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lua_module_path(&self) -> PathBuf {
|
||||||
|
expand_home(&self.lua.module_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
|
||||||
|
return Path::new(&xdg).join("bread").join("breadd.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
expand_home("~/.config/bread/breadd.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_home(input: &str) -> PathBuf {
|
||||||
|
if let Some(stripped) = input.strip_prefix("~/") {
|
||||||
|
if let Ok(home) = env::var("HOME") {
|
||||||
|
return Path::new(&home).join(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_lua_entry() -> String {
|
||||||
|
"~/.config/bread/init.lua".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_lua_modules() -> String {
|
||||||
|
"~/.config/bread/modules".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_poll_interval() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
"input".to_string(),
|
||||||
|
"drm".to_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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
breadd/src/core/mod.rs
Normal file
6
breadd/src/core/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod normalizer;
|
||||||
|
pub mod state_engine;
|
||||||
|
pub mod subscriptions;
|
||||||
|
pub mod supervisor;
|
||||||
|
pub mod types;
|
||||||
964
breadd/src/core/normalizer.rs
Normal file
964
breadd/src/core/normalizer.rs
Normal file
|
|
@ -0,0 +1,964 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
pub fn new(dedup_window_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
dedup_window_ms,
|
||||||
|
recent: RwLock::new(HashMap::new()),
|
||||||
|
seen_devices: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
let mut out = match raw.source {
|
||||||
|
AdapterSource::Udev => self.normalize_udev(raw),
|
||||||
|
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,
|
||||||
|
source: raw.source,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
out.retain(|ev| self.accept(ev));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
"device": "unknown",
|
||||||
|
"name": name,
|
||||||
|
"vendor": vendor,
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"product_id": product_id,
|
||||||
|
"subsystem": subsystem,
|
||||||
|
"raw": raw.payload,
|
||||||
|
}),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
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("");
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) {
|
||||||
|
events.push(BreadEvent {
|
||||||
|
event: if ac {
|
||||||
|
"bread.power.ac.connected".to_string()
|
||||||
|
} else {
|
||||||
|
"bread.power.ac.disconnected".to_string()
|
||||||
|
},
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) {
|
||||||
|
let battery_event = if level <= 5 {
|
||||||
|
Some("bread.power.battery.critical")
|
||||||
|
} else if level <= 10 {
|
||||||
|
Some("bread.power.battery.very_low")
|
||||||
|
} else if level <= 20 {
|
||||||
|
Some("bread.power.battery.low")
|
||||||
|
} else if level >= 100 {
|
||||||
|
Some("bread.power.battery.full")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(event) = battery_event {
|
||||||
|
events.push(BreadEvent {
|
||||||
|
event: event.to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if events.is_empty() {
|
||||||
|
events.push(BreadEvent {
|
||||||
|
event: "bread.power.changed".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Power,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 name = if online {
|
||||||
|
"bread.network.connected"
|
||||||
|
} else {
|
||||||
|
"bread.network.disconnected"
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![BreadEvent {
|
||||||
|
event: name.to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Network,
|
||||||
|
data: raw.payload.clone(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&self, event: &BreadEvent) -> bool {
|
||||||
|
let key = format!("{}:{}", event.event, event.data);
|
||||||
|
let now = event.timestamp;
|
||||||
|
|
||||||
|
// Fast path: check under read lock first.
|
||||||
|
{
|
||||||
|
let recent = self.recent.read().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some(last) = recent.get(&key) {
|
||||||
|
if now.saturating_sub(*last) < self.dedup_window_ms {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: acquire write lock, re-check, insert, and periodically evict.
|
||||||
|
let mut recent = self.recent.write().unwrap_or_else(|p| p.into_inner());
|
||||||
|
|
||||||
|
// Re-check after acquiring write lock (another thread may have inserted between locks).
|
||||||
|
if let Some(last) = recent.get(&key) {
|
||||||
|
if now.saturating_sub(*last) < self.dedup_window_ms {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
if evict_before > 0 {
|
||||||
|
recent.retain(|_, &mut last| last >= evict_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
1037
breadd/src/core/state_engine.rs
Normal file
1037
breadd/src/core/state_engine.rs
Normal file
File diff suppressed because it is too large
Load diff
293
breadd/src/core/subscriptions.rs
Normal file
293
breadd/src/core/subscriptions.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
||||||
|
pub struct SubscriptionId(pub u64);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Subscription {
|
||||||
|
pub id: SubscriptionId,
|
||||||
|
pub pattern: String,
|
||||||
|
pub once: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct SubscriptionTable {
|
||||||
|
entries: Vec<Subscription>,
|
||||||
|
by_id: HashMap<SubscriptionId, usize>,
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubscriptionTable {
|
||||||
|
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 };
|
||||||
|
self.entries.push(sub);
|
||||||
|
self.by_id.insert(id, self.entries.len() - 1);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, id: SubscriptionId) -> bool {
|
||||||
|
let Some(idx) = self.by_id.remove(&id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
self.entries.swap_remove(idx);
|
||||||
|
|
||||||
|
if idx < self.entries.len() {
|
||||||
|
// The element that was at `last_idx` is now at `idx`.
|
||||||
|
let swapped_id = self.entries[idx].id;
|
||||||
|
self.by_id.remove(&swapped_id); // remove stale last_idx entry
|
||||||
|
self.by_id.insert(swapped_id, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.entries.clear();
|
||||||
|
self.by_id.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|sub| matches_pattern(&sub.pattern, event_name))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_pattern(pattern: &str, event_name: &str) -> bool {
|
||||||
|
if pattern.ends_with(".*") {
|
||||||
|
let prefix = &pattern[..pattern.len() - 1];
|
||||||
|
return event_name.starts_with(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
breadd/src/core/supervisor.rs
Normal file
68
breadd/src/core/supervisor.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use tokio::sync::watch;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub fn spawn_supervised<F, Fut>(
|
||||||
|
name: &'static str,
|
||||||
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
|
mut task_factory: F,
|
||||||
|
) where
|
||||||
|
F: FnMut() -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||||
|
{
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
info!(adapter = name, "shutdown requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = tokio::select! {
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
info!(adapter = name, "shutdown requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result = task_factory() => result,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
info!(adapter = name, "adapter task exited cleanly");
|
||||||
|
attempt = 0;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(adapter = name, error = %err, "adapter task failed");
|
||||||
|
attempt = attempt.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
info!(adapter = name, "shutdown requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
|
||||||
|
warn!(
|
||||||
|
adapter = name,
|
||||||
|
delay_ms = wait_ms,
|
||||||
|
"restarting adapter after failure"
|
||||||
|
);
|
||||||
|
tokio::select! {
|
||||||
|
_ = sleep(Duration::from_millis(wait_ms)) => {},
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
info!(adapter = name, "shutdown requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
130
breadd/src/core/types.rs
Normal file
130
breadd/src/core/types.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Monitor {
|
||||||
|
pub name: String,
|
||||||
|
pub connected: bool,
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
pub position: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: String,
|
||||||
|
pub monitor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct DeviceTopology {
|
||||||
|
pub connected: Vec<Device>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Device {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct NetworkState {
|
||||||
|
pub interfaces: HashMap<String, InterfaceState>,
|
||||||
|
pub online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InterfaceState {
|
||||||
|
pub up: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PowerState {
|
||||||
|
pub ac_connected: bool,
|
||||||
|
pub battery_percent: Option<u8>,
|
||||||
|
pub battery_low: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileState {
|
||||||
|
pub active: String,
|
||||||
|
pub history: Vec<String>,
|
||||||
|
pub profiles: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfileState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
active: "default".to_string(),
|
||||||
|
history: Vec::new(),
|
||||||
|
profiles: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
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)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ModuleLoadState {
|
||||||
|
Loaded,
|
||||||
|
LoadError,
|
||||||
|
NotFound,
|
||||||
|
Degraded,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
478
breadd/src/ipc/mod.rs
Normal file
478
breadd/src/ipc/mod.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
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::{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, RwLock};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::adapters::AdapterStatus;
|
||||||
|
use crate::core::state_engine::StateHandle;
|
||||||
|
use crate::lua::RuntimeHandle;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Server {
|
||||||
|
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>>>,
|
||||||
|
started_at: Instant,
|
||||||
|
pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct IpcRequest {
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct IpcResponse {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
state_handle,
|
||||||
|
event_tx,
|
||||||
|
lua_runtime,
|
||||||
|
emit_tx,
|
||||||
|
adapter_status,
|
||||||
|
subscription_count,
|
||||||
|
event_buffer,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
pid: process::id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(&self, mut shutdown_rx: watch::Receiver<bool>) -> Result<()> {
|
||||||
|
if let Some(parent) = self.socket_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.socket_path.exists() {
|
||||||
|
fs::remove_file(&self.socket_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&self.socket_path)?;
|
||||||
|
fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?;
|
||||||
|
|
||||||
|
info!(socket = %self.socket_path.display(), "ipc server listening");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accept = listener.accept() => {
|
||||||
|
let (stream, _) = accept?;
|
||||||
|
let server = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = server.handle_connection(stream).await {
|
||||||
|
warn!(error = %err, "ipc connection failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(&self, stream: UnixStream) -> Result<()> {
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let mut lines = BufReader::new(read_half).lines();
|
||||||
|
|
||||||
|
while let Some(line) = lines.next_line().await? {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let req: IpcRequest = serde_json::from_str(&line)?;
|
||||||
|
if req.method == "events.subscribe" {
|
||||||
|
let filter = req
|
||||||
|
.params
|
||||||
|
.get("filter")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let ok = IpcResponse {
|
||||||
|
id: req.id,
|
||||||
|
result: Some(json!({ "subscribed": true })),
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
write_half
|
||||||
|
.write_all(format!("{}\n", serde_json::to_string(&ok)?).as_bytes())
|
||||||
|
.await?;
|
||||||
|
self.stream_events(&mut write_half, filter).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match self.handle_request(req).await {
|
||||||
|
Ok(res) => IpcResponse {
|
||||||
|
id: res.0,
|
||||||
|
result: Some(res.1),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err((id, err)) => IpcResponse {
|
||||||
|
id,
|
||||||
|
result: None,
|
||||||
|
error: Some(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
write_half
|
||||||
|
.write_all(format!("{}\n", serde_json::to_string(&response)?).as_bytes())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })),
|
||||||
|
"state.get" => {
|
||||||
|
let key = req.params.get("key").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let value = self
|
||||||
|
.state_handle
|
||||||
|
.state_get(key)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("state path not found"));
|
||||||
|
value.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
"state.dump" => Ok(self.state_handle.state_dump().await),
|
||||||
|
"modules.list" => {
|
||||||
|
let full = self.state_handle.state_dump().await;
|
||||||
|
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
|
||||||
|
}
|
||||||
|
"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
|
||||||
|
.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
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|v| v.get("profiles"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
"profile.activate" => {
|
||||||
|
let Some(name) = req.params.get("name").and_then(Value::as_str) else {
|
||||||
|
return Err((id, "missing profile name".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
self.state_handle.set_profile(name.to_string());
|
||||||
|
if self
|
||||||
|
.emit_tx
|
||||||
|
.send(BreadEvent::new(
|
||||||
|
"bread.profile.activated",
|
||||||
|
AdapterSource::System,
|
||||||
|
json!({ "name": name }),
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err((id, "emit channel closed".to_string()));
|
||||||
|
}
|
||||||
|
Ok(json!({ "active": name }))
|
||||||
|
}
|
||||||
|
"emit" => {
|
||||||
|
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!({}));
|
||||||
|
if self
|
||||||
|
.emit_tx
|
||||||
|
.send(BreadEvent::new(event, AdapterSource::System, data))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err((id, "emit channel closed".to_string()));
|
||||||
|
}
|
||||||
|
Ok(json!({ "emitted": true }))
|
||||||
|
}
|
||||||
|
"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()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(v) => Ok((id, v)),
|
||||||
|
Err(err) => Err((id, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_events(
|
||||||
|
&self,
|
||||||
|
writer: &mut tokio::net::unix::OwnedWriteHalf,
|
||||||
|
filter: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut rx = self.event_tx.subscribe();
|
||||||
|
loop {
|
||||||
|
let evt = rx.recv().await?;
|
||||||
|
if let Some(filter) = filter.as_deref() {
|
||||||
|
if !matches_filter(&evt.event, filter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = format!("{}\n", serde_json::to_string(&evt)?);
|
||||||
|
if let Err(err) = writer.write_all(line.as_bytes()).await {
|
||||||
|
error!(error = %err, "failed to write event stream line");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.*"));
|
||||||
|
}
|
||||||
|
}
|
||||||
2459
breadd/src/lua/mod.rs
Normal file
2459
breadd/src/lua/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
159
breadd/src/main.rs
Normal file
159
breadd/src/main.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
mod adapters;
|
||||||
|
mod core;
|
||||||
|
mod ipc;
|
||||||
|
mod lua;
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||||
|
use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
||||||
|
use tracing::{error, info};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::core::config::Config;
|
||||||
|
use crate::core::normalizer::EventNormalizer;
|
||||||
|
use crate::core::state_engine::{run_state_engine, StateHandle};
|
||||||
|
use crate::core::types::RuntimeState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let config = Config::load()?;
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::new(config.daemon.log_level.clone()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("starting breadd");
|
||||||
|
|
||||||
|
let state = Arc::new(RwLock::new(RuntimeState::default()));
|
||||||
|
|
||||||
|
let (raw_tx, mut raw_rx) = mpsc::channel::<RawEvent>(2048);
|
||||||
|
let (normalized_tx, normalized_rx) = mpsc::unbounded_channel::<BreadEvent>();
|
||||||
|
let (state_cmd_tx, state_cmd_rx) = mpsc::unbounded_channel();
|
||||||
|
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_tx = lua_runtime.sender();
|
||||||
|
|
||||||
|
tokio::spawn(run_state_engine(
|
||||||
|
normalized_rx,
|
||||||
|
state_cmd_rx,
|
||||||
|
state.clone(),
|
||||||
|
lua_tx,
|
||||||
|
event_stream_tx.clone(),
|
||||||
|
subscription_count.clone(),
|
||||||
|
shutdown_rx.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms));
|
||||||
|
{
|
||||||
|
let normalizer = normalizer.clone();
|
||||||
|
let normalized_tx = normalized_tx.clone();
|
||||||
|
let mut shutdown_rx = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
if *shutdown_rx.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybe_raw = raw_rx.recv() => {
|
||||||
|
let Some(raw) = maybe_raw else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
for event in normalizer.normalize(&raw) {
|
||||||
|
if normalized_tx.send(event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
serde_json::json!({}),
|
||||||
|
));
|
||||||
|
|
||||||
|
let ipc_server = ipc::Server::new(
|
||||||
|
config.socket_path(),
|
||||||
|
state_handle,
|
||||||
|
event_stream_tx,
|
||||||
|
lua_runtime.clone(),
|
||||||
|
normalized_tx,
|
||||||
|
adapter_status,
|
||||||
|
subscription_count,
|
||||||
|
event_buffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("breadd fully started");
|
||||||
|
tokio::select! {
|
||||||
|
result = ipc_server.serve(shutdown_rx.clone()) => {
|
||||||
|
if let Err(err) = result {
|
||||||
|
error!(error = %err, "ipc server failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = wait_for_shutdown() => {
|
||||||
|
info!("shutdown signal received");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
|
||||||
|
lua_runtime.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_shutdown() {
|
||||||
|
let ctrl_c = tokio::signal::ctrl_c();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
|
let mut sigterm =
|
||||||
|
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = sigterm.recv() => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = ctrl_c.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
518
breadd/tests/ipc_integration.rs
Normal file
518
breadd/tests/ipc_integration.rs
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ping_and_state_dump_work() -> Result<()> {
|
||||||
|
let harness = TestHarness::spawn()?;
|
||||||
|
harness.wait_until_ready().await?;
|
||||||
|
|
||||||
|
let ping = harness.send_request("ping", json!({})).await?;
|
||||||
|
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
|
|
||||||
|
let health = harness.send_request("health", json!({})).await?;
|
||||||
|
assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true));
|
||||||
|
assert!(health.get("version").and_then(Value::as_str).is_some());
|
||||||
|
assert!(health.get("uptime_ms").and_then(Value::as_u64).is_some());
|
||||||
|
|
||||||
|
let dump = harness.send_request("state.dump", json!({})).await?;
|
||||||
|
assert!(dump.get("devices").is_some());
|
||||||
|
assert!(dump.get("profile").is_some());
|
||||||
|
|
||||||
|
harness.shutdown();
|
||||||
|
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()?;
|
||||||
|
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-1",
|
||||||
|
"method": "events.subscribe",
|
||||||
|
"params": {
|
||||||
|
"filter": "bread.system.*"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
write_half
|
||||||
|
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(read_half).lines();
|
||||||
|
|
||||||
|
let ack = reader
|
||||||
|
.next_line()
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("missing subscribe ack"))?;
|
||||||
|
let ack_json: Value = serde_json::from_str(&ack)?;
|
||||||
|
assert_eq!(
|
||||||
|
ack_json
|
||||||
|
.get("result")
|
||||||
|
.and_then(|v| v.get("subscribed"))
|
||||||
|
.and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
harness
|
||||||
|
.send_request(
|
||||||
|
"emit",
|
||||||
|
json!({
|
||||||
|
"event": "bread.system.test",
|
||||||
|
"data": { "ok": true }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut got = false;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let Some(line) = reader.next_line().await? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let event: Value = serde_json::from_str(&line)?;
|
||||||
|
if event.get("event").and_then(Value::as_str) == Some("bread.system.test") {
|
||||||
|
got = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(got, "did not receive emitted event on stream");
|
||||||
|
harness.shutdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestHarness {
|
||||||
|
_temp: TempDir,
|
||||||
|
child: Child,
|
||||||
|
socket_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
let home = temp.path().join("home");
|
||||||
|
fs::create_dir_all(&runtime_dir)?;
|
||||||
|
fs::create_dir_all(&config_home)?;
|
||||||
|
fs::create_dir_all(&home)?;
|
||||||
|
|
||||||
|
let bread_cfg = config_home.join("bread");
|
||||||
|
fs::create_dir_all(bread_cfg.join("modules"))?;
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
bread_cfg.join("init.lua"),
|
||||||
|
"bread.on('bread.system.startup', function() end)\n",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
bread_cfg.join("breadd.toml"),
|
||||||
|
r#"
|
||||||
|
[daemon]
|
||||||
|
log_level = "error"
|
||||||
|
|
||||||
|
[lua]
|
||||||
|
entry_point = "~/.config/bread/init.lua"
|
||||||
|
module_path = "~/.config/bread/modules"
|
||||||
|
|
||||||
|
[adapters.hyprland]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[adapters.udev]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[adapters.power]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[adapters.network]
|
||||||
|
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)
|
||||||
|
.env("XDG_CONFIG_HOME", &config_home)
|
||||||
|
.env("HOME", &home)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
_temp: temp,
|
||||||
|
child,
|
||||||
|
socket_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socket_path(&self) -> &Path {
|
||||||
|
&self.socket_path
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_ready(&self) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if self.socket_path.exists() {
|
||||||
|
let ping = self.send_request("ping", json!({})).await;
|
||||||
|
if ping.is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("daemon did not become ready in time"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
|
||||||
|
let stream = UnixStream::connect(self.socket_path()).await?;
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
let req = json!({
|
||||||
|
"id": "1",
|
||||||
|
"method": method,
|
||||||
|
"params": 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!("missing ipc response"))?;
|
||||||
|
let parsed: Value = serde_json::from_str(&line)?;
|
||||||
|
|
||||||
|
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
|
||||||
|
return Err(anyhow!(err.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
packaging/README.md
Normal file
47
packaging/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
Packaging
|
||||||
|
=========
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
36
packaging/arch/PKGBUILD
Normal file
36
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
|
pkgname=bread
|
||||||
|
pkgver=1.0.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A reactive automation fabric for Linux desktops"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/Breadway/bread"
|
||||||
|
license=('MIT')
|
||||||
|
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')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
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 "${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"
|
||||||
|
}
|
||||||
29
packaging/arch/README.md
Normal file
29
packaging/arch/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
Arch packaging
|
||||||
|
==============
|
||||||
|
|
||||||
|
`PKGBUILD` builds and installs both `breadd` and `bread` from source.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
19
packaging/systemd/breadd.service
Normal file
19
packaging/systemd/breadd.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Bread Runtime Daemon
|
||||||
|
After=graphical-session.target
|
||||||
|
Wants=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/breadd
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
UMask=0077
|
||||||
|
RuntimeDirectory=bread
|
||||||
|
RuntimeDirectoryMode=0700
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=5
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
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