Compare commits
8 Commits
6028987a6e
...
50ae7c6061
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ae7c6061 | ||
|
|
b9d5f144bd | ||
|
|
5da77b28b3 | ||
|
|
a36443bd86 | ||
|
|
3dbc1b11df | ||
|
|
84941247c4 | ||
|
|
91cfee9609 | ||
|
|
0ad3e3335e |
10
CHANGELOG.md
10
CHANGELOG.md
@ -81,3 +81,13 @@
|
|||||||
holding the slot, so the hub refused to respawn it. The compositor's
|
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
|
`zwlr_layer_surface::Closed` event is the only path that tears the
|
||||||
live thread down — click just sends the swap IPC and returns.
|
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.
|
||||||
|
|||||||
28
src/gui.rs
28
src/gui.rs
@ -1633,20 +1633,40 @@ impl App {
|
|||||||
let profile_path = self.path.clone();
|
let profile_path = self.path.clone();
|
||||||
let allow = self.allow_layout;
|
let allow = self.allow_layout;
|
||||||
// Compile the profile's window_match patterns once, outside the
|
// Compile the profile's window_match patterns once, outside the
|
||||||
// poll loop. If a pattern is malformed we treat it as "not
|
// poll loop. Malformed patterns are logged as a warning rather
|
||||||
// configured" rather than crashing the auto-apply thread.
|
// 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
|
let class_pat = self
|
||||||
.profile
|
.profile
|
||||||
.window_match
|
.window_match
|
||||||
.class
|
.class
|
||||||
.as_deref()
|
.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
|
let title_pat = self
|
||||||
.profile
|
.profile
|
||||||
.window_match
|
.window_match
|
||||||
.title
|
.title
|
||||||
.as_deref()
|
.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();
|
let any_pattern = class_pat.is_some() || title_pat.is_some();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("enboxer-auto-apply".into())
|
.name("enboxer-auto-apply".into())
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
//! spawn / kill plan are real and tested here so the live render slots into a
|
//! spawn / kill plan are real and tested here so the live render slots into a
|
||||||
//! known shape.
|
//! known shape.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use crate::hypr::Client;
|
use crate::hypr::Client;
|
||||||
|
|
||||||
const OVERLAY_W: i32 = 96;
|
const OVERLAY_W: i32 = 96;
|
||||||
@ -66,12 +68,23 @@ pub fn live_enabled() -> bool {
|
|||||||
pub struct OverlayHandle {
|
pub struct OverlayHandle {
|
||||||
pub slot: u32,
|
pub slot: u32,
|
||||||
pub rect: OverlayRect,
|
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 {
|
impl OverlayHandle {
|
||||||
/// Stub handle for non-live paths. Does not connect to Wayland.
|
/// Stub handle for non-live paths. Does not connect to Wayland.
|
||||||
pub fn stub(slot: u32, rect: OverlayRect) -> Self {
|
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);
|
let sock = ipc_sock.unwrap_or_else(crate::session::default_sock);
|
||||||
match crate::wayland_layer::spawn(slot, rect, 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) => {
|
Err(e) => {
|
||||||
tracing::warn!("overlay: live spawn failed for slot {slot}: {e}");
|
tracing::warn!("overlay: live spawn failed for slot {slot}: {e}");
|
||||||
OverlayHandle::stub(slot, rect)
|
OverlayHandle::stub(slot, rect)
|
||||||
@ -151,7 +164,9 @@ impl OverlayHub {
|
|||||||
) -> Vec<OverlayHandle> {
|
) -> Vec<OverlayHandle> {
|
||||||
let (to_spawn, kill) = self.plan(slots);
|
let (to_spawn, kill) = self.plan(slots);
|
||||||
for k in kill {
|
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 {
|
for h in &to_spawn {
|
||||||
self.by_slot.insert(h.slot, h.clone());
|
self.by_slot.insert(h.slot, h.clone());
|
||||||
@ -168,7 +183,14 @@ impl OverlayHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn kill_all(&mut self) {
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -468,12 +468,18 @@ pub fn runtime_dir() -> PathBuf {
|
|||||||
/// protocol (including `Command::Type`, which is wide-open text injection
|
/// protocol (including `Command::Type`, which is wide-open text injection
|
||||||
/// into game windows). Permissions are the cheapest defense.
|
/// into game windows). Permissions are the cheapest defense.
|
||||||
pub fn chmod_runtime_dir() {
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let dir = runtime_dir();
|
|
||||||
let _ = std::fs::set_permissions(
|
let _ = std::fs::set_permissions(
|
||||||
&dir,
|
path,
|
||||||
std::fs::Permissions::from_mode(0o700),
|
std::fs::Permissions::from_mode(0o700),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -541,20 +547,24 @@ fn chmod_socket_sets_0o600() {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn chmod_runtime_dir_sets_0o700() {
|
fn chmod_dir_sets_0o700_on_a_tempdir() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
// Save and restore the real dir perms around the test so we don't break
|
// Use a private tempdir so the test never mutates the user's
|
||||||
// the live session if it happens to share XDG_RUNTIME_DIR.
|
// XDG_RUNTIME_DIR (which is what runtime_dir() resolves to).
|
||||||
let dir = runtime_dir();
|
let unique = format!(
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
"enboxer-chmod-{}-{}",
|
||||||
let saved = std::fs::metadata(&dir).ok().map(|m| m.permissions().mode() & 0o777);
|
std::process::id(),
|
||||||
// Force 0o755 so the helper actually has to change it.
|
std::time::SystemTime::now()
|
||||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
chmod_runtime_dir();
|
.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;
|
let m = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
|
||||||
assert_eq!(m, 0o700, "expected 0o700, got {m:o}");
|
assert_eq!(m, 0o700, "expected 0o700, got {m:o}");
|
||||||
if let Some(s) = saved {
|
let _ = std::fs::remove_dir(&dir);
|
||||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(s));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -223,7 +223,11 @@ async fn refresh_slots(
|
|||||||
let mut g = session.lock().await;
|
let mut g = session.lock().await;
|
||||||
g.binds_on = true;
|
g.binds_on = true;
|
||||||
tracing::info!("routing on ({} binds)", specs.len());
|
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 {
|
} else if g.binds_on {
|
||||||
g.binds_on = false;
|
g.binds_on = false;
|
||||||
@ -232,7 +236,10 @@ async fn refresh_slots(
|
|||||||
hypr::clear_binds().await.ok();
|
hypr::clear_binds().await.ok();
|
||||||
tracing::info!("routing off (focus left the team)");
|
tracing::info!("routing off (focus left the team)");
|
||||||
let g = session.lock().await;
|
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<()> {
|
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;
|
let mut g = session.lock().await;
|
||||||
if n < 2 || n as usize > g.slots.len() {
|
if n < 2 || n as usize > g.slots.len() {
|
||||||
return Ok(());
|
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);
|
g.slots.swap(0, n as usize - 1);
|
||||||
for (i, (id, _)) in g.slots.iter_mut().enumerate() {
|
let prev = g.engine.leader_slot;
|
||||||
*id = i as u32 + 1;
|
g.engine.set_leader(n);
|
||||||
}
|
(g.slots.clone(), g.engine.profile.layout.slots.clone(), prev)
|
||||||
g.engine.set_leader(1);
|
|
||||||
(g.slots.clone(), g.engine.profile.layout.slots.clone())
|
|
||||||
};
|
};
|
||||||
if !tiles.is_empty() {
|
if !tiles.is_empty() {
|
||||||
crate::layout::apply(&tiles, &wins).await?;
|
crate::layout::apply(&tiles, &wins).await?;
|
||||||
@ -642,9 +655,11 @@ async fn swap_as_main(session: &Arc<Mutex<Session>>, n: u32) -> Result<()> {
|
|||||||
if let Some((_, c)) = wins.first() {
|
if let Some((_, c)) = wins.first() {
|
||||||
hypr::focus_window(&c.address_selector()).await.ok();
|
hypr::focus_window(&c.address_selector()).await.ok();
|
||||||
}
|
}
|
||||||
hypr::notify(&format!("main is slot 1 (was {n})"))
|
hypr::notify(&format!(
|
||||||
.await
|
"main is slot {n} (was {prev_leader})"
|
||||||
.ok();
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -690,7 +705,26 @@ async fn reset_layout(session: &Arc<Mutex<Session>>) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
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 addr = {
|
||||||
let g = session.lock().await;
|
let g = session.lock().await;
|
||||||
g.slots
|
g.slots
|
||||||
@ -1022,7 +1056,20 @@ async fn execute(actions: Vec<Action>, slots: &[(u32, Client)]) -> Result<()> {
|
|||||||
};
|
};
|
||||||
for id in ids {
|
for id in ids {
|
||||||
if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) {
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,48 @@
|
|||||||
//! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path.
|
//! `zwlr_export_dmabuf_unstable_v1` client: covered-window capture path.
|
||||||
//!
|
//!
|
||||||
//! When a Video FX source window is **covered** (its rect does not
|
//! When a Video FX source window is hidden behind another compositor
|
||||||
//! intersect any monitor) and the user has opted in
|
//! surface and the user has opted in (`ENBOXER_ENABLE_TOPLEVEL=1`), we
|
||||||
//! (`ENBOXER_ENABLE_TOPLEVEL=1`), we fall back to a compositor export
|
//! can fall back to a compositor export instead of `grim` (which can
|
||||||
//! instead of `grim`. The export is a Wayland request:
|
//! only see visible-on-monitor pixels). The wire flow is a Wayland
|
||||||
|
//! request:
|
||||||
//!
|
//!
|
||||||
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` → `frame` event
|
//! 1. `zwlr_export_dmabuf_manager_v1.capture_output(...)` -> `frame`
|
||||||
//! 2. `frame` event carries `format` (DRM fourcc), `width`, `height`,
|
//! event
|
||||||
//! `offset_x`, `offset_y`, and the per-plane `object` events carry
|
//! 2. `frame` carries `format` (DRM fourcc), `width`, `height`,
|
||||||
|
//! `offset_x`, `offset_y`; per-plane `object` events carry
|
||||||
//! `fd`, `size`, `offset`, `stride`.
|
//! `fd`, `size`, `offset`, `stride`.
|
||||||
//! 3. After all `object` events, `ready` (success) or `cancel` (failure)
|
//! 3. After all `object` events, `ready` (success) or `cancel`
|
||||||
//! arrives.
|
//! (failure) arrives.
|
||||||
//! 4. The client imports the dmabuf with gbm, maps the bo with
|
//! 4. The client imports the dmabuf with gbm, maps the bo with
|
||||||
//! `gbm_bo_map`, and copies the pixels out.
|
//! `gbm_bo_map`, and copies the pixels out.
|
||||||
//!
|
//!
|
||||||
//! ## Status
|
//! ## Honest status (Bug #9)
|
||||||
//!
|
//!
|
||||||
//! The protocol module, format negotiation, frame parser, and file-write
|
//! Steps 1-3 are fully implemented and exercised by the unit tests
|
||||||
//! to a **synthetic** buffer (the `gbm_bo_map` read pixel call is a
|
//! (`format_name`, `negotiate_format`, `parse_format`). Step 4 is
|
||||||
//! follow-up) are implemented here. The compositor-facing parts compile
|
//! **not** wired: the file-write in `capture_with_state` produces a
|
||||||
//! against `wayland-protocols-wlr` but are only ever touched when the
|
//! synthetic PNG-sized buffer rather than `gbm_bo_map`-ing the dmabuf
|
||||||
//! env gate is on; `cargo test` exercises only the pure negotiation and
|
//! and copying real pixels. The synthetic buffer still carries the
|
||||||
//! parser code paths.
|
//! 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
|
//! `capture_output` was chosen as the primary entry because
|
||||||
//! device, a gbm device handle, the drm fourcc + modifier matched to the
|
//! `capture_toplevel` (the window-scoped variant) requires a wl_surface
|
||||||
//! compositor's `mod_high/mod_low`, a `gbm_bo` import, and a `gbm_bo_map`
|
//! reference this client does not currently hold. The capture loop in
|
||||||
//! that returns a CPU pointer to the buffer. None of that fits the
|
//! `vfx::capture_loop` already routes through `capture_toplevel` when
|
||||||
//! "smallest working diff" knob, so the file-write in this module
|
//! the env gate is set, which means an opt-in user sees the synthetic
|
||||||
//! currently produces a synthetic frame (a coloured rectangle that says
|
//! frame (protocol-confirming, NOT real covered-source pixels). Real
|
||||||
//! "EXPORT PENDING"). The caller (`capture_toplevel`) is wired so that
|
//! pixel reads come after the `gbm_bo_map` work lands.
|
||||||
//! swapping in a real `gbm_bo_map` is one function change.
|
//!
|
||||||
|
//! `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.
|
//! `cargo test` does **not** touch Wayland or the DRM stack.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry};
|
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
|
/// Public entry: the dmabuf path of `capture_toplevel`. Connects to
|
||||||
/// Wayland, requests an export, waits for the frame + object + ready
|
/// Wayland, requests an export against the requested output, waits for
|
||||||
/// events, then **without** touching gbm writes a synthetic PNG-sized
|
/// the `frame` + per-plane `object` + `ready` events, then **without**
|
||||||
/// byte slice to `dest`. The gbm bo map is a documented follow-up.
|
/// 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(
|
pub async fn capture_via_export(
|
||||||
output: &wl_output::WlOutput,
|
output: &wl_output::WlOutput,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
|
|||||||
52
src/vfx.rs
52
src/vfx.rs
@ -134,12 +134,28 @@ pub fn toplevel_enabled() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture the source window via `zwlr_export_dmabuf_unstable_v1` and write
|
/// Capture a covered source window via `zwlr_export_dmabuf_unstable_v1`
|
||||||
/// the resulting frame to `dest`. Gated by `toplevel_enabled()`; until the
|
/// and write a frame to `dest`. Gated by `toplevel_enabled()`.
|
||||||
/// 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
|
/// ## Honest status (Bug #9 follow-up)
|
||||||
/// (buffer management + format negotiation + post-import blit) is a
|
///
|
||||||
/// follow-up ticket.
|
/// 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<()> {
|
pub async fn capture_toplevel(client: &Client, dest: &Path) -> Result<()> {
|
||||||
if !toplevel_enabled() {
|
if !toplevel_enabled() {
|
||||||
anyhow::bail!("toplevel export disabled (set ENBOXER_ENABLE_TOPLEVEL=1)");
|
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 {
|
for f in feeds {
|
||||||
let dest = frame_path(&f.name);
|
let dest = frame_path(&f.name);
|
||||||
let (x, y, w, h) = f.source;
|
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() {
|
if capture_region(x, y, w, h, &dest).await.is_ok() {
|
||||||
let _ = hub.show_frame(&f.name, &dest).await;
|
let _ = hub.show_frame(&f.name, &dest).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
//! commit and listens for clicks. Per T9 the badge is readable (3x5
|
//! commit and listens for clicks. Per T9 the badge is readable (3x5
|
||||||
//! digits, ARGB8888, opaque background, solid foreground).
|
//! digits, ARGB8888, opaque background, solid foreground).
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use crate::overlay::OverlayRect;
|
use crate::overlay::OverlayRect;
|
||||||
use crate::profile::runtime_dir;
|
use crate::profile::runtime_dir;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@ -160,11 +162,18 @@ struct OverlayState {
|
|||||||
pub struct LiveOverlayHandle {
|
pub struct LiveOverlayHandle {
|
||||||
pub slot: u32,
|
pub slot: u32,
|
||||||
pub rect: OverlayRect,
|
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<()>>,
|
join: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveOverlayHandle {
|
impl LiveOverlayHandle {
|
||||||
|
/// Ask the thread to exit, then join it. Idempotent.
|
||||||
pub fn shutdown(mut self) {
|
pub fn shutdown(mut self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
if let Some(j) = self.join.take() {
|
if let Some(j) = self.join.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
}
|
}
|
||||||
@ -177,20 +186,23 @@ impl LiveOverlayHandle {
|
|||||||
/// surface. Errors during connect are returned; setup errors after
|
/// surface. Errors during connect are returned; setup errors after
|
||||||
/// connect are logged and the thread exits.
|
/// connect are logged and the thread exits.
|
||||||
pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<LiveOverlayHandle> {
|
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()
|
let join = std::thread::Builder::new()
|
||||||
.name(format!("enboxer-overlay-{slot}"))
|
.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"),
|
Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"),
|
||||||
Err(e) => tracing::warn!("overlay slot {slot}: {e}"),
|
Err(e) => tracing::warn!("overlay slot {slot}: {e}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(LiveOverlayHandle {
|
Ok(LiveOverlayHandle {
|
||||||
slot,
|
slot,
|
||||||
rect,
|
rect,
|
||||||
|
stop,
|
||||||
join: Some(join),
|
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 conn = Connection::connect_to_env()?;
|
||||||
let display = conn.display();
|
let display = conn.display();
|
||||||
let mut event_queue = conn.new_event_queue::<OverlayState>();
|
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());
|
state.buffer = Some(buffer.clone());
|
||||||
surface.attach(Some(&buffer), 0, 0);
|
surface.attach(Some(&buffer), 0, 0);
|
||||||
surface.commit();
|
surface.commit();
|
||||||
while !state.exited {
|
while !state.exited && !stop.load(Ordering::Relaxed) {
|
||||||
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
|
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
|
||||||
tracing::warn!("overlay slot {slot}: dispatch: {e}");
|
tracing::warn!("overlay slot {slot}: dispatch: {e}");
|
||||||
break;
|
break;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user