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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
42
AGENTS.md
Normal 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`.
|
||||||
|
|
@ -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):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).*
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue