feat: add bread-sync module for snapshot and restore functionality

- Introduced `bread-sync` module with core functionalities for syncing system state via Git.
- Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles.
- Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`.
- Created comprehensive tests for sync operations, package parsing, and machine profile management.
- Enhanced `udev` adapter to include vendor and product IDs for scanned devices.
- Updated state engine to handle module clearing commands.
- Introduced Lua integration for accessing machine information and file system operations.
- Improved packaging documentation for Arch Linux and systemd service setup.
This commit is contained in:
Breadway 2026-05-12 00:20:45 +08:00
parent 251c586b6f
commit 364a35142e
25 changed files with 3930 additions and 92 deletions

View file

@ -101,6 +101,8 @@ struct ScannedDevice {
id: String,
name: String,
subsystem: String,
vendor_id: Option<String>,
product_id: Option<String>,
}
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
@ -148,6 +150,8 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(&event, "ID_VENDOR"),
"id_model": prop_str(&event, "ID_MODEL"),
"vendor_id": prop_str(&event, "ID_VENDOR_ID"),
"product_id": prop_str(&event, "ID_MODEL_ID"),
}),
timestamp: now_unix_ms(),
};
@ -183,11 +187,19 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string());
let id = dev.syspath().to_string_lossy().to_string();
let vendor_id = dev
.property_value("ID_VENDOR_ID")
.map(|v| v.to_string_lossy().to_string());
let product_id = dev
.property_value("ID_MODEL_ID")
.map(|v| v.to_string_lossy().to_string());
out.push(ScannedDevice {
id,
name,
subsystem,
vendor_id,
product_id,
});
}
@ -203,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
"id": dev.id,
"name": dev.name,
"subsystem": dev.subsystem,
"vendor_id": dev.vendor_id,
"product_id": dev.product_id,
}),
timestamp: now_unix_ms(),
}
@ -226,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
id: format!("drm:{name}"),
name,
subsystem: "drm".to_string(),
vendor_id: None,
product_id: None,
});
}
}
@ -242,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
id: format!("input:{name}"),
name,
subsystem: "input".to_string(),
vendor_id: None,
product_id: None,
});
}
}
@ -257,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
id: format!("power_supply:{name}"),
name,
subsystem: "power_supply".to_string(),
vendor_id: None,
product_id: None,
});
}
}
@ -269,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
let syspath = entry.path();
let vendor_id = fs::read_to_string(syspath.join("idVendor"))
.ok()
.map(|s| s.trim().to_string());
let product_id = fs::read_to_string(syspath.join("idProduct"))
.ok()
.map(|s| s.trim().to_string());
out.push(ScannedDevice {
id: format!("usb:{name}"),
name,
subsystem: "usb".to_string(),
vendor_id,
product_id,
});
}
}

View file

@ -36,6 +36,7 @@ pub enum StateCommand {
id: SubscriptionId,
},
ClearSubscriptions,
ClearModules,
SetModuleStatus {
name: String,
status: ModuleLoadState,
@ -112,6 +113,10 @@ impl StateHandle {
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
}
pub fn clear_modules(&self) {
let _ = self.command_tx.send(StateCommand::ClearModules);
}
pub fn set_module_status(
&self,
name: String,
@ -236,6 +241,9 @@ async fn handle_command(
watches.clear();
subscription_count.store(0, Ordering::Relaxed);
}
StateCommand::ClearModules => {
state.write().await.modules.clear();
}
StateCommand::SetModuleStatus {
name,
status,
@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
vendor_id: data
.get("vendor_id")
.and_then(Value::as_str)
.map(ToString::to_string),
product_id: data
.get("product_id")
.and_then(Value::as_str)
.map(ToString::to_string),
});
} else {
state.devices.connected.retain(|d| d.id != id);

View file

@ -57,6 +57,10 @@ pub struct Device {
pub name: String,
pub class: DeviceClass,
pub subsystem: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub vendor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]

View file

@ -267,6 +267,39 @@ impl Server {
"recent_errors": recent_errors,
}))
}
"sync.status" => {
let cfg_home = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|_| {
std::env::var("HOME")
.map(|h| std::path::PathBuf::from(h).join(".config"))
})
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
let sync_path = cfg_home.join("bread").join("sync.toml");
match std::fs::read_to_string(&sync_path)
.ok()
.and_then(|s| s.parse::<toml::Value>().ok())
{
Some(toml) => {
let machine = toml
.get("machine")
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let remote = toml
.get("remote")
.and_then(|r| r.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(json!({
"initialized": true,
"machine": machine,
"remote": remote,
}))
}
None => Ok(json!({ "initialized": false })),
}
}
"events.replay" => {
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0);
let cutoff = now_unix_ms().saturating_sub(since_ms);

View file

@ -9,6 +9,7 @@ use std::time::Duration;
use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent};
use libc;
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
use serde::Serialize;
use serde_json::Value as JsonValue;
@ -250,6 +251,7 @@ impl LuaEngine {
self.run_on_unload();
self.cancel_all_timers();
self.state_handle.clear_subscriptions();
self.state_handle.clear_modules();
self.lua = Lua::new();
self.handlers
.lock()
@ -837,6 +839,66 @@ impl LuaEngine {
})?;
bread.set("module", module_fn)?;
// bread.machine — machine name and tags from sync.toml
let machine_tbl = self.lua.create_table()?;
let name_fn = self.lua.create_function(|_lua, ()| {
Ok(lua_machine_name())
})?;
machine_tbl.set("name", name_fn)?;
let tags_fn = self.lua.create_function(|lua, ()| {
let tags = lua_machine_tags();
let tbl = lua.create_table()?;
for (i, tag) in tags.iter().enumerate() {
tbl.set(i + 1, tag.clone())?;
}
Ok(tbl)
})?;
machine_tbl.set("tags", tags_fn)?;
let has_tag_fn = self.lua.create_function(|_lua, tag: String| {
Ok(lua_machine_tags().contains(&tag))
})?;
machine_tbl.set("has_tag", has_tag_fn)?;
bread.set("machine", machine_tbl)?;
// bread.fs — file system helpers
let fs_tbl = self.lua.create_table()?;
let write_fn = self.lua.create_function(|_lua, (path, content): (String, String)| {
let expanded = lua_expand_path(&path);
if let Some(parent) = expanded.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LuaError::external(e.to_string()))?;
}
std::fs::write(&expanded, content)
.map_err(|e| LuaError::external(e.to_string()))
})?;
fs_tbl.set("write", write_fn)?;
let read_fn = self.lua.create_function(|_lua, path: String| {
let expanded = lua_expand_path(&path);
match std::fs::read_to_string(&expanded) {
Ok(s) => Ok(Some(s)),
Err(_) => Ok(None),
}
})?;
fs_tbl.set("read", read_fn)?;
let exists_fn = self.lua.create_function(|_lua, path: String| {
Ok(lua_expand_path(&path).exists())
})?;
fs_tbl.set("exists", exists_fn)?;
let expand_fn = self.lua.create_function(|_lua, path: String| {
Ok(lua_expand_path(&path).to_string_lossy().to_string())
})?;
fs_tbl.set("expand", expand_fn)?;
bread.set("fs", fs_tbl)?;
globals.set("bread", bread)?;
self.install_require_loader()?;
self.install_wait_helper()?;
@ -927,7 +989,7 @@ impl LuaEngine {
fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone()));
let result = if let Some(source) = decl.source.as_deref() {
let result = if let Some(source) = decl.source {
self.load_lua_source(source, &decl.name)
} else {
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
@ -1296,16 +1358,31 @@ impl LuaEngine {
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?;
// Build a minimal bread stub: bread.module() captures the decl and aborts;
// all other bread.* accesses return a no-op callable so modules that call
// bread.log() or bread.fs.exists() before bread.module() don't crash during scanning.
let bread = lua.create_table()?;
bread.set("module", module_fn)?;
lua.globals().set("bread", bread)?;
lua.load(r#"
local _noop = function(...) end
local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop }
local _noop_tbl = setmetatable({}, _noop_tbl_mt)
setmetatable(bread, {
__index = function(_, k)
if k == "module" then return rawget(bread, "module") end
return _noop_tbl
end
})
"#).exec()?;
let src = fs::read_to_string(path)?;
let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec();
// bread.module() throws MODULE_DECL_ABORT to abort scanning early.
// mlua may wrap the error in CallbackError, so match on string content.
if let Err(err) = result {
match err {
LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {}
other => return Err(anyhow!(other.to_string())),
if !err.to_string().contains(MODULE_DECL_ABORT) {
return Err(anyhow!(err.to_string()));
}
}
@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: St
});
}
fn lua_expand_path(path: &str) -> std::path::PathBuf {
if path == "~" {
if let Some(home) = dirs_home() {
return home;
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs_home() {
return home.join(rest);
}
}
std::path::PathBuf::from(path)
}
fn dirs_home() -> Option<std::path::PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Some(std::path::PathBuf::from(home));
}
None
}
fn lua_machine_name() -> String {
if let Ok(sync_toml) = read_sync_toml() {
if let Some(name) = sync_toml
.get("machine")
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
{
return name.to_string();
}
}
lua_hostname()
}
fn lua_hostname() -> String {
// Try gethostname via libc
let mut buf = [0u8; 256];
unsafe {
if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 {
if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() {
if !s.is_empty() {
return s.to_string();
}
}
}
}
// Fall back to /etc/hostname
if let Ok(h) = std::fs::read_to_string("/etc/hostname") {
let trimmed = h.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "unknown".to_string())
}
fn lua_machine_tags() -> Vec<String> {
if let Ok(sync_toml) = read_sync_toml() {
if let Some(tags) = sync_toml
.get("machine")
.and_then(|m| m.get("tags"))
.and_then(|v| v.as_array())
{
return tags
.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect();
}
}
vec![]
}
fn read_sync_toml() -> anyhow::Result<toml::Value> {
let config_dir = std::env::var("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|_| {
std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})
.unwrap_or_else(|_| std::path::PathBuf::from(".config"));
let path = config_dir.join("bread").join("sync.toml");
let raw = std::fs::read_to_string(path)?;
Ok(raw.parse::<toml::Value>()?)
}
const BUILTIN_MONITORS: &str = r#"
local M = bread.module({ name = "bread.monitors", version = "1.0.0" })