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

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Result};
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tracing::{debug, warn};
@ -24,6 +24,13 @@ impl Adapter for HyprlandAdapter {
debug!("hyprland adapter started");
let socket = hyprland_event_socket()?;
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 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) {
if let Some((kind, data)) = line.split_once(">>") {
return (kind.to_string(), data.to_string());

View file

@ -196,6 +196,17 @@ impl EventNormalizer {
}
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
.payload
.get("kind")

View file

@ -395,55 +395,180 @@ fn value_at_path(value: &Value, path: &str) -> Option<Value> {
Some(current.clone())
}
/// Logical Hyprland state key: both the legacy flat name and its
/// `bread.hyprland.*` sibling collapse to the same suffix
/// (`monitor.connected`, `workspace.changed`, …).
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) {
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 {
state.monitors.push(crate::core::types::Monitor {
name: name.to_string(),
connected: true,
resolution: data
.get("resolution")
.and_then(Value::as_str)
.map(ToString::to_string),
position: data
.get("position")
.and_then(Value::as_str)
.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();
}
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(m) = state.monitors.iter_mut().find(|m| m.name == name) {
m.connected = false;
}
}
}
"workspace.changed" => {
state.active_workspace = workspace_id_from_data(&event.data);
}
"workspace.created" => {
if let Some(id) = workspace_id_from_data(&event.data) {
if !state.workspaces.iter().any(|w| w.id == id) {
state.workspaces.push(crate::core::types::Workspace {
id,
monitor: json_stringish(event.data.get("monitor")),
});
}
}
}
"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.monitor.connected" => {
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) {
m.connected = true;
} else {
state.monitors.push(crate::core::types::Monitor {
name: name.to_string(),
connected: true,
resolution: event
.data
.get("resolution")
.and_then(Value::as_str)
.map(ToString::to_string),
position: event
.data
.get("position")
.and_then(Value::as_str)
.map(ToString::to_string),
});
}
}
}
"bread.monitor.disconnected" => {
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) {
m.connected = false;
}
}
}
"bread.workspace.changed" => {
let ws = event
.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" => {
state.active_window = event
.data
.get("window")
.or_else(|| event.data.get("class"))
.or_else(|| event.data.get("address"))
.and_then(Value::as_str)
.map(ToString::to_string);
}
"bread.device.connected" => {
apply_device_change(state, &event.data, true);
}
@ -787,6 +912,67 @@ mod tests {
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 ──────────────────────────────────────────────
#[test]

View file

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