diff --git a/Documentation.md b/Documentation.md index f1a0aca..36c6d73 100644 --- a/Documentation.md +++ b/Documentation.md @@ -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 @@ -135,16 +138,18 @@ installed_at = "2026-01-01T00:00:00Z" ## Sync: snapshot and restore -Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. +Bread sync snapshots your config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required. ```bash -# First-time setup +# First-time setup (remote optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Snapshot and push +# Commit local snapshot bread sync push +bread sync push --message "before reinstall" -# On another machine: pull and apply +# Apply snapshot to this machine bread sync pull # Also reinstall packages from snapshot @@ -153,12 +158,55 @@ bread sync pull --install-packages # See what has changed bread sync status bread sync diff -bread sync diff --remote # List known machines bread sync machines ``` +### 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--.tar.gz) +bread sync export + +# Export to a specific path +bread sync export --output ~/backups/bread.tar.gz +bread sync export --output /mnt/usb/bread-snapshot/ # directory + +# Apply a snapshot on another machine +bread sync import bread-export-hermes-2026-05-16.tar.gz +bread sync import /mnt/usb/bread-snapshot/ + +# Also install packages from the snapshot +bread sync import bread-export.tar.gz --install-packages + +# Skip cloning git repos back to their original locations +bread sync import bread-export.tar.gz --no-clone-repos + +# Skip confirmation prompt +bread sync import bread-export.tar.gz --yes +``` + +Each export snapshot includes: + +| Directory | Contents | +|-----------|----------| +| `bread/` | `~/.config/bread/` (your Bread config) | +| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) | +| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. | +| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) | +| `local-fonts/` | `~/.local/share/fonts/` | +| `systemd/` | `~/.config/systemd/user/` units | +| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) | +| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) | +| `machines/` | Per-machine profile with tags and last-sync time | +| `manifest.toml` | Path map for exact restoration on import | +| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) | + +**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back. + Configure sync in `~/.config/bread/sync.toml`: ```toml @@ -179,16 +227,6 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: - -``` -~/.local/share/bread/sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) -├── machines/ ← per-machine profiles with tags and last-sync time -└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) -``` - ## Debugging tips - Run `bread events` to see live normalized events. @@ -402,6 +440,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 +761,7 @@ Events are delivered as a `BreadEvent`: |-------|------| | `bread.system.startup` | `{}` | -#### Devices (udev) +#### Devices (udev / Bluetooth) | 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`. +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 | diff --git a/README.md b/README.md index c9b3d67..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -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 @@ -197,14 +201,19 @@ bread modules update [name] # Re-install one or all GitHub-sourced mod bread modules info # Show full manifest and daemon status # Sync -bread sync init # Initialize sync for this machine -bread sync push # Snapshot and push current state to remote -bread sync pull # Pull and apply latest state from remote +bread sync init # Initialize sync for this machine (remote optional) +bread sync push # Commit local snapshot +bread sync push --message "note" # Commit with a custom message +bread sync pull # Apply local snapshot to this machine bread sync pull --install-packages # Also install packages from snapshot bread sync status # Show what has changed since last push bread sync diff # Show file-level diff vs last commit -bread sync diff --remote # Show diff vs remote 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 # Apply a portable snapshot (.tar.gz or directory) +bread sync import --install-packages # Also install packages +bread sync import --no-clone-repos # Skip cloning git repos ``` --- @@ -262,27 +271,31 @@ return M ## 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 -# First-time setup +# First-time setup (remote is optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Push current state +# Commit a local snapshot bread sync push -# On another machine: pull and apply -bread sync pull +# Create a portable .tar.gz (no git auth required) +bread sync export -# Check what's pending -bread sync status +# On another machine: apply the snapshot +bread sync import bread-export-hermes-2026-05-16.tar.gz + +# Also install packages on import +bread sync import bread-export.tar.gz --install-packages ``` Configure what gets synced in `~/.config/bread/sync.toml`: ```toml [remote] -url = "git@github.com:you/bread-config.git" +url = "git@github.com:you/bread-config.git" # optional branch = "main" [machine] @@ -298,14 +311,21 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: +A portable export snapshot contains: ``` -sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) +bread-export-hermes-2026-05-16/ +├── bread/ ← ~/.config/bread/ +├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, … +├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, … +├── local-bin/ ← ~/.local/bin/ scripts +├── local-fonts/ ← ~/.local/share/fonts/ +├── systemd/ ← ~/.config/systemd/user/ units +├── system/ ← udev rules, modprobe, sysctl (sudo required for some) +├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt ├── machines/ ← per-machine profiles -└── 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...`. | `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 +538,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. diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0e1b4a2..924c7b3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -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--.tar.gz + #[arg(long, short)] + output: Option, + }, + /// 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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>, + 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 { + 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 { match SyncConfig::load(cfg_dir) { Ok(c) => Ok(c), diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 3f43385..25bdac7 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -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)); } } diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs index 606a637..9760449 100644 --- a/bread-sync/src/config.rs +++ b/bread-sync/src/config.rs @@ -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())); diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs new file mode 100644 index 0000000..9397f4b --- /dev/null +++ b/bread-sync/src/export.rs @@ -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, + /// High-level list of config dir names (for display). + pub configs: Vec, + /// Git repos found on the source machine. + #[serde(default)] + pub repos: Vec, + pub system: bool, + pub packages: Vec, + // Legacy fields kept for forward compat (ignored on import) + #[serde(default)] + pub bread: bool, + #[serde(default)] + pub dotfiles: Vec, + #[serde(default)] + pub local_bin: Vec, + #[serde(default)] + pub systemd_units: Vec, +} + +/// 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 { + fs::create_dir_all(staging)?; + + let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); + let mut path_map: Vec = Vec::new(); + let mut included_configs: Vec = 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// + 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 = 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> { + let mut applied: Vec = Vec::new(); + + // Read manifest to get the path map + let manifest_path = staging.join("manifest.toml"); + let path_map: Vec = if manifest_path.exists() { + let raw = fs::read_to_string(&manifest_path)?; + toml::from_str::(&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::(&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 { + let mut dirs: Vec = 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 { + let nc_dirs = nextcloud_sync_dirs(home); + let mut repos: Vec = 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, 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 +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs index 4b89f1a..e508750 100644 --- a/bread-sync/src/lib.rs +++ b/bread-sync/src/lib.rs @@ -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; diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs index 59f8e4c..b1548ae 100644 --- a/bread-sync/src/packages.rs +++ b/bread-sync/src/packages.rs @@ -9,6 +9,7 @@ use std::process::Command; pub fn snapshot(manager: &str, dest: &Path) -> Result { 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 { .collect() } +fn run_aur() -> Result> { + 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> { match Command::new("pacman").arg("-Qe").output() { Ok(out) if out.status.success() => { diff --git a/breadd/src/adapters/bluetooth.rs b/breadd/src/adapters/bluetooth.rs new file mode 100644 index 0000000..128b7cf --- /dev/null +++ b/breadd/src/adapters/bluetooth.rs @@ -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) { + 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) -> 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) -> Result { + let conn = zbus::Connection::system().await?; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + + let objects: HashMap>> = + 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 { + 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, Vec) = + 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>, + ) = 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) = 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("/"), ""); + } +} diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index c4915c1..dcd7870 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -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(); diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 4a78321..b1be12c 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -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"); diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 49e071d..963838d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -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 { + 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 { 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] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index caf49df..b7a7453 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -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> { } 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(factory: F) +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future, +{ + 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(factory: F) -> anyhow::Result +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, + 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 { + 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>, + > = 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 { + 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> { + 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>, + > = 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) +}