State engine applies bread.hyprland.* and snapshots compositor topology
All checks were successful
dev release / build (push) Successful in 1m29s

Match both legacy and namespaced Hyprland events so flipping
legacy_hyprland_event_names no longer freezes monitors/workspace/window.
On event-socket connect, query socket1 and emit bread.hyprland.snapshot
to populate RuntimeState (including workspaces). API 1.7.1.
Track AGENTS.md instead of gitignored CLAUDE.md.
This commit is contained in:
Breadway 2026-08-15 22:03:30 +08:00
parent d3517d1433
commit 6063eb901c
8 changed files with 348 additions and 52 deletions

1
.gitignore vendored
View file

@ -36,4 +36,3 @@ DAEMON.md
LUA_RUNTIME.md LUA_RUNTIME.md
CLAUDE_SPEC.md CLAUDE_SPEC.md
.claude .claude
CLAUDE.md

42
AGENTS.md Normal file
View file

@ -0,0 +1,42 @@
# AGENTS.md — Repo hygiene
This repo follows the branch/release workflow in `CONTRIBUTING.md`.
Read that before any git, branch, or release work. Do not invent a
different workflow.
Day-to-day: one long-lived branch (`main`). New work goes on
`feature/<name>` or `fix/<name>`, then back into `main`. There is no
`dev` or `beta` branch — those names are **bakery tracks**, published
from `main` (dev) and from tags (`vX.Y.Z-rc.N` = beta, `vX.Y.Z` = stable).
API surface, event names, and IPC contracts live in `Documentation.md`
(kept honest by `api-schema.toml` + `cargo run -p xtask -- check-docs`).
Treat that file, not this one, as the source of truth.
## Remotes
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
- `github` — mirror. Push tags/releases per `CONTRIBUTING.md`; day-to-day
work pushes to `origin` only.
## CI
- `dev-release.yml` — push to `main` (includes `check-docs`).
- `rc-release.yml``vX.Y.Z-rc.N` tag.
- `release.yml` — other `v*` tags.
All three run on the self-hosted hestia runner. Nothing else runs on
plain commits or PRs. Distribution is `bakery`, not a PKGBUILD.
## Local architecture (still true)
- `breadd` — daemon (adapters → normalizer → state engine → Lua / IPC).
- `bread` — CLI over `$XDG_RUNTIME_DIR/bread/breadd.sock`.
- `bread-emit` — fire-and-forget IPC emit (hooks + command bus).
- `bread-module-host` — sandboxed out-of-process module runtime.
- Known-app registry and reserved domains: `bread-shared/src/apps.rs`.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't commit straight to `main`.

View file

@ -23,7 +23,9 @@ compositor backend — see `Documentation.md`'s
**Current behavior:** both names fire by default (`[compat] **Current behavior:** both names fire by default (`[compat]
legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses
the legacy names; only `bread.hyprland.*` fires. the legacy names; only `bread.hyprland.*` fires. The state engine applies
*both* names (since v1.7.1), so disabling the legacy emit no longer freezes
`bread.state` monitors / active workspace / active window.
**Deferred follow-up (not yet scheduled):** **Deferred follow-up (not yet scheduled):**

View file

@ -43,7 +43,7 @@ The Lua API surface, the IPC method set, the event-name vocabulary, and the runt
- **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline. - **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline.
- **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session. - **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session.
This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `CLAUDE.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality. This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `AGENTS.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality.
## Getting started ## Getting started
@ -1466,6 +1466,7 @@ Every Hyprland-sourced event below is dual-emitted: the daemon fires both the le
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` | | `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` | | `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) | | `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
| `bread.hyprland.snapshot` *(Since: v1.7.1)* | `{ monitors, workspaces, active_workspace, active_window }` — emitted once after the Hyprland event socket connects (and again after a reconnect). `bread.state` applies this event to replace compositor topology so monitors/workspaces/focus are populated before the next live event. Not dual-emitted under a legacy name. |
##### Compatibility: `[compat]` config ##### Compatibility: `[compat]` config
@ -1765,4 +1766,5 @@ The `health` response's `api_version` field lets a client — the CLI, a Lua mod
- *The unsourced path is tagged `AdapterSource::Manual`, not `System``System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.* - *The unsourced path is tagged `AdapterSource::Manual`, not `System``System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS``terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.* - *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS``terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
- *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.* - *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.*
- *Since: v1.7.1 — the state engine applies both legacy Hyprland names and `bread.hyprland.*` (so flipping `[compat] legacy_hyprland_event_names = false` no longer freezes monitors/workspace/window). `RuntimeState.workspaces` is written on `workspace.created`/`destroyed` and replaced by `bread.hyprland.snapshot`. `API_VERSION` bumped from `1.7.0` to `1.7.1`.*
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).* - *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, warn}; use tracing::{debug, warn};
@ -24,6 +24,13 @@ impl Adapter for HyprlandAdapter {
debug!("hyprland adapter started"); debug!("hyprland adapter started");
let socket = hyprland_event_socket()?; let socket = hyprland_event_socket()?;
let stream = UnixStream::connect(&socket).await?; let stream = UnixStream::connect(&socket).await?;
// Snapshot current compositor topology *after* the event socket is
// connected so we don't miss a change that lands during the query,
// then apply the snapshot first so bread.state is populated before
// the live stream. Failure is non-fatal: live events still work.
if let Err(e) = emit_topology_snapshot(&tx).await {
warn!("hyprland topology snapshot failed: {e}");
}
let reader = BufReader::new(stream); let reader = BufReader::new(stream);
let mut lines = reader.lines(); let mut lines = reader.lines();
@ -83,6 +90,53 @@ fn hyprland_event_socket() -> Result<PathBuf> {
} }
} }
/// Request socket sits next to `.socket2.sock` as `.socket.sock`.
fn hyprland_request_socket() -> Result<PathBuf> {
let events = hyprland_event_socket()?;
let parent = events
.parent()
.ok_or_else(|| anyhow!("hyprland event socket has no parent dir"))?;
Ok(parent.join(".socket.sock"))
}
async fn hyprland_request_json(request: &str) -> Result<serde_json::Value> {
let path = hyprland_request_socket()?;
let mut stream = UnixStream::connect(&path).await?;
stream.write_all(request.as_bytes()).await?;
stream.shutdown().await?;
let mut buf = String::new();
stream.read_to_string(&mut buf).await?;
serde_json::from_str(&buf)
.map_err(|e| anyhow!("hyprland {request} JSON: {e}"))
}
/// Query current monitors / workspaces / focus and emit one
/// `hyprland.snapshot` RawEvent. The normalizer turns that into
/// `bread.hyprland.snapshot`; the state engine replaces topology from it.
async fn emit_topology_snapshot(tx: &mpsc::Sender<RawEvent>) -> Result<()> {
let monitors = hyprland_request_json("j/monitors").await.unwrap_or(json!([]));
let workspaces = hyprland_request_json("j/workspaces")
.await
.unwrap_or(json!([]));
let active_workspace = hyprland_request_json("j/activeworkspace").await.ok();
let active_window = hyprland_request_json("j/activewindow").await.ok();
tx.send(RawEvent {
source: AdapterSource::Hyprland,
kind: "hyprland.snapshot".to_string(),
payload: json!({
"monitors": monitors,
"workspaces": workspaces,
"active_workspace": active_workspace,
"active_window": active_window,
}),
timestamp: now_unix_ms(),
})
.await
.map_err(|_| anyhow!("raw channel closed during hyprland snapshot"))?;
Ok(())
}
fn parse_hyprland_line(line: &str) -> (String, String) { fn parse_hyprland_line(line: &str) -> (String, String) {
if let Some((kind, data)) = line.split_once(">>") { if let Some((kind, data)) = line.split_once(">>") {
return (kind.to_string(), data.to_string()); return (kind.to_string(), data.to_string());

View file

@ -196,6 +196,17 @@ impl EventNormalizer {
} }
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> { fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
if raw.kind == "hyprland.snapshot" {
return vec![BreadEvent {
event: "bread.hyprland.snapshot".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}];
}
let kind = raw let kind = raw
.payload .payload
.get("kind") .get("kind")

View file

@ -395,55 +395,180 @@ fn value_at_path(value: &Value, path: &str) -> Option<Value> {
Some(current.clone()) Some(current.clone())
} }
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { /// Logical Hyprland state key: both the legacy flat name and its
match event.event.as_str() { /// `bread.hyprland.*` sibling collapse to the same suffix
"bread.monitor.connected" => { /// (`monitor.connected`, `workspace.changed`, …).
if let Some(name) = event.data.get("name").and_then(Value::as_str) { fn hyprland_state_key(event: &str) -> Option<&str> {
if let Some(rest) = event.strip_prefix("bread.hyprland.") {
return Some(rest);
}
match event {
"bread.monitor.connected" => Some("monitor.connected"),
"bread.monitor.disconnected" => Some("monitor.disconnected"),
"bread.workspace.changed" => Some("workspace.changed"),
"bread.workspace.created" => Some("workspace.created"),
"bread.workspace.destroyed" => Some("workspace.destroyed"),
"bread.window.focus.changed" => Some("window.focus.changed"),
"bread.window.focused" => Some("window.focused"),
"bread.window.opened" => Some("window.opened"),
"bread.window.closed" => Some("window.closed"),
"bread.window.moved" => Some("window.moved"),
_ => None,
}
}
fn json_stringish(value: Option<&Value>) -> Option<String> {
let value = value?;
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
value.as_i64().map(|n| n.to_string())
}
fn workspace_id_from_data(data: &Value) -> Option<String> {
json_stringish(data.get("workspace"))
.or_else(|| json_stringish(data.get("id")))
.or_else(|| json_stringish(data.get("name")))
.or_else(|| {
data.get("data")
.and_then(Value::as_str)
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.filter(|s| !s.is_empty())
})
}
fn active_window_from_data(data: &Value) -> Option<String> {
json_stringish(data.get("window"))
.or_else(|| json_stringish(data.get("class")))
.or_else(|| json_stringish(data.get("address")))
.or_else(|| {
data.get("data")
.and_then(Value::as_str)
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.filter(|s| !s.is_empty())
})
}
fn upsert_monitor(state: &mut RuntimeState, data: &Value) {
let Some(name) = data.get("name").and_then(Value::as_str) else {
return;
};
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
m.connected = true; m.connected = true;
if let Some(res) = data.get("resolution").and_then(Value::as_str) {
m.resolution = Some(res.to_string());
}
if let Some(pos) = data.get("position").and_then(Value::as_str) {
m.position = Some(pos.to_string());
}
} else { } else {
state.monitors.push(crate::core::types::Monitor { state.monitors.push(crate::core::types::Monitor {
name: name.to_string(), name: name.to_string(),
connected: true, connected: true,
resolution: event resolution: data
.data
.get("resolution") .get("resolution")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToString::to_string), .map(ToString::to_string),
position: event position: data
.data
.get("position") .get("position")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToString::to_string), .map(ToString::to_string),
}); });
} }
} }
fn apply_hyprland_snapshot(state: &mut RuntimeState, data: &Value) {
if let Some(arr) = data.get("monitors").and_then(Value::as_array) {
state.monitors = arr
.iter()
.filter_map(|m| {
let name = m.get("name").and_then(Value::as_str)?;
let width = m.get("width").and_then(Value::as_u64);
let height = m.get("height").and_then(Value::as_u64);
let x = m.get("x").and_then(Value::as_i64);
let y = m.get("y").and_then(Value::as_i64);
let disabled = m.get("disabled").and_then(Value::as_bool).unwrap_or(false);
Some(crate::core::types::Monitor {
name: name.to_string(),
connected: !disabled,
resolution: match (width, height) {
(Some(w), Some(h)) => Some(format!("{w}x{h}")),
_ => None,
},
position: match (x, y) {
(Some(x), Some(y)) => Some(format!("{x}x{y}")),
_ => None,
},
})
})
.collect();
} }
"bread.monitor.disconnected" => { if let Some(arr) = data.get("workspaces").and_then(Value::as_array) {
state.workspaces = arr
.iter()
.filter_map(|ws| {
let id = json_stringish(ws.get("id")).or_else(|| json_stringish(ws.get("name")))?;
Some(crate::core::types::Workspace {
id,
monitor: json_stringish(ws.get("monitor")),
})
})
.collect();
}
if let Some(aw) = data.get("active_workspace") {
state.active_workspace =
json_stringish(aw.get("name")).or_else(|| json_stringish(aw.get("id")));
}
if let Some(win) = data.get("active_window") {
state.active_window = json_stringish(win.get("address"))
.or_else(|| json_stringish(win.get("class")))
.or_else(|| json_stringish(win.get("title")));
}
}
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
if event.event == "bread.hyprland.snapshot" {
apply_hyprland_snapshot(state, &event.data);
return;
}
if let Some(key) = hyprland_state_key(event.event.as_str()) {
match key {
"monitor.connected" => upsert_monitor(state, &event.data),
"monitor.disconnected" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) { if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
m.connected = false; m.connected = false;
} }
} }
} }
"bread.workspace.changed" => { "workspace.changed" => {
let ws = event state.active_workspace = workspace_id_from_data(&event.data);
.data
.get("workspace")
.or_else(|| event.data.get("id"))
.and_then(Value::as_str)
.map(ToString::to_string);
state.active_workspace = ws;
} }
"bread.window.focus.changed" | "bread.window.focused" => { "workspace.created" => {
state.active_window = event if let Some(id) = workspace_id_from_data(&event.data) {
.data if !state.workspaces.iter().any(|w| w.id == id) {
.get("window") state.workspaces.push(crate::core::types::Workspace {
.or_else(|| event.data.get("class")) id,
.or_else(|| event.data.get("address")) monitor: json_stringish(event.data.get("monitor")),
.and_then(Value::as_str) });
.map(ToString::to_string);
} }
}
}
"workspace.destroyed" => {
if let Some(id) = workspace_id_from_data(&event.data) {
state.workspaces.retain(|w| w.id != id);
}
}
"window.focus.changed" | "window.focused" => {
state.active_window = active_window_from_data(&event.data);
}
_ => {}
}
return;
}
match event.event.as_str() {
"bread.device.connected" => { "bread.device.connected" => {
apply_device_change(state, &event.data, true); apply_device_change(state, &event.data, true);
} }
@ -787,6 +912,67 @@ mod tests {
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef")); assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
} }
#[test]
fn namespaced_hyprland_events_update_the_same_state() {
let mut state = RuntimeState::default();
apply_event_to_state(
&mut state,
&ev(
"bread.hyprland.monitor.connected",
json!({"name": "HDMI-A-1"}),
),
);
apply_event_to_state(
&mut state,
&ev("bread.hyprland.workspace.changed", json!({"id": 4})),
);
apply_event_to_state(
&mut state,
&ev(
"bread.hyprland.window.focus.changed",
json!({"kind": "activewindow", "data": "kitty,foo"}),
),
);
apply_event_to_state(
&mut state,
&ev("bread.hyprland.workspace.created", json!({"workspace": "9"})),
);
assert_eq!(state.monitors.len(), 1);
assert_eq!(state.monitors[0].name, "HDMI-A-1");
assert_eq!(state.active_workspace.as_deref(), Some("4"));
assert_eq!(state.active_window.as_deref(), Some("kitty"));
assert_eq!(state.workspaces.len(), 1);
assert_eq!(state.workspaces[0].id, "9");
}
#[test]
fn hyprland_snapshot_replaces_topology() {
let mut state = RuntimeState::default();
apply_event_to_state(
&mut state,
&ev("bread.monitor.connected", json!({"name": "stale"})),
);
apply_event_to_state(
&mut state,
&ev(
"bread.hyprland.snapshot",
json!({
"monitors": [{"name": "eDP-1", "width": 1920, "height": 1200, "x": 0, "y": 0}],
"workspaces": [{"id": 1, "name": "1", "monitor": "eDP-1"}],
"active_workspace": {"id": 1, "name": "1"},
"active_window": {"address": "0xabc", "class": "kitty"}
}),
),
);
assert_eq!(state.monitors.len(), 1);
assert_eq!(state.monitors[0].name, "eDP-1");
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1200"));
assert_eq!(state.workspaces.len(), 1);
assert_eq!(state.workspaces[0].id, "1");
assert_eq!(state.active_workspace.as_deref(), Some("1"));
assert_eq!(state.active_window.as_deref(), Some("0xabc"));
}
// ─── apply_device_change ────────────────────────────────────────────── // ─── apply_device_change ──────────────────────────────────────────────
#[test] #[test]

View file

@ -41,7 +41,7 @@ mod module_host_bridge;
/// sourced `AdapterSource::App` emit may publish commands to another known /// sourced `AdapterSource::App` emit may publish commands to another known
/// app. `command` stays in `RESERVED_DOMAINS` so it cannot be claimed as /// app. `command` stays in `RESERVED_DOMAINS` so it cannot be claimed as
/// an app id. /// an app id.
const API_VERSION: &str = "1.7.0"; const API_VERSION: &str = "1.7.1";
#[derive(Clone)] #[derive(Clone)]
pub struct Server { pub struct Server {