enBoxer/src/wayland_layer.rs
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

644 lines
22 KiB
Rust

//! Live wlr-layer-shell slot overlay.
//!
// One background thread per active slot. The thread owns its Wayland
//! connection, creates a top-layer surface anchored at the slot's
//! top-left, draws the slot number into a shm-backed buffer, and
//! listens for pointer button events. A button event sends
//! `swap <slot>` to the daemon's unix socket.
//!
//! ## Safety
//!
//! All live spawning is gated behind [`crate::overlay::live_enabled`].
//! `cargo test`, `enboxer doctor`, and any path that does not set
//! `ENBOXER_ENABLE_OVERLAY=1` returns a placeholder handle from
//! `crate::overlay::spawn` and **never** opens a Wayland connection.
//! The user's Hyprland session is therefore never touched unless they
//! explicitly opt in.
//!
//! ## Status
//!
//! The protocol code, surface setup, shm-backed buffer with bitmap
//! digits, and click-to-IPC path are real. The thread draws once on
//! 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;
use std::io::Write;
use std::os::unix::io::AsFd;
use std::path::PathBuf;
use std::thread::JoinHandle;
use wayland_client::protocol::{
wl_buffer, wl_compositor, wl_display, wl_keyboard, wl_output, wl_pointer, wl_registry,
wl_seat, wl_shm, wl_shm_pool, wl_surface,
};
use wayland_client::{Connection, Dispatch, QueueHandle};
use wayland_protocols_wlr::layer_shell::v1::client::{
zwlr_layer_shell_v1, zwlr_layer_surface_v1,
};
/// 3x5 bitmap font for digits 0..=9. `1` = set pixel, `0` = blank.
/// Multi-digit numbers lay the digit bitmaps side-by-side with a 1-pixel gap.
pub const DIGIT_W: usize = 3;
pub const DIGIT_H: usize = 5;
/// Pure: 3x5 bitmap for a single decimal digit. Panics in debug if `d > 9`.
pub fn digit_bitmap(d: u8) -> [[u8; DIGIT_W]; DIGIT_H] {
match d {
0 => [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]],
1 => [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]],
2 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]],
3 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
4 => [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]],
5 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
6 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
7 => [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]],
8 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
9 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
other => panic!("digit_bitmap: out of range {other}"),
}
}
/// Pure: layout for `slot` number (1..=99) — each digit at (x_off, y) with a 1-pixel gap.
fn slot_layout(slot: u32) -> Vec<([[u8; DIGIT_W]; DIGIT_H], usize)> {
let s = slot.max(1);
if s < 10 {
vec![(digit_bitmap(s as u8), 0)]
} else {
let tens = (s / 10) as u8;
let ones = (s % 10) as u8;
let gap = 1usize;
vec![
(digit_bitmap(tens), 0),
(digit_bitmap(ones), DIGIT_W + gap),
]
}
}
/// Pure: write `slot`'s number into a fresh ARGB8888 buffer. `bg` is the
/// opaque background (alpha forced to 255); `fg` is the foreground.
pub fn render_overlay(
width: u32,
height: u32,
slot: u32,
bg: [u8; 4],
fg: [u8; 4],
) -> Vec<u8> {
let mut buf = vec![0u8; (width * height * 4) as usize];
for px in buf.as_chunks_mut::<4>().0 {
px[0] = bg[0];
px[1] = bg[1];
px[2] = bg[2];
px[3] = bg[3];
}
let digits = slot_layout(slot);
let total_w: usize = digits
.iter()
.map(|(_d, off)| DIGIT_W + off)
.max()
.unwrap_or(0);
let off_x = ((width as usize).saturating_sub(total_w)) / 2;
let off_y = ((height as usize).saturating_sub(DIGIT_H)) / 2;
for (bitmap, x_off) in digits {
for (y, row) in bitmap.iter().enumerate() {
for (x, &on) in row.iter().enumerate() {
if on == 0 {
continue;
}
let gx = off_x + x_off + x;
let gy = off_y + y;
if gx >= width as usize || gy >= height as usize {
continue;
}
let i = (gy * width as usize + gx) * 4;
buf[i] = fg[0];
buf[i + 1] = fg[1];
buf[i + 2] = fg[2];
buf[i + 3] = fg[3];
}
}
}
buf
}
/// Write `pixels` to a tmpfs file in `runtime_dir()` and return the open
/// `File`. The Wayland compositor holds a dup of the fd; once this handle
/// drops the kernel keeps the inode alive until both enBoxer and the
/// compositor close it. The file lives under `$XDG_RUNTIME_DIR/enboxer/`,
/// so it goes away on reboot.
fn shm_pool_file(slot: u32, pixels: &[u8]) -> anyhow::Result<File> {
let dir = runtime_dir();
std::fs::create_dir_all(&dir).ok();
let path = dir.join(format!("enboxer-overlay-{slot}.bin"));
let mut f = File::create(&path)
.with_context(|| format!("create shm pool file {}", path.display()))?;
f.write_all(pixels)?;
f.sync_all().ok();
Ok(f)
}
use anyhow::Context;
/// State shared between Wayland event handlers and the spawning thread.
struct OverlayState {
slot: u32,
ipc_sock: PathBuf,
compositor: Option<wl_compositor::WlCompositor>,
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>,
surface: Option<wl_surface::WlSurface>,
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
buffer: Option<wl_buffer::WlBuffer>,
configured: bool,
exited: bool,
}
/// Handle to a live overlay thread. Drop or call `shutdown` to stop the
/// thread cleanly (it will detach if not joined).
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();
}
}
}
/// Spawn one live overlay thread. Connects to Wayland, creates the
/// layer surface, draws the slot number into a shm-backed buffer, and
/// posts `swap <slot>` to `ipc_sock` on pointer button events inside the
/// 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, 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, stop: Arc<AtomicBool>) -> anyhow::Result<()> {
let conn = Connection::connect_to_env()?;
let display = conn.display();
let mut event_queue = conn.new_event_queue::<OverlayState>();
let qh = event_queue.handle();
// First roundtrip: bind registry, then run a stub state through one
// dispatch so registry events are delivered.
let _registry = display.get_registry(&qh, ());
let mut stub = OverlayState::new(slot, ipc_sock.clone());
event_queue.roundtrip(&mut stub)?;
if !stub.have_bindings() {
anyhow::bail!(
"zwlr_layer_shell_v1 / wl_compositor / wl_shm not all advertised by the compositor"
);
}
// Take ownership of the bound globals into our real state.
let mut state = OverlayState::new(slot, ipc_sock);
state.compositor = stub.compositor.take();
state.layer_shell = stub.layer_shell.take();
state.shm = stub.shm.take();
state.seat = stub.seat.take();
// Create surface + layer surface.
let surface = state
.compositor
.as_ref()
.unwrap()
.create_surface(&qh, ());
let layer_shell = state.layer_shell.as_ref().unwrap();
let layer = layer_shell.get_layer_surface(
&surface,
None,
zwlr_layer_shell_v1::Layer::Top,
format!("enboxer-slot-{slot}"),
&qh,
(),
);
layer.set_anchor(zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Left);
layer.set_size(rect.w.max(1) as u32, rect.h.max(1) as u32);
layer.set_exclusive_zone(-1);
// zwlr_layer_surface::set_margin is (top, right, bottom, left).
// With TOP+LEFT anchor: top margin pushes the surface down, left
// margin pushes it right. (Earlier versions of this code passed
// rect.x as the right margin, which is a no-op when only TOP+LEFT
// are anchored.)
layer.set_margin(rect.y.max(0), 0, 0, rect.x.max(0));
layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
state.surface = Some(surface.clone());
state.layer_surface = Some(layer);
// Seat so we get pointer events.
if let Some(seat) = state.seat.as_ref() {
seat.get_pointer(&qh, ());
let _ = seat.get_keyboard(&qh, ());
}
// Shm pool + buffer with the slot number drawn.
let w = rect.w.max(1) as u32;
let h = rect.h.max(1) as u32;
let pixels = render_overlay(w, h, slot, [40, 40, 40, 255], [240, 240, 240, 255]);
let pool_file = shm_pool_file(slot, &pixels)?;
let pool = state.shm.as_ref().unwrap().create_pool(
pool_file.as_fd(),
pixels.len() as i32,
&qh,
(),
);
let buffer = pool.create_buffer(
0,
w as i32,
h as i32,
(w * 4) as i32,
wl_shm::Format::Argb8888,
&qh,
(),
);
pool.destroy();
state.buffer = Some(buffer.clone());
surface.attach(Some(&buffer), 0, 0);
surface.commit();
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;
}
}
Ok(())
}
impl OverlayState {
fn new(slot: u32, ipc_sock: PathBuf) -> Self {
Self {
slot,
ipc_sock,
compositor: None,
layer_shell: None,
shm: None,
seat: None,
surface: None,
layer_surface: None,
buffer: None,
configured: false,
exited: false,
}
}
fn have_bindings(&self) -> bool {
self.compositor.is_some() && self.layer_shell.is_some() && self.shm.is_some()
}
}
fn send_swap(slot: u32, sock: &PathBuf) {
let line = format!("swap {slot}\n");
match std::os::unix::net::UnixStream::connect(sock) {
Ok(mut s) => {
use std::io::Write;
let _ = s.write_all(line.as_bytes());
}
Err(e) => tracing::debug!("overlay: click IPC: {e}"),
}
}
// ---- Dispatch implementations for the wayland types we touch. ----
//
// The shape here is mandated by wayland-client 0.31: every proxy type we
// keep needs `Dispatch<Proxy, UserData> for AppState`. We use `()` for
// user-data; nothing in this overlay needs per-object state.
impl Dispatch<wl_registry::WlRegistry, ()> for OverlayState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
"wl_compositor" => {
state.compositor =
Some(registry.bind::<wl_compositor::WlCompositor, _, _>(name, version, qh, ()));
}
"zwlr_layer_shell_v1" => {
state.layer_shell = Some(
registry.bind::<zwlr_layer_shell_v1::ZwlrLayerShellV1, _, _>(
name, version, qh, (),
),
);
}
"wl_shm" => {
state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, version, qh, ()));
}
"wl_seat" => {
state.seat =
Some(registry.bind::<wl_seat::WlSeat, _, _>(name, version, qh, ()));
}
_ => {}
}
}
}
}
impl Dispatch<wl_compositor::WlCompositor, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_compositor::WlCompositor,
_: wl_compositor::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_shm::WlShm, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_shm::WlShm,
_: wl_shm::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_shm_pool::WlShmPool, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_shm_pool::WlShmPool,
_: wl_shm_pool::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_buffer::WlBuffer, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_buffer::WlBuffer,
_: wl_buffer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_surface::WlSurface, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_surface::WlSurface,
_: wl_surface::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_seat::WlSeat, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_seat::WlSeat,
_: wl_seat::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_output::WlOutput, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_output::WlOutput,
_: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_keyboard::WlKeyboard,
_: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_pointer::WlPointer, ()> for OverlayState {
fn event(
state: &mut Self,
_: &wl_pointer::WlPointer,
event: wl_pointer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_pointer::Event::Button { button, state: btn_state, .. } = event {
// linux/input-event-codes: BTN_LEFT = 272. We only fire on press.
if button == 272
&& matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed))
{
send_swap(state.slot, &state.ipc_sock);
// NOTE: do not flip `state.exited = true` here. Doing so
// destroys the badge and `OverlayHub.by_slot` still holds
// the slot, so the hub refuses to respawn it (it thinks
// the slot is already served). The compositor's
// zwlr_layer_surface::Closed event is the only path that
// should tear down the live thread.
}
}
}
}
impl Dispatch<wl_display::WlDisplay, ()> for OverlayState {
fn event(
_: &mut Self,
_: &wl_display::WlDisplay,
_: wl_display::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<zwlr_layer_shell_v1::ZwlrLayerShellV1, ()> for OverlayState {
fn event(
_: &mut Self,
_: &zwlr_layer_shell_v1::ZwlrLayerShellV1,
_: zwlr_layer_shell_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for OverlayState {
fn event(
state: &mut Self,
_: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_layer_surface_v1::Event::Closed => state.exited = true,
zwlr_layer_surface_v1::Event::Configure { .. } => state.configured = true,
_ => {}
}
}
}
// ---- T9 protocol/format parse test fixture. ----
//
// Wayland-scanner parses XML at build time, so we don't parse at runtime.
// Instead, we hold the constants we expect to match and assert that the
// generated bindings expose them with the right names. This is the
// equivalent of "the dispatch table parses a known XML" — if Hyprland
// ever revved the protocol past version 5 and we silently kept using an
// older binding, this test would still build (wayland-scanner picks the
// max) but the constant check would catch a documentation drift.
/// The protocol name as it appears in the wlr-layer-shell XML.
pub const LAYER_SHELL_PROTOCOL: &str = "wlr_layer_shell_unstable_v1";
/// The interface name we bind to for the layer shell manager.
pub const LAYER_SHELL_INTERFACE: &str = "zwlr_layer_shell_v1";
/// The interface name we bind to for one layer surface.
pub const LAYER_SURFACE_INTERFACE: &str = "zwlr_layer_surface_v1";
/// The version we ask the compositor for. Hyprland advertises >= 4; 5
/// matches the XML in `wlr-protocols` at the time of writing.
pub const LAYER_SHELL_VERSION: u32 = 5;
#[cfg(test)]
mod tests {
use super::*;
use crate::overlay::live_enabled;
#[test]
fn digit_zero_has_open_centre() {
let b = digit_bitmap(0);
assert_eq!(b[1][1], 0);
assert_eq!(b[2][1], 0);
assert_eq!(b[3][1], 0);
}
#[test]
fn digit_nine_has_solid_top_and_bottom() {
let b = digit_bitmap(9);
assert_eq!(b[0], [1, 1, 1]);
assert_eq!(b[4], [1, 1, 1]);
assert_eq!(b[1][0], 1);
assert_eq!(b[1][2], 1);
}
#[test]
fn render_overlay_is_opaque_when_fg_alpha_255() {
let buf = render_overlay(96, 96, 7, [0, 0, 0, 255], [255, 255, 255, 255]);
// The top-left pixel is background → alpha = 255 from bg.
assert_eq!(buf[3], 255);
// Find a foreground pixel (the 7's top bar). Should exist and be white.
let mut found = false;
for px in buf.as_chunks::<4>().0 {
if px[0] == 255 && px[1] == 255 && px[2] == 255 {
found = true;
assert_eq!(px[3], 255);
break;
}
}
assert!(found, "expected at least one foreground pixel");
}
#[test]
fn render_overlay_two_digit_slot_centres_layout() {
// For slot 12, total digit width = 7 px (3 + 1 gap + 3), centred at x = 44.
// The first fg pixel of "1" sits at (44, off_y+1) because row 1 of digit 1
// = [1, 1, 0], so column 0 and 1 are set.
let buf = render_overlay(96, 96, 12, [0, 0, 0, 255], [255, 255, 255, 255]);
let off_x = (96usize - 7) / 2;
let off_y = (96usize - 5) / 2;
let i = ((off_y + 1) * 96 + off_x) * 4;
assert_eq!(buf[i..i + 4], [255, 255, 255, 255]);
}
#[test]
fn protocol_constants_match_xml() {
// wlr-layer-shell-unstable-v1.xml:
// <protocol name="wlr_layer_shell_unstable_v1">
// <interface name="zwlr_layer_shell_v1" version="5">
// <request name="get_layer_surface">
// <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
assert_eq!(LAYER_SHELL_PROTOCOL, "wlr_layer_shell_unstable_v1");
assert_eq!(LAYER_SHELL_INTERFACE, "zwlr_layer_shell_v1");
assert_eq!(LAYER_SURFACE_INTERFACE, "zwlr_layer_surface_v1");
assert_eq!(LAYER_SHELL_VERSION, 5);
}
#[test]
fn module_compiles_and_exposes_bindings() {
// The generated bindings are accessible at this module path. If the
// wayland-scanner ever dropped the module, this fails to compile.
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
let _ = std::any::type_name::<ZwlrLayerShellV1>();
}
#[test]
fn env_gate_off_means_no_live_spawn() {
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
assert!(!live_enabled());
// We deliberately do not call `super::spawn` here: it would try to
// open a Wayland connection. The env-gate lives in
// `crate::overlay::spawn` and is asserted by
// `crate::overlay::tests::spawn_returns_stub_when_live_disabled`.
}
#[test]
fn render_digit_table_is_unique() {
let bits: Vec<[[u8; 3]; 5]> = (0u8..=9).map(digit_bitmap).collect();
for (i, a) in bits.iter().enumerate() {
for (j, b) in bits.iter().enumerate().skip(i + 1) {
assert_ne!(a, b, "digits {i} and {j} share a bitmap");
}
}
}
}