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:
parent
96e42bc370
commit
e39b168398
25 changed files with 3930 additions and 92 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue