State engine applies bread.hyprland.* and snapshots compositor topology
All checks were successful
dev release / build (push) Successful in 1m29s
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:
parent
d3517d1433
commit
6063eb901c
8 changed files with 348 additions and 52 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue