Merge pull request #7 from Breadway/dev
Some checks failed
CI / build-and-test (ubuntu-latest, stable) (push) Failing after 6s
CI / build-and-test (macos-latest, stable) (push) Has been cancelled

Document sync export/import and update snapshot layout
This commit is contained in:
Breadway 2026-05-16 22:18:41 +08:00 committed by GitHub
commit 4f4bb46eed
13 changed files with 2123 additions and 111 deletions

View file

@ -10,6 +10,7 @@
- [Sync: snapshot and restore](#sync-snapshot-and-restore) - [Sync: snapshot and restore](#sync-snapshot-and-restore)
- [Debugging tips](#debugging-tips) - [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api) - [Dictionary: Lua API](#dictionary-lua-api)
- [Bluetooth](#bluetooth)
- [Dictionary: Built-in modules](#dictionary-built-in-modules) - [Dictionary: Built-in modules](#dictionary-built-in-modules)
- [Dictionary: Event reference](#dictionary-event-reference) - [Dictionary: Event reference](#dictionary-event-reference)
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema) - [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
@ -23,6 +24,8 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`)
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here - **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
- **CLI** (`bread`) — talks to the daemon over a Unix socket - **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. 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 ## Getting started
@ -135,16 +138,18 @@ installed_at = "2026-01-01T00:00:00Z"
## Sync: snapshot and restore ## Sync: snapshot and restore
Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. 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 ```bash
# First-time setup # First-time setup (remote optional)
bread sync init
bread sync init --remote git@github.com:you/bread-config.git bread sync init --remote git@github.com:you/bread-config.git
# Snapshot and push # Commit local snapshot
bread sync push bread sync push
bread sync push --message "before reinstall"
# On another machine: pull and apply # Apply snapshot to this machine
bread sync pull bread sync pull
# Also reinstall packages from snapshot # Also reinstall packages from snapshot
@ -153,12 +158,55 @@ bread sync pull --install-packages
# See what has changed # See what has changed
bread sync status bread sync status
bread sync diff bread sync diff
bread sync diff --remote
# List known machines # List known machines
bread sync 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`: Configure sync in `~/.config/bread/sync.toml`:
```toml ```toml
@ -179,16 +227,6 @@ include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"] exclude = ["**/.git", "**/*.cache"]
``` ```
The sync repo stores:
```
~/.local/share/bread/sync-repo/
├── bread/ ← ~/.config/bread/ snapshot
├── configs/ ← delegate paths (nvim, waybar, etc.)
├── machines/ ← per-machine profiles with tags and last-sync time
└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.)
```
## Debugging tips ## Debugging tips
- Run `bread events` to see live normalized events. - Run `bread events` to see live normalized events.
@ -402,6 +440,83 @@ bread.hyprland.on_raw("activewindow", function(raw)
end) 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 ### Module lifecycle hooks
All hooks are optional. All hooks are optional.
@ -646,7 +761,7 @@ Events are delivered as a `BreadEvent`:
|-------|------| |-------|------|
| `bread.system.startup` | `{}` | | `bread.system.startup` | `{}` |
#### Devices (udev) #### Devices (udev / Bluetooth)
| Event | Data | | Event | Data |
|-------|------| |-------|------|
@ -657,6 +772,26 @@ Events are delivered as a `BreadEvent`:
`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`. `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 #### Hyprland
| Event | Data | | Event | Data |

View file

@ -51,7 +51,7 @@ packaging/ Arch PKGBUILD and systemd user service
The daemon is structured in four layers: The daemon is structured in four layers:
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces - **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
- **Normalizer** — transforms raw adapter signals into semantic Bread events - **Normalizer** — transforms raw adapter signals into semantic Bread events
- **State engine** — maintains runtime state and dispatches events to subscribers - **State engine** — maintains runtime state and dispatches events to subscribers
- **Lua runtime** — loads your modules, registers handlers, executes automation - **Lua runtime** — loads your modules, registers handlers, executes automation
@ -68,6 +68,7 @@ The daemon is structured in four layers:
Optional but preferred: Optional but preferred:
- UPower (for battery events via D-Bus rather than sysfs polling) - UPower (for battery events via D-Bus rather than sysfs polling)
- rtnetlink (for network events; falls back to sysfs polling without it) - rtnetlink (for network events; falls back to sysfs polling without it)
- BlueZ (for Bluetooth device events and control)
--- ---
@ -138,6 +139,9 @@ poll_interval_secs = 30
[adapters.network] [adapters.network]
enabled = true enabled = true
[adapters.bluetooth]
enabled = true
[events] [events]
dedup_window_ms = 100 dedup_window_ms = 100
@ -197,14 +201,19 @@ bread modules update [name] # Re-install one or all GitHub-sourced mod
bread modules info <name> # Show full manifest and daemon status bread modules info <name> # Show full manifest and daemon status
# Sync # Sync
bread sync init # Initialize sync for this machine bread sync init # Initialize sync for this machine (remote optional)
bread sync push # Snapshot and push current state to remote bread sync push # Commit local snapshot
bread sync pull # Pull and apply latest state from remote 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 pull --install-packages # Also install packages from snapshot
bread sync status # Show what has changed since last push bread sync status # Show what has changed since last push
bread sync diff # Show file-level diff vs last commit bread sync diff # Show file-level diff vs last commit
bread sync diff --remote # Show diff vs remote
bread sync machines # List known machines from sync repo 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
``` ```
--- ---
@ -262,27 +271,31 @@ return M
## Sync system ## Sync system
Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. 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 ```bash
# First-time setup # First-time setup (remote is optional)
bread sync init
bread sync init --remote git@github.com:you/bread-config.git bread sync init --remote git@github.com:you/bread-config.git
# Push current state # Commit a local snapshot
bread sync push bread sync push
# On another machine: pull and apply # Create a portable .tar.gz (no git auth required)
bread sync pull bread sync export
# Check what's pending # On another machine: apply the snapshot
bread sync status 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`: Configure what gets synced in `~/.config/bread/sync.toml`:
```toml ```toml
[remote] [remote]
url = "git@github.com:you/bread-config.git" url = "git@github.com:you/bread-config.git" # optional
branch = "main" branch = "main"
[machine] [machine]
@ -298,14 +311,21 @@ include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"] exclude = ["**/.git", "**/*.cache"]
``` ```
The sync repo stores: A portable export snapshot contains:
``` ```
sync-repo/ bread-export-hermes-2026-05-16/
├── bread/ ← ~/.config/bread/ snapshot ├── bread/ ← ~/.config/bread/
├── configs/ ← delegate paths (nvim, waybar, etc.) ├── 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 ├── machines/ ← per-machine profiles
└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) ├── manifest.toml ← path map for exact restore
└── restore.sh ← shell script for manual restore
``` ```
--- ---
@ -335,6 +355,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.power.battery.full` | Battery at 100% | | `bread.power.battery.full` | Battery at 100% |
| `bread.network.connected` | Network interface came online | | `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline | | `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.profile.activated` | Profile switched |
| `bread.notify.sent` | Desktop notification dispatched | | `bread.notify.sent` | Desktop notification dispatched |
@ -516,6 +538,44 @@ bread.hyprland.on_raw("activewindow", function(raw)
end) 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 ### Module-scoped storage
Survives hot reload; does not survive daemon restart. Survives hot reload; does not survive daemon restart.

View file

@ -3,7 +3,7 @@ mod modules_mgmt;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bread_sync::{ use bread_sync::{
config::{bread_config_dir, SyncConfig}, config::{bread_config_dir, SyncConfig},
delegates, machine, packages, SyncRepo, delegates, machine, packages, apply_import, stage_export, SyncRepo,
}; };
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use notify::{RecommendedWatcher, RecursiveMode, Watcher};
@ -143,6 +143,26 @@ enum SyncCommand {
}, },
/// List known machines from sync repo /// List known machines from sync repo
Machines, Machines,
/// Create a portable export archive (no git auth required)
Export {
/// Output path: directory or .tar.gz file. Defaults to ./bread-export-<machine>-<date>.tar.gz
#[arg(long, short)]
output: Option<PathBuf>,
},
/// Apply a portable export archive to this machine
Import {
/// Path to a bread export directory or .tar.gz file
from: PathBuf,
/// Also install packages from the package manifests
#[arg(long)]
install_packages: bool,
/// Skip cloning git repositories to their original locations
#[arg(long)]
no_clone_repos: bool,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
} }
#[tokio::main] #[tokio::main]
@ -447,6 +467,10 @@ async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> {
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?, SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?, SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?,
SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?, SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?,
SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?,
SyncCommand::Import { from, install_packages, no_clone_repos, yes } => {
cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await?
}
} }
Ok(()) Ok(())
} }
@ -464,7 +488,7 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
let remote_url = match remote { let remote_url = match remote {
Some(u) => u, Some(u) => u,
None => { None => {
print!("Sync remote URL (git remote or path): "); print!("Sync remote URL (leave empty for local-only, e.g. git@github.com:you/config): ");
io::stdout().flush()?; io::stdout().flush()?;
let mut line = String::new(); let mut line = String::new();
io::stdin().read_line(&mut line)?; io::stdin().read_line(&mut line)?;
@ -512,15 +536,17 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
}; };
config.save(cfg_dir)?; config.save(cfg_dir)?;
// If it looks like a URL (not a local path), check if it exists
if !remote_url.starts_with('/') && !remote_url.starts_with('.') {
println!("remote does not exist yet — it will be created on first push");
}
println!(); println!();
println!("sync initialized"); println!("sync initialized");
println!(" machine: {}", machine_name); println!(" machine: {}", machine_name);
println!(" remote: {}", remote_url); if remote_url.is_empty() {
println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)");
} else {
println!(" remote: {}", remote_url);
if !remote_url.starts_with('/') && !remote_url.starts_with('.') {
println!(" note: remote will be created on first push");
}
}
println!(" config: {}", cfg_dir.join("sync.toml").display()); println!(" config: {}", cfg_dir.join("sync.toml").display());
Ok(()) Ok(())
} }
@ -529,19 +555,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
let config = load_sync_config(cfg_dir)?; let config = load_sync_config(cfg_dir)?;
let repo_path = SyncConfig::local_repo_path(); let repo_path = SyncConfig::local_repo_path();
// Clone or open the local sync repo let repo = if repo_path.exists() {
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; SyncRepo::open(&repo_path)?
} else {
SyncRepo::init(&repo_path)?
};
// Snapshot bread/ directory // Snapshot bread/ directory
let bread_dest = repo_path.join("bread"); let bread_dest = repo_path.join("bread");
delegates::sync_dir( delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?;
cfg_dir,
&bread_dest,
&[
// Don't recurse into the sync repo itself
".git".to_string(),
],
)?;
// Snapshot delegate configs // Snapshot delegate configs
let configs_dir = repo_path.join("configs"); let configs_dir = repo_path.join("configs");
@ -559,22 +581,16 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
for manager in &config.packages.managers { for manager in &config.packages.managers {
let dest_file = packages_dir.join(format!("{manager}.txt")); let dest_file = packages_dir.join(format!("{manager}.txt"));
if let Err(e) = packages::snapshot(manager, &dest_file) { if let Err(e) = packages::snapshot(manager, &dest_file) {
eprintln!( eprintln!("bread: warning: package snapshot for {manager} failed: {e}");
"bread: warning: package snapshot for {} failed: {}",
manager, e
);
} }
} }
} }
// Write machine profile // Write machine profile
let machines_dir = repo_path.join("machines"); let machines_dir = repo_path.join("machines");
let profile = machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); .write(&machines_dir)?;
profile.write(&machines_dir)?;
// Set remote and commit
repo.set_remote("origin", &config.remote.url)?;
let commit_msg = message.unwrap_or_else(|| { let commit_msg = message.unwrap_or_else(|| {
format!( format!(
"sync: {} {}", "sync: {} {}",
@ -584,19 +600,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
}); });
if repo.commit(&commit_msg)?.is_none() { if repo.commit(&commit_msg)?.is_none() {
println!("nothing to push — already up to date"); println!("nothing to commit — already up to date");
return Ok(()); return Ok(());
} }
repo.push("origin", &config.remote.branch)?; println!("committed sync for {}", config.machine.name);
println!(" snapshot: {}", repo_path.display());
println!("pushed sync for {}", config.machine.name); println!(" tip: run 'bread sync export' to create a portable snapshot");
println!(" bread config: {}", cfg_dir.display());
if !config.delegates.include.is_empty() {
println!(" delegates: {}", config.delegates.include.len());
}
if config.packages.enabled { if config.packages.enabled {
println!(" packages: {}", config.packages.managers.join(", ")); println!(" packages: {}", config.packages.managers.join(", "));
} }
Ok(()) Ok(())
} }
@ -605,15 +617,9 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) ->
let config = load_sync_config(cfg_dir)?; let config = load_sync_config(cfg_dir)?;
let repo_path = SyncConfig::local_repo_path(); let repo_path = SyncConfig::local_repo_path();
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; if !repo_path.exists() {
repo.set_remote("origin", &config.remote.url)?; eprintln!("bread: no local snapshot found. Run 'bread sync push' first.");
std::process::exit(1);
match repo.pull("origin", &config.remote.branch) {
Ok(()) => {}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
} }
// Apply bread/ → ~/.config/bread/ // Apply bread/ → ~/.config/bread/
@ -667,29 +673,25 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
if !repo_path.exists() { if !repo_path.exists() {
println!("bread sync status"); println!("bread sync status");
println!(" not yet pushed"); println!(" not yet committed — run 'bread sync push'");
return Ok(()); return Ok(());
} }
let repo = SyncRepo::open(&repo_path)?; let repo = SyncRepo::open(&repo_path)?;
repo.set_remote("origin", &config.remote.url)?;
// Fetch remote refs without merging let last_commit = repo
let _ = repo.fetch("origin", &config.remote.branch);
let last_push = repo
.last_commit_time() .last_commit_time()
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "never".to_string()); .unwrap_or_else(|| "never".to_string());
println!("bread sync status"); println!("bread sync status");
println!(" machine {}", config.machine.name); println!(" machine {}", config.machine.name);
println!(" remote {}", config.remote.url); println!(" snapshot {}", repo_path.display());
println!(" last push {}", last_push); println!(" last commit {}", last_commit);
let local_changes = repo.local_changes()?; let local_changes = repo.local_changes()?;
println!(); println!();
println!("local changes (not yet pushed):"); println!("uncommitted changes:");
if local_changes.is_empty() { if local_changes.is_empty() {
println!(" none"); println!(" none");
} else { } else {
@ -698,22 +700,11 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
} }
} }
let remote_changes = repo.remote_changes("origin", &config.remote.branch)?;
println!();
println!("remote changes (not yet pulled):");
if remote_changes.is_empty() {
println!(" none");
} else {
for (ch, path) in &remote_changes {
println!(" {} {}", ch, path);
}
}
Ok(()) Ok(())
} }
async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> {
let config = load_sync_config(cfg_dir)?; let _config = load_sync_config(cfg_dir)?;
let repo_path = SyncConfig::local_repo_path(); let repo_path = SyncConfig::local_repo_path();
if !repo_path.exists() { if !repo_path.exists() {
@ -722,15 +713,7 @@ async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> {
} }
let repo = SyncRepo::open(&repo_path)?; let repo = SyncRepo::open(&repo_path)?;
let diff = repo.working_diff()?;
let diff = if vs_remote {
repo.set_remote("origin", &config.remote.url)?;
let _ = repo.fetch("origin", &config.remote.branch);
repo.remote_diff("origin", &config.remote.branch)?
} else {
repo.working_diff()?
};
print!("{}", diff); print!("{}", diff);
Ok(()) Ok(())
} }
@ -752,6 +735,238 @@ async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
async fn cmd_sync_export(cfg_dir: &Path, output: Option<PathBuf>) -> Result<()> {
// Load sync config if available; fall back to machine defaults.
let config = match SyncConfig::load(cfg_dir) {
Ok(c) => c,
Err(_) => {
let name = machine::hostname();
SyncConfig {
remote: bread_sync::config::RemoteConfig {
url: String::new(),
branch: "main".to_string(),
},
machine: bread_sync::config::MachineConfig { name, tags: vec![] },
packages: bread_sync::config::PackagesConfig::default(),
delegates: bread_sync::config::DelegatesConfig::default(),
}
}
};
let date = chrono::Utc::now().format("%Y-%m-%d");
let export_name = format!("bread-export-{}-{}", config.machine.name, date);
// Decide: tarball or directory?
let (staging_path, make_tarball, final_path) = match &output {
Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => {
// User wants a .tar.gz at a specific path
let staging = std::env::temp_dir().join(&export_name);
(staging, true, p.clone())
}
Some(p) if p.is_dir() || !p.exists() => {
// User wants a directory
let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() };
(dir.clone(), false, dir)
}
Some(p) => {
anyhow::bail!("output path {} already exists and is not a directory", p.display());
}
None => {
// Default: .tar.gz in current directory
let tarball = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!("{export_name}.tar.gz"));
let staging = std::env::temp_dir().join(&export_name);
(staging, true, tarball)
}
};
// Stage everything into the staging directory
let manifest = stage_export(cfg_dir, &config, &staging_path)
.context("failed to stage export")?;
// Optionally pack into a tarball
if make_tarball {
create_tarball(&staging_path, &final_path)
.context("failed to create tarball")?;
std::fs::remove_dir_all(&staging_path).ok();
}
println!("exported to {}", final_path.display());
println!(" machine: {}", manifest.machine);
if !manifest.configs.is_empty() {
println!(" configs: {}", manifest.configs.join(", "));
}
if !manifest.path_map.is_empty() {
let file_count = manifest.path_map.iter().filter(|r| r.is_file).count();
let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count();
if file_count > 0 {
println!(" dotfiles: {} file(s)", file_count);
}
if dir_count > manifest.configs.len() {
println!(" dirs: {} total", dir_count);
}
}
if !manifest.packages.is_empty() {
println!(" packages: {}", manifest.packages.join(", "));
}
if !manifest.repos.is_empty() {
println!(" repos: {} git repositories tracked", manifest.repos.len());
}
if manifest.system {
println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)");
}
Ok(())
}
async fn cmd_sync_import(
cfg_dir: &Path,
from: PathBuf,
install_packages: bool,
clone_repos: bool,
yes: bool,
socket: &Path,
) -> Result<()> {
// Determine staging directory
let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz");
let (staging, _tmp_guard) = if is_tarball {
let tmp = tempfile::tempdir().context("failed to create temp dir")?;
extract_tarball(&from, tmp.path()).context("failed to extract tarball")?;
// GitHub-style tarballs extract into a single subdirectory; unwrap if needed
let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf());
(inner, Some(tmp))
} else if from.is_dir() {
(from.clone(), None)
} else {
anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display());
};
// Read manifest for summary
let manifest_path = staging.join("manifest.toml");
if !manifest_path.exists() {
anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display());
}
let manifest_raw = std::fs::read_to_string(&manifest_path)?;
let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw)
.context("failed to parse manifest.toml")?;
println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]);
println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") });
println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") });
if !manifest.repos.is_empty() {
println!(" repos: {} git repositories found", manifest.repos.len());
if clone_repos {
println!(" (will be cloned to their original locations)");
} else {
println!(" (skipping clone — remove --no-clone-repos to restore)");
}
}
if manifest.system {
println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically");
}
if !yes {
print!("\nApply to ~/.config and ~/.local? (y/n): ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
if !line.trim().eq_ignore_ascii_case("y") {
println!("aborted");
return Ok(());
}
}
let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos)
.context("import failed")?;
println!();
for item in &applied {
println!(" + {item}");
}
if manifest.system {
println!();
println!("system files were NOT applied automatically. To restore them:");
println!(" {}/restore.sh", staging.display());
}
// Notify daemon
try_daemon_reload(socket).await;
Ok(())
}
fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> {
use flate2::{write::GzEncoder, Compression};
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(dest)
.with_context(|| format!("failed to create {}", dest.display()))?;
let encoder = GzEncoder::new(file, Compression::default());
let mut archive = tar::Builder::new(encoder);
let base_name = src_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("bread-export");
// Walk the staging directory and append every file
append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?;
archive.finish()?;
Ok(())
}
fn append_dir_recursive(
archive: &mut tar::Builder<flate2::write::GzEncoder<std::fs::File>>,
root: &Path,
current: &Path,
base_name: &str,
) -> Result<()> {
for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? {
let entry = entry?;
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(&path);
let tar_path = PathBuf::from(base_name).join(rel);
if path.is_dir() {
archive.append_dir(&tar_path, &path)?;
append_dir_recursive(archive, root, &path, base_name)?;
} else if path.is_file() {
archive.append_path_with_name(&path, &tar_path)?;
}
}
Ok(())
}
fn extract_tarball(src: &Path, dest: &Path) -> Result<()> {
use flate2::read::GzDecoder;
let file = std::fs::File::open(src)
.with_context(|| format!("failed to open {}", src.display()))?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)
.with_context(|| format!("failed to extract {}", src.display()))?;
Ok(())
}
/// If a directory contains exactly one subdirectory and nothing else, return it.
fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
let entries: Vec<_> = std::fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok())
.collect();
if entries.len() == 1 && entries[0].path().is_dir() {
Some(entries[0].path())
} else {
None
}
}
fn load_sync_config(cfg_dir: &Path) -> Result<SyncConfig> { fn load_sync_config(cfg_dir: &Path) -> Result<SyncConfig> {
match SyncConfig::load(cfg_dir) { match SyncConfig::load(cfg_dir) {
Ok(c) => Ok(c), Ok(c) => Ok(c),

View file

@ -26,6 +26,8 @@ pub enum AdapterSource {
/// Internal events synthesized by the daemon itself /// Internal events synthesized by the daemon itself
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`). /// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
System, System,
/// BlueZ Bluetooth stack via D-Bus.
Bluetooth,
} }
/// An unnormalized event as emitted by an adapter. /// An unnormalized event as emitted by an adapter.
@ -114,6 +116,10 @@ mod tests {
serde_json::to_string(&AdapterSource::System).unwrap(), serde_json::to_string(&AdapterSource::System).unwrap(),
"\"system\"" "\"system\""
); );
assert_eq!(
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
);
} }
#[test] #[test]
@ -124,6 +130,7 @@ mod tests {
AdapterSource::Power, AdapterSource::Power,
AdapterSource::Network, AdapterSource::Network,
AdapterSource::System, AdapterSource::System,
AdapterSource::Bluetooth,
] { ] {
let s = serde_json::to_string(&source).unwrap(); let s = serde_json::to_string(&source).unwrap();
let back: AdapterSource = serde_json::from_str(&s).unwrap(); let back: AdapterSource = serde_json::from_str(&s).unwrap();
@ -205,7 +212,8 @@ mod tests {
set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Udev); set.insert(AdapterSource::Udev);
assert_eq!(set.len(), 2); set.insert(AdapterSource::Bluetooth);
assert_eq!(set.len(), 3);
assert!(set.contains(&AdapterSource::Hyprland)); assert!(set.contains(&AdapterSource::Hyprland));
} }
} }

View file

@ -50,6 +50,7 @@ impl Default for PackagesConfig {
enabled: true, enabled: true,
managers: vec![ managers: vec![
"pacman".to_string(), "pacman".to_string(),
"aur".to_string(),
"pip".to_string(), "pip".to_string(),
"npm".to_string(), "npm".to_string(),
"cargo".to_string(), "cargo".to_string(),
@ -194,6 +195,7 @@ mod tests {
let cfg = PackagesConfig::default(); let cfg = PackagesConfig::default();
assert!(cfg.enabled); assert!(cfg.enabled);
assert!(cfg.managers.contains(&"pacman".to_string())); 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(&"pip".to_string()));
assert!(cfg.managers.contains(&"npm".to_string())); assert!(cfg.managers.contains(&"npm".to_string()));
assert!(cfg.managers.contains(&"cargo".to_string())); assert!(cfg.managers.contains(&"cargo".to_string()));

850
bread-sync/src/export.rs Normal file
View 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
}

View file

@ -1,9 +1,11 @@
/// Bread sync: snapshot and restore system state via a Git remote. /// Bread sync: snapshot and restore system state via a Git remote.
pub mod config; pub mod config;
pub mod delegates; pub mod delegates;
pub mod export;
pub mod git; pub mod git;
pub mod machine; pub mod machine;
pub mod packages; pub mod packages;
pub use config::SyncConfig; pub use config::SyncConfig;
pub use export::{apply_import, stage_export, ExportManifest};
pub use git::SyncRepo; pub use git::SyncRepo;

View file

@ -9,6 +9,7 @@ use std::process::Command;
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> { pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
let content = match manager { let content = match manager {
"pacman" => run_pacman()?, "pacman" => run_pacman()?,
"aur" => run_aur()?,
"pip" => run_pip()?, "pip" => run_pip()?,
"npm" => run_npm()?, "npm" => run_npm()?,
"cargo" => run_cargo()?, "cargo" => run_cargo()?,
@ -87,6 +88,17 @@ pub fn parse_cargo(content: &str) -> Vec<String> {
.collect() .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>> { fn run_pacman() -> Result<Option<String>> {
match Command::new("pacman").arg("-Qe").output() { match Command::new("pacman").arg("-Qe").output() {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {

View 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("/"), "");
}
}

View file

@ -10,6 +10,7 @@ use tracing::info;
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised; use crate::core::supervisor::spawn_supervised;
pub mod bluetooth;
pub mod hyprland; pub mod hyprland;
pub mod network; pub mod network;
pub mod network_rtnetlink; pub mod network_rtnetlink;
@ -86,6 +87,12 @@ impl Manager {
} }
} }
if self.config.adapters.bluetooth.enabled {
let adapter = bluetooth::BluetoothAdapter::new();
adapter.enumerate_existing(&self.raw_tx).await;
self.spawn_adapter(adapter);
}
if self.config.adapters.network.enabled { if self.config.adapters.network.enabled {
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter // Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
let rt = network_rtnetlink::RtnetlinkAdapter::new(); let rt = network_rtnetlink::RtnetlinkAdapter::new();

View file

@ -55,6 +55,8 @@ pub struct AdaptersConfig {
pub power: PowerConfig, pub power: PowerConfig,
#[serde(default)] #[serde(default)]
pub network: AdapterToggle, pub network: AdapterToggle,
#[serde(default)]
pub bluetooth: AdapterToggle,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -306,6 +308,7 @@ mod tests {
assert!(cfg.adapters.udev.enabled); assert!(cfg.adapters.udev.enabled);
assert!(cfg.adapters.power.enabled); assert!(cfg.adapters.power.enabled);
assert!(cfg.adapters.network.enabled); assert!(cfg.adapters.network.enabled);
assert!(cfg.adapters.bluetooth.enabled);
assert_eq!(cfg.adapters.power.poll_interval_secs, 30); assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
assert_eq!(cfg.events.dedup_window_ms, 100); assert_eq!(cfg.events.dedup_window_ms, 100);
assert_eq!(cfg.notifications.default_timeout_ms, 3000); assert_eq!(cfg.notifications.default_timeout_ms, 3000);
@ -359,6 +362,9 @@ poll_interval_secs = 5
[adapters.network] [adapters.network]
enabled = false enabled = false
[adapters.bluetooth]
enabled = false
[events] [events]
dedup_window_ms = 250 dedup_window_ms = 250
@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send"
assert!(!cfg.adapters.power.enabled); assert!(!cfg.adapters.power.enabled);
assert_eq!(cfg.adapters.power.poll_interval_secs, 5); assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
assert!(!cfg.adapters.network.enabled); assert!(!cfg.adapters.network.enabled);
assert!(!cfg.adapters.bluetooth.enabled);
assert_eq!(cfg.events.dedup_window_ms, 250); assert_eq!(cfg.events.dedup_window_ms, 250);
assert_eq!(cfg.notifications.default_timeout_ms, 1000); assert_eq!(cfg.notifications.default_timeout_ms, 1000);
assert_eq!(cfg.notifications.default_urgency, "critical"); assert_eq!(cfg.notifications.default_urgency, "critical");

View file

@ -31,6 +31,7 @@ impl EventNormalizer {
AdapterSource::Hyprland => self.normalize_hyprland(raw), AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw), AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw), AdapterSource::Network => self.normalize_network(raw),
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
AdapterSource::System => vec![BreadEvent { AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(), event: raw.kind.clone(),
timestamp: raw.timestamp, timestamp: raw.timestamp,
@ -303,6 +304,83 @@ impl EventNormalizer {
events 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> { fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw let online = raw
.payload .payload
@ -661,6 +739,123 @@ mod tests {
assert!(names.contains(&"bread.power.battery.critical")); 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 ─────────────────────────────────────────────────────────── // ─── Network ───────────────────────────────────────────────────────────
#[test] #[test]

View file

@ -934,6 +934,74 @@ impl LuaEngine {
bread.set("fs", fs_tbl)?; bread.set("fs", fs_tbl)?;
// bread.bluetooth — BlueZ control
let bluetooth_tbl = self.lua.create_table()?;
let power_fn = self.lua.create_function(move |_lua, enabled: bool| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_set_powered(enabled).await {
tracing::warn!("bread.bluetooth.power failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("power", power_fn)?;
let powered_fn = self.lua.create_function(move |_lua, ()| {
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
})?;
bluetooth_tbl.set("powered", powered_fn)?;
let connect_fn = self.lua.create_function(move |_lua, address: String| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_connect(address).await {
tracing::warn!("bread.bluetooth.connect failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("connect", connect_fn)?;
let disconnect_fn = self.lua.create_function(move |_lua, address: String| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_disconnect(address).await {
tracing::warn!("bread.bluetooth.disconnect failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("disconnect", disconnect_fn)?;
let scan_fn = self.lua.create_function(move |_lua, enabled: bool| {
bluetooth_spawn(move || async move {
if let Err(e) = bluetooth_set_scanning(enabled).await {
tracing::warn!("bread.bluetooth.scan failed: {e}");
}
});
Ok(())
})?;
bluetooth_tbl.set("scan", scan_fn)?;
let devices_fn = self.lua.create_function(move |lua, ()| {
let devs = match bluetooth_query(|| bluetooth_list_devices()) {
Ok(d) => d,
Err(_) => return Ok(Value::Nil),
};
let tbl = lua.create_table()?;
for (i, dev) in devs.iter().enumerate() {
let dt = lua.create_table()?;
dt.set("address", dev.address.clone())?;
dt.set("name", dev.name.clone())?;
dt.set("connected", dev.connected)?;
dt.set("paired", dev.paired)?;
tbl.set(i + 1, dt)?;
}
Ok(Value::Table(tbl))
})?;
bluetooth_tbl.set("devices", devices_fn)?;
bread.set("bluetooth", bluetooth_tbl)?;
globals.set("bread", bread)?; globals.set("bread", bread)?;
self.install_require_loader()?; self.install_require_loader()?;
self.install_wait_helper()?; self.install_wait_helper()?;
@ -2193,3 +2261,199 @@ fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
} }
Ok(out) Ok(out)
} }
// ─── Bluetooth helpers ────────────────────────────────────────────────────────
/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget
/// async Bluetooth operation. Needed because the Lua thread runs inside
/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden.
fn bluetooth_spawn<F, Fut>(factory: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()>,
{
std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bluetooth action thread")
.block_on(factory());
});
}
/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua
/// gets a return value.
fn bluetooth_query<F, Fut, T>(factory: F) -> anyhow::Result<T>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = anyhow::Result<T>>,
T: Send + 'static,
{
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bluetooth query thread")
.block_on(factory());
let _ = tx.send(result);
});
rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
}
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
let msg = conn
.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
)
.await?;
let objects: std::collections::HashMap<
OwnedObjectPath,
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
> = msg.body()?;
for (path, interfaces) in &objects {
if interfaces.contains_key("org.bluez.Adapter1") {
return Ok(path.as_str().to_string());
}
}
Err(anyhow::anyhow!("no Bluetooth adapter found"))
}
async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
conn.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.freedesktop.DBus.Properties"),
"Set",
&(
"org.bluez.Adapter1",
"Powered",
zbus::zvariant::Value::from(enabled),
),
)
.await?;
Ok(())
}
async fn bluetooth_get_powered() -> anyhow::Result<bool> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let msg = conn
.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.freedesktop.DBus.Properties"),
"Get",
&("org.bluez.Adapter1", "Powered"),
)
.await?;
let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?;
let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false));
Ok(json.as_bool().unwrap_or(false))
}
async fn bluetooth_connect(address: String) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
conn.call_method(
Some("org.bluez"),
dev_path.as_str(),
Some("org.bluez.Device1"),
"Connect",
&(),
)
.await?;
Ok(())
}
async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
conn.call_method(
Some("org.bluez"),
dev_path.as_str(),
Some("org.bluez.Device1"),
"Disconnect",
&(),
)
.await?;
Ok(())
}
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
let conn = zbus::Connection::system().await?;
let adapter = bluetooth_find_adapter(&conn).await?;
let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
conn.call_method(
Some("org.bluez"),
adapter.as_str(),
Some("org.bluez.Adapter1"),
method,
&(),
)
.await?;
Ok(())
}
struct BluetoothDevice {
address: String,
name: String,
connected: bool,
paired: bool,
}
async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
let conn = zbus::Connection::system().await?;
let msg = conn
.call_method(
Some("org.bluez"),
"/",
Some("org.freedesktop.DBus.ObjectManager"),
"GetManagedObjects",
&(),
)
.await?;
let objects: std::collections::HashMap<
OwnedObjectPath,
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
> = msg.body()?;
let mut devices = Vec::new();
for (_, interfaces) in &objects {
if let Some(props) = interfaces.get("org.bluez.Device1") {
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
devices.push(BluetoothDevice {
address: json
.get("Address")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
name: json
.get("Name")
.or_else(|| json.get("Alias"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
connected: json
.get("Connected")
.and_then(|v| v.as_bool())
.unwrap_or(false),
paired: json
.get("Paired")
.and_then(|v| v.as_bool())
.unwrap_or(false),
});
}
}
Ok(devices)
}