From 6063eb901c5e100fe2fe85beafd372ee785a83e6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH] State engine applies bread.hyprland.* and snapshots compositor topology 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. --- .gitignore | 1 - AGENTS.md | 42 +++++ DEPRECATIONS.md | 4 +- Documentation.md | 4 +- breadd/src/adapters/hyprland.rs | 56 ++++++- breadd/src/core/normalizer.rs | 11 ++ breadd/src/core/state_engine.rs | 280 ++++++++++++++++++++++++++------ breadd/src/ipc/mod.rs | 2 +- 8 files changed, 348 insertions(+), 52 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index acf737f..c244516 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,3 @@ DAEMON.md LUA_RUNTIME.md CLAUDE_SPEC.md .claude -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f5f181 --- /dev/null +++ b/AGENTS.md @@ -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/` or `fix/`, 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`. diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index cb9941c..bea97d9 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -23,7 +23,9 @@ compositor backend — see `Documentation.md`'s **Current behavior:** both names fire by default (`[compat] 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):** diff --git a/Documentation.md b/Documentation.md index 499bfff..33053dc 100644 --- a/Documentation.md +++ b/Documentation.md @@ -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. - **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 @@ -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.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.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 @@ -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 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..` 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 ` 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).* diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2c4a47b..7778d91 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -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 { } } +/// Request socket sits next to `.socket2.sock` as `.socket.sock`. +fn hyprland_request_socket() -> Result { + 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 { + 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) -> 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()); diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 11f758f..d765296 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -196,6 +196,17 @@ impl EventNormalizer { } fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { + 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") diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 439045b..5807168 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -395,55 +395,180 @@ fn value_at_path(value: &Value, path: &str) -> Option { 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 { + 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 { + 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 { + 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] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 0c4f2db..141b1a3 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -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 {