bread-capture: switch capture isolation to headless Sway
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.
This commit is contained in:
parent
686af0d3dc
commit
d059d99437
4 changed files with 124 additions and 341 deletions
|
|
@ -1,45 +1,44 @@
|
|||
//! Runs each capture target inside a throwaway nested Hyprland instance
|
||||
//! instead of the operator's live desktop, so nothing on their screen (other
|
||||
//! windows, a differently-themed real bar, whatever's behind a popover) can
|
||||
//! leak into a capture, and the capture in turn never flashes across their
|
||||
//! desktop either.
|
||||
//! Runs each capture target inside a headless Sway instance instead of the
|
||||
//! operator's live desktop, so nothing on their screen (other windows, a
|
||||
//! differently-themed real bar, whatever's behind a popover) can leak into a
|
||||
//! capture, and the capture never flashes across their desktop either.
|
||||
//!
|
||||
//! Mechanics, established empirically against Hyprland 0.55 (Lua config) —
|
||||
//! there's no single documented "run headless" switch that fits this case:
|
||||
//! - A genuinely headless (zero real output) backend needs
|
||||
//! `AQ_NO_KMS_REQUIREMENT`, but that's for a vGPU with no display output at
|
||||
//! all. This machine's GPU already has a real output claimed by the live
|
||||
//! session's `Hyprland`, and only one process can hold that session's seat
|
||||
//! (logind) at a time — a second instance trying DRM directly fails with
|
||||
//! "Device or resource busy", not a headless fallback.
|
||||
//! - The working path is nesting: keep `WAYLAND_DISPLAY` pointed at the
|
||||
//! outer session so the new `Hyprland` connects to it as an ordinary
|
||||
//! Wayland *client* (Aquamarine's Wayland backend) — from the outer
|
||||
//! session's point of view it's just a window. It auto-picks its own new
|
||||
//! server socket name (`wayland-N`, skipping ones already taken) and its
|
||||
//! own `HYPRLAND_INSTANCE_SIGNATURE`; neither is knowable in advance, so
|
||||
//! both are discovered by diffing directory listings before/after spawn.
|
||||
//! - That nested window's pixel size is decided by the *outer* compositor
|
||||
//! (it's just a regular window there), not by any monitor rule inside the
|
||||
//! nested config — so getting a fixed, consistent capture canvas means
|
||||
//! floating + exact-resizing it via one-shot `hyprctl --instance <outer>
|
||||
//! dispatch` calls against the outer session, targeted at the new
|
||||
//! window's `address` (found by matching the outer client list's `pid`
|
||||
//! against the spawned process's own pid — unambiguous, no reliance on
|
||||
//! window class/title/timing).
|
||||
//! - The outer compositor throttles frame callbacks for occluded surfaces:
|
||||
//! with the nested window unfocused/covered, `grim` (run *inside* the
|
||||
//! nested session) hangs forever waiting on `ext_image_copy_capture`'s
|
||||
//! `.ready()` event, because Aquamarine's Wayland backend never gets a
|
||||
//! frame tick to render one. Focusing the nested window (raising it, so
|
||||
//! it's actually presented) is what makes captures complete instead of
|
||||
//! hanging — confirmed by reproducing the hang and then clearing it with
|
||||
//! nothing else changed.
|
||||
//! - Vanilla Hyprland draws its own branded default background/logo when no
|
||||
//! client owns the background layer (not blank), plus a red on-screen
|
||||
//! watchdog/XDG-desktop/gui-utils warning overlay when started directly
|
||||
//! like this rather than via `start-hyprland`. Both are disabled via
|
||||
//! `misc:*` config, not by launch flags.
|
||||
//! This replaced an earlier nested-Hyprland approach (see git history for
|
||||
//! `feature/capture-isolation` if you want the gory details). That worked,
|
||||
//! but Hyprland's own backend library (Aquamarine) has no genuinely headless
|
||||
//! mode when a live session already holds the seat — the only path was
|
||||
//! nesting a full second Hyprland as an ordinary Wayland *client* of the
|
||||
//! outer session, which meant: the outer compositor deciding the nested
|
||||
//! window's pixel size (so every capture needed an outer-session
|
||||
//! float+resize dispatch), the outer compositor throttling frame callbacks
|
||||
//! for occluded surfaces (so the nested window also had to be *focused*, or
|
||||
//! `grim` run inside it hung forever waiting on a frame that never came),
|
||||
//! and — the thing that ultimately motivated dropping this approach — no way
|
||||
//! to fully suppress the brief real, visible flash of that window on the
|
||||
//! operator's actual screen (Lua-config Hyprland has no `keyword`-based
|
||||
//! pre-emptive windowrule injection, and parking it on an untoggled special
|
||||
//! workspace produced broken, half-rendered captures instead).
|
||||
//!
|
||||
//! wlroots (which Sway, not Hyprland, is built directly on) has a real
|
||||
//! headless backend: `WLR_BACKENDS=headless` skips DRM and Wayland-client
|
||||
//! backends entirely and synthesizes a virtual output with no seat/DRM-master
|
||||
//! claim at all — no fight with logind over the live session's seat, and no
|
||||
//! window anywhere, nested or otherwise, for the operator to ever see. Empirically
|
||||
//! confirmed on this machine: zero visible footprint, `zwlr_layer_shell_v1`
|
||||
//! and `zwlr_screencopy_manager_v1` both present (so a layer-shell bar and
|
||||
//! `grim` both work), and a manual `grim` capture against it completes
|
||||
//! instantly with no focus/occlusion dance required.
|
||||
//!
|
||||
//! One consequence of not nesting inside Hyprland at all: breadbar's
|
||||
//! workspace list (`src/bar/workspaces.rs`, via the `hyprland` crate) talks
|
||||
//! to whatever `HYPRLAND_INSTANCE_SIGNATURE` points at. Left alone, that
|
||||
//! still points at the operator's real, live Hyprland instance — a data leak
|
||||
//! into an otherwise-isolated capture (real workspace names/count showing up
|
||||
//! in a bar screenshot that's supposed to be clean). Sway has no equivalent
|
||||
//! IPC this needs to keep working, so [`Isolation::start`] unsets it;
|
||||
//! breadbar already has to tolerate a missing/dead Hyprland connection
|
||||
//! gracefully (it survives Hyprland restarting), so this just exercises that
|
||||
//! same fallback path instead of a real error case.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::HashSet;
|
||||
|
|
@ -48,93 +47,71 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const HYPRCTL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Settle time after focusing the nested window — gives the outer
|
||||
/// compositor a moment to actually start presenting it (see module docs on
|
||||
/// occlusion throttling) before anything tries to capture through it.
|
||||
const FOCUS_SETTLE: Duration = Duration::from_millis(400);
|
||||
|
||||
/// Matches bread-theme's `FIXED_BACKGROUND` (`bread-theme/src/palette.rs`) —
|
||||
/// so the empty canvas behind a capture reads as "the app's own dark theme",
|
||||
/// not an arbitrary compositor default.
|
||||
const BACKGROUND_COLOR: &str = "#0c0c0c";
|
||||
|
||||
pub struct Isolation {
|
||||
child: Child,
|
||||
pub wayland_display: String,
|
||||
pub instance_signature: String,
|
||||
config_path: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Isolation {
|
||||
/// Spawn the nested instance, size its outer window to `width`x`height`,
|
||||
/// and set `WAYLAND_DISPLAY`/`HYPRLAND_INSTANCE_SIGNATURE` on *this*
|
||||
/// process's own environment so every subsequent
|
||||
/// Spawn the headless instance sized to `width`x`height`, and set
|
||||
/// `WAYLAND_DISPLAY` on *this* process's own environment (and unset
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE`) so every subsequent
|
||||
/// `bread_utils::proc::run` spawn (the target app, and in turn its own
|
||||
/// `grim` calls) inherits them and lands inside the nested session.
|
||||
/// `grim` calls) inherits them and lands inside the isolated instance.
|
||||
pub fn start(width: u32, height: u32) -> Result<Self> {
|
||||
let outer_wayland_display =
|
||||
std::env::var("WAYLAND_DISPLAY").context("WAYLAND_DISPLAY not set — isolation requires running inside a live Hyprland/Wayland session to nest inside")?;
|
||||
let outer_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
.context("HYPRLAND_INSTANCE_SIGNATURE not set — isolation requires running inside a live Hyprland session")?;
|
||||
let runtime_dir = PathBuf::from(std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()));
|
||||
let runtime_dir = PathBuf::from(
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()),
|
||||
);
|
||||
let config_path = write_headless_config(width, height)?;
|
||||
let before_sockets: HashSet<String> = dir_names(&runtime_dir)
|
||||
.into_iter()
|
||||
.filter(|n| is_wayland_socket_name(n))
|
||||
.collect();
|
||||
|
||||
let config_path = write_nested_config()?;
|
||||
let hypr_dir = runtime_dir.join("hypr");
|
||||
let before_instances = dir_names(&hypr_dir);
|
||||
let before_sockets: HashSet<String> = dir_names(&runtime_dir).into_iter().filter(|n| is_wayland_socket_name(n)).collect();
|
||||
|
||||
let child = Command::new("Hyprland")
|
||||
.arg("--config")
|
||||
let child = Command::new("sway")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.env("WAYLAND_DISPLAY", &outer_wayland_display)
|
||||
.env("WLR_BACKENDS", "headless")
|
||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||
.env_remove("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("spawning nested Hyprland")?;
|
||||
let pid = child.id();
|
||||
.context("spawning headless sway")?;
|
||||
|
||||
let mut isolation = Isolation {
|
||||
child,
|
||||
wayland_display: String::new(),
|
||||
instance_signature: String::new(),
|
||||
config_path,
|
||||
runtime_dir: runtime_dir.clone(),
|
||||
};
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
isolation.instance_signature = poll_for_new(&hypr_dir, &before_instances, DISCOVERY_TIMEOUT, |_| true)
|
||||
.context("waiting for nested Hyprland's instance signature to appear")?;
|
||||
isolation.wayland_display = poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name)
|
||||
.context("waiting for nested Hyprland's Wayland socket to appear")?;
|
||||
|
||||
let addr = poll_for_client_address(&outer_signature, pid, DISCOVERY_TIMEOUT)
|
||||
.context("waiting for the nested Hyprland window to appear in the outer session")?;
|
||||
outer_dispatch(&outer_signature, &format!("hl.dsp.window.float({{ window = 'address:{addr}' }})"))?;
|
||||
outer_dispatch(
|
||||
&outer_signature,
|
||||
&format!("hl.dsp.window.resize({{ x = {width}, y = {height}, window = 'address:{addr}' }})"),
|
||||
)?;
|
||||
// Must be focused/raised, not just resized — an occluded nested
|
||||
// window never gets frame callbacks from the outer compositor,
|
||||
// and grim run inside it hangs forever waiting on one. See
|
||||
// module docs.
|
||||
outer_dispatch(&outer_signature, &format!("hl.dsp.focus({{ window = 'address:{addr}' }})"))?;
|
||||
std::thread::sleep(FOCUS_SETTLE);
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(e) = result {
|
||||
match poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name)
|
||||
.context("waiting for headless sway's Wayland socket to appear")
|
||||
{
|
||||
Ok(name) => isolation.wayland_display = name,
|
||||
Err(e) => {
|
||||
// Best-effort teardown of the half-started instance before
|
||||
// propagating — the normal Drop impl still runs too, but doing
|
||||
// it here as well means a failure this early doesn't depend on
|
||||
// isolation ever being bound to a variable that outlives this
|
||||
// function.
|
||||
// propagating — the normal Drop impl still runs too, but
|
||||
// doing it here as well means a failure this early doesn't
|
||||
// depend on isolation ever being bound to a variable that
|
||||
// outlives this function.
|
||||
let _ = isolation.child.kill();
|
||||
let _ = isolation.child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
std::env::set_var("WAYLAND_DISPLAY", &isolation.wayland_display);
|
||||
std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", &isolation.instance_signature);
|
||||
std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
|
||||
|
||||
Ok(isolation)
|
||||
}
|
||||
|
|
@ -145,41 +122,23 @@ impl Drop for Isolation {
|
|||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_file(&self.config_path);
|
||||
if !self.instance_signature.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(self.runtime_dir.join("hypr").join(&self.instance_signature));
|
||||
// Killing sway doesn't unlink the socket it bound — confirmed
|
||||
// empirically, a killed instance leaves both files behind — so
|
||||
// without this, every capture run permanently orphans a
|
||||
// `wayland-N`/`wayland-N.lock` pair in the runtime dir.
|
||||
if !self.wayland_display.is_empty() {
|
||||
let _ = std::fs::remove_file(self.runtime_dir.join(&self.wayland_display));
|
||||
let _ = std::fs::remove_file(self.runtime_dir.join(format!("{}.lock", self.wayland_display)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_nested_config() -> Result<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!("bread-capture-hypr-{}.lua", std::process::id()));
|
||||
// No exec-once/wallpaper daemon at all — that's the entire "no
|
||||
// background" mechanism (nothing ever claims the background layer).
|
||||
// misc.background_color matches bread-theme's own FIXED_BACKGROUND
|
||||
// (#0c0c0c, see bread-theme/src/palette.rs) so the empty canvas behind
|
||||
// a capture reads as "the app's own dark theme", not an arbitrary color.
|
||||
// disable_hyprland_logo/disable_splash_rendering turn off Hyprland's own
|
||||
// branded default background (drawn even with zero clients); the three
|
||||
// disable_* checks below turn off the on-screen red warning banner
|
||||
// Hyprland draws when started outside `start-hyprland`/without a
|
||||
// matching XDG_CURRENT_DESKTOP/without hyprland-dialog installed — all
|
||||
// expected and harmless here, but they'd otherwise show up in captures.
|
||||
let contents = r#"hl.monitor({
|
||||
output = "WAYLAND-1",
|
||||
scale = "1",
|
||||
})
|
||||
hl.config({
|
||||
misc = {
|
||||
disable_hyprland_logo = true,
|
||||
disable_splash_rendering = true,
|
||||
force_default_wallpaper = 0,
|
||||
background_color = "rgba(0c0c0cff)",
|
||||
disable_xdg_env_checks = true,
|
||||
disable_hyprland_guiutils_check = true,
|
||||
disable_watchdog_warning = true,
|
||||
},
|
||||
})
|
||||
"#;
|
||||
fn write_headless_config(width: u32, height: u32) -> Result<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id()));
|
||||
let contents = format!(
|
||||
"output HEADLESS-1 resolution {width}x{height}\n\
|
||||
output HEADLESS-1 bg {BACKGROUND_COLOR} solid_color\n"
|
||||
);
|
||||
std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
|
@ -198,7 +157,12 @@ fn is_wayland_socket_name(name: &str) -> bool {
|
|||
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn poll_for_new(dir: &Path, before: &HashSet<String>, timeout: Duration, relevant: impl Fn(&str) -> bool) -> Result<String> {
|
||||
fn poll_for_new(
|
||||
dir: &Path,
|
||||
before: &HashSet<String>,
|
||||
timeout: Duration,
|
||||
relevant: impl Fn(&str) -> bool,
|
||||
) -> Result<String> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let after = dir_names(dir);
|
||||
|
|
@ -206,38 +170,11 @@ fn poll_for_new(dir: &Path, before: &HashSet<String>, timeout: Duration, relevan
|
|||
return Ok(name.clone());
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
bail!("timed out after {timeout:?} waiting for a new entry in {}", dir.display());
|
||||
bail!(
|
||||
"timed out after {timeout:?} waiting for a new entry in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_for_client_address(outer_signature: &str, pid: u32, timeout: Duration) -> Result<String> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(clients) = bread_utils::proc::run_json("hyprctl", &["--instance", outer_signature, "clients", "-j"], HYPRCTL_TIMEOUT) {
|
||||
if let Some(arr) = clients.as_array() {
|
||||
let found = arr
|
||||
.iter()
|
||||
.find(|c| c.get("pid").and_then(|v| v.as_u64()) == Some(pid as u64))
|
||||
.and_then(|c| c.get("address"))
|
||||
.and_then(|v| v.as_str());
|
||||
if let Some(addr) = found {
|
||||
return Ok(addr.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
bail!("timed out after {timeout:?} waiting for pid {pid}'s window in the outer session's client list");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn outer_dispatch(outer_signature: &str, lua_expr: &str) -> Result<()> {
|
||||
let result = bread_utils::proc::run("hyprctl", &["--instance", outer_signature, "dispatch", lua_expr], HYPRCTL_TIMEOUT);
|
||||
if !result.success {
|
||||
bail!("hyprctl dispatch failed ({lua_expr}): {}", result.stderr.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those
|
||||
//! only earn their complexity once more apps are wired up.
|
||||
//!
|
||||
//! By default every capture runs inside a throwaway nested Hyprland instance
|
||||
//! By default every capture runs inside a throwaway headless Sway instance
|
||||
//! (see [`isolation`]) rather than the operator's live desktop, so another
|
||||
//! window (or their own differently-themed real bar) can't leak into a
|
||||
//! capture. `--no-isolate` skips that and captures directly against whatever
|
||||
|
|
@ -39,8 +39,8 @@ struct Cli {
|
|||
#[arg(long, default_value = "./screenshots")]
|
||||
out_dir: PathBuf,
|
||||
|
||||
/// Capture directly against the current session instead of a nested,
|
||||
/// throwaway Hyprland instance. Off by default so captures can't pick up
|
||||
/// Capture directly against the current session instead of a headless,
|
||||
/// throwaway Sway instance. Off by default so captures can't pick up
|
||||
/// whatever else is on the operator's desktop.
|
||||
#[arg(long)]
|
||||
no_isolate: bool,
|
||||
|
|
@ -63,13 +63,21 @@ fn main() -> Result<()> {
|
|||
Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?)
|
||||
};
|
||||
|
||||
let width_str = cli.isolate_width.to_string();
|
||||
let height_str = cli.isolate_height.to_string();
|
||||
|
||||
let mut failed = false;
|
||||
for (view, filename) in BREADBAR_TARGETS {
|
||||
let out_path = cli.out_dir.join(filename);
|
||||
let out_str = out_path.to_string_lossy();
|
||||
let result = bread_utils::proc::run(
|
||||
&cli.app_path,
|
||||
&["--screenshot", view, "--output", &out_str],
|
||||
&[
|
||||
"--screenshot", view,
|
||||
"--output", &out_str,
|
||||
"--width", &width_str,
|
||||
"--height", &height_str,
|
||||
],
|
||||
CAPTURE_TIMEOUT,
|
||||
);
|
||||
if result.success {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
//! Capture primitives for the bread ecosystem's UI screenshot tooling (see
|
||||
//! Capture primitive for the bread ecosystem's UI screenshot tooling (see
|
||||
//! `bread-capture`, the orchestrator that drives this crate's consumers).
|
||||
//!
|
||||
//! A "view" being screenshotted is either:
|
||||
//! - its own layer-shell surface (a bar, launcher, ...) — captured tightly via
|
||||
//! [`capture_layer`], geometry from `hyprctl layers`.
|
||||
//! - a transient popover/popup — not a separate layer surface under
|
||||
//! gtk4-layer-shell (it's an xdg_popup Hyprland doesn't list individually),
|
||||
//! so the only reliable capture is [`capture_output`]: whatever's currently
|
||||
//! on the focused monitor.
|
||||
//! Deliberately compositor-agnostic: no Hyprland IPC, no layer/output
|
||||
//! lookup. `bread-capture` runs every target app inside an isolated,
|
||||
//! headless compositor instance of a known, fixed size (see its
|
||||
//! `isolation` module), so the caller already knows exactly what region to
|
||||
//! grab — there's nothing to query.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
|
|
@ -15,36 +13,18 @@ use std::time::Duration;
|
|||
|
||||
const GRIM_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Capture a named layer-shell surface belonging to *this* process
|
||||
/// (`std::process::id()`), identified by its `namespace` (e.g. `"breadbar"`).
|
||||
/// Namespace alone can't identify "our own" surface when another instance
|
||||
/// under the same namespace is already running (breadbar commonly is), so
|
||||
/// this matches by pid too — see `bread_utils::hypr::find_layer`.
|
||||
pub fn capture_layer(namespace: &str, out: &Path) -> Result<()> {
|
||||
let layer = bread_utils::hypr::find_layer(namespace, std::process::id())
|
||||
.with_context(|| format!("no layer surface found for namespace={namespace}"))?;
|
||||
let geometry = format!("{},{} {}x{}", layer.x, layer.y, layer.w, layer.h);
|
||||
run_grim(&geometry, out)
|
||||
}
|
||||
|
||||
/// Capture the entire focused output (monitor). Used for views whose
|
||||
/// interesting content isn't its own layer surface — see the module doc.
|
||||
pub fn capture_output(out: &Path) -> Result<()> {
|
||||
let monitor = bread_utils::hypr::focused_monitor().context("no focused monitor found")?;
|
||||
let (w, h) = monitor.logical_size();
|
||||
let geometry = format!("{},{} {}x{}", monitor.x, monitor.y, w, h);
|
||||
run_grim(&geometry, out)
|
||||
}
|
||||
|
||||
fn run_grim(geometry: &str, out: &Path) -> Result<()> {
|
||||
/// Capture a `w`x`h` region at `(x, y)` (compositor-global coordinates) to
|
||||
/// `out` via `grim -g`.
|
||||
pub fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &Path) -> Result<()> {
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let out_str = out.to_str().context("output path is not valid UTF-8")?;
|
||||
let result = bread_utils::proc::run("grim", &["-g", geometry, out_str], GRIM_TIMEOUT);
|
||||
let geometry = format!("{x},{y} {w}x{h}");
|
||||
let result = bread_utils::proc::run("grim", &["-g", &geometry, out_str], GRIM_TIMEOUT);
|
||||
if !result.success {
|
||||
bail!("grim failed for geometry {geometry}: {}", result.stderr);
|
||||
bail!("grim failed for geometry {geometry}: {}", result.stderr.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,81 +152,10 @@ pub struct Monitor {
|
|||
pub y: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
#[serde(default = "one")]
|
||||
pub scale: f64,
|
||||
#[serde(default)]
|
||||
pub transform: i64,
|
||||
#[serde(default)]
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// Logical (scaled, transform-aware) output size, matching what `grim -g`
|
||||
/// expects — `width`/`height` above are physical pixels. A 90/270-degree
|
||||
/// transform swaps the axes before the scale divide, same as breadshot's
|
||||
/// `monitor_geometry`, which this mirrors.
|
||||
pub fn logical_size(&self) -> (i32, i32) {
|
||||
if self.transform % 2 == 0 {
|
||||
(
|
||||
(self.width as f64 / self.scale).round() as i32,
|
||||
(self.height as f64 / self.scale).round() as i32,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
(self.height as f64 / self.scale).round() as i32,
|
||||
(self.width as f64 / self.scale).round() as i32,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry from `hyprctl layers -j` — a layer-shell surface (bar, launcher,
|
||||
/// lock screen, ...), keyed by the `namespace` its client registered.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Layer {
|
||||
pub namespace: String,
|
||||
pub pid: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
/// Find a layer-shell surface by namespace *and* pid. Namespace alone isn't
|
||||
/// enough to identify "our own" surface — several bread apps (breadbar
|
||||
/// notably) are typically already running under the same namespace when a
|
||||
/// second instance starts up for some other purpose (e.g. screenshot mode),
|
||||
/// so callers pass their own `std::process::id()` to disambiguate.
|
||||
pub fn find_layer(namespace: &str, pid: u32) -> Option<Layer> {
|
||||
find_layer_in(&request_json("j/layers")?, namespace, pid)
|
||||
}
|
||||
|
||||
/// Parsing half of [`find_layer`], split out so it's testable without a live
|
||||
/// Hyprland socket.
|
||||
fn find_layer_in(root: &serde_json::Value, namespace: &str, pid: u32) -> Option<Layer> {
|
||||
let monitors = root.as_object()?;
|
||||
for monitor in monitors.values() {
|
||||
let Some(levels) = monitor.get("levels").and_then(|v| v.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
for layers in levels.values() {
|
||||
for layer in layers.as_array().into_iter().flatten() {
|
||||
let Ok(l) = serde_json::from_value::<Layer>(layer.clone()) else {
|
||||
continue;
|
||||
};
|
||||
if l.namespace == namespace && l.pid == pid {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -261,77 +190,6 @@ pub fn active_workspace_name() -> Option<String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn logical_size_divides_by_scale() {
|
||||
let m = Monitor {
|
||||
name: "eDP-1".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 3840,
|
||||
height: 2400,
|
||||
scale: 2.0,
|
||||
transform: 0,
|
||||
focused: true,
|
||||
};
|
||||
assert_eq!(m.logical_size(), (1920, 1200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_size_swaps_axes_on_rotated_transform() {
|
||||
let m = Monitor {
|
||||
name: "eDP-1".into(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
scale: 1.0,
|
||||
transform: 1,
|
||||
focused: true,
|
||||
};
|
||||
assert_eq!(m.logical_size(), (1200, 1920));
|
||||
}
|
||||
|
||||
// Real shape confirmed live via `hyprctl layers -j`: per-monitor map ->
|
||||
// "levels" map (layer index "0".."3") -> array of layer objects.
|
||||
const LAYERS_JSON: &str = r#"{
|
||||
"eDP-1": {
|
||||
"levels": {
|
||||
"0": [
|
||||
{"address":"0x1","x":0,"y":0,"w":1920,"h":1200,"namespace":"awww-daemon","pid":2481}
|
||||
],
|
||||
"1": [],
|
||||
"2": [
|
||||
{"address":"0x2","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":1601316},
|
||||
{"address":"0x3","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":9999}
|
||||
],
|
||||
"3": []
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn find_layer_in_matches_namespace_and_pid() {
|
||||
let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap();
|
||||
let layer = find_layer_in(&root, "breadbar", 9999).unwrap();
|
||||
assert_eq!(layer.pid, 9999);
|
||||
assert_eq!(layer.w, 1920);
|
||||
assert_eq!(layer.h, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_layer_in_ignores_same_namespace_wrong_pid() {
|
||||
let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap();
|
||||
// pid 1601316 is a real breadbar layer in the fixture, but not the one
|
||||
// we're asking for — must not fall back to it.
|
||||
assert!(find_layer_in(&root, "breadbar", 42).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_layer_in_returns_none_for_missing_namespace() {
|
||||
let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap();
|
||||
assert!(find_layer_in(&root, "breadbox", 1601316).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_state_deserializes_from_bool() {
|
||||
let s: FullscreenState = serde_json::from_str("true").unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue