commit
28df873b92
18 changed files with 1654 additions and 101 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
||||||
target/
|
target/
|
||||||
Overview.md
|
Overview.md
|
||||||
DAEMON.md
|
DAEMON.md
|
||||||
.github/
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
.github
|
||||||
131
Cargo.lock
generated
131
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
84
README.md
84
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 ¤t_map {
|
for (id, dev) in ¤t_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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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..])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)?;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
36
scripts/install.sh
Executable 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue