Merge pull request #2 from Breadway/dev

Dev
This commit is contained in:
Breadway 2026-05-11 20:30:59 +08:00 committed by GitHub
commit 5dd0b0b0e0
18 changed files with 1654 additions and 101 deletions

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
target/ target/
Overview.md Overview.md
DAEMON.md DAEMON.md
.github/ .claude
CLAUDE.md
.github

131
Cargo.lock generated
View file

@ -289,6 +289,8 @@ dependencies = [
"anyhow", "anyhow",
"bread-shared", "bread-shared",
"clap", "clap",
"libc",
"notify",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@ -429,6 +431,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -579,6 +590,16 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -591,6 +612,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@ -798,6 +828,26 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@ -830,6 +880,26 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64"
dependencies = [
"bitflags 2.11.1",
"libc",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -952,6 +1022,18 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@ -1083,6 +1165,25 @@ dependencies = [
"memoffset 0.7.1", "memoffset 0.7.1",
] ]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.1",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -1402,6 +1503,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -1639,7 +1749,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.2.0",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
@ -1844,6 +1954,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@ -1930,6 +2050,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"

View file

@ -72,16 +72,20 @@ Optional but preferred:
```bash ```bash
git clone https://github.com/Breadway/bread.git git clone https://github.com/Breadway/bread.git
cd bread cd bread
cargo build --release
``` ```
Binaries will be at `target/release/breadd` and `target/release/bread`. Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon:
Install them:
```bash ```bash
sudo install -Dm755 target/release/breadd /usr/local/bin/breadd bash scripts/install.sh
sudo install -Dm755 target/release/bread /usr/local/bin/bread ```
Or do it step by step:
```bash
cargo build --release
sudo install -Dm755 target/release/breadd /usr/bin/breadd
sudo install -Dm755 target/release/bread /usr/bin/bread
``` ```
### Arch Linux (PKGBUILD) ### Arch Linux (PKGBUILD)
@ -130,6 +134,15 @@ enabled = true
[events] [events]
dedup_window_ms = 100 dedup_window_ms = 100
[notifications]
default_timeout_ms = 5000
default_urgency = "normal"
notify_send_path = "notify-send"
[modules]
builtin = true # load built-in modules (monitors, devices, etc.)
disable = [] # list of built-in module names to disable
``` ```
Your automation lives in `~/.config/bread/init.lua`: Your automation lives in `~/.config/bread/init.lua`:
@ -153,15 +166,18 @@ All commands communicate with the running daemon over a Unix socket at `$XDG_RUN
```bash ```bash
bread reload # Hot-reload all Lua modules bread reload # Hot-reload all Lua modules
bread reload --watch # Watch config dir and reload on changes
bread state # Dump full runtime state as JSON bread state # Dump full runtime state as JSON
bread events # Stream live normalized events bread events # Stream live normalized events
bread events --filter bread.device.* # Stream filtered events bread events --filter bread.device.* # Stream filtered events
bread events --since 60 # Replay events from the last 60 seconds
bread modules # List loaded modules and status bread modules # List loaded modules and status
bread profile-list # List defined profiles bread profile-list # List defined profiles
bread profile-activate <name> # Activate a named profile bread profile-activate <name> # Activate a named profile
bread emit <event> --data '{}' # Manually fire an event (for testing) bread emit <event> --data '{}' # Manually fire an event (for testing)
bread ping # Check daemon connectivity bread ping # Check daemon connectivity
bread health # Daemon version, uptime, PID bread health # Daemon version, uptime, PID
bread doctor # Diagnose daemon and module health
``` ```
--- ---
@ -201,16 +217,26 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
### Events ### Events
```lua ```lua
-- Subscribe to an event -- Subscribe to an event; returns a numeric ID
bread.on("bread.monitor.connected", function(event) local id = bread.on("bread.monitor.connected", function(event)
print(event.data.name) print(event.data.name)
end) end)
-- Unsubscribe by ID
bread.off(id)
-- Subscribe once, then auto-unsubscribe -- Subscribe once, then auto-unsubscribe
bread.once("bread.system.startup", function(event) bread.once("bread.system.startup", function(event)
-- runs exactly once -- runs exactly once
end) end)
-- Subscribe with a predicate filter
bread.filter("bread.device.connected", function(event)
return event.data.class == "keyboard"
end, function(event)
bread.exec("xset r rate 200 40")
end)
-- Emit a custom event (for cross-module communication) -- Emit a custom event (for cross-module communication)
bread.emit("mymodule.something", { key = "value" }) bread.emit("mymodule.something", { key = "value" })
``` ```
@ -222,6 +248,12 @@ bread.emit("mymodule.something", { key = "value" })
local monitors = bread.state.get("monitors") local monitors = bread.state.get("monitors")
local workspace = bread.state.get("active_workspace") local workspace = bread.state.get("active_workspace")
local power = bread.state.get("power") local power = bread.state.get("power")
local devices = bread.state.get("devices")
-- Watch a state key and fire on changes
bread.state.watch("active_workspace", function(new, old)
print("workspace changed from " .. tostring(old) .. " to " .. tostring(new))
end)
``` ```
### Profiles ### Profiles
@ -231,12 +263,42 @@ bread.profile.activate("desk")
bread.profile.activate("default") bread.profile.activate("default")
``` ```
### Execution ### Execution and notifications
```lua ```lua
-- Fire-and-forget: returns immediately, process runs in background -- Fire-and-forget: returns immediately, process runs in background
bread.exec("kitty") bread.exec("kitty")
bread.exec("notify-send 'Dock connected'")
-- Desktop notification
bread.notify("Dock connected", { urgency = "normal", timeout = 3000 })
```
### Timers
```lua
-- Run once after a delay (ms)
bread.after(500, function()
bread.exec("some-delayed-command")
end)
-- Run on a repeating interval (ms); returns a timer ID
local id = bread.every(60000, function()
bread.log("tick")
end)
bread.cancel(id)
-- Debounce a rapidly-firing handler
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
```
### Logging
```lua
bread.log("Module loaded")
bread.warn("Unexpected state")
bread.error("Something failed")
``` ```
--- ---
@ -255,7 +317,7 @@ Response:
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
``` ```
Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `emit`. Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`.
`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. `events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects.

View file

@ -3,6 +3,10 @@ name = "bread-cli"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]]
name = "bread"
path = "src/main.rs"
[dependencies] [dependencies]
bread-shared = { path = "../bread-shared" } bread-shared = { path = "../bread-shared" }
serde.workspace = true serde.workspace = true
@ -10,3 +14,5 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
notify = "6.1"
libc = "0.2"

View file

@ -1,10 +1,14 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::env; use std::env;
use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::sync::mpsc;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")]
@ -16,13 +20,32 @@ struct Cli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Commands { enum Commands {
/// Hot-reload all Lua modules /// Hot-reload all Lua modules
Reload, Reload {
/// Watch config directory and reload on changes
#[arg(long)]
watch: bool,
},
/// Dump current runtime state /// Dump current runtime state
State, State {
/// Optional dotted path into RuntimeState
path: Option<String>,
/// Output raw JSON
#[arg(long)]
json: bool,
},
/// Stream live normalized events /// Stream live normalized events
Events { Events {
#[arg(long)] #[arg(long)]
filter: Option<String>, filter: Option<String>,
/// Output raw JSON
#[arg(long)]
json: bool,
/// Comma-separated fields to display
#[arg(long)]
fields: Option<String>,
/// Replay events from the last N seconds
#[arg(long)]
since: Option<u64>,
}, },
/// List loaded modules and status /// List loaded modules and status
Modules, Modules,
@ -40,6 +63,12 @@ enum Commands {
Ping, Ping,
/// Fetch daemon health details /// Fetch daemon health details
Health, Health,
/// Diagnose daemon and module health
Doctor {
/// Output raw JSON
#[arg(long)]
json: bool,
},
} }
#[tokio::main] #[tokio::main]
@ -48,16 +77,38 @@ async fn main() -> Result<()> {
let socket = daemon_socket_path(); let socket = daemon_socket_path();
match &cli.command { match &cli.command {
Commands::Reload => { Commands::Reload { watch } => {
let response = send_request(&socket, "modules.reload", json!({})).await?; if *watch {
print_json(&response)?; watch_reload(&socket).await?;
} else {
let response = send_request(&socket, "modules.reload", json!({})).await?;
print_reload(&response);
}
} }
Commands::State => { Commands::State { path, json } => {
let response = send_request(&socket, "state.dump", json!({})).await?; if *json {
print_json(&response)?; let response = if let Some(path) = path {
send_request(&socket, "state.get", json!({ "key": path })).await?
} else {
send_request(&socket, "state.dump", json!({})).await?
};
print_json(&response)?;
} else {
let response = if let Some(path) = path {
send_request(&socket, "state.get", json!({ "key": path })).await?
} else {
send_request(&socket, "state.dump", json!({})).await?
};
print_state_formatted(path.as_deref(), &response);
}
} }
Commands::Events { filter } => { Commands::Events {
stream_events(&socket, filter.clone()).await?; filter,
json,
fields,
since,
} => {
stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?;
} }
Commands::Modules => { Commands::Modules => {
let response = send_request(&socket, "modules.list", json!({})).await?; let response = send_request(&socket, "modules.list", json!({})).await?;
@ -92,6 +143,14 @@ async fn main() -> Result<()> {
let response = send_request(&socket, "health", json!({})).await?; let response = send_request(&socket, "health", json!({})).await?;
print_json(&response)?; print_json(&response)?;
} }
Commands::Doctor { json } => {
if *json {
let response = send_request(&socket, "health", json!({})).await?;
print_json(&response)?;
} else {
print_doctor(&socket).await?;
}
}
} }
Ok(()) Ok(())
@ -128,7 +187,26 @@ async fn send_request(socket: &Path, method: &str, params: Value) -> Result<Valu
Ok(response.get("result").cloned().unwrap_or_else(|| json!({}))) Ok(response.get("result").cloned().unwrap_or_else(|| json!({})))
} }
async fn stream_events(socket: &Path, filter: Option<String>) -> Result<()> { async fn stream_events(
socket: &Path,
filter: Option<String>,
raw_json: bool,
fields: Option<String>,
since: Option<u64>,
) -> Result<()> {
if let Some(seconds) = since {
let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?;
if let Some(list) = replay.as_array() {
for item in list {
if raw_json {
println!("{}", serde_json::to_string_pretty(item)?);
} else {
print_event(item, fields.as_deref());
}
}
}
}
let stream = UnixStream::connect(socket).await?; let stream = UnixStream::connect(socket).await?;
let (read_half, mut write_half) = stream.into_split(); let (read_half, mut write_half) = stream.into_split();
let request = json!({ let request = json!({
@ -146,7 +224,11 @@ async fn stream_events(socket: &Path, filter: Option<String>) -> Result<()> {
let mut lines = BufReader::new(read_half).lines(); let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? { while let Some(line) = lines.next_line().await? {
let value: Value = serde_json::from_str(&line)?; let value: Value = serde_json::from_str(&line)?;
println!("{}", serde_json::to_string_pretty(&value)?); if raw_json {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
print_event(&value, fields.as_deref());
}
} }
Ok(()) Ok(())
@ -156,3 +238,231 @@ fn print_json(value: &Value) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?); println!("{}", serde_json::to_string_pretty(value)?);
Ok(()) Ok(())
} }
fn print_state_formatted(path: Option<&str>, value: &Value) {
if let Some(path) = path {
println!("{path}");
}
print_value(value, 0);
}
fn print_value(value: &Value, indent: usize) {
let pad = " ".repeat(indent);
match value {
Value::Object(map) => {
for (key, val) in map {
println!("{pad}{key}");
print_value(val, indent + 2);
}
}
Value::Array(list) => {
for (idx, val) in list.iter().enumerate() {
println!("{pad}[{idx}]");
print_value(val, indent + 2);
}
}
other => {
println!("{pad}{}", other);
}
}
}
fn print_event(event: &Value, fields: Option<&str>) {
if let Some(fields) = fields {
let mut out = serde_json::Map::new();
for field in fields.split(',') {
let field = field.trim();
if field.is_empty() {
continue;
}
if let Some(val) = event.get(field) {
out.insert(field.to_string(), val.clone());
}
}
println!("{}", Value::Object(out));
return;
}
let ts = event.get("timestamp").and_then(Value::as_u64).unwrap_or(0);
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("?");
let source = event.get("source").and_then(Value::as_str).unwrap_or("?");
let time = format_timestamp(ts);
println!("{time} {event_name} source={source}");
if let Some(data) = event.get("data") {
println!(" data: {}", data);
}
}
fn format_timestamp(ms: u64) -> String {
let secs = ms / 1000;
let millis = ms % 1000;
// SAFETY: localtime_r is thread-safe. We pass a valid pointer to a
// zeroed tm struct and read the result only after the call returns.
let local_secs = unsafe {
let mut tm: libc::tm = std::mem::zeroed();
let t = secs as libc::time_t;
libc::localtime_r(&t, &mut tm);
tm.tm_hour as u64 * 3600
+ tm.tm_min as u64 * 60
+ tm.tm_sec as u64
};
let h = (local_secs / 3600) % 24;
let m = (local_secs / 60) % 60;
let s = local_secs % 60;
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis)
}
fn print_reload(value: &Value) {
println!("reloading lua runtime...");
if let Some(mods) = value.get("modules").and_then(Value::as_array) {
for module in mods {
let name = module.get("name").and_then(Value::as_str).unwrap_or("?");
let status = module.get("status").and_then(Value::as_str).unwrap_or("?");
let error = module.get("last_error").and_then(Value::as_str);
if let Some(error) = error {
println!("{name} {status}");
println!(" {error}");
} else {
println!("{name} {status}");
}
}
}
}
async fn watch_reload(socket: &Path) -> Result<()> {
let config_dir = config_directory();
println!("watching {} for changes...", config_dir.display());
let (tx, mut rx) = mpsc::unbounded_channel();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})?;
watcher.watch(&config_dir, RecursiveMode::Recursive)?;
while let Some(msg) = rx.recv().await {
if msg.is_err() {
continue;
}
// Debounce: drain any follow-up events that arrive within 150ms.
// A single file save typically generates 2-3 fs events in rapid succession.
tokio::time::sleep(Duration::from_millis(150)).await;
while rx.try_recv().is_ok() {}
let response = send_request(socket, "modules.reload", json!({})).await?;
print_reload(&response);
}
Ok(())
}
async fn print_doctor(socket: &Path) -> Result<()> {
let stream = match UnixStream::connect(socket).await {
Ok(stream) => stream,
Err(err) => {
if err.kind() == io::ErrorKind::NotFound {
println!("bread doctor");
println!(" daemon ✗ not running");
println!(" socket {} (not found)", socket.display());
println!();
println!(" start the daemon: systemctl --user start breadd");
println!(" view logs: journalctl --user -u breadd -f");
return Ok(());
}
return Err(err.into());
}
};
let response = send_request_with_stream(stream, "health", json!({})).await?;
render_doctor(&response);
Ok(())
}
fn render_doctor(health: &Value) {
println!("bread doctor");
let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false);
let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0);
let version = health.get("version").and_then(Value::as_str).unwrap_or("unknown");
let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0);
let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?");
println!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid);
println!(" version {version}");
println!(" uptime {}s", uptime_ms / 1000);
println!(" socket {socket}");
if let Some(adapters) = health.get("adapters").and_then(Value::as_object) {
println!();
println!("adapters");
for (name, status) in adapters {
println!(" {:20} {}", name, status);
}
}
if let Some(modules) = health.get("modules").and_then(Value::as_array) {
println!();
println!("modules");
for module in modules {
let name = module.get("name").and_then(Value::as_str).unwrap_or("?");
let status = module.get("status").and_then(Value::as_str).unwrap_or("?");
let error = module.get("last_error").and_then(Value::as_str);
println!(" {:30} {}", name, status);
if let Some(error) = error {
println!("{error}");
}
}
}
if let Some(count) = health.get("subscriptions").and_then(Value::as_u64) {
println!();
println!("subscriptions {count}");
}
if let Some(errors) = health.get("recent_errors").and_then(Value::as_array) {
if !errors.is_empty() {
println!();
println!("recent errors ({} total)", errors.len());
for entry in errors.iter().take(5) {
println!(" {entry}");
}
}
}
}
async fn send_request_with_stream(
stream: UnixStream,
method: &str,
params: Value,
) -> Result<Value> {
let (read_half, mut write_half) = stream.into_split();
let request = json!({
"id": "1",
"method": method,
"params": params,
});
write_half
.write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes())
.await?;
let mut lines = BufReader::new(read_half).lines();
let Some(line) = lines.next_line().await? else {
anyhow::bail!("daemon closed connection without response");
};
let response: Value = serde_json::from_str(&line)?;
if let Some(error) = response.get("error").and_then(Value::as_str) {
anyhow::bail!(error.to_string());
}
Ok(response.get("result").cloned().unwrap_or_else(|| json!({})))
}
fn config_directory() -> PathBuf {
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Path::new(&xdg).join("bread");
}
if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(".config/bread");
}
PathBuf::from(".config/bread")
}

View file

@ -1,8 +1,11 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use bread_shared::RawEvent; use bread_shared::RawEvent;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch, RwLock};
use tracing::info; use tracing::info;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::supervisor::spawn_supervised; use crate::core::supervisor::spawn_supervised;
@ -14,6 +17,13 @@ pub mod udev;
pub mod network_rtnetlink; pub mod network_rtnetlink;
pub mod power_upower; pub mod power_upower;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterStatus {
Connected,
Disconnected,
}
#[async_trait] #[async_trait]
pub trait Adapter: Send + Sync { pub trait Adapter: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -30,6 +40,7 @@ pub struct Manager {
raw_tx: mpsc::Sender<RawEvent>, raw_tx: mpsc::Sender<RawEvent>,
config: Config, config: Config,
shutdown_rx: watch::Receiver<bool>, shutdown_rx: watch::Receiver<bool>,
status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
} }
impl Manager { impl Manager {
@ -42,9 +53,14 @@ impl Manager {
raw_tx, raw_tx,
config, config,
shutdown_rx, shutdown_rx,
status: Arc::new(RwLock::new(HashMap::new())),
} }
} }
pub fn status_handle(&self) -> Arc<RwLock<HashMap<String, AdapterStatus>>> {
self.status.clone()
}
pub async fn start_all(&self) -> Result<()> { pub async fn start_all(&self) -> Result<()> {
info!("starting adapters"); info!("starting adapters");
@ -91,17 +107,27 @@ impl Manager {
let tx = self.raw_tx.clone(); let tx = self.raw_tx.clone();
let shutdown_rx = self.shutdown_rx.clone(); let shutdown_rx = self.shutdown_rx.clone();
let shutdown_for_task = shutdown_rx.clone(); let shutdown_for_task = shutdown_rx.clone();
let status = self.status.clone();
spawn_supervised(name, shutdown_rx, move || { spawn_supervised(name, shutdown_rx, move || {
let adapter = adapter.clone(); let adapter = adapter.clone();
let tx = tx.clone(); let tx = tx.clone();
let mut shutdown_rx = shutdown_for_task.clone(); let mut shutdown_rx = shutdown_for_task.clone();
let status = status.clone();
async move { async move {
adapter.on_connect().await?; adapter.on_connect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Connected);
}
let result = tokio::select! { let result = tokio::select! {
result = adapter.run(tx) => result, result = adapter.run(tx) => result,
_ = shutdown_rx.changed() => Ok(()), _ = shutdown_rx.changed() => Ok(()),
}; };
adapter.on_disconnect().await?; adapter.on_disconnect().await?;
{
let mut guard = status.write().await;
guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected);
}
result result
} }
}); });

View file

@ -52,18 +52,23 @@ impl Adapter for UdevAdapter {
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> { async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
debug!("udev adapter started"); debug!("udev adapter started");
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await { match run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
return Ok(()); Ok(()) => return Ok(()),
Err(err) => {
tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)");
}
} }
// Fallback for environments where monitor sockets are unavailable. // Fallback: poll sysfs every 2 seconds for environments where the
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)? // netlink socket is unavailable (missing plugdev membership, containers, etc).
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)
.unwrap_or_default()
.into_iter() .into_iter()
.map(|d| (d.id.clone(), d)) .map(|d| (d.id.clone(), d))
.collect(); .collect();
loop { loop {
let current = scan_devices(&self.subsystems)?; let current = scan_devices(&self.subsystems).unwrap_or_default();
let current_map: HashMap<String, ScannedDevice> = current let current_map: HashMap<String, ScannedDevice> = current
.into_iter() .into_iter()
.map(|d| (d.id.clone(), d)) .map(|d| (d.id.clone(), d))
@ -71,13 +76,17 @@ impl Adapter for UdevAdapter {
for (id, dev) in &current_map { for (id, dev) in &current_map {
if !known.contains_key(id) { if !known.contains_key(id) {
tx.send(raw_change_event("add", dev)).await?; if tx.send(raw_change_event("add", dev)).await.is_err() {
return Ok(());
}
} }
} }
for (id, dev) in &known { for (id, dev) in &known {
if !current_map.contains_key(id) { if !current_map.contains_key(id) {
tx.send(raw_change_event("remove", dev)).await?; if tx.send(raw_change_event("remove", dev)).await.is_err() {
return Ok(());
}
} }
} }
@ -130,6 +139,15 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
"id": id, "id": id,
"name": name, "name": name,
"subsystem": subsystem, "subsystem": subsystem,
"id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"),
"id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"),
"id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"),
"id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"),
"id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"),
"id_usb_class": prop_str(&event, "ID_USB_CLASS"),
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(&event, "ID_VENDOR"),
"id_model": prop_str(&event, "ID_MODEL"),
}), }),
timestamp: now_unix_ms(), timestamp: now_unix_ms(),
}; };
@ -263,3 +281,17 @@ fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
Ok(out) Ok(out)
} }
fn prop_bool(event: &udev::Event, key: &str) -> bool {
event
.property_value(key)
.and_then(|v| v.to_str())
.map(|v| v == "1")
.unwrap_or(false)
}
fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
event
.property_value(key)
.map(|v| v.to_string_lossy().to_string())
}

View file

@ -12,8 +12,12 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub lua: LuaConfig, pub lua: LuaConfig,
#[serde(default)] #[serde(default)]
pub modules: ModulesConfig,
#[serde(default)]
pub adapters: AdaptersConfig, pub adapters: AdaptersConfig,
#[serde(default)] #[serde(default)]
pub notifications: NotificationsConfig,
#[serde(default)]
pub events: EventsConfig, pub events: EventsConfig,
} }
@ -33,6 +37,14 @@ pub struct LuaConfig {
pub module_path: String, pub module_path: String,
} }
#[derive(Debug, Clone, Deserialize)]
pub struct ModulesConfig {
#[serde(default = "default_true")]
pub builtin: bool,
#[serde(default)]
pub disable: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AdaptersConfig { pub struct AdaptersConfig {
#[serde(default)] #[serde(default)]
@ -73,12 +85,24 @@ pub struct EventsConfig {
pub dedup_window_ms: u64, pub dedup_window_ms: u64,
} }
#[derive(Debug, Clone, Deserialize)]
pub struct NotificationsConfig {
#[serde(default = "default_notify_timeout")]
pub default_timeout_ms: i64,
#[serde(default = "default_notify_urgency")]
pub default_urgency: String,
#[serde(default = "default_notify_path")]
pub notify_send_path: String,
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
daemon: DaemonConfig::default(), daemon: DaemonConfig::default(),
lua: LuaConfig::default(), lua: LuaConfig::default(),
modules: ModulesConfig::default(),
adapters: AdaptersConfig::default(), adapters: AdaptersConfig::default(),
notifications: NotificationsConfig::default(),
events: EventsConfig::default(), events: EventsConfig::default(),
} }
} }
@ -102,6 +126,15 @@ impl Default for LuaConfig {
} }
} }
impl Default for ModulesConfig {
fn default() -> Self {
Self {
builtin: default_true(),
disable: Vec::new(),
}
}
}
impl Default for AdaptersConfig { impl Default for AdaptersConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -147,6 +180,16 @@ impl Default for EventsConfig {
} }
} }
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
default_timeout_ms: default_notify_timeout(),
default_urgency: default_notify_urgency(),
notify_send_path: default_notify_path(),
}
}
}
impl Config { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let path = config_path(); let path = config_path();
@ -218,6 +261,18 @@ fn default_dedup_window() -> u64 {
100 100
} }
fn default_notify_timeout() -> i64 {
3000
}
fn default_notify_urgency() -> String {
"normal".to_string()
}
fn default_notify_path() -> String {
"notify-send".to_string()
}
fn default_udev_subsystems() -> Vec<String> { fn default_udev_subsystems() -> Vec<String> {
vec![ vec![
"usb".to_string(), "usb".to_string(),

View file

@ -80,22 +80,102 @@ impl EventNormalizer {
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
let mapped = match kind { let data = raw
"workspace" | "workspacev2" => "bread.workspace.changed", .payload
"monitoradded" => "bread.monitor.connected", .get("data")
"monitorremoved" => "bread.monitor.disconnected", .and_then(Value::as_str)
"activewindow" | "activewindowv2" => "bread.window.focus.changed", .unwrap_or("");
"openwindow" => "bread.window.opened",
"closewindow" => "bread.window.closed",
_ => "bread.hyprland.event",
};
vec![BreadEvent { match kind {
event: mapped.to_string(), "workspace" | "workspacev2" => vec![BreadEvent {
timestamp: raw.timestamp, event: "bread.workspace.changed".to_string(),
source: AdapterSource::Hyprland, timestamp: raw.timestamp,
data: raw.payload.clone(), source: AdapterSource::Hyprland,
}] data: raw.payload.clone(),
}],
"createworkspace" => vec![BreadEvent {
event: "bread.workspace.created".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"destroyworkspace" => vec![BreadEvent {
event: "bread.workspace.destroyed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "workspace": data }),
}],
"monitoradded" => vec![BreadEvent {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
"activewindowv2" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.focused".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&"")
}),
}]
}
"openwindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.opened".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
"class": fields.get(2).unwrap_or(&""),
"title": fields.get(3).unwrap_or(&""),
}),
}]
}
"closewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.closed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "address": fields.get(0).unwrap_or(&"") }),
}]
}
"movewindow" => {
let fields = split_hyprland_fields(data);
vec![BreadEvent {
event: "bread.window.moved".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
}),
}]
}
_ => vec![BreadEvent {
event: "bread.hyprland.event".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}],
}
} }
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
@ -201,34 +281,112 @@ impl EventNormalizer {
} }
} }
fn split_hyprland_fields(data: &str) -> Vec<&str> {
if data.is_empty() {
return Vec::new();
}
data.split(">>").collect()
}
fn classify_device(payload: &Value) -> DeviceClass { fn classify_device(payload: &Value) -> DeviceClass {
let name = payload
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let subsystem = payload let subsystem = payload
.get("subsystem") .get("subsystem")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_lowercase(); .to_lowercase();
if name.contains("dock") { // --- Property-based classification (reliable, hardware-agnostic) ---
return DeviceClass::Dock;
} // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device.
if subsystem == "input" && name.contains("keyboard") { if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Keyboard; return DeviceClass::Keyboard;
} }
if subsystem == "input" && name.contains("mouse") {
// ID_INPUT_MOUSE=1 covers mice and trackballs.
if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Mouse; return DeviceClass::Mouse;
} }
// ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc).
if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) {
return DeviceClass::Tablet;
}
// USB class 0x09 = Hub. Docks expose a hub interface; they also typically
// expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces.
// We check for hub + at least one of those secondary interfaces.
if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) {
let ifaces_lc = ifaces.to_lowercase();
let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902");
let has_secondary = ifaces_lc.contains(":0e") // video
|| ifaces_lc.contains(":0200") // CDC ethernet
|| ifaces_lc.contains(":0100") // audio
|| ifaces_lc.contains(":0801"); // mass storage
if has_hub && has_secondary {
return DeviceClass::Dock;
}
}
// USB class 0x01 = Audio.
if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) {
if cls == "01" || cls.to_lowercase() == "0x01" {
return DeviceClass::Audio;
}
// USB class 0x08 = Mass Storage.
if cls == "08" || cls.to_lowercase() == "0x08" {
return DeviceClass::Storage;
}
}
// DRM subsystem = display connector.
if subsystem == "drm" { if subsystem == "drm" {
return DeviceClass::Display; return DeviceClass::Display;
} }
if subsystem == "sound" || name.contains("audio") {
// Block devices = storage.
if subsystem == "block" {
return DeviceClass::Storage;
}
// Sound subsystem = audio.
if subsystem == "sound" {
return DeviceClass::Audio; return DeviceClass::Audio;
} }
if subsystem == "block" || name.contains("storage") {
// --- Name-based fallback (catches user-registered patterns and obvious names) ---
// This runs last so the property-based rules above always win.
let name = payload
.get("name")
.and_then(Value::as_str)
.or_else(|| payload.get("id_model").and_then(Value::as_str))
.unwrap_or_default()
.to_lowercase();
let vendor = payload
.get("id_vendor")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let combined = format!("{name} {vendor}");
if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") {
return DeviceClass::Dock;
}
if combined.contains("keyboard") || combined.contains("kbd") {
return DeviceClass::Keyboard;
}
if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") {
return DeviceClass::Mouse;
}
if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") {
return DeviceClass::Tablet;
}
if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") {
return DeviceClass::Audio;
}
if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") {
return DeviceClass::Storage; return DeviceClass::Storage;
} }

View file

@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result; use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent}; use bread_shared::{AdapterSource, BreadEvent};
@ -15,6 +16,7 @@ use crate::lua::LuaMessage;
pub struct StateHandle { pub struct StateHandle {
state: Arc<RwLock<RuntimeState>>, state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>, command_tx: mpsc::UnboundedSender<StateCommand>,
subscription_count: Arc<AtomicU64>,
} }
pub enum StateCommand { pub enum StateCommand {
@ -38,6 +40,7 @@ pub enum StateCommand {
name: String, name: String,
status: ModuleLoadState, status: ModuleLoadState,
last_error: Option<String>, last_error: Option<String>,
builtin: bool,
}, },
SetProfile { SetProfile {
name: String, name: String,
@ -45,8 +48,16 @@ pub enum StateCommand {
} }
impl StateHandle { impl StateHandle {
pub fn new(state: Arc<RwLock<RuntimeState>>, command_tx: mpsc::UnboundedSender<StateCommand>) -> Self { pub fn new(
Self { state, command_tx } state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>,
subscription_count: Arc<AtomicU64>,
) -> Self {
Self {
state,
command_tx,
subscription_count,
}
} }
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> { pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
@ -101,17 +112,28 @@ impl StateHandle {
let _ = self.command_tx.send(StateCommand::ClearSubscriptions); let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
} }
pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option<String>) { pub fn set_module_status(
&self,
name: String,
status: ModuleLoadState,
last_error: Option<String>,
builtin: bool,
) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus { let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name, name,
status, status,
last_error, last_error,
builtin,
}); });
} }
pub fn set_profile(&self, name: String) { pub fn set_profile(&self, name: String) {
let _ = self.command_tx.send(StateCommand::SetProfile { name }); let _ = self.command_tx.send(StateCommand::SetProfile { name });
} }
pub fn subscription_count(&self) -> Arc<AtomicU64> {
self.subscription_count.clone()
}
} }
pub async fn run_state_engine( pub async fn run_state_engine(
@ -120,6 +142,7 @@ pub async fn run_state_engine(
state: Arc<RwLock<RuntimeState>>, state: Arc<RwLock<RuntimeState>>,
lua_tx: mpsc::UnboundedSender<LuaMessage>, lua_tx: mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: broadcast::Sender<BreadEvent>, event_stream_tx: broadcast::Sender<BreadEvent>,
subscription_count: Arc<AtomicU64>,
mut shutdown_rx: watch::Receiver<bool>, mut shutdown_rx: watch::Receiver<bool>,
) { ) {
let mut subscriptions = SubscriptionTable::default(); let mut subscriptions = SubscriptionTable::default();
@ -136,7 +159,7 @@ pub async fn run_state_engine(
let Some(cmd) = maybe_cmd else { let Some(cmd) = maybe_cmd else {
break; break;
}; };
handle_command(cmd, &state, &mut subscriptions, &mut watches).await; handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
} }
maybe_event = event_rx.recv() => { maybe_event = event_rx.recv() => {
let Some(event) = maybe_event else { let Some(event) = maybe_event else {
@ -158,7 +181,7 @@ pub async fn run_state_engine(
apply_event_to_state(&mut guard, &event); apply_event_to_state(&mut guard, &event);
} }
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx); dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) { if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
for (_id, path) in watches.iter() { for (_id, path) in watches.iter() {
@ -174,7 +197,7 @@ pub async fn run_state_engine(
"old": old_val, "old": old_val,
}), }),
); );
dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx); dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
} }
} }
} }
@ -190,13 +213,17 @@ async fn handle_command(
state: &Arc<RwLock<RuntimeState>>, state: &Arc<RwLock<RuntimeState>>,
subscriptions: &mut SubscriptionTable, subscriptions: &mut SubscriptionTable,
watches: &mut HashMap<SubscriptionId, String>, watches: &mut HashMap<SubscriptionId, String>,
subscription_count: &Arc<AtomicU64>,
) { ) {
match cmd { match cmd {
StateCommand::RegisterSubscription { id, pattern, once } => { StateCommand::RegisterSubscription { id, pattern, once } => {
subscriptions.add_with_id(id, pattern, once); subscriptions.add_with_id(id, pattern, once);
subscription_count.fetch_add(1, Ordering::Relaxed);
} }
StateCommand::RemoveSubscription { id } => { StateCommand::RemoveSubscription { id } => {
subscriptions.remove(id); if subscriptions.remove(id) {
subscription_count.fetch_sub(1, Ordering::Relaxed);
}
} }
StateCommand::RegisterWatch { id, path } => { StateCommand::RegisterWatch { id, path } => {
watches.insert(id, path); watches.insert(id, path);
@ -207,21 +234,25 @@ async fn handle_command(
StateCommand::ClearSubscriptions => { StateCommand::ClearSubscriptions => {
subscriptions.clear(); subscriptions.clear();
watches.clear(); watches.clear();
subscription_count.store(0, Ordering::Relaxed);
} }
StateCommand::SetModuleStatus { StateCommand::SetModuleStatus {
name, name,
status, status,
last_error, last_error,
builtin,
} => { } => {
let mut guard = state.write().await; let mut guard = state.write().await;
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) { if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
existing.status = status; existing.status = status;
existing.last_error = last_error; existing.last_error = last_error;
existing.builtin = builtin;
} else { } else {
guard.modules.push(crate::core::types::ModuleStatus { guard.modules.push(crate::core::types::ModuleStatus {
name, name,
status, status,
last_error, last_error,
builtin,
store: HashMap::new(), store: HashMap::new(),
}); });
} }
@ -242,6 +273,7 @@ fn dispatch_event(
subscriptions: &mut SubscriptionTable, subscriptions: &mut SubscriptionTable,
lua_tx: &mpsc::UnboundedSender<LuaMessage>, lua_tx: &mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: &broadcast::Sender<BreadEvent>, event_stream_tx: &broadcast::Sender<BreadEvent>,
subscription_count: &Arc<AtomicU64>,
) { ) {
let _ = event_stream_tx.send(event.clone()); let _ = event_stream_tx.send(event.clone());
@ -254,7 +286,9 @@ fn dispatch_event(
} }
for sub in matches.into_iter().filter(|s| s.once) { for sub in matches.into_iter().filter(|s| s.once) {
subscriptions.remove(sub.id); if subscriptions.remove(sub.id) {
subscription_count.fetch_sub(1, Ordering::Relaxed);
}
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
} }
} }
@ -302,11 +336,12 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
.map(ToString::to_string); .map(ToString::to_string);
state.active_workspace = ws; state.active_workspace = ws;
} }
"bread.window.focus.changed" => { "bread.window.focus.changed" | "bread.window.focused" => {
state.active_window = event state.active_window = event
.data .data
.get("window") .get("window")
.or_else(|| event.data.get("class")) .or_else(|| event.data.get("class"))
.or_else(|| event.data.get("address"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToString::to_string); .map(ToString::to_string);
} }

View file

@ -35,7 +35,6 @@ impl SubscriptionTable {
// swap_remove moves the last element into `idx`. We need to update by_id // swap_remove moves the last element into `idx`. We need to update by_id
// for that element. But first, remove its stale entry (it was at the last // for that element. But first, remove its stale entry (it was at the last
// position before the swap); then re-insert it at the new position. // position before the swap); then re-insert it at the new position.
let _last_idx = self.entries.len() - 1;
self.entries.swap_remove(idx); self.entries.swap_remove(idx);
if idx < self.entries.len() { if idx < self.entries.len() {

View file

@ -123,6 +123,8 @@ pub struct ModuleStatus {
pub status: ModuleLoadState, pub status: ModuleLoadState,
pub last_error: Option<String>, pub last_error: Option<String>,
#[serde(default)] #[serde(default)]
pub builtin: bool,
#[serde(default)]
pub store: HashMap<String, Value>, pub store: HashMap<String, Value>,
} }

View file

@ -1,18 +1,22 @@
use std::collections::{HashMap, VecDeque};
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process; use std::process;
use std::time::Instant; use std::time::Instant;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent}; use bread_shared::{now_unix_ms, AdapterSource, BreadEvent};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch}; use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::adapters::AdapterStatus;
use crate::core::state_engine::StateHandle; use crate::core::state_engine::StateHandle;
use crate::lua::RuntimeHandle; use crate::lua::RuntimeHandle;
@ -23,6 +27,9 @@ pub struct Server {
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
started_at: Instant, started_at: Instant,
pid: u32, pid: u32,
} }
@ -51,6 +58,9 @@ impl Server {
event_tx: broadcast::Sender<BreadEvent>, event_tx: broadcast::Sender<BreadEvent>,
lua_runtime: RuntimeHandle, lua_runtime: RuntimeHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
subscription_count: Arc<AtomicU64>,
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
) -> Self { ) -> Self {
Self { Self {
socket_path, socket_path,
@ -58,6 +68,9 @@ impl Server {
event_tx, event_tx,
lua_runtime, lua_runtime,
emit_tx, emit_tx,
adapter_status,
subscription_count,
event_buffer,
started_at: Instant::now(), started_at: Instant::now(),
pid: process::id(), pid: process::id(),
} }
@ -166,12 +179,25 @@ impl Server {
let full = self.state_handle.state_dump().await; let full = self.state_handle.state_dump().await;
Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) Ok(full.get("modules").cloned().unwrap_or_else(|| json!([])))
} }
"modules.reload" => self "modules.reload" => {
.lua_runtime let started = Instant::now();
.reload() if let Err(err) = self.lua_runtime.reload().await {
.await return Err((id, err.to_string()));
.map(|_| json!({ "reloaded": true })) }
.map_err(|e| e.to_string()), let duration_ms = started.elapsed().as_millis();
let modules = self
.state_handle
.state_dump()
.await
.get("modules")
.cloned()
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"modules": modules,
}))
}
"profile.list" => { "profile.list" => {
let full = self.state_handle.state_dump().await; let full = self.state_handle.state_dump().await;
let profiles = full let profiles = full
@ -224,13 +250,38 @@ impl Server {
} }
"health" => { "health" => {
let uptime_ms = self.started_at.elapsed().as_millis(); let uptime_ms = self.started_at.elapsed().as_millis();
let state = self.state_handle.state_dump().await;
let modules = state.get("modules").cloned().unwrap_or_else(|| json!([]));
let adapters = self.adapter_status.read().await.clone();
let subscription_count = self.subscription_count.load(std::sync::atomic::Ordering::Relaxed);
let recent_errors = self.lua_runtime.recent_errors();
Ok(json!({ Ok(json!({
"ok": true, "ok": true,
"pid": self.pid, "pid": self.pid,
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"uptime_ms": uptime_ms, "uptime_ms": uptime_ms,
"socket": self.socket_path.to_string_lossy(),
"adapters": adapters,
"modules": modules,
"subscriptions": subscription_count,
"recent_errors": recent_errors,
})) }))
} }
"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);
let replay: Vec<BreadEvent> = self
.event_buffer
.lock()
.map(|buf| {
buf.iter()
.filter(|e| e.timestamp >= cutoff)
.cloned()
.collect()
})
.unwrap_or_default();
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
}
_ => Err("unknown method".to_string()), _ => Err("unknown method".to_string()),
}; };
@ -264,9 +315,67 @@ impl Server {
} }
fn matches_filter(event_name: &str, pattern: &str) -> bool { fn matches_filter(event_name: &str, pattern: &str) -> bool {
// Delegate to the same glob logic used by the subscription table so that
// `bread events --filter "bread.device.**"` behaves identically to
// `bread.on("bread.device.**", ...)` in Lua.
if pattern.ends_with(".*") { if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1]; let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix); return event_name.starts_with(prefix);
} }
event_name == pattern
if let Some(prefix) = pattern.strip_suffix(".**") {
if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) {
return true;
}
return false;
}
matches_glob_filter(pattern.as_bytes(), event_name.as_bytes())
}
fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' {
let rest = &pattern[2..];
if rest.is_empty() {
return true;
}
for offset in 0..=text.len() {
if matches_glob_filter(rest, &text[offset..]) {
return true;
}
}
return false;
}
match pattern[0] {
b'*' => {
let mut offset = 0;
loop {
if matches_glob_filter(&pattern[1..], &text[offset..]) {
return true;
}
if offset == text.len() || text[offset] == b'.' {
break;
}
offset += 1;
}
false
}
b'?' => {
if text.is_empty() || text[0] == b'.' {
return false;
}
matches_glob_filter(&pattern[1..], &text[1..])
}
ch => {
if text.first().copied() != Some(ch) {
return false;
}
matches_glob_filter(&pattern[1..], &text[1..])
}
}
} }

View file

@ -1,5 +1,5 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet, VecDeque};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
@ -10,16 +10,18 @@ use std::time::Duration;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use bread_shared::{AdapterSource, BreadEvent}; use bread_shared::{AdapterSource, BreadEvent};
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value};
use serde::Serialize;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use tokio::sync::{mpsc, oneshot, watch, RwLock}; use tokio::sync::{mpsc, oneshot, watch, RwLock};
use tokio::task; use tokio::task;
use tokio::time::{interval, sleep}; use tokio::time::{interval_at, sleep, Instant};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::core::config::Config; use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
use crate::core::state_engine::StateHandle; use crate::core::state_engine::StateHandle;
use crate::core::subscriptions::SubscriptionId; use crate::core::subscriptions::SubscriptionId;
use crate::core::types::{ModuleLoadState, RuntimeState}; use crate::core::types::{ModuleLoadState, RuntimeState};
use bread_shared::now_unix_ms;
pub enum LuaMessage { pub enum LuaMessage {
Event { Event {
@ -38,9 +40,17 @@ pub enum LuaMessage {
Shutdown, Shutdown,
} }
#[derive(Debug, Clone, Serialize)]
pub struct ErrorEntry {
pub timestamp: u64,
pub module: Option<String>,
pub message: String,
}
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeHandle { pub struct RuntimeHandle {
tx: mpsc::UnboundedSender<LuaMessage>, tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
} }
impl RuntimeHandle { impl RuntimeHandle {
@ -63,6 +73,13 @@ impl RuntimeHandle {
pub fn shutdown(&self) { pub fn shutdown(&self) {
let _ = self.tx.send(LuaMessage::Shutdown); let _ = self.tx.send(LuaMessage::Shutdown);
} }
pub fn recent_errors(&self) -> Vec<ErrorEntry> {
self.recent_errors
.lock()
.map(|buf| buf.iter().cloned().collect())
.unwrap_or_default()
}
} }
pub fn spawn_runtime( pub fn spawn_runtime(
@ -71,7 +88,11 @@ pub fn spawn_runtime(
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
) -> Result<RuntimeHandle> { ) -> Result<RuntimeHandle> {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
let handle = RuntimeHandle { tx }; let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50)));
let handle = RuntimeHandle {
tx,
recent_errors: recent_errors.clone(),
};
let thread_tx = handle.tx.clone(); let thread_tx = handle.tx.clone();
std::thread::Builder::new() std::thread::Builder::new()
@ -83,7 +104,13 @@ pub fn spawn_runtime(
.expect("failed to create lua runtime thread"); .expect("failed to create lua runtime thread");
rt.block_on(async move { rt.block_on(async move {
let mut engine = match LuaEngine::new(config, state_handle, emit_tx, thread_tx.clone()) { let mut engine = match LuaEngine::new(
config,
state_handle,
emit_tx,
thread_tx.clone(),
recent_errors,
) {
Ok(engine) => engine, Ok(engine) => engine,
Err(err) => { Err(err) => {
error!(error = %err, "failed to initialize lua engine"); error!(error = %err, "failed to initialize lua engine");
@ -160,6 +187,8 @@ struct ModuleDecl {
version: Option<String>, version: Option<String>,
after: Vec<String>, after: Vec<String>,
path: PathBuf, path: PathBuf,
source: Option<&'static str>,
builtin: bool,
} }
struct ModuleInfo { struct ModuleInfo {
@ -182,6 +211,9 @@ struct LuaEngine {
lua_tx: mpsc::UnboundedSender<LuaMessage>, lua_tx: mpsc::UnboundedSender<LuaMessage>,
entry_point: PathBuf, entry_point: PathBuf,
module_path: PathBuf, module_path: PathBuf,
modules_config: ModulesConfig,
notifications_config: NotificationsConfig,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
} }
impl LuaEngine { impl LuaEngine {
@ -190,6 +222,7 @@ impl LuaEngine {
state_handle: StateHandle, state_handle: StateHandle,
emit_tx: mpsc::UnboundedSender<BreadEvent>, emit_tx: mpsc::UnboundedSender<BreadEvent>,
lua_tx: mpsc::UnboundedSender<LuaMessage>, lua_tx: mpsc::UnboundedSender<LuaMessage>,
recent_errors: Arc<Mutex<VecDeque<ErrorEntry>>>,
) -> Result<Self> { ) -> Result<Self> {
Ok(Self { Ok(Self {
lua: Lua::new(), lua: Lua::new(),
@ -207,6 +240,9 @@ impl LuaEngine {
lua_tx, lua_tx,
entry_point: config.lua_entry_point(), entry_point: config.lua_entry_point(),
module_path: config.lua_module_path(), module_path: config.lua_module_path(),
modules_config: config.modules.clone(),
notifications_config: config.notifications.clone(),
recent_errors,
}) })
} }
@ -324,7 +360,9 @@ impl LuaEngine {
.map_err(|_| LuaError::external("missing filter function"))?; .map_err(|_| LuaError::external("missing filter function"))?;
Some(lua.create_registry_value(filter_fn)?) Some(lua.create_registry_value(filter_fn)?)
} else { } else {
return Err(LuaError::external("missing filter options")); return Err(LuaError::external(
"bread.filter requires an opts table with a 'filter' function: bread.filter(pattern, fn, { filter = fn })",
));
}; };
let module = current_module let module = current_module
.lock() .lock()
@ -503,6 +541,61 @@ impl LuaEngine {
})?; })?;
bread.set("exec", exec_fn)?; bread.set("exec", exec_fn)?;
let notify_path = self.notifications_config.notify_send_path.clone();
let default_urgency = self.notifications_config.default_urgency.clone();
let default_timeout = self.notifications_config.default_timeout_ms;
let emit_tx = self.emit_tx.clone();
let notify_fn = self
.lua
.create_function(move |_lua, (message, opts): (String, Option<Table>)| {
let title: String = opts
.as_ref()
.and_then(|o| o.get("title").ok())
.unwrap_or_else(|| "bread".to_string());
let urgency: String = opts
.as_ref()
.and_then(|o| o.get("urgency").ok())
.unwrap_or_else(|| default_urgency.clone());
let timeout: i64 = opts
.as_ref()
.and_then(|o| o.get("timeout").ok())
.unwrap_or(default_timeout);
let icon: Option<String> = opts.as_ref().and_then(|o| o.get("icon").ok());
let cmd_path = notify_path.clone();
let title_clone = title.clone();
let message_clone = message.clone();
let urgency_clone = urgency.clone();
task::spawn_blocking(move || {
let mut cmd = std::process::Command::new(cmd_path);
cmd.args([
"--app-name",
"bread",
"--urgency",
&urgency_clone,
"--expire-time",
&timeout.to_string(),
]);
if let Some(icon) = icon {
cmd.args(["--icon", &icon]);
}
let _ = cmd.args([&title_clone, &message_clone]).status();
});
let _ = emit_tx.send(BreadEvent::new(
"bread.notify.sent",
AdapterSource::System,
serde_json::json!({
"title": title,
"message": message,
"urgency": urgency,
}),
));
Ok(())
})?;
bread.set("notify", notify_fn)?;
let timers = self.timers.clone(); let timers = self.timers.clone();
let next_timer_id = self.next_timer_id.clone(); let next_timer_id = self.next_timer_id.clone();
let lua_tx = self.lua_tx.clone(); let lua_tx = self.lua_tx.clone();
@ -556,7 +649,8 @@ impl LuaEngine {
); );
let lua_tx = lua_tx.clone(); let lua_tx = lua_tx.clone();
task::spawn(async move { task::spawn(async move {
let mut ticker = interval(Duration::from_millis(interval_ms)); let start = Instant::now() + Duration::from_millis(interval_ms);
let mut ticker = interval_at(start, Duration::from_millis(interval_ms));
loop { loop {
tokio::select! { tokio::select! {
_ = ticker.tick() => { _ = ticker.tick() => {
@ -746,16 +840,28 @@ impl LuaEngine {
globals.set("bread", bread)?; globals.set("bread", bread)?;
self.install_require_loader()?; self.install_require_loader()?;
self.install_wait_helper()?; self.install_wait_helper()?;
self.install_log_helpers()?;
self.install_debounce()?;
Ok(()) Ok(())
} }
fn load_init_and_modules(&self) -> Result<()> { fn load_init_and_modules(&self) -> Result<()> {
self.load_lua_file(&self.entry_point, "init")?; self.load_lua_file(&self.entry_point, "init", false)?;
let mut files = list_lua_files(&self.module_path)?; let mut files = list_lua_files(&self.module_path)?;
files.sort(); files.sort();
let disabled: HashSet<String> = self
.modules_config
.disable
.iter()
.cloned()
.collect();
let mut decls = Vec::new(); let mut decls = Vec::new();
if self.modules_config.builtin {
decls.extend(builtin_module_decls(&disabled));
}
for path in files.into_iter().filter(|p| !is_lib_path(&self.module_path, p)) { for path in files.into_iter().filter(|p| !is_lib_path(&self.module_path, p)) {
match self.scan_module_decl(&path) { match self.scan_module_decl(&path) {
Ok(decl) => decls.push(decl), Ok(decl) => decls.push(decl),
@ -765,6 +871,7 @@ impl LuaEngine {
name, name,
ModuleLoadState::LoadError, ModuleLoadState::LoadError,
Some(err.to_string()), Some(err.to_string()),
false,
); );
} }
} }
@ -784,7 +891,7 @@ impl LuaEngine {
for (name, err) in dep_errors { for (name, err) in dep_errors {
self.state_handle self.state_handle
.set_module_status(name, ModuleLoadState::LoadError, Some(err)); .set_module_status(name, ModuleLoadState::LoadError, Some(err), false);
} }
let mut load_order = Vec::new(); let mut load_order = Vec::new();
@ -792,14 +899,19 @@ impl LuaEngine {
load_order.push(decl.name.clone()); load_order.push(decl.name.clone());
match self.load_module(&decl) { match self.load_module(&decl) {
Ok(()) => { Ok(()) => {
self.state_handle self.state_handle.set_module_status(
.set_module_status(decl.name.clone(), ModuleLoadState::Loaded, None); decl.name.clone(),
ModuleLoadState::Loaded,
None,
decl.builtin,
);
} }
Err(err) => { Err(err) => {
self.state_handle.set_module_status( self.state_handle.set_module_status(
decl.name.clone(), decl.name.clone(),
ModuleLoadState::LoadError, ModuleLoadState::LoadError,
Some(err.to_string()), Some(err.to_string()),
decl.builtin,
); );
} }
} }
@ -815,7 +927,11 @@ impl LuaEngine {
fn load_module(&self, decl: &ModuleDecl) -> Result<()> { fn load_module(&self, decl: &ModuleDecl) -> Result<()> {
self.set_current_module(Some(decl.name.clone())); self.set_current_module(Some(decl.name.clone()));
let result = self.load_lua_file(&decl.path, &decl.name); let result = if let Some(source) = decl.source.as_deref() {
self.load_lua_source(source, &decl.name)
} else {
self.load_lua_file(&decl.path, &decl.name, decl.builtin)
};
self.set_current_module(None); self.set_current_module(None);
result?; result?;
@ -827,13 +943,14 @@ impl LuaEngine {
Ok(()) Ok(())
} }
fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> { fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> {
if !path.exists() { if !path.exists() {
warn!(path = %path.display(), "lua file does not exist; skipping"); warn!(path = %path.display(), "lua file does not exist; skipping");
self.state_handle.set_module_status( self.state_handle.set_module_status(
module_name.to_string(), module_name.to_string(),
ModuleLoadState::NotFound, ModuleLoadState::NotFound,
None, None,
builtin,
); );
return Ok(()); return Ok(());
} }
@ -843,6 +960,14 @@ impl LuaEngine {
Ok(()) Ok(())
} }
fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> {
self.lua
.load(source)
.set_name(module_name)
.exec()
.map_err(|e| anyhow!(e.to_string()))
}
fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> { fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> {
let (callback, filter, raw_kind, kind, module) = { let (callback, filter, raw_kind, kind, module) = {
let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); let handlers = self.handlers.lock().expect("lua handlers mutex poisoned");
@ -935,8 +1060,13 @@ impl LuaEngine {
if let Some(hook) = self.get_module_hook(name, "on_load") { if let Some(hook) = self.get_module_hook(name, "on_load") {
if let Err(err) = hook.call::<_, ()>(()) { if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_load failed"); error!(module = %name, error = %err, "module on_load failed");
self.state_handle let builtin = self.module_is_builtin(name);
.set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string())); self.state_handle.set_module_status(
name.to_string(),
ModuleLoadState::LoadError,
Some(err.to_string()),
builtin,
);
} }
} }
} }
@ -951,10 +1081,12 @@ impl LuaEngine {
if let Some(hook) = self.get_module_hook(&name, "on_reload") { if let Some(hook) = self.get_module_hook(&name, "on_reload") {
if let Err(err) = hook.call::<_, ()>(()) { if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_reload failed"); error!(module = %name, error = %err, "module on_reload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status( self.state_handle.set_module_status(
name.to_string(), name.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin,
); );
} }
} }
@ -971,10 +1103,12 @@ impl LuaEngine {
if let Some(hook) = self.get_module_hook(&name, "on_unload") { if let Some(hook) = self.get_module_hook(&name, "on_unload") {
if let Err(err) = hook.call::<_, ()>(()) { if let Err(err) = hook.call::<_, ()>(()) {
error!(module = %name, error = %err, "module on_unload failed"); error!(module = %name, error = %err, "module on_unload failed");
let builtin = self.module_is_builtin(&name);
self.state_handle.set_module_status( self.state_handle.set_module_status(
name.to_string(), name.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin,
); );
} }
} }
@ -983,10 +1117,22 @@ impl LuaEngine {
fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) { fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) {
if let Some(module) = module { if let Some(module) = module {
let builtin = self.module_is_builtin(module);
if let Ok(mut buf) = self.recent_errors.lock() {
if buf.len() >= 50 {
buf.pop_front();
}
buf.push_back(ErrorEntry {
timestamp: now_unix_ms(),
module: Some(module.to_string()),
message: err.to_string(),
});
}
self.state_handle.set_module_status( self.state_handle.set_module_status(
module.to_string(), module.to_string(),
ModuleLoadState::Degraded, ModuleLoadState::Degraded,
Some(err.to_string()), Some(err.to_string()),
builtin,
); );
if let Some(hook) = self.get_module_hook(module, "on_error") { if let Some(hook) = self.get_module_hook(module, "on_error") {
match hook.call::<_, bool>(err.to_string()) { match hook.call::<_, bool>(err.to_string()) {
@ -1022,6 +1168,14 @@ impl LuaEngine {
.unwrap_or(false) .unwrap_or(false)
} }
fn module_is_builtin(&self, name: &str) -> bool {
self.module_decls
.lock()
.ok()
.and_then(|map| map.get(name).map(|d| d.builtin))
.unwrap_or(false)
}
fn set_current_module(&self, name: Option<String>) { fn set_current_module(&self, name: Option<String>) {
if let Ok(mut guard) = self.current_module.lock() { if let Ok(mut guard) = self.current_module.lock() {
*guard = name; *guard = name;
@ -1036,6 +1190,90 @@ impl LuaEngine {
} }
} }
fn install_log_helpers(&self) -> Result<()> {
// bread.log(msg) → tracing::info
// bread.warn(msg) → tracing::warn
// bread.error(msg) → tracing::error
//
// Each accepts any Lua value and coerces it to a string via tostring()
// so callers can do bread.log(some_table) without a crash.
self.lua.load(r#"
local _bread = bread
local function stringify(v)
if type(v) == "string" then
return v
end
return tostring(v)
end
function _bread.log(msg)
_bread.__log_info(stringify(msg))
end
function _bread.warn(msg)
_bread.__log_warn(stringify(msg))
end
function _bread.error(msg)
_bread.__log_error(stringify(msg))
end
"#).exec()?;
// Register the raw Rust-backed log functions that the Lua wrappers call.
let globals = self.lua.globals();
let bread: mlua::Table = globals.get("bread")?;
let info_fn = self.lua.create_function(|_, msg: String| {
tracing::info!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_info", info_fn)?;
let warn_fn = self.lua.create_function(|_, msg: String| {
tracing::warn!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_warn", warn_fn)?;
let error_fn = self.lua.create_function(|_, msg: String| {
tracing::error!(target: "bread.lua", "{}", msg);
Ok(())
})?;
bread.set("__log_error", error_fn)?;
Ok(())
}
fn install_debounce(&self) -> Result<()> {
// bread.debounce(delay_ms, fn) → wrapped_fn
//
// Returns a new function. When that function is called, it resets a
// timer. The original function is only called once the timer expires
// without being reset. Useful for rapid hardware events (e.g. monitor
// topology changes that fire multiple events in quick succession).
//
// Because the Lua runtime is single-threaded, we implement this in
// pure Lua using bread.cancel / bread.after.
self.lua.load(r#"
function bread.debounce(delay_ms, fn)
local timer_id = nil
return function(...)
local args = { ... }
if timer_id then
bread.cancel(timer_id)
timer_id = nil
end
timer_id = bread.after(delay_ms, function()
timer_id = nil
fn(table.unpack(args))
end)
end
end
"#).exec()?;
Ok(())
}
fn scan_module_decl(&self, path: &Path) -> Result<ModuleDecl> { fn scan_module_decl(&self, path: &Path) -> Result<ModuleDecl> {
const MODULE_DECL_ABORT: &str = "__bread_module_decl__"; const MODULE_DECL_ABORT: &str = "__bread_module_decl__";
let lua = Lua::new(); let lua = Lua::new();
@ -1052,6 +1290,8 @@ impl LuaEngine {
version, version,
after, after,
path: module_path.clone(), path: module_path.clone(),
source: None,
builtin: false,
}); });
Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string()))
})?; })?;
@ -1119,6 +1359,14 @@ impl LuaEngine {
self.lua self.lua
.load( .load(
r#" r#"
bread.spawn = function(fn)
local co = coroutine.create(fn)
local ok, err = coroutine.resume(co)
if not ok then
error(err)
end
end
bread.wait = function(pattern, opts) bread.wait = function(pattern, opts)
if type(pattern) ~= "string" then if type(pattern) ~= "string" then
error("bread.wait requires a pattern string") error("bread.wait requires a pattern string")
@ -1251,7 +1499,15 @@ fn state_value_to_lua<'lua>(
state_arc: &Arc<RwLock<RuntimeState>>, state_arc: &Arc<RwLock<RuntimeState>>,
path: &str, path: &str,
) -> mlua::Result<Value<'lua>> { ) -> mlua::Result<Value<'lua>> {
let snapshot = state_arc.blocking_read(); // The Lua thread runs a current_thread runtime. blocking_read and block_in_place
// both require the multi-thread runtime and panic here. try_read succeeds
// immediately in the common case; the write lock is held for microseconds.
let snapshot = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
};
let mut value = serde_json::to_value(&*snapshot) let mut value = serde_json::to_value(&*snapshot)
.map_err(|e| LuaError::external(e.to_string()))?; .map_err(|e| LuaError::external(e.to_string()))?;
if path.is_empty() { if path.is_empty() {
@ -1270,13 +1526,23 @@ fn state_value_to_lua<'lua>(
} }
fn module_store_get(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: &str) -> Option<JsonValue> { fn module_store_get(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: &str) -> Option<JsonValue> {
let guard = state_arc.blocking_read(); let guard = loop {
if let Ok(g) = state_arc.try_read() {
break g;
}
std::hint::spin_loop();
};
let entry = guard.modules.iter().find(|m| m.name == module)?; let entry = guard.modules.iter().find(|m| m.name == module)?;
entry.store.get(key).cloned() entry.store.get(key).cloned()
} }
fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: String, value: JsonValue) { fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: String, value: JsonValue) {
let mut guard = state_arc.blocking_write(); let mut guard = loop {
if let Ok(g) = state_arc.try_write() {
break g;
}
std::hint::spin_loop();
};
if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) { if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) {
entry.store.insert(key, value); entry.store.insert(key, value);
return; return;
@ -1288,10 +1554,307 @@ fn module_store_set(state_arc: &Arc<RwLock<RuntimeState>>, module: &str, key: St
name: module.to_string(), name: module.to_string(),
status: ModuleLoadState::Loaded, status: ModuleLoadState::Loaded,
last_error: None, last_error: None,
builtin: false,
store, store,
}); });
} }
const BUILTIN_MONITORS: &str = r#"
local M = bread.module({ name = "bread.monitors", version = "1.0.0" })
local workflows = {}
local layouts = {}
local function matches_when(event_name, when)
if when == "connected" then
return event_name == "bread.monitor.connected"
elseif when == "disconnected" then
return event_name == "bread.monitor.disconnected"
elseif when == "changed" then
return event_name == "bread.monitor.changed"
end
return false
end
local function matches_monitors(list, event)
if not list or #list == 0 then
return true
end
local name = event.data and event.data.name
if not name then
return false
end
for _, monitor in ipairs(list) do
if monitor == name then
return true
end
end
return false
end
local function run_workflow(wf, event)
if type(wf.run) == "function" then
wf.run(event)
elseif type(wf.run) == "string" then
bread.exec(wf.run)
end
end
function M.on(opts)
table.insert(workflows, opts)
end
function M.layout(name, fn)
layouts[name] = fn
end
function M.apply(name)
return function()
local fn = layouts[name]
if fn then
fn()
end
end
end
function M.on_load()
bread.on("bread.monitor.**", function(event)
for _, wf in ipairs(workflows) do
if matches_when(event.event, wf.when) and matches_monitors(wf.monitors, event) then
run_workflow(wf, event)
end
end
end)
end
return M
"#;
const BUILTIN_DEVICES: &str = r#"
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
local rules = {}
local user_patterns = {} -- { { pattern = "...", class = "..." }, ... }
local function matches_rule(rule, event)
local class = rule.class
local when = rule.when
local data = event.data or {}
if when == "connected" and event.event ~= "bread.device.connected" then
if not event.event:match("%.connected$") then
return false
end
elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then
if not event.event:match("%.disconnected$") then
return false
end
end
if class and data.class ~= class then
return false
end
if rule.name and data.name and not tostring(data.name):match(rule.name) then
return false
end
return true
end
local function run_rule(rule, event)
if type(rule.run) == "function" then
rule.run(event)
elseif type(rule.run) == "string" then
bread.exec(rule.run)
end
end
-- Reclassify an event's data.class based on user-registered name patterns.
-- Called before rule matching so that user-registered patterns take effect
-- even for devices that the daemon classified as Unknown.
local function apply_user_patterns(event)
if not event.data then return event end
local name = tostring(event.data.name or ""):lower()
local vendor = tostring(event.data.vendor or ""):lower()
local combined = name .. " " .. vendor
for _, p in ipairs(user_patterns) do
if combined:find(p.pattern, 1, true) then
-- Return a shallow copy with the class overridden so we don't
-- mutate the original event that other handlers may receive.
local patched = {}
for k, v in pairs(event) do patched[k] = v end
patched.data = {}
for k, v in pairs(event.data) do patched.data[k] = v end
patched.data.class = p.class
return patched
end
end
return event
end
function M.on(opts)
table.insert(rules, opts)
end
-- Register a user-defined device pattern so the daemon can correctly classify
-- hardware that the automatic classifier doesn't recognise.
--
-- Usage:
-- local devices = require("bread.devices")
-- devices.register("CalDigit", "dock")
-- devices.register("Keychron", "keyboard")
-- devices.register("MX Master", "mouse")
--
-- The pattern is matched case-insensitively against the device name and vendor
-- combined. The class must be one of: dock, keyboard, mouse, tablet, display,
-- storage, audio, unknown.
function M.register(pattern, class)
table.insert(user_patterns, { pattern = pattern:lower(), class = class })
end
function M.on_load()
bread.on("bread.device.**", function(event)
local patched = apply_user_patterns(event)
for _, rule in ipairs(rules) do
if matches_rule(rule, patched) then
run_rule(rule, patched)
end
end
end)
end
return M
"#;
const BUILTIN_WORKSPACES: &str = r#"
local M = bread.module({ name = "bread.workspaces", version = "1.0.0", after = { "bread.monitors" } })
local assignments = {}
local rules = {}
function M.assign(workspace, monitor)
table.insert(assignments, { workspace = workspace, monitor = monitor })
end
function M.pin(opts)
table.insert(rules, opts)
end
function M.apply_assignments()
local monitors = bread.state.monitors()
local active = {}
for _, m in ipairs(monitors) do
if m.connected then
active[m.name] = true
end
end
for _, a in ipairs(assignments) do
if active[a.monitor] then
bread.hyprland.dispatch("moveworkspacetomonitor", a.workspace .. " " .. a.monitor)
end
end
end
function M.on_load()
bread.on("bread.monitor.**", function()
M.apply_assignments()
end)
bread.on("bread.window.opened", function(event)
for _, rule in ipairs(rules) do
if event.data and event.data.class and event.data.class:match(rule.app) then
local address = event.data.address or ""
bread.hyprland.dispatch("movetoworkspacesilent", rule.workspace .. ",address:" .. address)
end
end
end)
bread.once("bread.system.startup", function()
M.apply_assignments()
end)
end
return M
"#;
const BUILTIN_BINDS: &str = r#"
local M = bread.module({ name = "bread.binds", version = "1.0.0" })
local active = {}
local function bind_string(opts)
local mods = table.concat(opts.mods or {}, " ")
local args = opts.args or ""
if mods ~= "" then
return mods .. ", " .. opts.key .. ", " .. opts.dispatch .. ", " .. args
end
return opts.key .. ", " .. opts.dispatch .. ", " .. args
end
function M.add(opts)
local bind = bind_string(opts)
bread.hyprland.keyword("bind", bind)
active[opts.key] = opts
return opts.key
end
function M.remove(key)
local bind = active[key]
if not bind then
return
end
bread.hyprland.keyword("unbind", bind_string(bind))
active[key] = nil
end
function M.replace(key, opts)
M.remove(key)
return M.add(opts)
end
function M.on_unload()
for key, _ in pairs(active) do
M.remove(key)
end
end
return M
"#;
fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
let mut out = Vec::new();
let entries = vec![
("bread.monitors", "1.0.0", Vec::new(), BUILTIN_MONITORS),
("bread.devices", "1.0.0", Vec::new(), BUILTIN_DEVICES),
(
"bread.workspaces",
"1.0.0",
vec!["bread.monitors".to_string()],
BUILTIN_WORKSPACES,
),
("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS),
];
for (name, version, after, source) in entries {
if disabled.contains(name) {
continue;
}
out.push(ModuleDecl {
name: name.to_string(),
version: Some(version.to_string()),
after,
path: PathBuf::from(format!("<builtin:{name}>")),
source: Some(source),
builtin: true,
});
}
out
}
fn hyprland_request_socket() -> Result<PathBuf> { fn hyprland_request_socket() -> Result<PathBuf> {
let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
@ -1307,7 +1870,7 @@ fn hyprland_request(request: &str) -> Result<String> {
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
let socket = hyprland_request_socket()?; let socket = hyprland_request_socket()?;
let mut stream = UnixStream::connect(socket)?; let mut stream = UnixStream::connect(&socket)?;
stream.write_all(request.as_bytes())?; stream.write_all(request.as_bytes())?;
let mut buffer = String::new(); let mut buffer = String::new();
stream.read_to_string(&mut buffer)?; stream.read_to_string(&mut buffer)?;

View file

@ -3,7 +3,9 @@ mod core;
mod ipc; mod ipc;
mod lua; mod lua;
use std::collections::VecDeque;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use anyhow::Result; use anyhow::Result;
use bread_shared::{AdapterSource, BreadEvent, RawEvent}; use bread_shared::{AdapterSource, BreadEvent, RawEvent};
@ -33,7 +35,8 @@ async fn main() -> Result<()> {
let (event_stream_tx, _) = broadcast::channel(2048); let (event_stream_tx, _) = broadcast::channel(2048);
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
let state_handle = StateHandle::new(state.clone(), state_cmd_tx); let subscription_count = Arc::new(AtomicU64::new(0));
let state_handle = StateHandle::new(state.clone(), state_cmd_tx, subscription_count.clone());
let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
let lua_tx = lua_runtime.sender(); let lua_tx = lua_runtime.sender();
@ -44,6 +47,7 @@ async fn main() -> Result<()> {
state.clone(), state.clone(),
lua_tx, lua_tx,
event_stream_tx.clone(), event_stream_tx.clone(),
subscription_count.clone(),
shutdown_rx.clone(), shutdown_rx.clone(),
)); ));
@ -78,6 +82,28 @@ async fn main() -> Result<()> {
let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone());
adapter_manager.start_all().await?; adapter_manager.start_all().await?;
let adapter_status = adapter_manager.status_handle();
let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000)));
{
let mut rx = event_stream_tx.subscribe();
let event_buffer = event_buffer.clone();
tokio::spawn(async move {
loop {
let evt = match rx.recv().await {
Ok(evt) => evt,
Err(_) => break,
};
if let Ok(mut buf) = event_buffer.lock() {
if buf.len() >= 1000 {
buf.pop_front();
}
buf.push_back(evt);
}
}
});
}
let _ = normalized_tx.send(BreadEvent::new( let _ = normalized_tx.send(BreadEvent::new(
"bread.system.startup", "bread.system.startup",
AdapterSource::System, AdapterSource::System,
@ -90,6 +116,9 @@ async fn main() -> Result<()> {
event_stream_tx, event_stream_tx,
lua_runtime.clone(), lua_runtime.clone(),
normalized_tx, normalized_tx,
adapter_status,
subscription_count,
event_buffer,
); );
info!("breadd fully started"); info!("breadd fully started");

View file

@ -20,6 +20,6 @@ build() {
package() { package() {
cd "${srcdir}/${pkgname}-${pkgver}" cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd"
install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli" install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread"
install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service"
} }

View file

@ -5,7 +5,7 @@ Wants=graphical-session.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.cargo/bin/breadd ExecStart=/usr/bin/breadd
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077

36
scripts/install.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}"
SERVICE_DIR="${HOME}/.config/systemd/user"
# ── build ──────────────────────────────────────────────────────────────────────
echo "building bread (release)..."
cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml"
# ── install binaries ───────────────────────────────────────────────────────────
echo "installing binaries to $INSTALL_PREFIX (requires sudo)..."
sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd"
sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread"
echo " installed $INSTALL_PREFIX/breadd"
echo " installed $INSTALL_PREFIX/bread"
# ── systemd user service ───────────────────────────────────────────────────────
echo "installing systemd user service..."
mkdir -p "$SERVICE_DIR"
install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service"
echo " installed $SERVICE_DIR/breadd.service"
systemctl --user daemon-reload
systemctl --user enable --now breadd
echo " breadd enabled and started"
# ── verify ─────────────────────────────────────────────────────────────────────
sleep 0.5
if bread ping &>/dev/null; then
echo ""
bread doctor
else
echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20"
fi