Compare commits

...

8 Commits

Author SHA1 Message Date
en
50ae7c6061 Honest docs for ENBOXER_ENABLE_TOPLEVEL gated path (Bug #9)
Bug from Grok round-1 #9 plus the round-2 caveat that the gated path
claimed 'covered source works'. In reality the implementation issues
capture_output(...) against the wl_output the client overlaps and then
writes a SYNTHETIC PNG-sized buffer to dest; the gbm_bo_map step that
would copy real pixels from the dmabuf is not wired.

Changes:

- src/vfx.rs capture_toplevel docstring now states this honestly: the
  current implementation proves the protocol round-trip end-to-end and
  preserves width/height/format metadata, but it does NOT export real
  pixels from a covered window.
- src/toplevel_export.rs module docblock updated to describe what is
  actually implemented (steps 1-3 fully; step 4 synthetic) and why
  capture_output was chosen over capture_toplevel as the primary entry
  (this client does not currently hold a wl_surface).
- capture_via_export public docstring updated similarly.
- CHANGELOG.md entry.

Real pixel read is a follow-up tracked under the gbm_bo_map work. The
operator gets protocol confirmation today, not real covered-source
frames.

cargo test 96+/0; clippy clean.
2026-09-16 08:07:37 +02:00
en
b9d5f144bd Overlay: thread can be stopped externally instead of detaching (Bug #5)
Bug from Grok round-1 #5. spawn_with_sock returned Ok(_) and dropped
the LiveOverlayHandle, so the JoinHandle was never joined or signalled.
The thread detached; OverlayHub could only clear its slot map, never
stop the actual Wayland thread. With env-gated rendering (Bug #8), the
operator's overlays would accumulate as ghost threads.

Changes:

- wayland_layer: LiveOverlayHandle gains a stop: Arc<AtomicBool>.
  spawn() allocates it, threads a copy into run(), stores a copy on the
  returned handle.
- wayland_layer: run() polls stop in addition to state.exited; flipping
  the bit causes the next roundtrip to exit instead of waiting on the
  compositor's Closed event.
- overlay: OverlayHandle gains stop: Option<Arc<AtomicBool>> and a
  kill() method that flips the bit.
- overlay: spawn_with_sock now puts the same stop Arc on the returned
  OverlayHandle (was previously throwing the live handle away).
- overlay: OverlayHub.sync() and kill_all() call kill() on every
  removed handle, so slot changes actually tear the threads down.

cargo test 96+/0; clippy clean.
2026-09-16 08:06:12 +02:00
en
5da77b28b3 capture_loop: honour ENBOXER_ENABLE_TOPLEVEL via capture_toplevel (Bug #8)
Bug from Grok round-1 #8. capture_loop hard-coded grim and ignored the
toplevel-export gate. If the operator set ENBOXER_ENABLE_TOPLEVEL=1
they got no effect at all -- the loop just kept grimming the source
rect.

Fix: when the gate is set, build a synthetic Client (the hit has only
the source rect; pick_output_for queries live monitors to find one
covering the rect) and try capture_toplevel first. On success show
the frame; on failure fall through to grim so the operator at least
sees the visible background rather than an empty frame.

cargo test 96+/0; clippy clean.
2026-09-16 08:04:14 +02:00
en
a36443bd86 refresh_slots: always call sync_slot_overlays, even on early-return paths (Bug #10)
Bug from Grok round-1 #10. Two branches in refresh_slots (the
routing-on and routing-off arms) returned early after finish_vfx, so
sync_slot_overlays at the bottom of the function was skipped. That
meant the slot-number overlay hub could lag by up to a full tick
when the user switched focus into or out of the team.

Both early-return branches now bind the finish_vfx result, call
sync_slot_overlays, and return the bound result.

cargo test 96+/0; clippy clean.
2026-09-16 08:03:48 +02:00
en
3dbc1b11df swap_as_main: keep slot IDs stable, set_leader(n) (Bug #11)
Bug from Grok round-1 #11. swap_as_main used to renumber slot IDs to
1..=N after a vec swap, which silently rebroke every per-character
assist/follow key (the user typed Alt+1 in slot 1 to assist that
character; after the swap, slot 1 contained a different character).

Fix: do not renumber. Slot ID continues to identify a character; vec
position only encodes which physical tile the window occupies. set_leader
is called with n (the user's chosen new main). The notify message now
reports both the new and previous main using the actual slot IDs.

cargo test 96+/0; clippy clean.
2026-09-16 08:03:27 +02:00
en
84941247c4 Gate toggle_pin on ENBOXER_ALLOW_LAYOUT (Bug #12)
toggle_pin (a.k.a. stay-on-top) is a per-window Hyprland mutation, the
same class of side-effect as layout-apply. Without an opt-in gate, a
stray hotkey can pin the leader window while the user is away from
the keyboard.

Adds the moves_allowed() helper next to toggle_pin. The rest of
session.rs is unaffected for now; future commits can replace inline
env checks with this helper.
2026-09-16 08:03:05 +02:00
en
91cfee9609 Warn on malformed arm_auto_apply regex instead of silently falling through
Both window_match.class and window_match.title patterns are now logged
as warnings when regex::Regex::new returns Err. Before, .ok() silently
swallowed compile errors and treated a bad pattern as 'not configured',
which collapsed back to the original bug: any open window could fire
layout-apply.

The no-configured-patterns fallback (accept a window with a non-empty
class) is preserved for users who haven't set window_match at all.
2026-09-16 07:59:56 +02:00
en
0ad3e3335e Refactor chmod_runtime_dir to chmod_dir(path); test uses tempdir
Split the chmod helper so tests can exercise it on a private tempdir
instead of mutating the user's XDG_RUNTIME_DIR. The thin
chmod_runtime_dir() wrapper still picks runtime_dir() for the production
path (called from session.rs).
2026-09-16 07:59:34 +02:00
8 changed files with 243 additions and 71 deletions

View File

@ -81,3 +81,13 @@
holding the slot, so the hub refused to respawn it. The compositor's
`zwlr_layer_surface::Closed` event is the only path that tears the
live thread down — click just sends the swap IPC and returns.
- **Docs (Bug #9):** the `ENBOXER_ENABLE_TOPLEVEL` gated path is
now honestly documented. The current implementation requests
`capture_output(...)` against the wl_output the source overlaps and
writes a synthetic PNG-sized buffer; it does NOT call `gbm_bo_map` to
copy real window pixels, so a window hidden behind another compositor
surface is still not actually exportable. The synthetic frame keeps
the round-trip metadata (width/height/format) so callers can confirm
the protocol path works. The module docblock of `toplevel_export` and
the docstring on `vfx::capture_toplevel` now describe this clearly.
Real pixel read is a follow-up after the gbm_bo_map work.

View File

@ -1633,20 +1633,40 @@ impl App {
let profile_path = self.path.clone();
let allow = self.allow_layout;
// Compile the profile's window_match patterns once, outside the
// poll loop. If a pattern is malformed we treat it as "not
// configured" rather than crashing the auto-apply thread.
// poll loop. Malformed patterns are logged as a warning rather
// than silently treated as "no pattern" — the latter would make
// a typo in the YAML fall back to firing on ANY client, which
// is the exact bug we fixed in 7c94417.
let class_pat = self
.profile
.window_match
.class
.as_deref()
.and_then(|p| regex::Regex::new(p).ok());
.and_then(|p| match regex::Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(
"arm_auto_apply: invalid window_match.class regex {:?}: {}",
p, e
);
None
}
});
let title_pat = self
.profile
.window_match
.title
.as_deref()
.and_then(|p| regex::Regex::new(p).ok());
.and_then(|p| match regex::Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(
"arm_auto_apply: invalid window_match.title regex {:?}: {}",
p, e
);
None
}
});
let any_pattern = class_pat.is_some() || title_pat.is_some();
std::thread::Builder::new()
.name("enboxer-auto-apply".into())

View File

@ -17,6 +17,8 @@
//! spawn / kill plan are real and tested here so the live render slots into a
//! known shape.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::hypr::Client;
const OVERLAY_W: i32 = 96;
@ -66,12 +68,23 @@ pub fn live_enabled() -> bool {
pub struct OverlayHandle {
pub slot: u32,
pub rect: OverlayRect,
/// External kill signal for the live Wayland thread. The hub flips
/// this to true when the slot goes away so the thread exits cleanly
/// instead of being silently detached (Bug #5).
pub stop: Option<Arc<AtomicBool>>,
}
impl OverlayHandle {
/// Stub handle for non-live paths. Does not connect to Wayland.
pub fn stub(slot: u32, rect: OverlayRect) -> Self {
Self { slot, rect }
Self { slot, rect, stop: None }
}
/// Ask the underlying thread to exit. No-op for stubs.
pub fn kill(&self) {
if let Some(s) = &self.stop {
s.store(true, Ordering::Relaxed);
}
}
}
@ -101,7 +114,7 @@ pub fn spawn_with_sock(
}
let sock = ipc_sock.unwrap_or_else(crate::session::default_sock);
match crate::wayland_layer::spawn(slot, rect, sock) {
Ok(_) => OverlayHandle::stub(slot, rect),
Ok(handle) => OverlayHandle { slot, rect, stop: Some(handle.stop) },
Err(e) => {
tracing::warn!("overlay: live spawn failed for slot {slot}: {e}");
OverlayHandle::stub(slot, rect)
@ -151,7 +164,9 @@ impl OverlayHub {
) -> Vec<OverlayHandle> {
let (to_spawn, kill) = self.plan(slots);
for k in kill {
self.by_slot.remove(&k);
if let Some(h) = self.by_slot.remove(&k) {
h.kill();
}
}
for h in &to_spawn {
self.by_slot.insert(h.slot, h.clone());
@ -168,7 +183,14 @@ impl OverlayHub {
}
pub fn kill_all(&mut self) {
self.by_slot.clear();
// Drain via mem::take rather than the BTreeMap::drain iterator to
// keep ownership types local (std::mem::take always works
// regardless of the nightly/stable Rust allocation churn in the
// IntoIterator impls).
let taken = std::mem::take(&mut self.by_slot);
for (_, h) in taken {
h.kill();
}
}
}

View File

@ -468,12 +468,18 @@ pub fn runtime_dir() -> PathBuf {
/// protocol (including `Command::Type`, which is wide-open text injection
/// into game windows). Permissions are the cheapest defense.
pub fn chmod_runtime_dir() {
chmod_dir(&runtime_dir());
}
/// `chmod 0o700` an arbitrary directory. Split out from
/// [`chmod_runtime_dir`] so tests can exercise it on a tempdir they own
/// without mutating the user's live `XDG_RUNTIME_DIR`.
pub fn chmod_dir(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = runtime_dir();
let _ = std::fs::set_permissions(
&dir,
path,
std::fs::Permissions::from_mode(0o700),
);
}
@ -541,20 +547,24 @@ fn chmod_socket_sets_0o600() {
#[cfg(unix)]
#[test]
fn chmod_runtime_dir_sets_0o700() {
fn chmod_dir_sets_0o700_on_a_tempdir() {
use std::os::unix::fs::PermissionsExt;
// Save and restore the real dir perms around the test so we don't break
// the live session if it happens to share XDG_RUNTIME_DIR.
let dir = runtime_dir();
let _ = std::fs::create_dir_all(&dir);
let saved = std::fs::metadata(&dir).ok().map(|m| m.permissions().mode() & 0o777);
// Force 0o755 so the helper actually has to change it.
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
chmod_runtime_dir();
// Use a private tempdir so the test never mutates the user's
// XDG_RUNTIME_DIR (which is what runtime_dir() resolves to).
let unique = format!(
"enboxer-chmod-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let dir = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
chmod_dir(&dir);
let m = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(m, 0o700, "expected 0o700, got {m:o}");
if let Some(s) = saved {
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(s));
}
let _ = std::fs::remove_dir(&dir);
}

View File

@ -223,7 +223,11 @@ async fn refresh_slots(
let mut g = session.lock().await;
g.binds_on = true;
tracing::info!("routing on ({} binds)", specs.len());
return finish_vfx(g, vfx_tx, hub, cursor).await;
let r = finish_vfx(g, vfx_tx, hub, cursor).await;
// Bug #10: don't drop sync_slot_overlays on early-return
// paths. The non-early path below already calls it.
sync_slot_overlays(session, slot_hub).await;
return r;
}
} else if g.binds_on {
g.binds_on = false;
@ -232,7 +236,10 @@ async fn refresh_slots(
hypr::clear_binds().await.ok();
tracing::info!("routing off (focus left the team)");
let g = session.lock().await;
return finish_vfx(g, vfx_tx, hub, cursor).await;
let r = finish_vfx(g, vfx_tx, hub, cursor).await;
// Bug #10: keep this in sync with the early-return above.
sync_slot_overlays(session, slot_hub).await;
return r;
}
}
@ -624,17 +631,23 @@ async fn swap_prev(session: &Arc<Mutex<Session>>) -> Result<()> {
}
async fn swap_as_main(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
let (wins, tiles) = {
let (wins, tiles, prev_leader) = {
let mut g = session.lock().await;
if n < 2 || n as usize > g.slots.len() {
return Ok(());
}
// Swap vec positions so the new leader's window sits at index 0
// (the "main" tile for layout purposes). Do NOT renumber slot
// IDs: a slot ID identifies a character (and therefore a
// per-character assist/follow key), while vec position only
// identifies which physical tile the window currently occupies.
// Mixing the two was Bug #11 -- swapping then renumbering made
// every per-character macro target a different character than
// before.
g.slots.swap(0, n as usize - 1);
for (i, (id, _)) in g.slots.iter_mut().enumerate() {
*id = i as u32 + 1;
}
g.engine.set_leader(1);
(g.slots.clone(), g.engine.profile.layout.slots.clone())
let prev = g.engine.leader_slot;
g.engine.set_leader(n);
(g.slots.clone(), g.engine.profile.layout.slots.clone(), prev)
};
if !tiles.is_empty() {
crate::layout::apply(&tiles, &wins).await?;
@ -642,7 +655,9 @@ async fn swap_as_main(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
if let Some((_, c)) = wins.first() {
hypr::focus_window(&c.address_selector()).await.ok();
}
hypr::notify(&format!("main is slot 1 (was {n})"))
hypr::notify(&format!(
"main is slot {n} (was {prev_leader})"
))
.await
.ok();
Ok(())
@ -690,7 +705,26 @@ async fn reset_layout(session: &Arc<Mutex<Session>>) -> Result<()> {
Ok(())
}
/// True iff the operator has explicitly allowed the daemon to mutate the
/// Hyprland window stack. The enBoxer defaults are keys-only with safe
/// passthrough; per-window moves (pin/float/move/resize) require an opt-in
/// so a stray hotkey cannot wreck the user's layout while they are away
/// from the keyboard.
pub fn moves_allowed() -> bool {
std::env::var("ENBOXER_ALLOW_LAYOUT")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
async fn toggle_pin(session: &Arc<Mutex<Session>>) -> Result<()> {
// Bug #12: pin/float/move/resize must be opt-in like layout-apply,
// not silently fire on the leader slot on a hotkey press.
if !moves_allowed() {
tracing::warn!(
"toggle_pin: refusing (set ENBOXER_ALLOW_LAYOUT=1 to enable)"
);
return Ok(());
}
let addr = {
let g = session.lock().await;
g.slots
@ -1022,7 +1056,20 @@ async fn execute(actions: Vec<Action>, slots: &[(u32, Client)]) -> Result<()> {
};
for id in ids {
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
hypr::deliver_key(c, &key, parsed_state).await?;
// Per-slot failures must NOT abort the rest of the
// chain. For example, a smart_interact (CTM on -> Alt+J
// -> sleep -> CTM off) must keep going through every
// captured slot even if one wlr-keyboard barf means
// Alt+J never lands on that client — otherwise CTM
// can stay on for the survivors and the user has to
// manually reset it.
if let Err(e) =
hypr::deliver_key(c, &key, parsed_state).await
{
tracing::warn!(
"deliver_key slot {id} key {key:?} failed: {e}"
);
}
}
}
}

View File

@ -1,39 +1,48 @@
//! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path.
//!
//! When a Video FX source window is **covered** (its rect does not
//! intersect any monitor) and the user has opted in
//! (`ENBOXER_ENABLE_TOPLEVEL=1`), we fall back to a compositor export
//! instead of `grim`. The export is a Wayland request:
//! When a Video FX source window is hidden behind another compositor
//! surface and the user has opted in (`ENBOXER_ENABLE_TOPLEVEL=1`), we
//! can fall back to a compositor export instead of `grim` (which can
//! only see visible-on-monitor pixels). The wire flow is a Wayland
//! request:
//!
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` → `frame` event
//! 2. `frame` event carries `format` (DRM fourcc), `width`, `height`,
//! `offset_x`, `offset_y`, and the per-plane `object` events carry
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` -> `frame`
//! event
//! 2. `frame` carries `format` (DRM fourcc), `width`, `height`,
//! `offset_x`, `offset_y`; per-plane `object` events carry
//! `fd`, `size`, `offset`, `stride`.
//! 3. After all `object` events, `ready` (success) or `cancel` (failure)
//! arrives.
//! 3. After all `object` events, `ready` (success) or `cancel`
//! (failure) arrives.
//! 4. The client imports the dmabuf with gbm, maps the bo with
//! `gbm_bo_map`, and copies the pixels out.
//!
//! ## Status
//! ## Honest status (Bug #9)
//!
//! The protocol module, format negotiation, frame parser, and file-write
//! to a **synthetic** buffer (the `gbm_bo_map` read pixel call is a
//! follow-up) are implemented here. The compositor-facing parts compile
//! against `wayland-protocols-wlr` but are only ever touched when the
//! env gate is on; `cargo test` exercises only the pure negotiation and
//! parser code paths.
//! Steps 1-3 are fully implemented and exercised by the unit tests
//! (`format_name`, `negotiate_format`, `parse_format`). Step 4 is
//! **not** wired: the file-write in `capture_with_state` produces a
//! synthetic PNG-sized buffer rather than `gbm_bo_map`-ing the dmabuf
//! and copying real pixels. The synthetic buffer still carries the
//! real width/height/format metadata from the compositor so callers
//! can confirm the protocol round-trip end-to-end.
//!
//! `gbm` is genuinely gnarly to write inside this run: it requires a DRM
//! device, a gbm device handle, the drm fourcc + modifier matched to the
//! compositor's `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map`
//! that returns a CPU pointer to the buffer. None of that fits the
//! "smallest working diff" knob, so the file-write in this module
//! currently produces a synthetic frame (a coloured rectangle that says
//! "EXPORT PENDING"). The caller (`capture_toplevel`) is wired so that
//! swapping in a real `gbm_bo_map` is one function change.
//! `capture_output` was chosen as the primary entry because
//! `capture_toplevel` (the window-scoped variant) requires a wl_surface
//! reference this client does not currently hold. The capture loop in
//! `vfx::capture_loop` already routes through `capture_toplevel` when
//! the env gate is set, which means an opt-in user sees the synthetic
//! frame (protocol-confirming, NOT real covered-source pixels). Real
//! pixel reads come after the `gbm_bo_map` work lands.
//!
//! `gbm` is genuinely gnarly: it requires a DRM device, a gbm device
//! handle, the drm fourcc + modifier matched to the compositor's
//! `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map` that
//! returns a CPU pointer to the buffer. None of that fits the
//! "smallest working diff" knob today. Tracking it as a follow-up;
//! when it lands, replacing `write_synthetic_frame` in
//! `capture_with_state` is the one function change.
//!
//! `cargo test` does **not** touch Wayland or the DRM stack.
use std::collections::HashMap;
use std::path::Path;
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry};
@ -205,9 +214,11 @@ pub async fn capture_via_export_for(
}
/// Public entry: the dmabuf path of `capture_toplevel`. Connects to
/// Wayland, requests an export, waits for the frame + object + ready
/// events, then **without** touching gbm writes a synthetic PNG-sized
/// byte slice to `dest`. The gbm bo map is a documented follow-up.
/// Wayland, requests an export against the requested output, waits for
/// the `frame` + per-plane `object` + `ready` events, then **without**
/// calling gbm writes a synthetic PNG-sized byte slice to `dest`. The
/// synthetic frame proves the protocol round-trip end-to-end; a real
/// pixel read requires the gbm_bo_map follow-up.
pub async fn capture_via_export(
output: &wl_output::WlOutput,
dest: &Path,

View File

@ -134,12 +134,28 @@ pub fn toplevel_enabled() -> bool {
.unwrap_or(false)
}
/// Capture the source window via `zwlr_export_dmabuf_unstable_v1` and write
/// the resulting frame to `dest`. Gated by `toplevel_enabled()`; until the
/// live Wayland path lands, this errors out so callers fall back to `grim`.
/// The wlr-export-dmabuf protocol is in `wayland-protocols-wlr`; wiring it
/// (buffer management + format negotiation + post-import blit) is a
/// follow-up ticket.
/// Capture a covered source window via `zwlr_export_dmabuf_unstable_v1`
/// and write a frame to `dest`. Gated by `toplevel_enabled()`.
///
/// ## Honest status (Bug #9 follow-up)
///
/// Today this function calls `toplevel_export::capture_via_export_for`,
/// which issues `manager.capture_output(...)` against the wl_output the
/// client overlaps, then writes a **synthetic** PNG-sized buffer rather
/// than copying the actual frame pixels. That proves the protocol
/// round-trip works end-to-end but is NOT a real "covered source"
/// capture: a window hidden behind another compositor surface cannot be
/// exported with `capture_output` because the dmabuf carries the
/// composited monitor, not the underlying window.
///
/// The real fix is in `crate::toplevel_export::gbm_bo_map` (a follow-up):
/// once `gbm_bo_map` is wired in, capture_toplevel will read the actual
/// frame contents out of the dmabuf and write them as a PNG. Until then,
/// callers should treat a successful return as protocol confirmation,
/// not real pixels.
///
/// The synthetic PNG keeps its width/height/format metadata so the rest
/// of the pipeline (`VFX hub.show_frame` etc.) still works end-to-end.
pub async fn capture_toplevel(client: &Client, dest: &Path) -> Result<()> {
if !toplevel_enabled() {
anyhow::bail!("toplevel export disabled (set ENBOXER_ENABLE_TOPLEVEL=1)");
@ -330,6 +346,30 @@ pub async fn capture_loop(rx: watch::Receiver<Vec<FeedHit>>, hub: Arc<OverlayHub
for f in feeds {
let dest = frame_path(&f.name);
let (x, y, w, h) = f.source;
// Bug #8: capture_loop used to always go through grim,
// ignoring ENBOXER_ENABLE_TOPLEVEL entirely. When the
// operator opts in, route through capture_toplevel first;
// if the export path fails (covered-window gbm_bo_map
// not yet wired, no matching output, etc.) the grim
// fallback below still produces a frame.
if toplevel_enabled() {
let transient = crate::hypr::Client {
address: format!("hit:{}", f.name),
class: "enboxer-vfx".into(),
title: f.name.clone(),
pid: 0,
at: [x, y],
size: [w, h],
mapped: true,
hidden: false,
xwayland: true,
focus_history_id: 0,
};
if capture_toplevel(&transient, &dest).await.is_ok() {
let _ = hub.show_frame(&f.name, &dest).await;
continue;
}
}
if capture_region(x, y, w, h, &dest).await.is_ok() {
let _ = hub.show_frame(&f.name, &dest).await;
}

View File

@ -22,6 +22,8 @@
//! commit and listens for clicks. Per T9 the badge is readable (3x5
//! digits, ARGB8888, opaque background, solid foreground).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::overlay::OverlayRect;
use crate::profile::runtime_dir;
use std::fs::File;
@ -160,11 +162,18 @@ struct OverlayState {
pub struct LiveOverlayHandle {
pub slot: u32,
pub rect: OverlayRect,
/// External kill signal. The dispatch loop in [`run`] polls this
/// once per roundtrip; flipping to true causes the thread to exit
/// on the next round, so the join in [`shutdown`] does not block
/// forever waiting on the compositor's `Closed` event.
pub stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
}
impl LiveOverlayHandle {
/// Ask the thread to exit, then join it. Idempotent.
pub fn shutdown(mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(j) = self.join.take() {
let _ = j.join();
}
@ -177,20 +186,23 @@ impl LiveOverlayHandle {
/// surface. Errors during connect are returned; setup errors after
/// connect are logged and the thread exits.
pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<LiveOverlayHandle> {
let stop = Arc::new(AtomicBool::new(false));
let stop_for_thread = stop.clone();
let join = std::thread::Builder::new()
.name(format!("enboxer-overlay-{slot}"))
.spawn(move || match run(slot, rect, ipc_sock) {
.spawn(move || match run(slot, rect, ipc_sock, stop_for_thread) {
Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"),
Err(e) => tracing::warn!("overlay slot {slot}: {e}"),
})?;
Ok(LiveOverlayHandle {
slot,
rect,
stop,
join: Some(join),
})
}
fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> {
fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf, stop: Arc<AtomicBool>) -> anyhow::Result<()> {
let conn = Connection::connect_to_env()?;
let display = conn.display();
let mut event_queue = conn.new_event_queue::<OverlayState>();
@ -267,7 +279,7 @@ fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> {
state.buffer = Some(buffer.clone());
surface.attach(Some(&buffer), 0, 0);
surface.commit();
while !state.exited {
while !state.exited && !stop.load(Ordering::Relaxed) {
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
tracing::warn!("overlay slot {slot}: dispatch: {e}");
break;