diff --git a/bread-capture/src/isolation.rs b/bread-capture/src/isolation.rs index 1220b02..ef43da2 100644 --- a/bread-capture/src/isolation.rs +++ b/bread-capture/src/isolation.rs @@ -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 -//! 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 { - 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 = 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 = 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 { - // 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. - let _ = isolation.child.kill(); - let _ = isolation.child.wait(); - return Err(e); + 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. + 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 { - 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 { + 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, timeout: Duration, relevant: impl Fn(&str) -> bool) -> Result { +fn poll_for_new( + dir: &Path, + before: &HashSet, + timeout: Duration, + relevant: impl Fn(&str) -> bool, +) -> Result { let start = Instant::now(); loop { let after = dir_names(dir); @@ -206,38 +170,11 @@ fn poll_for_new(dir: &Path, before: &HashSet, 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 { - 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(()) -} diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 7d71a4b..3309bcc 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -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 { diff --git a/bread-screenshots/src/lib.rs b/bread-screenshots/src/lib.rs index b6bb610..d8e4eb8 100644 --- a/bread-screenshots/src/lib.rs +++ b/bread-screenshots/src/lib.rs @@ -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(()) } diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index 228c53c..def3e98 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -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 { - 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 { - 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.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 { 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();