Nested Hyprland worked but had real limits: the outer compositor decided the nested window's pixel size (needing an outer-session float+resize dispatch per capture), occluded surfaces got no frame callbacks (so grim hung unless the nested window was also focused/raised), and there was no way to fully suppress a brief real, visible flash of that window on the operator's desktop. wlroots' WLR_BACKENDS=headless (Sway, not Hyprland, is built on wlroots directly) has a genuine headless backend: no seat/DRM-master claim, no window anywhere, ever. Confirmed empirically: zero visible footprint, both zwlr_layer_shell_v1 and zwlr_screencopy_manager_v1 present, grim completes instantly with no focus dance needed. This drops the Hyprland-specific plumbing that no longer applies: - bread-screenshots now exposes one compositor-agnostic capture_region primitive instead of capture_layer/capture_output, since the isolated canvas size is always known up front rather than queried via hyprctl. - bread-utils::hypr loses the Monitor scale/transform/logical_size and Layer/find_layer additions that only existed to support that querying. - bread-capture's isolation module spawns headless Sway instead of a nested Hyprland instance, and passes --width/--height through to the target app so it knows the canvas size without asking anyone. Also fixes a socket leak in isolation teardown: killing the compositor (Hyprland or Sway) doesn't unlink the wayland-N/.lock files it created, so every capture run was orphaning a socket pair in the runtime dir. Drop now removes them explicitly.
252 lines
9 KiB
Rust
252 lines
9 KiB
Rust
//! Hyprland IPC client: socket1 request/response (JSON) and socket2 path
|
|
//! resolution.
|
|
//!
|
|
//! The socket-path resolution + raw request/response round trip was
|
|
//! duplicated near-verbatim in `breadbox/src/main.rs` (`get_active_workspace`,
|
|
//! lines 26-42) and `breadclip/src/position.rs` (`hyprctl_json`, lines
|
|
//! 58-71) — same `HYPRLAND_INSTANCE_SIGNATURE`/`XDG_RUNTIME_DIR` env lookup,
|
|
//! same `.socket.sock` path format, same connect/write/shutdown-write/
|
|
//! read-to-string sequence. `breadmon/src/main.rs`'s `hyprland_socket2_path`
|
|
//! duplicates just the path-resolution half for the event socket.
|
|
//!
|
|
//! `active_window`'s `fullscreen` field deserializes leniently as either a
|
|
//! JSON bool or integer: Hyprland has changed this field's type across
|
|
//! versions (older releases emit a bool, `0`/`1`; newer ones emit an
|
|
//! integer fullscreen *mode* — `0` none, `1` maximized, `2` fullscreen), and
|
|
//! a client hard-coded to one shape silently misreads the other instead of
|
|
//! erroring. `breadclip`'s own version (`as_i64().unwrap_or(0) != 0`) only
|
|
//! handles the integer shape; a bool `true` would `.as_i64()` to `None` and
|
|
//! silently read as "not fullscreen".
|
|
|
|
use serde::Deserialize;
|
|
use std::env;
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
|
|
/// Which of Hyprland's two IPC sockets: `.socket.sock` (request/response) or
|
|
/// `.socket2.sock` (event stream).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Socket {
|
|
Request,
|
|
Events,
|
|
}
|
|
|
|
/// Resolve the path to one of Hyprland's IPC sockets from
|
|
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
|
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
|
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
|
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
|
|
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
|
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
|
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
|
let file = match kind {
|
|
Socket::Request => ".socket.sock",
|
|
Socket::Events => ".socket2.sock",
|
|
};
|
|
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
|
}
|
|
|
|
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
|
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
|
/// matches every current consumer (breadbox, breadclip), which call it from
|
|
/// non-async GTK app code.
|
|
///
|
|
/// Read/write timeouts are set on the socket (both original hand-rolled
|
|
/// implementations this replaces — breadbox's `get_active_workspace`,
|
|
/// breadclip's `hyprctl_json` — had none): a Hyprland instance that's
|
|
/// wedged or mid-reload could otherwise hang this call, and every current
|
|
/// caller runs it on the GTK main thread, so a hang here freezes the whole
|
|
/// UI, not just this query.
|
|
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
pub fn request(request: &str) -> Option<String> {
|
|
let socket = socket_path(Socket::Request)?;
|
|
let mut stream = UnixStream::connect(&socket).ok()?;
|
|
stream.set_read_timeout(Some(REQUEST_TIMEOUT)).ok()?;
|
|
stream.set_write_timeout(Some(REQUEST_TIMEOUT)).ok()?;
|
|
stream.write_all(request.as_bytes()).ok()?;
|
|
stream.shutdown(std::net::Shutdown::Write).ok()?;
|
|
|
|
let mut buf = String::new();
|
|
stream.read_to_string(&mut buf).ok()?;
|
|
Some(buf)
|
|
}
|
|
|
|
/// Like [`request`], parsed as JSON. `request` should already carry the `j/`
|
|
/// prefix Hyprland expects for JSON responses (e.g. `"j/activewindow"`).
|
|
pub fn request_json(request_str: &str) -> Option<serde_json::Value> {
|
|
serde_json::from_str(&request(request_str)?).ok()
|
|
}
|
|
|
|
/// Connect to the socket2 event stream. Callers read newline-delimited
|
|
/// `EVENT>>DATA` lines from the returned stream themselves — event framing
|
|
/// and reconnect/backoff policy are genuinely per-consumer (see
|
|
/// `breadmon`'s hotplug listener), so this only replaces the duplicated
|
|
/// path-resolution + connect boilerplate, not a full event-loop
|
|
/// abstraction.
|
|
pub fn connect_events() -> Option<UnixStream> {
|
|
let socket = socket_path(Socket::Events)?;
|
|
UnixStream::connect(&socket).ok()
|
|
}
|
|
|
|
/// Hyprland's `fullscreen` field, tolerant of either representation it has
|
|
/// shipped across versions: a plain bool, or an integer fullscreen mode
|
|
/// (`0` = none, nonzero = some fullscreen mode).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub struct FullscreenState(bool);
|
|
|
|
impl FullscreenState {
|
|
pub fn is_fullscreen(self) -> bool {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for FullscreenState {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum Repr {
|
|
Bool(bool),
|
|
Int(i64),
|
|
}
|
|
Ok(match Repr::deserialize(deserializer)? {
|
|
Repr::Bool(b) => FullscreenState(b),
|
|
Repr::Int(i) => FullscreenState(i != 0),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ActiveWindow {
|
|
#[serde(default)]
|
|
pub class: String,
|
|
#[serde(default)]
|
|
pub fullscreen: FullscreenState,
|
|
pub at: (i32, i32),
|
|
pub size: (i32, i32),
|
|
}
|
|
|
|
impl ActiveWindow {
|
|
pub fn x(&self) -> i32 {
|
|
self.at.0
|
|
}
|
|
pub fn y(&self) -> i32 {
|
|
self.at.1
|
|
}
|
|
pub fn width(&self) -> i32 {
|
|
self.size.0
|
|
}
|
|
pub fn height(&self) -> i32 {
|
|
self.size.1
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Monitor {
|
|
pub name: String,
|
|
pub x: i32,
|
|
pub y: i32,
|
|
pub width: i32,
|
|
pub height: i32,
|
|
#[serde(default)]
|
|
pub focused: bool,
|
|
}
|
|
|
|
/// Query the currently active (focused) window. Returns `None` if the
|
|
/// window is fullscreen or no window is focused — same "centre the popup
|
|
/// instead" contract `breadclip`'s original `get_active_window` had.
|
|
pub fn active_window() -> Option<ActiveWindow> {
|
|
let win: ActiveWindow = serde_json::from_value(request_json("j/activewindow")?).ok()?;
|
|
if win.fullscreen.is_fullscreen() || win.class.is_empty() {
|
|
return None;
|
|
}
|
|
Some(win)
|
|
}
|
|
|
|
/// Query all monitors and return the focused one (or the first, if none
|
|
/// report as focused).
|
|
pub fn focused_monitor() -> Option<Monitor> {
|
|
let monitors: Vec<Monitor> = serde_json::from_value(request_json("j/monitors")?).ok()?;
|
|
monitors
|
|
.iter()
|
|
.find(|m| m.focused)
|
|
.or_else(|| monitors.first())
|
|
.cloned()
|
|
}
|
|
|
|
/// The active workspace's name (e.g. `"1"`, `"special:scratch"`).
|
|
pub fn active_workspace_name() -> Option<String> {
|
|
request_json("j/activeworkspace")?
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fullscreen_state_deserializes_from_bool() {
|
|
let s: FullscreenState = serde_json::from_str("true").unwrap();
|
|
assert!(s.is_fullscreen());
|
|
let s: FullscreenState = serde_json::from_str("false").unwrap();
|
|
assert!(!s.is_fullscreen());
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_state_deserializes_from_int() {
|
|
let s: FullscreenState = serde_json::from_str("0").unwrap();
|
|
assert!(!s.is_fullscreen());
|
|
let s: FullscreenState = serde_json::from_str("2").unwrap();
|
|
assert!(s.is_fullscreen());
|
|
}
|
|
|
|
#[test]
|
|
fn active_window_parses_bool_fullscreen_shape() {
|
|
let json = r#"{"class":"kitty","fullscreen":true,"at":[10,20],"size":[300,400]}"#;
|
|
let win: ActiveWindow = serde_json::from_str(json).unwrap();
|
|
assert!(win.fullscreen.is_fullscreen());
|
|
assert_eq!(win.x(), 10);
|
|
assert_eq!(win.height(), 400);
|
|
}
|
|
|
|
#[test]
|
|
fn active_window_parses_int_fullscreen_shape() {
|
|
let json = r#"{"class":"kitty","fullscreen":1,"at":[0,0],"size":[100,100]}"#;
|
|
let win: ActiveWindow = serde_json::from_str(json).unwrap();
|
|
assert!(win.fullscreen.is_fullscreen());
|
|
}
|
|
|
|
// Both env-var-dependent cases share one test function: `set_var`/
|
|
// `remove_var` are process-global, and cargo runs tests in parallel
|
|
// threads by default, so two separate #[test] fns racing on the same
|
|
// vars would be flaky.
|
|
#[test]
|
|
fn socket_path_env_var_behavior() {
|
|
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
|
|
unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") };
|
|
assert!(socket_path(Socket::Request).is_none());
|
|
|
|
unsafe {
|
|
env::set_var("HYPRLAND_INSTANCE_SIGNATURE", "test-sig");
|
|
env::set_var("XDG_RUNTIME_DIR", "/run/user/9999");
|
|
}
|
|
assert_eq!(
|
|
socket_path(Socket::Request).unwrap(),
|
|
PathBuf::from("/run/user/9999/hypr/test-sig/.socket.sock")
|
|
);
|
|
assert_eq!(
|
|
socket_path(Socket::Events).unwrap(),
|
|
PathBuf::from("/run/user/9999/hypr/test-sig/.socket2.sock")
|
|
);
|
|
unsafe {
|
|
env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
|
|
env::remove_var("XDG_RUNTIME_DIR");
|
|
}
|
|
}
|
|
}
|