Enhance installation process, update service paths, and improve device classification

This commit is contained in:
Breadway 2026-05-11 18:39:39 +08:00
parent edb2ba338a
commit 55d103b3cf
12 changed files with 323 additions and 70 deletions

3
.gitignore vendored
View file

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

1
Cargo.lock generated
View file

@ -289,6 +289,7 @@ dependencies = [
"anyhow", "anyhow",
"bread-shared", "bread-shared",
"clap", "clap",
"libc",
"notify", "notify",
"serde", "serde",
"serde_json", "serde_json",

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

View file

@ -5,7 +5,7 @@ use serde_json::{json, Value};
use std::env; use std::env;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH}; 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; use tokio::sync::mpsc;
@ -331,18 +331,6 @@ fn print_reload(value: &Value) {
} }
} }
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)?;
use tokio::time::{sleep, Duration};
async fn watch_reload(socket: &Path) -> Result<()> { async fn watch_reload(socket: &Path) -> Result<()> {
let config_dir = config_directory(); let config_dir = config_directory();
println!("watching {} for changes...", config_dir.display()); println!("watching {} for changes...", config_dir.display());
@ -360,7 +348,7 @@ async fn watch_reload(socket: &Path) -> Result<()> {
// Debounce: drain any follow-up events that arrive within 150ms. // Debounce: drain any follow-up events that arrive within 150ms.
// A single file save typically generates 2-3 fs events in rapid succession. // A single file save typically generates 2-3 fs events in rapid succession.
sleep(Duration::from_millis(150)).await; tokio::time::sleep(Duration::from_millis(150)).await;
while rx.try_recv().is_ok() {} while rx.try_recv().is_ok() {}
let response = send_request(socket, "modules.reload", json!({})).await?; let response = send_request(socket, "modules.reload", json!({})).await?;
@ -370,9 +358,6 @@ async fn watch_reload(socket: &Path) -> Result<()> {
Ok(()) Ok(())
} }
Ok(())
}
async fn print_doctor(socket: &Path) -> Result<()> { async fn print_doctor(socket: &Path) -> Result<()> {
let stream = match UnixStream::connect(socket).await { let stream = match UnixStream::connect(socket).await {
Ok(stream) => stream, Ok(stream) => stream,

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

@ -289,33 +289,104 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
} }
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

@ -270,14 +270,16 @@ impl Server {
"events.replay" => { "events.replay" => {
let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); 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 cutoff = now_unix_ms().saturating_sub(since_ms);
let mut replay = Vec::new(); let replay: Vec<BreadEvent> = self
if let Ok(buf) = self.event_buffer.lock() { .event_buffer
for event in buf.iter() { .lock()
if event.timestamp >= cutoff { .map(|buf| {
replay.push(event); buf.iter()
} .filter(|e| e.timestamp >= cutoff)
} .cloned()
} .collect()
})
.unwrap_or_default();
Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([])))
} }
_ => Err("unknown method".to_string()), _ => Err("unknown method".to_string()),

View file

@ -1499,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() {
@ -1518,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;
@ -1616,6 +1634,7 @@ const BUILTIN_DEVICES: &str = r#"
local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local M = bread.module({ name = "bread.devices", version = "1.0.0" })
local rules = {} local rules = {}
local user_patterns = {} -- { { pattern = "...", class = "..." }, ... }
local function matches_rule(rule, event) local function matches_rule(rule, event)
local class = rule.class local class = rule.class
@ -1651,15 +1670,55 @@ local function run_rule(rule, event)
end end
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) function M.on(opts)
table.insert(rules, opts) table.insert(rules, opts)
end 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() function M.on_load()
bread.on("bread.device.**", function(event) bread.on("bread.device.**", function(event)
local patched = apply_user_patterns(event)
for _, rule in ipairs(rules) do for _, rule in ipairs(rules) do
if matches_rule(rule, event) then if matches_rule(rule, patched) then
run_rule(rule, event) run_rule(rule, patched)
end end
end end
end) end)
@ -1811,13 +1870,11 @@ 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()?;
tokio::task::block_in_place(|| {
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)?;
Ok(buffer) Ok(buffer)
})
} }
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> { fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {

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