Commiting for bread sync
This commit is contained in:
parent
9a471f3158
commit
fc27916a5d
13 changed files with 2040 additions and 79 deletions
102
Documentation.md
102
Documentation.md
|
|
@ -10,6 +10,7 @@
|
||||||
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
|
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
|
||||||
- [Debugging tips](#debugging-tips)
|
- [Debugging tips](#debugging-tips)
|
||||||
- [Dictionary: Lua API](#dictionary-lua-api)
|
- [Dictionary: Lua API](#dictionary-lua-api)
|
||||||
|
- [Bluetooth](#bluetooth)
|
||||||
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
|
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
|
||||||
- [Dictionary: Event reference](#dictionary-event-reference)
|
- [Dictionary: Event reference](#dictionary-event-reference)
|
||||||
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
|
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
|
||||||
|
|
@ -23,6 +24,8 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`)
|
||||||
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
|
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
|
||||||
- **CLI** (`bread`) — talks to the daemon over a Unix socket
|
- **CLI** (`bread`) — talks to the daemon over a Unix socket
|
||||||
|
|
||||||
|
Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, and BlueZ Bluetooth.
|
||||||
|
|
||||||
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
|
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
@ -402,6 +405,83 @@ bread.hyprland.on_raw("activewindow", function(raw)
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bluetooth
|
||||||
|
|
||||||
|
The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`.
|
||||||
|
|
||||||
|
#### `bread.bluetooth.power(enabled)`
|
||||||
|
Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget.
|
||||||
|
|
||||||
|
#### `bread.bluetooth.powered() -> bool | nil`
|
||||||
|
Returns the current power state of the adapter, or `nil` if unavailable.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if bread.bluetooth.powered() then
|
||||||
|
bread.log("Bluetooth is on")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bread.bluetooth.connect(address)`
|
||||||
|
Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `bread.bluetooth.disconnect(address)`
|
||||||
|
Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`.
|
||||||
|
|
||||||
|
#### `bread.bluetooth.scan(enabled)`
|
||||||
|
Start (`true`) or stop (`false`) device discovery.
|
||||||
|
|
||||||
|
#### `bread.bluetooth.devices() -> table | nil`
|
||||||
|
Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local devs = bread.bluetooth.devices()
|
||||||
|
if devs then
|
||||||
|
for _, dev in ipairs(devs) do
|
||||||
|
bread.log(dev.name .. " " .. dev.address
|
||||||
|
.. (dev.connected and " [connected]" or ""))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Each device table:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` |
|
||||||
|
| `name` | string | Device name from BlueZ (Alias or Name property) |
|
||||||
|
| `connected` | bool | Whether the device is currently connected |
|
||||||
|
| `paired` | bool | Whether the device is paired |
|
||||||
|
|
||||||
|
#### Example: auto-connect headphones on AC power
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local M = bread.module({ name = "headphones", version = "1.0.0" })
|
||||||
|
local HEADPHONES = "AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
function M.on_load()
|
||||||
|
bread.state.watch("power.ac_connected", function(ac)
|
||||||
|
if ac then
|
||||||
|
bread.bluetooth.power(true)
|
||||||
|
bread.bluetooth.connect(HEADPHONES)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: turn off Bluetooth on battery
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.state.watch("power.ac_connected", function(ac)
|
||||||
|
bread.bluetooth.power(ac)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
### Module lifecycle hooks
|
### Module lifecycle hooks
|
||||||
|
|
||||||
All hooks are optional.
|
All hooks are optional.
|
||||||
|
|
@ -646,7 +726,7 @@ Events are delivered as a `BreadEvent`:
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `bread.system.startup` | `{}` |
|
| `bread.system.startup` | `{}` |
|
||||||
|
|
||||||
#### Devices (udev)
|
#### Devices (udev / Bluetooth)
|
||||||
|
|
||||||
| Event | Data |
|
| 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`.
|
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
|
||||||
|
|
||||||
|
Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`:
|
||||||
|
|
||||||
|
| `subsystem` | Source | Unique identifier field |
|
||||||
|
|-------------|--------|------------------------|
|
||||||
|
| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` |
|
||||||
|
| `"bluetooth"` | BlueZ | `address` (MAC address) |
|
||||||
|
|
||||||
|
#### Bluetooth (BlueZ)
|
||||||
|
|
||||||
|
| Event | Data |
|
||||||
|
|-------|------|
|
||||||
|
| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` |
|
||||||
|
| `bread.device.disconnected` | same |
|
||||||
|
| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` |
|
||||||
|
| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` |
|
||||||
|
|
||||||
|
`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects.
|
||||||
|
|
||||||
|
`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup.
|
||||||
|
|
||||||
#### Hyprland
|
#### Hyprland
|
||||||
|
|
||||||
| Event | Data |
|
| Event | Data |
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -51,7 +51,7 @@ packaging/ Arch PKGBUILD and systemd user service
|
||||||
|
|
||||||
The daemon is structured in four layers:
|
The daemon is structured in four layers:
|
||||||
|
|
||||||
- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces
|
- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ)
|
||||||
- **Normalizer** — transforms raw adapter signals into semantic Bread events
|
- **Normalizer** — transforms raw adapter signals into semantic Bread events
|
||||||
- **State engine** — maintains runtime state and dispatches events to subscribers
|
- **State engine** — maintains runtime state and dispatches events to subscribers
|
||||||
- **Lua runtime** — loads your modules, registers handlers, executes automation
|
- **Lua runtime** — loads your modules, registers handlers, executes automation
|
||||||
|
|
@ -68,6 +68,7 @@ The daemon is structured in four layers:
|
||||||
Optional but preferred:
|
Optional but preferred:
|
||||||
- UPower (for battery events via D-Bus rather than sysfs polling)
|
- UPower (for battery events via D-Bus rather than sysfs polling)
|
||||||
- rtnetlink (for network events; falls back to sysfs polling without it)
|
- rtnetlink (for network events; falls back to sysfs polling without it)
|
||||||
|
- BlueZ (for Bluetooth device events and control)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -138,6 +139,9 @@ poll_interval_secs = 30
|
||||||
[adapters.network]
|
[adapters.network]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
|
[adapters.bluetooth]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
[events]
|
[events]
|
||||||
dedup_window_ms = 100
|
dedup_window_ms = 100
|
||||||
|
|
||||||
|
|
@ -335,6 +339,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
||||||
| `bread.power.battery.full` | Battery at 100% |
|
| `bread.power.battery.full` | Battery at 100% |
|
||||||
| `bread.network.connected` | Network interface came online |
|
| `bread.network.connected` | Network interface came online |
|
||||||
| `bread.network.disconnected` | Network interface went offline |
|
| `bread.network.disconnected` | Network interface went offline |
|
||||||
|
| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered |
|
||||||
|
| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ |
|
||||||
| `bread.profile.activated` | Profile switched |
|
| `bread.profile.activated` | Profile switched |
|
||||||
| `bread.notify.sent` | Desktop notification dispatched |
|
| `bread.notify.sent` | Desktop notification dispatched |
|
||||||
|
|
||||||
|
|
@ -516,6 +522,44 @@ bread.hyprland.on_raw("activewindow", function(raw)
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bluetooth
|
||||||
|
|
||||||
|
The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Power the adapter on or off
|
||||||
|
bread.bluetooth.power(true)
|
||||||
|
bread.bluetooth.power(false)
|
||||||
|
|
||||||
|
-- Query current power state (returns true/false, or nil if unavailable)
|
||||||
|
local on = bread.bluetooth.powered()
|
||||||
|
|
||||||
|
-- Connect/disconnect a paired device by MAC address
|
||||||
|
-- Fire-and-forget; result arrives as bread.device.connected/disconnected
|
||||||
|
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||||
|
bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF")
|
||||||
|
|
||||||
|
-- Start or stop device discovery
|
||||||
|
bread.bluetooth.scan(true)
|
||||||
|
bread.bluetooth.scan(false)
|
||||||
|
|
||||||
|
-- List all devices known to BlueZ
|
||||||
|
local devs = bread.bluetooth.devices()
|
||||||
|
-- Returns nil if BlueZ is unavailable, otherwise:
|
||||||
|
-- { { address, name, connected, paired }, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Example — auto-connect headphones when Bluetooth powers on:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.state.watch("power.ac_connected", function(ac)
|
||||||
|
if ac then
|
||||||
|
bread.bluetooth.power(true)
|
||||||
|
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
### Module-scoped storage
|
### Module-scoped storage
|
||||||
|
|
||||||
Survives hot reload; does not survive daemon restart.
|
Survives hot reload; does not survive daemon restart.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ mod modules_mgmt;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bread_sync::{
|
use bread_sync::{
|
||||||
config::{bread_config_dir, SyncConfig},
|
config::{bread_config_dir, SyncConfig},
|
||||||
delegates, machine, packages, SyncRepo,
|
delegates, machine, packages, apply_import, stage_export, SyncRepo,
|
||||||
};
|
};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
@ -143,6 +143,26 @@ enum SyncCommand {
|
||||||
},
|
},
|
||||||
/// List known machines from sync repo
|
/// List known machines from sync repo
|
||||||
Machines,
|
Machines,
|
||||||
|
/// Create a portable export archive (no git auth required)
|
||||||
|
Export {
|
||||||
|
/// Output path: directory or .tar.gz file. Defaults to ./bread-export-<machine>-<date>.tar.gz
|
||||||
|
#[arg(long, short)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Apply a portable export archive to this machine
|
||||||
|
Import {
|
||||||
|
/// Path to a bread export directory or .tar.gz file
|
||||||
|
from: PathBuf,
|
||||||
|
/// Also install packages from the package manifests
|
||||||
|
#[arg(long)]
|
||||||
|
install_packages: bool,
|
||||||
|
/// Skip cloning git repositories to their original locations
|
||||||
|
#[arg(long)]
|
||||||
|
no_clone_repos: bool,
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -447,6 +467,10 @@ async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> {
|
||||||
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
|
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
|
||||||
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?,
|
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?,
|
||||||
SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?,
|
SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?,
|
||||||
|
SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?,
|
||||||
|
SyncCommand::Import { from, install_packages, no_clone_repos, yes } => {
|
||||||
|
cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -464,7 +488,7 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
|
||||||
let remote_url = match remote {
|
let remote_url = match remote {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => {
|
None => {
|
||||||
print!("Sync remote URL (git remote or path): ");
|
print!("Sync remote URL (leave empty for local-only, e.g. git@github.com:you/config): ");
|
||||||
io::stdout().flush()?;
|
io::stdout().flush()?;
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
io::stdin().read_line(&mut line)?;
|
io::stdin().read_line(&mut line)?;
|
||||||
|
|
@ -512,15 +536,17 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
|
||||||
};
|
};
|
||||||
config.save(cfg_dir)?;
|
config.save(cfg_dir)?;
|
||||||
|
|
||||||
// If it looks like a URL (not a local path), check if it exists
|
|
||||||
if !remote_url.starts_with('/') && !remote_url.starts_with('.') {
|
|
||||||
println!("remote does not exist yet — it will be created on first push");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("sync initialized");
|
println!("sync initialized");
|
||||||
println!(" machine: {}", machine_name);
|
println!(" machine: {}", machine_name);
|
||||||
|
if remote_url.is_empty() {
|
||||||
|
println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)");
|
||||||
|
} else {
|
||||||
println!(" remote: {}", remote_url);
|
println!(" remote: {}", remote_url);
|
||||||
|
if !remote_url.starts_with('/') && !remote_url.starts_with('.') {
|
||||||
|
println!(" note: remote will be created on first push");
|
||||||
|
}
|
||||||
|
}
|
||||||
println!(" config: {}", cfg_dir.join("sync.toml").display());
|
println!(" config: {}", cfg_dir.join("sync.toml").display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -529,19 +555,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
|
||||||
let config = load_sync_config(cfg_dir)?;
|
let config = load_sync_config(cfg_dir)?;
|
||||||
let repo_path = SyncConfig::local_repo_path();
|
let repo_path = SyncConfig::local_repo_path();
|
||||||
|
|
||||||
// Clone or open the local sync repo
|
let repo = if repo_path.exists() {
|
||||||
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?;
|
SyncRepo::open(&repo_path)?
|
||||||
|
} else {
|
||||||
|
SyncRepo::init(&repo_path)?
|
||||||
|
};
|
||||||
|
|
||||||
// Snapshot bread/ directory
|
// Snapshot bread/ directory
|
||||||
let bread_dest = repo_path.join("bread");
|
let bread_dest = repo_path.join("bread");
|
||||||
delegates::sync_dir(
|
delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?;
|
||||||
cfg_dir,
|
|
||||||
&bread_dest,
|
|
||||||
&[
|
|
||||||
// Don't recurse into the sync repo itself
|
|
||||||
".git".to_string(),
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Snapshot delegate configs
|
// Snapshot delegate configs
|
||||||
let configs_dir = repo_path.join("configs");
|
let configs_dir = repo_path.join("configs");
|
||||||
|
|
@ -559,22 +581,16 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
|
||||||
for manager in &config.packages.managers {
|
for manager in &config.packages.managers {
|
||||||
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
||||||
if let Err(e) = packages::snapshot(manager, &dest_file) {
|
if let Err(e) = packages::snapshot(manager, &dest_file) {
|
||||||
eprintln!(
|
eprintln!("bread: warning: package snapshot for {manager} failed: {e}");
|
||||||
"bread: warning: package snapshot for {} failed: {}",
|
|
||||||
manager, e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write machine profile
|
// Write machine profile
|
||||||
let machines_dir = repo_path.join("machines");
|
let machines_dir = repo_path.join("machines");
|
||||||
let profile =
|
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
|
||||||
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone());
|
.write(&machines_dir)?;
|
||||||
profile.write(&machines_dir)?;
|
|
||||||
|
|
||||||
// Set remote and commit
|
|
||||||
repo.set_remote("origin", &config.remote.url)?;
|
|
||||||
let commit_msg = message.unwrap_or_else(|| {
|
let commit_msg = message.unwrap_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"sync: {} {}",
|
"sync: {} {}",
|
||||||
|
|
@ -584,17 +600,13 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if repo.commit(&commit_msg)?.is_none() {
|
if repo.commit(&commit_msg)?.is_none() {
|
||||||
println!("nothing to push — already up to date");
|
println!("nothing to commit — already up to date");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.push("origin", &config.remote.branch)?;
|
println!("committed sync for {}", config.machine.name);
|
||||||
|
println!(" snapshot: {}", repo_path.display());
|
||||||
println!("pushed sync for {}", config.machine.name);
|
println!(" tip: run 'bread sync export' to create a portable snapshot");
|
||||||
println!(" bread config: {}", cfg_dir.display());
|
|
||||||
if !config.delegates.include.is_empty() {
|
|
||||||
println!(" delegates: {}", config.delegates.include.len());
|
|
||||||
}
|
|
||||||
if config.packages.enabled {
|
if config.packages.enabled {
|
||||||
println!(" packages: {}", config.packages.managers.join(", "));
|
println!(" packages: {}", config.packages.managers.join(", "));
|
||||||
}
|
}
|
||||||
|
|
@ -605,16 +617,10 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) ->
|
||||||
let config = load_sync_config(cfg_dir)?;
|
let config = load_sync_config(cfg_dir)?;
|
||||||
let repo_path = SyncConfig::local_repo_path();
|
let repo_path = SyncConfig::local_repo_path();
|
||||||
|
|
||||||
let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?;
|
if !repo_path.exists() {
|
||||||
repo.set_remote("origin", &config.remote.url)?;
|
eprintln!("bread: no local snapshot found. Run 'bread sync push' first.");
|
||||||
|
|
||||||
match repo.pull("origin", &config.remote.branch) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{}", e);
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Apply bread/ → ~/.config/bread/
|
// Apply bread/ → ~/.config/bread/
|
||||||
let bread_src = repo_path.join("bread");
|
let bread_src = repo_path.join("bread");
|
||||||
|
|
@ -667,29 +673,25 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
|
||||||
|
|
||||||
if !repo_path.exists() {
|
if !repo_path.exists() {
|
||||||
println!("bread sync status");
|
println!("bread sync status");
|
||||||
println!(" not yet pushed");
|
println!(" not yet committed — run 'bread sync push'");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let repo = SyncRepo::open(&repo_path)?;
|
let repo = SyncRepo::open(&repo_path)?;
|
||||||
repo.set_remote("origin", &config.remote.url)?;
|
|
||||||
|
|
||||||
// Fetch remote refs without merging
|
let last_commit = repo
|
||||||
let _ = repo.fetch("origin", &config.remote.branch);
|
|
||||||
|
|
||||||
let last_push = repo
|
|
||||||
.last_commit_time()
|
.last_commit_time()
|
||||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
.unwrap_or_else(|| "never".to_string());
|
.unwrap_or_else(|| "never".to_string());
|
||||||
|
|
||||||
println!("bread sync status");
|
println!("bread sync status");
|
||||||
println!(" machine {}", config.machine.name);
|
println!(" machine {}", config.machine.name);
|
||||||
println!(" remote {}", config.remote.url);
|
println!(" snapshot {}", repo_path.display());
|
||||||
println!(" last push {}", last_push);
|
println!(" last commit {}", last_commit);
|
||||||
|
|
||||||
let local_changes = repo.local_changes()?;
|
let local_changes = repo.local_changes()?;
|
||||||
println!();
|
println!();
|
||||||
println!("local changes (not yet pushed):");
|
println!("uncommitted changes:");
|
||||||
if local_changes.is_empty() {
|
if local_changes.is_empty() {
|
||||||
println!(" none");
|
println!(" none");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -698,22 +700,11 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let remote_changes = repo.remote_changes("origin", &config.remote.branch)?;
|
|
||||||
println!();
|
|
||||||
println!("remote changes (not yet pulled):");
|
|
||||||
if remote_changes.is_empty() {
|
|
||||||
println!(" none");
|
|
||||||
} else {
|
|
||||||
for (ch, path) in &remote_changes {
|
|
||||||
println!(" {} {}", ch, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> {
|
async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> {
|
||||||
let config = load_sync_config(cfg_dir)?;
|
let _config = load_sync_config(cfg_dir)?;
|
||||||
let repo_path = SyncConfig::local_repo_path();
|
let repo_path = SyncConfig::local_repo_path();
|
||||||
|
|
||||||
if !repo_path.exists() {
|
if !repo_path.exists() {
|
||||||
|
|
@ -722,15 +713,7 @@ async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let repo = SyncRepo::open(&repo_path)?;
|
let repo = SyncRepo::open(&repo_path)?;
|
||||||
|
let diff = repo.working_diff()?;
|
||||||
let diff = if vs_remote {
|
|
||||||
repo.set_remote("origin", &config.remote.url)?;
|
|
||||||
let _ = repo.fetch("origin", &config.remote.branch);
|
|
||||||
repo.remote_diff("origin", &config.remote.branch)?
|
|
||||||
} else {
|
|
||||||
repo.working_diff()?
|
|
||||||
};
|
|
||||||
|
|
||||||
print!("{}", diff);
|
print!("{}", diff);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -752,6 +735,238 @@ async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cmd_sync_export(cfg_dir: &Path, output: Option<PathBuf>) -> Result<()> {
|
||||||
|
// Load sync config if available; fall back to machine defaults.
|
||||||
|
let config = match SyncConfig::load(cfg_dir) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
let name = machine::hostname();
|
||||||
|
SyncConfig {
|
||||||
|
remote: bread_sync::config::RemoteConfig {
|
||||||
|
url: String::new(),
|
||||||
|
branch: "main".to_string(),
|
||||||
|
},
|
||||||
|
machine: bread_sync::config::MachineConfig { name, tags: vec![] },
|
||||||
|
packages: bread_sync::config::PackagesConfig::default(),
|
||||||
|
delegates: bread_sync::config::DelegatesConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let date = chrono::Utc::now().format("%Y-%m-%d");
|
||||||
|
let export_name = format!("bread-export-{}-{}", config.machine.name, date);
|
||||||
|
|
||||||
|
// Decide: tarball or directory?
|
||||||
|
let (staging_path, make_tarball, final_path) = match &output {
|
||||||
|
Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => {
|
||||||
|
// User wants a .tar.gz at a specific path
|
||||||
|
let staging = std::env::temp_dir().join(&export_name);
|
||||||
|
(staging, true, p.clone())
|
||||||
|
}
|
||||||
|
Some(p) if p.is_dir() || !p.exists() => {
|
||||||
|
// User wants a directory
|
||||||
|
let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() };
|
||||||
|
(dir.clone(), false, dir)
|
||||||
|
}
|
||||||
|
Some(p) => {
|
||||||
|
anyhow::bail!("output path {} already exists and is not a directory", p.display());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Default: .tar.gz in current directory
|
||||||
|
let tarball = std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join(format!("{export_name}.tar.gz"));
|
||||||
|
let staging = std::env::temp_dir().join(&export_name);
|
||||||
|
(staging, true, tarball)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stage everything into the staging directory
|
||||||
|
let manifest = stage_export(cfg_dir, &config, &staging_path)
|
||||||
|
.context("failed to stage export")?;
|
||||||
|
|
||||||
|
// Optionally pack into a tarball
|
||||||
|
if make_tarball {
|
||||||
|
create_tarball(&staging_path, &final_path)
|
||||||
|
.context("failed to create tarball")?;
|
||||||
|
std::fs::remove_dir_all(&staging_path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("exported to {}", final_path.display());
|
||||||
|
println!(" machine: {}", manifest.machine);
|
||||||
|
if !manifest.configs.is_empty() {
|
||||||
|
println!(" configs: {}", manifest.configs.join(", "));
|
||||||
|
}
|
||||||
|
if !manifest.path_map.is_empty() {
|
||||||
|
let file_count = manifest.path_map.iter().filter(|r| r.is_file).count();
|
||||||
|
let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count();
|
||||||
|
if file_count > 0 {
|
||||||
|
println!(" dotfiles: {} file(s)", file_count);
|
||||||
|
}
|
||||||
|
if dir_count > manifest.configs.len() {
|
||||||
|
println!(" dirs: {} total", dir_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !manifest.packages.is_empty() {
|
||||||
|
println!(" packages: {}", manifest.packages.join(", "));
|
||||||
|
}
|
||||||
|
if !manifest.repos.is_empty() {
|
||||||
|
println!(" repos: {} git repositories tracked", manifest.repos.len());
|
||||||
|
}
|
||||||
|
if manifest.system {
|
||||||
|
println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_sync_import(
|
||||||
|
cfg_dir: &Path,
|
||||||
|
from: PathBuf,
|
||||||
|
install_packages: bool,
|
||||||
|
clone_repos: bool,
|
||||||
|
yes: bool,
|
||||||
|
socket: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Determine staging directory
|
||||||
|
let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz");
|
||||||
|
|
||||||
|
let (staging, _tmp_guard) = if is_tarball {
|
||||||
|
let tmp = tempfile::tempdir().context("failed to create temp dir")?;
|
||||||
|
extract_tarball(&from, tmp.path()).context("failed to extract tarball")?;
|
||||||
|
// GitHub-style tarballs extract into a single subdirectory; unwrap if needed
|
||||||
|
let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf());
|
||||||
|
(inner, Some(tmp))
|
||||||
|
} else if from.is_dir() {
|
||||||
|
(from.clone(), None)
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read manifest for summary
|
||||||
|
let manifest_path = staging.join("manifest.toml");
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display());
|
||||||
|
}
|
||||||
|
let manifest_raw = std::fs::read_to_string(&manifest_path)?;
|
||||||
|
let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw)
|
||||||
|
.context("failed to parse manifest.toml")?;
|
||||||
|
|
||||||
|
println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]);
|
||||||
|
println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") });
|
||||||
|
println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") });
|
||||||
|
if !manifest.repos.is_empty() {
|
||||||
|
println!(" repos: {} git repositories found", manifest.repos.len());
|
||||||
|
if clone_repos {
|
||||||
|
println!(" (will be cloned to their original locations)");
|
||||||
|
} else {
|
||||||
|
println!(" (skipping clone — remove --no-clone-repos to restore)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.system {
|
||||||
|
println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !yes {
|
||||||
|
print!("\nApply to ~/.config and ~/.local? (y/n): ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
io::stdin().read_line(&mut line)?;
|
||||||
|
if !line.trim().eq_ignore_ascii_case("y") {
|
||||||
|
println!("aborted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos)
|
||||||
|
.context("import failed")?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
for item in &applied {
|
||||||
|
println!(" + {item}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.system {
|
||||||
|
println!();
|
||||||
|
println!("system files were NOT applied automatically. To restore them:");
|
||||||
|
println!(" {}/restore.sh", staging.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify daemon
|
||||||
|
try_daemon_reload(socket).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> {
|
||||||
|
use flate2::{write::GzEncoder, Compression};
|
||||||
|
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let file = std::fs::File::create(dest)
|
||||||
|
.with_context(|| format!("failed to create {}", dest.display()))?;
|
||||||
|
let encoder = GzEncoder::new(file, Compression::default());
|
||||||
|
let mut archive = tar::Builder::new(encoder);
|
||||||
|
|
||||||
|
let base_name = src_dir
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("bread-export");
|
||||||
|
|
||||||
|
// Walk the staging directory and append every file
|
||||||
|
append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?;
|
||||||
|
|
||||||
|
archive.finish()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_dir_recursive(
|
||||||
|
archive: &mut tar::Builder<flate2::write::GzEncoder<std::fs::File>>,
|
||||||
|
root: &Path,
|
||||||
|
current: &Path,
|
||||||
|
base_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||||
|
let tar_path = PathBuf::from(base_name).join(rel);
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
archive.append_dir(&tar_path, &path)?;
|
||||||
|
append_dir_recursive(archive, root, &path, base_name)?;
|
||||||
|
} else if path.is_file() {
|
||||||
|
archive.append_path_with_name(&path, &tar_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tarball(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
|
||||||
|
let file = std::fs::File::open(src)
|
||||||
|
.with_context(|| format!("failed to open {}", src.display()))?;
|
||||||
|
let decoder = GzDecoder::new(file);
|
||||||
|
let mut archive = tar::Archive::new(decoder);
|
||||||
|
archive.unpack(dest)
|
||||||
|
.with_context(|| format!("failed to extract {}", src.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If a directory contains exactly one subdirectory and nothing else, return it.
|
||||||
|
fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
|
||||||
|
let entries: Vec<_> = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.collect();
|
||||||
|
if entries.len() == 1 && entries[0].path().is_dir() {
|
||||||
|
Some(entries[0].path())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_sync_config(cfg_dir: &Path) -> Result<SyncConfig> {
|
fn load_sync_config(cfg_dir: &Path) -> Result<SyncConfig> {
|
||||||
match SyncConfig::load(cfg_dir) {
|
match SyncConfig::load(cfg_dir) {
|
||||||
Ok(c) => Ok(c),
|
Ok(c) => Ok(c),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ pub enum AdapterSource {
|
||||||
/// Internal events synthesized by the daemon itself
|
/// Internal events synthesized by the daemon itself
|
||||||
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
|
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
|
||||||
System,
|
System,
|
||||||
|
/// BlueZ Bluetooth stack via D-Bus.
|
||||||
|
Bluetooth,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unnormalized event as emitted by an adapter.
|
/// An unnormalized event as emitted by an adapter.
|
||||||
|
|
@ -114,6 +116,10 @@ mod tests {
|
||||||
serde_json::to_string(&AdapterSource::System).unwrap(),
|
serde_json::to_string(&AdapterSource::System).unwrap(),
|
||||||
"\"system\""
|
"\"system\""
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
|
||||||
|
"\"bluetooth\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -124,6 +130,7 @@ mod tests {
|
||||||
AdapterSource::Power,
|
AdapterSource::Power,
|
||||||
AdapterSource::Network,
|
AdapterSource::Network,
|
||||||
AdapterSource::System,
|
AdapterSource::System,
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
] {
|
] {
|
||||||
let s = serde_json::to_string(&source).unwrap();
|
let s = serde_json::to_string(&source).unwrap();
|
||||||
let back: AdapterSource = serde_json::from_str(&s).unwrap();
|
let back: AdapterSource = serde_json::from_str(&s).unwrap();
|
||||||
|
|
@ -205,7 +212,8 @@ mod tests {
|
||||||
set.insert(AdapterSource::Hyprland);
|
set.insert(AdapterSource::Hyprland);
|
||||||
set.insert(AdapterSource::Hyprland);
|
set.insert(AdapterSource::Hyprland);
|
||||||
set.insert(AdapterSource::Udev);
|
set.insert(AdapterSource::Udev);
|
||||||
assert_eq!(set.len(), 2);
|
set.insert(AdapterSource::Bluetooth);
|
||||||
|
assert_eq!(set.len(), 3);
|
||||||
assert!(set.contains(&AdapterSource::Hyprland));
|
assert!(set.contains(&AdapterSource::Hyprland));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ impl Default for PackagesConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
managers: vec![
|
managers: vec![
|
||||||
"pacman".to_string(),
|
"pacman".to_string(),
|
||||||
|
"aur".to_string(),
|
||||||
"pip".to_string(),
|
"pip".to_string(),
|
||||||
"npm".to_string(),
|
"npm".to_string(),
|
||||||
"cargo".to_string(),
|
"cargo".to_string(),
|
||||||
|
|
@ -194,6 +195,7 @@ mod tests {
|
||||||
let cfg = PackagesConfig::default();
|
let cfg = PackagesConfig::default();
|
||||||
assert!(cfg.enabled);
|
assert!(cfg.enabled);
|
||||||
assert!(cfg.managers.contains(&"pacman".to_string()));
|
assert!(cfg.managers.contains(&"pacman".to_string()));
|
||||||
|
assert!(cfg.managers.contains(&"aur".to_string()));
|
||||||
assert!(cfg.managers.contains(&"pip".to_string()));
|
assert!(cfg.managers.contains(&"pip".to_string()));
|
||||||
assert!(cfg.managers.contains(&"npm".to_string()));
|
assert!(cfg.managers.contains(&"npm".to_string()));
|
||||||
assert!(cfg.managers.contains(&"cargo".to_string()));
|
assert!(cfg.managers.contains(&"cargo".to_string()));
|
||||||
|
|
|
||||||
850
bread-sync/src/export.rs
Normal file
850
bread-sync/src/export.rs
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use git2::Repository;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::{expand_path, SyncConfig};
|
||||||
|
use crate::delegates::sync_dir;
|
||||||
|
use crate::machine::{hostname, MachineProfile};
|
||||||
|
use crate::packages;
|
||||||
|
|
||||||
|
/// Maps a staged path back to the original absolute path on the source machine.
|
||||||
|
/// Drives the import — no hardcoded paths needed.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PathRecord {
|
||||||
|
/// Relative path within the export (e.g. "configs/hypr").
|
||||||
|
pub staging: String,
|
||||||
|
/// Original path with `~` (e.g. "~/.config/hypr").
|
||||||
|
pub original: String,
|
||||||
|
/// Whether this is a single file (false = directory).
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_file: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A git repository found on the machine, keyed by its remote URL.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GitRepoRecord {
|
||||||
|
/// Path relative to $HOME (e.g. "Projects/bread").
|
||||||
|
pub path: String,
|
||||||
|
/// Remote URL (e.g. "https://github.com/Breadway/bread.git").
|
||||||
|
pub remote: String,
|
||||||
|
/// Branch that was checked out at export time.
|
||||||
|
pub branch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest stored in the export root as `manifest.toml`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExportManifest {
|
||||||
|
pub version: u32,
|
||||||
|
pub machine: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub exported_at: String,
|
||||||
|
/// Explicit staging→original path map for all captured items.
|
||||||
|
#[serde(default)]
|
||||||
|
pub path_map: Vec<PathRecord>,
|
||||||
|
/// High-level list of config dir names (for display).
|
||||||
|
pub configs: Vec<String>,
|
||||||
|
/// Git repos found on the source machine.
|
||||||
|
#[serde(default)]
|
||||||
|
pub repos: Vec<GitRepoRecord>,
|
||||||
|
pub system: bool,
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
// Legacy fields kept for forward compat (ignored on import)
|
||||||
|
#[serde(default)]
|
||||||
|
pub bread: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dotfiles: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_bin: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub systemd_units: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config directories always included in the export (if they exist on disk).
|
||||||
|
static BUILTIN_CONFIGS: &[(&str, &str)] = &[
|
||||||
|
("hypr", "~/.config/hypr"),
|
||||||
|
("fish", "~/.config/fish"),
|
||||||
|
("kitty", "~/.config/kitty"),
|
||||||
|
("nvim", "~/.config/nvim"),
|
||||||
|
("ags", "~/.config/ags"),
|
||||||
|
("wofi", "~/.config/wofi"),
|
||||||
|
("waybar", "~/.config/waybar"),
|
||||||
|
("dunst", "~/.config/dunst"),
|
||||||
|
("mako", "~/.config/mako"),
|
||||||
|
("hyprlock", "~/.config/hyprlock"),
|
||||||
|
("hyprpaper", "~/.config/hyprpaper"),
|
||||||
|
("swaylock", "~/.config/swaylock"),
|
||||||
|
("wlogout", "~/.config/wlogout"),
|
||||||
|
("swappy", "~/.config/swappy"),
|
||||||
|
("btop", "~/.config/btop"),
|
||||||
|
("waypaper", "~/.config/waypaper"),
|
||||||
|
("wal", "~/.config/wal"),
|
||||||
|
("gtk-3.0", "~/.config/gtk-3.0"),
|
||||||
|
("gtk-4.0", "~/.config/gtk-4.0"),
|
||||||
|
("keyd", "~/.config/keyd"),
|
||||||
|
("autostart", "~/.config/autostart"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Standalone dotfiles captured as individual files: (staging-name, source-path).
|
||||||
|
static BUILTIN_DOTFILES: &[(&str, &str)] = &[
|
||||||
|
(".gitconfig", "~/.gitconfig"),
|
||||||
|
("user-dirs.dirs", "~/.config/user-dirs.dirs"),
|
||||||
|
("mimeapps.list", "~/.config/mimeapps.list"),
|
||||||
|
("ssh_config", "~/.ssh/config"),
|
||||||
|
(".zshrc", "~/.zshrc"),
|
||||||
|
(".zprofile", "~/.zprofile"),
|
||||||
|
(".zshenv", "~/.zshenv"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// System-level directories. World-readable ones are copied directly;
|
||||||
|
/// root-only ones (networkmanager, bluetooth) require running with sudo.
|
||||||
|
static SYSTEM_PATHS: &[(&str, &str)] = &[
|
||||||
|
("udev", "/etc/udev/rules.d"),
|
||||||
|
("modprobe", "/etc/modprobe.d"),
|
||||||
|
("sysctl", "/etc/sysctl.d"),
|
||||||
|
("networkmanager", "/etc/NetworkManager/system-connections"),
|
||||||
|
("bluetooth", "/var/lib/bluetooth"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Directories excluded from every recursive copy.
|
||||||
|
static DEFAULT_EXCLUDES: &[&str] = &[
|
||||||
|
"**/.git",
|
||||||
|
"**/*.cache",
|
||||||
|
"**/node_modules",
|
||||||
|
"**/@girs",
|
||||||
|
"**/__pycache__",
|
||||||
|
"fish_variables?*",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Directories skipped when searching for git repos.
|
||||||
|
static GIT_SKIP_DIRS: &[&str] = &[
|
||||||
|
".local", "Nextcloud", "target", "node_modules", "__pycache__",
|
||||||
|
".cache", "snap", "flatpak", "@girs", "Steam",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── stage_export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a self-contained snapshot directory at `staging`.
|
||||||
|
pub fn stage_export(
|
||||||
|
cfg_dir: &Path,
|
||||||
|
config: &SyncConfig,
|
||||||
|
staging: &Path,
|
||||||
|
) -> Result<ExportManifest> {
|
||||||
|
fs::create_dir_all(staging)?;
|
||||||
|
|
||||||
|
let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
|
||||||
|
let mut path_map: Vec<PathRecord> = Vec::new();
|
||||||
|
let mut included_configs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Helper: tilde-ify an absolute path for storage in the manifest.
|
||||||
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
|
||||||
|
let tilde = |p: &Path| -> String {
|
||||||
|
p.strip_prefix(&home)
|
||||||
|
.map(|rel| format!("~/{}", rel.display()))
|
||||||
|
.unwrap_or_else(|_| p.display().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Bread config → bread/
|
||||||
|
let bread_dest = staging.join("bread");
|
||||||
|
sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: "bread".to_string(),
|
||||||
|
original: tilde(cfg_dir),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Built-in + delegate configs → configs/<name>/
|
||||||
|
let configs_dir = staging.join("configs");
|
||||||
|
|
||||||
|
for (name, raw_path) in BUILTIN_CONFIGS {
|
||||||
|
let src = expand_path(raw_path);
|
||||||
|
if src.exists() {
|
||||||
|
let dst = configs_dir.join(name);
|
||||||
|
sync_dir(&src, &dst, &excludes)
|
||||||
|
.with_context(|| format!("failed to snapshot {raw_path}"))?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: format!("configs/{name}"),
|
||||||
|
original: raw_path.to_string(),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
included_configs.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include);
|
||||||
|
for (basename, src_path) in &delegate_paths {
|
||||||
|
if src_path.exists() && !included_configs.contains(basename) {
|
||||||
|
let dst = configs_dir.join(basename);
|
||||||
|
sync_dir(src_path, &dst, &config.delegates.exclude)
|
||||||
|
.with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: format!("configs/{basename}"),
|
||||||
|
original: tilde(src_path),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
included_configs.push(basename.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dotfiles → dotfiles/
|
||||||
|
let dotfiles_dir = staging.join("dotfiles");
|
||||||
|
fs::create_dir_all(&dotfiles_dir)?;
|
||||||
|
|
||||||
|
for (dest_name, raw_path) in BUILTIN_DOTFILES {
|
||||||
|
let src = expand_path(raw_path);
|
||||||
|
if src.exists() {
|
||||||
|
fs::copy(&src, dotfiles_dir.join(dest_name))
|
||||||
|
.with_context(|| format!("failed to copy {raw_path}"))?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: format!("dotfiles/{dest_name}"),
|
||||||
|
original: raw_path.to_string(),
|
||||||
|
is_file: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ~/.local/bin custom scripts → local-bin/
|
||||||
|
// Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts).
|
||||||
|
let local_bin_src = expand_path("~/.local/bin");
|
||||||
|
let local_bin_dst = staging.join("local-bin");
|
||||||
|
if local_bin_src.exists() {
|
||||||
|
fs::create_dir_all(&local_bin_dst)?;
|
||||||
|
let mut any = false;
|
||||||
|
for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? {
|
||||||
|
let entry = entry?;
|
||||||
|
let meta = entry.metadata()?;
|
||||||
|
if meta.file_type().is_symlink() || meta.len() > 512 * 1024 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() {
|
||||||
|
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
fs::copy(&path, local_bin_dst.join(&name))?;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any {
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: "local-bin".to_string(),
|
||||||
|
original: "~/.local/bin".to_string(),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. ~/.local/share/fonts → local-fonts/
|
||||||
|
let fonts_src = expand_path("~/.local/share/fonts");
|
||||||
|
let fonts_dst = staging.join("local-fonts");
|
||||||
|
if fonts_src.exists() {
|
||||||
|
sync_dir(&fonts_src, &fonts_dst, &excludes)
|
||||||
|
.context("failed to snapshot fonts")?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: "local-fonts".to_string(),
|
||||||
|
original: "~/.local/share/fonts".to_string(),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. ~/.config/systemd/user → systemd/
|
||||||
|
let systemd_src = expand_path("~/.config/systemd/user");
|
||||||
|
let systemd_dst = staging.join("systemd");
|
||||||
|
if systemd_src.exists() {
|
||||||
|
sync_dir(&systemd_src, &systemd_dst, &excludes)
|
||||||
|
.context("failed to snapshot systemd user units")?;
|
||||||
|
path_map.push(PathRecord {
|
||||||
|
staging: "systemd".to_string(),
|
||||||
|
original: "~/.config/systemd/user".to_string(),
|
||||||
|
is_file: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. System configs → system/ (read-only; restore needs sudo)
|
||||||
|
let system_dst = staging.join("system");
|
||||||
|
let mut has_system = false;
|
||||||
|
for (name, raw_path) in SYSTEM_PATHS {
|
||||||
|
let src = PathBuf::from(raw_path);
|
||||||
|
if !src.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match sync_dir(&src, &system_dst.join(name), &excludes) {
|
||||||
|
Ok(_) => has_system = true,
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("Permission denied") || msg.contains("permission denied") {
|
||||||
|
eprintln!(
|
||||||
|
"bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("bread: warning: failed to snapshot {raw_path}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Package snapshots → packages/
|
||||||
|
let packages_dir = staging.join("packages");
|
||||||
|
let mut included_managers: Vec<String> = Vec::new();
|
||||||
|
if config.packages.enabled {
|
||||||
|
for manager in &config.packages.managers {
|
||||||
|
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
||||||
|
match packages::snapshot(manager, &dest_file) {
|
||||||
|
Ok(true) => included_managers.push(manager.clone()),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"bread: warning: package snapshot for {manager} failed: {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Machine profile → machines/
|
||||||
|
let machines_dir = staging.join("machines");
|
||||||
|
MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
|
||||||
|
.write(&machines_dir)?;
|
||||||
|
|
||||||
|
// 11. Git repositories — find all repos with a remote, commit+push each
|
||||||
|
let nc_dirs = nextcloud_sync_dirs(&home);
|
||||||
|
if !nc_dirs.is_empty() {
|
||||||
|
let labels: Vec<_> = nc_dirs.iter()
|
||||||
|
.map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string()))
|
||||||
|
.collect();
|
||||||
|
eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", "));
|
||||||
|
}
|
||||||
|
let repos = find_git_repos(&home);
|
||||||
|
commit_and_push_repos(&repos, &home);
|
||||||
|
|
||||||
|
// 12. Manifest
|
||||||
|
let manifest = ExportManifest {
|
||||||
|
version: 2,
|
||||||
|
machine: config.machine.name.clone(),
|
||||||
|
hostname: hostname(),
|
||||||
|
exported_at: Utc::now().to_rfc3339(),
|
||||||
|
path_map,
|
||||||
|
configs: included_configs,
|
||||||
|
repos,
|
||||||
|
system: has_system,
|
||||||
|
packages: included_managers,
|
||||||
|
bread: true,
|
||||||
|
dotfiles: vec![],
|
||||||
|
local_bin: vec![],
|
||||||
|
systemd_units: vec![],
|
||||||
|
};
|
||||||
|
fs::write(
|
||||||
|
staging.join("manifest.toml"),
|
||||||
|
toml::to_string_pretty(&manifest).context("failed to serialize manifest")?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 11. restore.sh
|
||||||
|
let restore_path = staging.join("restore.sh");
|
||||||
|
fs::write(&restore_path, generate_restore_sh(&manifest))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(&restore_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&restore_path, perms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── apply_import ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Apply a staged snapshot directory to this machine.
|
||||||
|
/// Returns a list of human-readable descriptions of what was applied.
|
||||||
|
pub fn apply_import(
|
||||||
|
staging: &Path,
|
||||||
|
cfg_dir: &Path,
|
||||||
|
install_packages: bool,
|
||||||
|
clone_repos: bool,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let mut applied: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Read manifest to get the path map
|
||||||
|
let manifest_path = staging.join("manifest.toml");
|
||||||
|
let path_map: Vec<PathRecord> = if manifest_path.exists() {
|
||||||
|
let raw = fs::read_to_string(&manifest_path)?;
|
||||||
|
toml::from_str::<ExportManifest>(&raw)
|
||||||
|
.map(|m| m.path_map)
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path_map.is_empty() {
|
||||||
|
// Manifest-driven restore: use path_map for exact original locations
|
||||||
|
for record in &path_map {
|
||||||
|
let src = staging.join(&record.staging);
|
||||||
|
if !src.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dst = expand_path(&record.original);
|
||||||
|
|
||||||
|
if record.is_file {
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
// Secure directory permissions for SSH
|
||||||
|
if record.staging.contains("ssh_config") {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Some(p) = dst.parent() {
|
||||||
|
if let Ok(m) = fs::metadata(p) {
|
||||||
|
let mut perms = m.permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
let _ = fs::set_permissions(p, perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::copy(&src, &dst)
|
||||||
|
.with_context(|| format!("failed to restore {}", record.original))?;
|
||||||
|
applied.push(record.original.clone());
|
||||||
|
} else {
|
||||||
|
sync_dir(&src, &dst, &[])
|
||||||
|
.with_context(|| format!("failed to restore {}", record.original))?;
|
||||||
|
applied.push(record.original.clone());
|
||||||
|
|
||||||
|
// Reload systemd if this was the systemd dir
|
||||||
|
if record.staging == "systemd" {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "daemon-reload"])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild font cache after restoring fonts
|
||||||
|
if record.staging == "local-fonts" {
|
||||||
|
let _ = std::process::Command::new("fc-cache").arg("-f").status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make local-bin scripts executable
|
||||||
|
if record.staging == "local-bin" {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Ok(entries) = fs::read_dir(&dst) {
|
||||||
|
for entry in entries.filter_map(|e| e.ok()) {
|
||||||
|
if entry.path().is_file() {
|
||||||
|
if let Ok(m) = fs::metadata(entry.path()) {
|
||||||
|
let mut perms = m.permissions();
|
||||||
|
perms.set_mode(perms.mode() | 0o111);
|
||||||
|
let _ = fs::set_permissions(entry.path(), perms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy fallback for v1 exports without path_map
|
||||||
|
let bread_src = staging.join("bread");
|
||||||
|
if bread_src.exists() {
|
||||||
|
sync_dir(&bread_src, cfg_dir, &[])?;
|
||||||
|
applied.push("~/.config/bread".to_string());
|
||||||
|
}
|
||||||
|
let configs_dir = staging.join("configs");
|
||||||
|
if configs_dir.exists() {
|
||||||
|
let config_home = expand_path("~/.config");
|
||||||
|
for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) {
|
||||||
|
let src = entry.path();
|
||||||
|
if src.is_dir() {
|
||||||
|
let name = src.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
sync_dir(&src, &config_home.join(&name), &[])?;
|
||||||
|
applied.push(format!("~/.config/{name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package installs
|
||||||
|
if install_packages {
|
||||||
|
let packages_dir = staging.join("packages");
|
||||||
|
if packages_dir.exists() {
|
||||||
|
install_packages_from(&packages_dir)?;
|
||||||
|
applied.push("packages installed".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone git repos
|
||||||
|
if clone_repos {
|
||||||
|
let manifest_path = staging.join("manifest.toml");
|
||||||
|
if manifest_path.exists() {
|
||||||
|
let raw = fs::read_to_string(&manifest_path)?;
|
||||||
|
if let Ok(manifest) = toml::from_str::<ExportManifest>(&raw) {
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()));
|
||||||
|
for repo in &manifest.repos {
|
||||||
|
let dest = home.join(&repo.path);
|
||||||
|
if dest.exists() {
|
||||||
|
applied.push(format!("skip (exists): ~/{}", repo.path));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
eprint!(" cloning ~/{} ... ", repo.path);
|
||||||
|
let status = std::process::Command::new("git")
|
||||||
|
.args(["clone", "--branch", &repo.branch, &repo.remote])
|
||||||
|
.arg(&dest)
|
||||||
|
.status();
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
eprintln!("done");
|
||||||
|
applied.push(format!("cloned ~/{}", repo.path));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("failed");
|
||||||
|
applied.push(format!("clone failed: ~/{}", repo.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── commit_and_push_repos ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) {
|
||||||
|
if repos.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!("bread: committing and pushing {} repo(s)...", repos.len());
|
||||||
|
for repo in repos {
|
||||||
|
let dir = home.join(&repo.path);
|
||||||
|
let dir_str = dir.to_string_lossy();
|
||||||
|
|
||||||
|
// Stage all changes
|
||||||
|
let add = std::process::Command::new("git")
|
||||||
|
.args(["-C", &dir_str, "add", "-A"])
|
||||||
|
.output();
|
||||||
|
if add.map(|o| !o.status.success()).unwrap_or(true) {
|
||||||
|
eprintln!(" ~/{}: git add failed, skipping", repo.path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's anything staged
|
||||||
|
let has_changes = std::process::Command::new("git")
|
||||||
|
.args(["-C", &dir_str, "diff", "--cached", "--quiet"])
|
||||||
|
.status()
|
||||||
|
.map(|s| !s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_changes {
|
||||||
|
let commit = std::process::Command::new("git")
|
||||||
|
.args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"])
|
||||||
|
.output();
|
||||||
|
match commit {
|
||||||
|
Ok(o) if o.status.success() => {}
|
||||||
|
Ok(o) => {
|
||||||
|
eprintln!(
|
||||||
|
" ~/{}: commit failed: {}",
|
||||||
|
repo.path,
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ~/{}: commit failed: {}", repo.path, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push
|
||||||
|
eprint!(" ~/{}: pushing... ", repo.path);
|
||||||
|
let push = std::process::Command::new("git")
|
||||||
|
.args(["-C", &dir_str, "push"])
|
||||||
|
.output();
|
||||||
|
match push {
|
||||||
|
Ok(o) if o.status.success() => eprintln!("ok"),
|
||||||
|
Ok(o) => eprintln!(
|
||||||
|
"failed: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(e) => eprintln!("failed: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── find_git_repos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots.
|
||||||
|
/// Always includes ~/Nextcloud if it exists, even without a config file.
|
||||||
|
fn nextcloud_sync_dirs(home: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
let cfg = home.join(".config/Nextcloud/nextcloud.cfg");
|
||||||
|
if let Ok(content) = fs::read_to_string(&cfg) {
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some(raw) = line.trim().strip_prefix("localPath=") {
|
||||||
|
let p = PathBuf::from(raw);
|
||||||
|
let p = if p.is_absolute() { p } else { home.join(p) };
|
||||||
|
if !dirs.contains(&p) {
|
||||||
|
dirs.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always treat ~/Nextcloud as off-limits if it exists
|
||||||
|
let default_nc = home.join("Nextcloud");
|
||||||
|
if default_nc.exists() && !dirs.contains(&default_nc) {
|
||||||
|
dirs.push(default_nc);
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_git_repos(home: &Path) -> Vec<GitRepoRecord> {
|
||||||
|
let nc_dirs = nextcloud_sync_dirs(home);
|
||||||
|
let mut repos: Vec<GitRepoRecord> = Vec::new();
|
||||||
|
|
||||||
|
// Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell)
|
||||||
|
walk_repos(home, home, 0, 1, &mut repos, &nc_dirs);
|
||||||
|
|
||||||
|
// Deeper search in common project directories
|
||||||
|
for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] {
|
||||||
|
let p = home.join(subdir);
|
||||||
|
if p.exists() {
|
||||||
|
walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf)
|
||||||
|
let config_dir = home.join(".config");
|
||||||
|
if config_dir.exists() {
|
||||||
|
walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by path, sort for determinism
|
||||||
|
repos.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
repos.dedup_by(|a, b| a.path == b.path);
|
||||||
|
repos
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec<GitRepoRecord>, nc_dirs: &[PathBuf]) {
|
||||||
|
// Skip anything inside a Nextcloud sync root
|
||||||
|
if nc_dirs.iter().any(|nc| dir.starts_with(nc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir.join(".git").exists() {
|
||||||
|
if let Ok(repo) = Repository::open(dir) {
|
||||||
|
let remote_url = repo
|
||||||
|
.find_remote("origin")
|
||||||
|
.ok()
|
||||||
|
.and_then(|r| r.url().map(str::to_string));
|
||||||
|
|
||||||
|
if let Some(remote) = remote_url {
|
||||||
|
let branch = repo
|
||||||
|
.head()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.shorthand().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "main".to_string());
|
||||||
|
|
||||||
|
let rel = dir
|
||||||
|
.strip_prefix(home)
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|_| dir.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
repos.push(GitRepoRecord { path: rel, remote, branch });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return; // don't recurse into git repos (skip submodules)
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth >= max_depth {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(dir) {
|
||||||
|
let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
if GIT_SKIP_DIRS.contains(&name.as_ref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── package install ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn install_packages_from(packages_dir: &Path) -> Result<()> {
|
||||||
|
let pacman_file = packages_dir.join("pacman.txt");
|
||||||
|
if pacman_file.exists() {
|
||||||
|
let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?);
|
||||||
|
if !pkgs.is_empty() {
|
||||||
|
eprintln!("bread: installing {} pacman packages...", pkgs.len());
|
||||||
|
let _ = std::process::Command::new("sudo")
|
||||||
|
.args(["pacman", "-S", "--needed"])
|
||||||
|
.args(&pkgs)
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cargo_file = packages_dir.join("cargo.txt");
|
||||||
|
if cargo_file.exists() {
|
||||||
|
for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) {
|
||||||
|
let _ = std::process::Command::new("cargo").args(["install", &pkg]).status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pip_file = packages_dir.join("pip.txt");
|
||||||
|
if pip_file.exists() {
|
||||||
|
let _ = std::process::Command::new("pip")
|
||||||
|
.args(["install", "--user", "-r"])
|
||||||
|
.arg(&pip_file)
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
let npm_file = packages_dir.join("npm.txt");
|
||||||
|
if npm_file.exists() {
|
||||||
|
for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) {
|
||||||
|
let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── restore.sh ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn generate_restore_sh(manifest: &ExportManifest) -> String {
|
||||||
|
let ts = &manifest.exported_at[..16];
|
||||||
|
let mut s = String::new();
|
||||||
|
|
||||||
|
s.push_str("#!/bin/bash\n");
|
||||||
|
s.push_str("set -e\n");
|
||||||
|
s.push_str("cd \"$(dirname \"$0\")\"\n");
|
||||||
|
s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n");
|
||||||
|
s.push_str(&format!(
|
||||||
|
"echo \"Restoring bread snapshot for {} ({})\"\n\n",
|
||||||
|
manifest.machine, ts
|
||||||
|
));
|
||||||
|
|
||||||
|
// Config dirs and dotfiles from path_map
|
||||||
|
let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect();
|
||||||
|
let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect();
|
||||||
|
|
||||||
|
if !dirs.is_empty() {
|
||||||
|
s.push_str("# configs and directories\n");
|
||||||
|
for r in &dirs {
|
||||||
|
let dst = &r.original;
|
||||||
|
let src = &r.staging;
|
||||||
|
s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n"));
|
||||||
|
s.push_str(&format!(" mkdir -p \"{dst}\"\n"));
|
||||||
|
s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n"));
|
||||||
|
if r.staging == "systemd" {
|
||||||
|
s.push_str(" systemctl --user daemon-reload\n");
|
||||||
|
}
|
||||||
|
if r.staging == "local-bin" {
|
||||||
|
s.push_str(" chmod +x \"${dst}\"/*\n");
|
||||||
|
}
|
||||||
|
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
|
||||||
|
s.push_str("fi\n");
|
||||||
|
}
|
||||||
|
s.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if !files.is_empty() {
|
||||||
|
s.push_str("# dotfiles\n");
|
||||||
|
for r in &files {
|
||||||
|
let dst = &r.original;
|
||||||
|
let src = &r.staging;
|
||||||
|
s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n"));
|
||||||
|
if r.staging.contains("ssh_config") {
|
||||||
|
s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n");
|
||||||
|
}
|
||||||
|
// Expand ~ in destination for shell
|
||||||
|
let dst_shell = dst.replace('~', "$HOME");
|
||||||
|
s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n"));
|
||||||
|
s.push_str(&format!(" echo \"[OK] {dst}\"\n"));
|
||||||
|
s.push_str("fi\n");
|
||||||
|
}
|
||||||
|
s.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages
|
||||||
|
if !manifest.packages.is_empty() {
|
||||||
|
s.push_str("echo \"\"\n");
|
||||||
|
s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n");
|
||||||
|
if manifest.packages.contains(&"pacman".to_string()) {
|
||||||
|
s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n");
|
||||||
|
}
|
||||||
|
if manifest.packages.contains(&"cargo".to_string()) {
|
||||||
|
s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n");
|
||||||
|
}
|
||||||
|
if manifest.packages.contains(&"pip".to_string()) {
|
||||||
|
s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n");
|
||||||
|
}
|
||||||
|
if manifest.packages.contains(&"npm".to_string()) {
|
||||||
|
s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n");
|
||||||
|
}
|
||||||
|
s.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// System files
|
||||||
|
if manifest.system {
|
||||||
|
s.push_str("echo \"\"\n");
|
||||||
|
s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n");
|
||||||
|
s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n");
|
||||||
|
s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n");
|
||||||
|
s.push_str("fi\n");
|
||||||
|
s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n");
|
||||||
|
s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n");
|
||||||
|
s.push_str("fi\n");
|
||||||
|
s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n");
|
||||||
|
s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n");
|
||||||
|
s.push_str("fi\n");
|
||||||
|
s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n");
|
||||||
|
s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n");
|
||||||
|
s.push_str("fi\n");
|
||||||
|
s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n");
|
||||||
|
s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n");
|
||||||
|
s.push_str("fi\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git repos
|
||||||
|
if !manifest.repos.is_empty() {
|
||||||
|
s.push_str("echo \"\"\n");
|
||||||
|
s.push_str("echo \"--- Git repositories ---\"\n");
|
||||||
|
for repo in &manifest.repos {
|
||||||
|
let dest = format!("$HOME/{}", repo.path);
|
||||||
|
let branch = &repo.branch;
|
||||||
|
let remote = &repo.remote;
|
||||||
|
// Create parent dir and clone; skip if already present
|
||||||
|
let parent = std::path::Path::new(&repo.path)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !parent.is_empty() {
|
||||||
|
s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n"));
|
||||||
|
}
|
||||||
|
s.push_str(&format!(
|
||||||
|
"if [ ! -d \"{dest}/.git\" ]; then\n"
|
||||||
|
));
|
||||||
|
s.push_str(&format!(
|
||||||
|
" git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n",
|
||||||
|
repo.path
|
||||||
|
));
|
||||||
|
s.push_str(&format!(
|
||||||
|
"else\n echo \"[skip] ~/{} (already exists)\"\nfi\n",
|
||||||
|
repo.path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
/// Bread sync: snapshot and restore system state via a Git remote.
|
/// Bread sync: snapshot and restore system state via a Git remote.
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod delegates;
|
pub mod delegates;
|
||||||
|
pub mod export;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod machine;
|
pub mod machine;
|
||||||
pub mod packages;
|
pub mod packages;
|
||||||
|
|
||||||
pub use config::SyncConfig;
|
pub use config::SyncConfig;
|
||||||
|
pub use export::{apply_import, stage_export, ExportManifest};
|
||||||
pub use git::SyncRepo;
|
pub use git::SyncRepo;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use std::process::Command;
|
||||||
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
|
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
|
||||||
let content = match manager {
|
let content = match manager {
|
||||||
"pacman" => run_pacman()?,
|
"pacman" => run_pacman()?,
|
||||||
|
"aur" => run_aur()?,
|
||||||
"pip" => run_pip()?,
|
"pip" => run_pip()?,
|
||||||
"npm" => run_npm()?,
|
"npm" => run_npm()?,
|
||||||
"cargo" => run_cargo()?,
|
"cargo" => run_cargo()?,
|
||||||
|
|
@ -87,6 +88,17 @@ pub fn parse_cargo(content: &str) -> Vec<String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_aur() -> Result<Option<String>> {
|
||||||
|
match Command::new("pacman").arg("-Qm").output() {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_pacman() -> Result<Option<String>> {
|
fn run_pacman() -> Result<Option<String>> {
|
||||||
match Command::new("pacman").arg("-Qe").output() {
|
match Command::new("pacman").arg("-Qe").output() {
|
||||||
Ok(out) if out.status.success() => {
|
Ok(out) if out.status.success() => {
|
||||||
|
|
|
||||||
255
breadd/src/adapters/bluetooth.rs
Normal file
255
breadd/src/adapters/bluetooth.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||||
|
use zbus::{Message, MessageStream};
|
||||||
|
|
||||||
|
use super::Adapter;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BluetoothAdapter;
|
||||||
|
|
||||||
|
impl BluetoothAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit `bluetooth.enumerate` events for every device that is currently connected.
|
||||||
|
/// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure.
|
||||||
|
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
|
||||||
|
match try_enumerate(tx).await {
|
||||||
|
Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"),
|
||||||
|
Err(e) => debug!("bluetooth enumeration skipped: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Adapter for BluetoothAdapter {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"bluetooth"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
|
info!("bluetooth adapter starting");
|
||||||
|
|
||||||
|
let conn = zbus::Connection::system()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?;
|
||||||
|
|
||||||
|
let mut stream = MessageStream::from(&conn);
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(message) => {
|
||||||
|
if let Some(event) = parse_bluetooth_message(&message) {
|
||||||
|
if tx.send(event).await.is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => debug!("bluetooth stream error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_enumerate(tx: &mpsc::Sender<RawEvent>) -> Result<usize> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let msg = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
"/",
|
||||||
|
Some("org.freedesktop.DBus.ObjectManager"),
|
||||||
|
"GetManagedObjects",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let objects: HashMap<OwnedObjectPath, HashMap<String, HashMap<String, OwnedValue>>> =
|
||||||
|
msg.body()?;
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for (path, interfaces) in objects {
|
||||||
|
let Some(props) = interfaces.get("org.bluez.Device1") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({}));
|
||||||
|
if !props_json
|
||||||
|
.get("Connected")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = props_json
|
||||||
|
.get("Name")
|
||||||
|
.or_else(|| props_json.get("Alias"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let address = props_json
|
||||||
|
.get("Address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let _ = tx
|
||||||
|
.send(RawEvent {
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
kind: "bluetooth.enumerate".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"path": path.as_str(),
|
||||||
|
"address": address,
|
||||||
|
"name": name,
|
||||||
|
"properties": props_json,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bluetooth_message(message: &Message) -> Option<RawEvent> {
|
||||||
|
let header = message.header().ok()?;
|
||||||
|
let interface = header.interface().ok()??.as_str().to_string();
|
||||||
|
let member = header.member().ok()??.as_str().to_string();
|
||||||
|
let path = header
|
||||||
|
.path()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|p| p.as_str().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Connected / disconnected — PropertiesChanged on a BlueZ device object
|
||||||
|
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
|
||||||
|
if !path.starts_with("/org/bluez/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (iface, changed, _): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||||
|
message.body().ok()?;
|
||||||
|
if iface != "org.bluez.Device1" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let changed_json = serde_json::to_value(&changed).ok()?;
|
||||||
|
let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?;
|
||||||
|
let address = address_from_path(&path);
|
||||||
|
let kind = if connected {
|
||||||
|
"bluetooth.device.connected"
|
||||||
|
} else {
|
||||||
|
"bluetooth.device.disconnected"
|
||||||
|
};
|
||||||
|
return Some(RawEvent {
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"path": path,
|
||||||
|
"address": address,
|
||||||
|
"properties": changed_json,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device paired / discovered — InterfacesAdded from BlueZ ObjectManager
|
||||||
|
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" {
|
||||||
|
let (obj_path, interfaces): (
|
||||||
|
OwnedObjectPath,
|
||||||
|
HashMap<String, HashMap<String, OwnedValue>>,
|
||||||
|
) = message.body().ok()?;
|
||||||
|
let obj_str = obj_path.as_str();
|
||||||
|
if !obj_str.starts_with("/org/bluez/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let props = interfaces.get("org.bluez.Device1")?;
|
||||||
|
let props_json = serde_json::to_value(props).ok()?;
|
||||||
|
let name = props_json
|
||||||
|
.get("Name")
|
||||||
|
.or_else(|| props_json.get("Alias"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let address = props_json
|
||||||
|
.get("Address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| address_from_path(obj_str));
|
||||||
|
return Some(RawEvent {
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
kind: "bluetooth.device.added".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"path": obj_str,
|
||||||
|
"address": address,
|
||||||
|
"name": name,
|
||||||
|
"properties": props_json,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device unpaired — InterfacesRemoved from BlueZ ObjectManager
|
||||||
|
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" {
|
||||||
|
let (obj_path, interfaces): (OwnedObjectPath, Vec<String>) = message.body().ok()?;
|
||||||
|
let obj_str = obj_path.as_str();
|
||||||
|
if !obj_str.starts_with("/org/bluez/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !interfaces.iter().any(|i| i == "org.bluez.Device1") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let address = address_from_path(obj_str);
|
||||||
|
return Some(RawEvent {
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
kind: "bluetooth.device.removed".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"path": obj_str,
|
||||||
|
"address": address,
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"`
|
||||||
|
fn address_from_path(path: &str) -> String {
|
||||||
|
path.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.and_then(|s| s.strip_prefix("dev_"))
|
||||||
|
.map(|s| s.replace('_', ":"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_from_path_parses_standard_bluez_path() {
|
||||||
|
assert_eq!(
|
||||||
|
address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"),
|
||||||
|
"AA:BB:CC:DD:EE:FF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_from_path_returns_empty_for_adapter_path() {
|
||||||
|
assert_eq!(address_from_path("/org/bluez/hci0"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_from_path_returns_empty_for_root() {
|
||||||
|
assert_eq!(address_from_path("/"), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use tracing::info;
|
||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
use crate::core::supervisor::spawn_supervised;
|
use crate::core::supervisor::spawn_supervised;
|
||||||
|
|
||||||
|
pub mod bluetooth;
|
||||||
pub mod hyprland;
|
pub mod hyprland;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod network_rtnetlink;
|
pub mod network_rtnetlink;
|
||||||
|
|
@ -86,6 +87,12 @@ impl Manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.config.adapters.bluetooth.enabled {
|
||||||
|
let adapter = bluetooth::BluetoothAdapter::new();
|
||||||
|
adapter.enumerate_existing(&self.raw_tx).await;
|
||||||
|
self.spawn_adapter(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
if self.config.adapters.network.enabled {
|
if self.config.adapters.network.enabled {
|
||||||
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
|
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
|
||||||
let rt = network_rtnetlink::RtnetlinkAdapter::new();
|
let rt = network_rtnetlink::RtnetlinkAdapter::new();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ pub struct AdaptersConfig {
|
||||||
pub power: PowerConfig,
|
pub power: PowerConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network: AdapterToggle,
|
pub network: AdapterToggle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bluetooth: AdapterToggle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
@ -306,6 +308,7 @@ mod tests {
|
||||||
assert!(cfg.adapters.udev.enabled);
|
assert!(cfg.adapters.udev.enabled);
|
||||||
assert!(cfg.adapters.power.enabled);
|
assert!(cfg.adapters.power.enabled);
|
||||||
assert!(cfg.adapters.network.enabled);
|
assert!(cfg.adapters.network.enabled);
|
||||||
|
assert!(cfg.adapters.bluetooth.enabled);
|
||||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
|
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
|
||||||
assert_eq!(cfg.events.dedup_window_ms, 100);
|
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||||
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
|
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
|
||||||
|
|
@ -359,6 +362,9 @@ poll_interval_secs = 5
|
||||||
[adapters.network]
|
[adapters.network]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
|
[adapters.bluetooth]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
[events]
|
[events]
|
||||||
dedup_window_ms = 250
|
dedup_window_ms = 250
|
||||||
|
|
||||||
|
|
@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send"
|
||||||
assert!(!cfg.adapters.power.enabled);
|
assert!(!cfg.adapters.power.enabled);
|
||||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
|
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
|
||||||
assert!(!cfg.adapters.network.enabled);
|
assert!(!cfg.adapters.network.enabled);
|
||||||
|
assert!(!cfg.adapters.bluetooth.enabled);
|
||||||
assert_eq!(cfg.events.dedup_window_ms, 250);
|
assert_eq!(cfg.events.dedup_window_ms, 250);
|
||||||
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
||||||
assert_eq!(cfg.notifications.default_urgency, "critical");
|
assert_eq!(cfg.notifications.default_urgency, "critical");
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ impl EventNormalizer {
|
||||||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||||
AdapterSource::Power => self.normalize_power(raw),
|
AdapterSource::Power => self.normalize_power(raw),
|
||||||
AdapterSource::Network => self.normalize_network(raw),
|
AdapterSource::Network => self.normalize_network(raw),
|
||||||
|
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
|
||||||
AdapterSource::System => vec![BreadEvent {
|
AdapterSource::System => vec![BreadEvent {
|
||||||
event: raw.kind.clone(),
|
event: raw.kind.clone(),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -303,6 +304,83 @@ impl EventNormalizer {
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
let path = raw
|
||||||
|
.payload
|
||||||
|
.get("path")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let address = raw
|
||||||
|
.payload
|
||||||
|
.get("address")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let name = raw
|
||||||
|
.payload
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| {
|
||||||
|
raw.payload
|
||||||
|
.pointer("/properties/Name")
|
||||||
|
.or_else(|| raw.payload.pointer("/properties/Alias"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
})
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
match raw.kind.as_str() {
|
||||||
|
"bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent {
|
||||||
|
event: "bread.device.connected".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
data: json!({
|
||||||
|
"id": path,
|
||||||
|
"device": "unknown",
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"subsystem": "bluetooth",
|
||||||
|
"raw": raw.payload,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
"bluetooth.device.disconnected" => vec![BreadEvent {
|
||||||
|
event: "bread.device.disconnected".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
data: json!({
|
||||||
|
"id": path,
|
||||||
|
"device": "unknown",
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"subsystem": "bluetooth",
|
||||||
|
"raw": raw.payload,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
"bluetooth.device.added" => vec![BreadEvent {
|
||||||
|
event: "bread.bluetooth.device.paired".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
data: json!({
|
||||||
|
"id": path,
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"subsystem": "bluetooth",
|
||||||
|
"raw": raw.payload,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
"bluetooth.device.removed" => vec![BreadEvent {
|
||||||
|
event: "bread.bluetooth.device.unpaired".to_string(),
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
source: AdapterSource::Bluetooth,
|
||||||
|
data: json!({
|
||||||
|
"id": path,
|
||||||
|
"address": address,
|
||||||
|
"subsystem": "bluetooth",
|
||||||
|
"raw": raw.payload,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
let online = raw
|
let online = raw
|
||||||
.payload
|
.payload
|
||||||
|
|
@ -661,6 +739,123 @@ mod tests {
|
||||||
assert!(names.contains(&"bread.power.battery.critical"));
|
assert!(names.contains(&"bread.power.battery.critical"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bluetooth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_connected_emits_device_connected() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let ev = raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"properties": { "Connected": true },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.device.connected",
|
||||||
|
ev.payload.clone(),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.device.connected");
|
||||||
|
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||||
|
assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth");
|
||||||
|
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_disconnected_emits_device_disconnected() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.device.disconnected",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"properties": { "Connected": false },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.device.disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_enumerate_includes_name() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.enumerate",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "WH-1000XM4",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.device.connected");
|
||||||
|
assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_paired_emits_bluetooth_specific_event() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.device.added",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "My Headphones",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.bluetooth.device.paired");
|
||||||
|
assert_eq!(out[0].data.get("name").unwrap(), "My Headphones");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_unpaired_emits_bluetooth_specific_event() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.device.removed",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].event, "bread.bluetooth.device.unpaired");
|
||||||
|
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_name_falls_back_to_properties() {
|
||||||
|
let n = EventNormalizer::new(0);
|
||||||
|
let out = n.normalize(&raw(
|
||||||
|
AdapterSource::Bluetooth,
|
||||||
|
"bluetooth.device.connected",
|
||||||
|
json!({
|
||||||
|
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||||
|
"address": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"properties": { "Connected": true, "Name": "Fallback Name" },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Network ───────────────────────────────────────────────────────────
|
// ─── Network ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -934,6 +934,74 @@ impl LuaEngine {
|
||||||
|
|
||||||
bread.set("fs", fs_tbl)?;
|
bread.set("fs", fs_tbl)?;
|
||||||
|
|
||||||
|
// bread.bluetooth — BlueZ control
|
||||||
|
let bluetooth_tbl = self.lua.create_table()?;
|
||||||
|
|
||||||
|
let power_fn = self.lua.create_function(move |_lua, enabled: bool| {
|
||||||
|
bluetooth_spawn(move || async move {
|
||||||
|
if let Err(e) = bluetooth_set_powered(enabled).await {
|
||||||
|
tracing::warn!("bread.bluetooth.power failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("power", power_fn)?;
|
||||||
|
|
||||||
|
let powered_fn = self.lua.create_function(move |_lua, ()| {
|
||||||
|
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("powered", powered_fn)?;
|
||||||
|
|
||||||
|
let connect_fn = self.lua.create_function(move |_lua, address: String| {
|
||||||
|
bluetooth_spawn(move || async move {
|
||||||
|
if let Err(e) = bluetooth_connect(address).await {
|
||||||
|
tracing::warn!("bread.bluetooth.connect failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("connect", connect_fn)?;
|
||||||
|
|
||||||
|
let disconnect_fn = self.lua.create_function(move |_lua, address: String| {
|
||||||
|
bluetooth_spawn(move || async move {
|
||||||
|
if let Err(e) = bluetooth_disconnect(address).await {
|
||||||
|
tracing::warn!("bread.bluetooth.disconnect failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("disconnect", disconnect_fn)?;
|
||||||
|
|
||||||
|
let scan_fn = self.lua.create_function(move |_lua, enabled: bool| {
|
||||||
|
bluetooth_spawn(move || async move {
|
||||||
|
if let Err(e) = bluetooth_set_scanning(enabled).await {
|
||||||
|
tracing::warn!("bread.bluetooth.scan failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("scan", scan_fn)?;
|
||||||
|
|
||||||
|
let devices_fn = self.lua.create_function(move |lua, ()| {
|
||||||
|
let devs = match bluetooth_query(|| bluetooth_list_devices()) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return Ok(Value::Nil),
|
||||||
|
};
|
||||||
|
let tbl = lua.create_table()?;
|
||||||
|
for (i, dev) in devs.iter().enumerate() {
|
||||||
|
let dt = lua.create_table()?;
|
||||||
|
dt.set("address", dev.address.clone())?;
|
||||||
|
dt.set("name", dev.name.clone())?;
|
||||||
|
dt.set("connected", dev.connected)?;
|
||||||
|
dt.set("paired", dev.paired)?;
|
||||||
|
tbl.set(i + 1, dt)?;
|
||||||
|
}
|
||||||
|
Ok(Value::Table(tbl))
|
||||||
|
})?;
|
||||||
|
bluetooth_tbl.set("devices", devices_fn)?;
|
||||||
|
|
||||||
|
bread.set("bluetooth", bluetooth_tbl)?;
|
||||||
|
|
||||||
globals.set("bread", bread)?;
|
globals.set("bread", bread)?;
|
||||||
self.install_require_loader()?;
|
self.install_require_loader()?;
|
||||||
self.install_wait_helper()?;
|
self.install_wait_helper()?;
|
||||||
|
|
@ -2193,3 +2261,199 @@ fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bluetooth helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget
|
||||||
|
/// async Bluetooth operation. Needed because the Lua thread runs inside
|
||||||
|
/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden.
|
||||||
|
fn bluetooth_spawn<F, Fut>(factory: F)
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut + Send + 'static,
|
||||||
|
Fut: std::future::Future<Output = ()>,
|
||||||
|
{
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("bluetooth action thread")
|
||||||
|
.block_on(factory());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua
|
||||||
|
/// gets a return value.
|
||||||
|
fn bluetooth_query<F, Fut, T>(factory: F) -> anyhow::Result<T>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut + Send + 'static,
|
||||||
|
Fut: std::future::Future<Output = anyhow::Result<T>>,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("bluetooth query thread")
|
||||||
|
.block_on(factory());
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
|
||||||
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||||
|
let msg = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
"/",
|
||||||
|
Some("org.freedesktop.DBus.ObjectManager"),
|
||||||
|
"GetManagedObjects",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let objects: std::collections::HashMap<
|
||||||
|
OwnedObjectPath,
|
||||||
|
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
|
||||||
|
> = msg.body()?;
|
||||||
|
for (path, interfaces) in &objects {
|
||||||
|
if interfaces.contains_key("org.bluez.Adapter1") {
|
||||||
|
return Ok(path.as_str().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!("no Bluetooth adapter found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||||
|
conn.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
adapter.as_str(),
|
||||||
|
Some("org.freedesktop.DBus.Properties"),
|
||||||
|
"Set",
|
||||||
|
&(
|
||||||
|
"org.bluez.Adapter1",
|
||||||
|
"Powered",
|
||||||
|
zbus::zvariant::Value::from(enabled),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_get_powered() -> anyhow::Result<bool> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||||
|
let msg = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
adapter.as_str(),
|
||||||
|
Some("org.freedesktop.DBus.Properties"),
|
||||||
|
"Get",
|
||||||
|
&("org.bluez.Adapter1", "Powered"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?;
|
||||||
|
let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false));
|
||||||
|
Ok(json.as_bool().unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_connect(address: String) -> anyhow::Result<()> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||||
|
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
|
||||||
|
conn.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
dev_path.as_str(),
|
||||||
|
Some("org.bluez.Device1"),
|
||||||
|
"Connect",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||||
|
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
|
||||||
|
conn.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
dev_path.as_str(),
|
||||||
|
Some("org.bluez.Device1"),
|
||||||
|
"Disconnect",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||||
|
let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
|
||||||
|
conn.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
adapter.as_str(),
|
||||||
|
Some("org.bluez.Adapter1"),
|
||||||
|
method,
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BluetoothDevice {
|
||||||
|
address: String,
|
||||||
|
name: String,
|
||||||
|
connected: bool,
|
||||||
|
paired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
|
||||||
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||||
|
let conn = zbus::Connection::system().await?;
|
||||||
|
let msg = conn
|
||||||
|
.call_method(
|
||||||
|
Some("org.bluez"),
|
||||||
|
"/",
|
||||||
|
Some("org.freedesktop.DBus.ObjectManager"),
|
||||||
|
"GetManagedObjects",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let objects: std::collections::HashMap<
|
||||||
|
OwnedObjectPath,
|
||||||
|
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
|
||||||
|
> = msg.body()?;
|
||||||
|
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
for (_, interfaces) in &objects {
|
||||||
|
if let Some(props) = interfaces.get("org.bluez.Device1") {
|
||||||
|
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
devices.push(BluetoothDevice {
|
||||||
|
address: json
|
||||||
|
.get("Address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string(),
|
||||||
|
name: json
|
||||||
|
.get("Name")
|
||||||
|
.or_else(|| json.get("Alias"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string(),
|
||||||
|
connected: json
|
||||||
|
.get("Connected")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
paired: json
|
||||||
|
.get("Paired")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue