Commiting for bread sync

This commit is contained in:
Breadway 2026-05-16 19:44:19 +08:00
parent 9a471f3158
commit fc27916a5d
13 changed files with 2040 additions and 79 deletions

View file

@ -10,6 +10,7 @@
- [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)
@ -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
- **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
@ -402,6 +405,83 @@ bread.hyprland.on_raw("activewindow", function(raw)
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.
@ -646,7 +726,7 @@ Events are delivered as a `BreadEvent`:
|-------|------|
| `bread.system.startup` | `{}` |
#### Devices (udev)
#### Devices (udev / Bluetooth)
| Event | Data |
|-------|------|
@ -657,6 +737,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`.
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 |

View file

@ -51,7 +51,7 @@ packaging/ Arch PKGBUILD and systemd user service
The daemon is structured in four layers:
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces
- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
- **Normalizer** — transforms raw adapter signals into semantic Bread events
- **State engine** — maintains runtime state and dispatches events to subscribers
- **Lua runtime** — loads your modules, registers handlers, executes automation
@ -68,6 +68,7 @@ The daemon is structured in four layers:
Optional but preferred:
- UPower (for battery events via D-Bus rather than sysfs polling)
- rtnetlink (for network events; falls back to sysfs polling without it)
- BlueZ (for Bluetooth device events and control)
---
@ -138,6 +139,9 @@ poll_interval_secs = 30
[adapters.network]
enabled = true
[adapters.bluetooth]
enabled = true
[events]
dedup_window_ms = 100
@ -335,6 +339,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
| `bread.power.battery.full` | Battery at 100% |
| `bread.network.connected` | Network interface came online |
| `bread.network.disconnected` | Network interface went offline |
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
| `bread.profile.activated` | Profile switched |
| `bread.notify.sent` | Desktop notification dispatched |
@ -516,6 +522,44 @@ bread.hyprland.on_raw("activewindow", function(raw)
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.

View file

@ -3,7 +3,7 @@ mod modules_mgmt;
use anyhow::{Context, Result};
use bread_sync::{
config::{bread_config_dir, SyncConfig},
delegates, machine, packages, SyncRepo,
delegates, machine, packages, apply_import, stage_export, SyncRepo,
};
use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
@ -143,6 +143,26 @@ enum SyncCommand {
},
/// List known machines from sync repo
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]
@ -447,6 +467,10 @@ async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> {
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).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(())
}
@ -464,7 +488,7 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
let remote_url = match remote {
Some(u) => u,
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()?;
let mut line = String::new();
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)?;
// 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!("sync initialized");
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());
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 repo_path = SyncConfig::local_repo_path();
// Clone or open the local sync repo
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?;
let repo = if repo_path.exists() {
SyncRepo::open(&repo_path)?
} else {
SyncRepo::init(&repo_path)?
};
// Snapshot bread/ directory
let bread_dest = repo_path.join("bread");
delegates::sync_dir(
cfg_dir,
&bread_dest,
&[
// Don't recurse into the sync repo itself
".git".to_string(),
],
)?;
delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?;
// Snapshot delegate 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 {
let dest_file = packages_dir.join(format!("{manager}.txt"));
if let Err(e) = packages::snapshot(manager, &dest_file) {
eprintln!(
"bread: warning: package snapshot for {} failed: {}",
manager, e
);
eprintln!("bread: warning: package snapshot for {manager} failed: {e}");
}
}
}
// Write machine profile
let machines_dir = repo_path.join("machines");
let profile =
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone());
profile.write(&machines_dir)?;
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
.write(&machines_dir)?;
// Set remote and commit
repo.set_remote("origin", &config.remote.url)?;
let commit_msg = message.unwrap_or_else(|| {
format!(
"sync: {} {}",
@ -584,19 +600,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
});
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(());
}
repo.push("origin", &config.remote.branch)?;
println!("pushed sync for {}", config.machine.name);
println!(" bread config: {}", cfg_dir.display());
if !config.delegates.include.is_empty() {
println!(" delegates: {}", config.delegates.include.len());
}
println!("committed sync for {}", config.machine.name);
println!(" snapshot: {}", repo_path.display());
println!(" tip: run 'bread sync export' to create a portable snapshot");
if config.packages.enabled {
println!(" packages: {}", config.packages.managers.join(", "));
println!(" packages: {}", config.packages.managers.join(", "));
}
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 repo_path = SyncConfig::local_repo_path();
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?;
repo.set_remote("origin", &config.remote.url)?;
match repo.pull("origin", &config.remote.branch) {
Ok(()) => {}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
if !repo_path.exists() {
eprintln!("bread: no local snapshot found. Run 'bread sync push' first.");
std::process::exit(1);
}
// Apply bread/ → ~/.config/bread/
@ -667,29 +673,25 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
if !repo_path.exists() {
println!("bread sync status");
println!(" not yet pushed");
println!(" not yet committed — run 'bread sync push'");
return Ok(());
}
let repo = SyncRepo::open(&repo_path)?;
repo.set_remote("origin", &config.remote.url)?;
// Fetch remote refs without merging
let _ = repo.fetch("origin", &config.remote.branch);
let last_push = repo
let last_commit = repo
.last_commit_time()
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "never".to_string());
println!("bread sync status");
println!(" machine {}", config.machine.name);
println!(" remote {}", config.remote.url);
println!(" last push {}", last_push);
println!(" snapshot {}", repo_path.display());
println!(" last commit {}", last_commit);
let local_changes = repo.local_changes()?;
println!();
println!("local changes (not yet pushed):");
println!("uncommitted changes:");
if local_changes.is_empty() {
println!(" none");
} 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(())
}
async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> {
let config = load_sync_config(cfg_dir)?;
async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> {
let _config = load_sync_config(cfg_dir)?;
let repo_path = SyncConfig::local_repo_path();
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 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()?
};
let diff = repo.working_diff()?;
print!("{}", diff);
Ok(())
}
@ -752,6 +735,238 @@ async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> {
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> {
match SyncConfig::load(cfg_dir) {
Ok(c) => Ok(c),

View file

@ -26,6 +26,8 @@ pub enum AdapterSource {
/// 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.
@ -114,6 +116,10 @@ mod tests {
serde_json::to_string(&AdapterSource::System).unwrap(),
"\"system\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
);
}
#[test]
@ -124,6 +130,7 @@ mod tests {
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();
@ -205,7 +212,8 @@ mod tests {
set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Hyprland);
set.insert(AdapterSource::Udev);
assert_eq!(set.len(), 2);
set.insert(AdapterSource::Bluetooth);
assert_eq!(set.len(), 3);
assert!(set.contains(&AdapterSource::Hyprland));
}
}

View file

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

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.
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;

View file

@ -9,6 +9,7 @@ use std::process::Command;
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()?,
@ -87,6 +88,17 @@ pub fn parse_cargo(content: &str) -> Vec<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() => {

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::supervisor::spawn_supervised;
pub mod bluetooth;
pub mod hyprland;
pub mod network;
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 {
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
let rt = network_rtnetlink::RtnetlinkAdapter::new();

View file

@ -55,6 +55,8 @@ pub struct AdaptersConfig {
pub power: PowerConfig,
#[serde(default)]
pub network: AdapterToggle,
#[serde(default)]
pub bluetooth: AdapterToggle,
}
#[derive(Debug, Clone, Deserialize)]
@ -306,6 +308,7 @@ mod tests {
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);
@ -359,6 +362,9 @@ poll_interval_secs = 5
[adapters.network]
enabled = false
[adapters.bluetooth]
enabled = false
[events]
dedup_window_ms = 250
@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send"
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");

View file

@ -31,6 +31,7 @@ impl EventNormalizer {
AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw),
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(),
timestamp: raw.timestamp,
@ -303,6 +304,83 @@ impl EventNormalizer {
events
}
fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let path = raw
.payload
.get("path")
.and_then(Value::as_str)
.unwrap_or("unknown");
let address = raw
.payload
.get("address")
.and_then(Value::as_str)
.unwrap_or("unknown");
let name = raw
.payload
.get("name")
.and_then(Value::as_str)
.or_else(|| {
raw.payload
.pointer("/properties/Name")
.or_else(|| raw.payload.pointer("/properties/Alias"))
.and_then(Value::as_str)
})
.unwrap_or("unknown");
match raw.kind.as_str() {
"bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent {
event: "bread.device.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"device": "unknown",
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.disconnected" => vec![BreadEvent {
event: "bread.device.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"device": "unknown",
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.added" => vec![BreadEvent {
event: "bread.bluetooth.device.paired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"name": name,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
"bluetooth.device.removed" => vec![BreadEvent {
event: "bread.bluetooth.device.unpaired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
data: json!({
"id": path,
"address": address,
"subsystem": "bluetooth",
"raw": raw.payload,
}),
}],
_ => vec![],
}
}
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw
.payload
@ -661,6 +739,123 @@ mod tests {
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]

View file

@ -934,6 +934,74 @@ impl LuaEngine {
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)?;
self.install_require_loader()?;
self.install_wait_helper()?;
@ -2193,3 +2261,199 @@ fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
}
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)
}