Compare commits

..

5 Commits

Author SHA1 Message Date
en
6028987a6e Overlay: set_margin order + click handler no longer kills the badge
- set_margin (wayland_layer.rs:234): correct arg order to
  (top, right, bottom, left). The previous code passed rect.x as
  the right margin, a no-op under TOP+LEFT anchoring, so the
  overlay's x offset was silently dropped. Comment updated to
  document the protocol's true shape.

- click handler (wayland_layer.rs:466): drop 'state.exited = true'
  after send_swap. Flipping exited destroyed the badge but left
  OverlayHub.by_slot still holding the slot, so the hub refused to
  respawn it. The compositor's zwlr_layer_surface::Closed event
  is the only path that drives thread teardown — the click now
  just fires the swap IPC and returns.

- CHANGELOG entries.

cargo test 96+/0; clippy clean.
2026-09-16 05:58:27 +02:00
en
1b8b2cdf24 GUI: arm_auto_apply waits for window_match; new-team pads characters
- arm_auto_apply (gui.rs ~1625): compile profile.window_match.class and
  .title regexes once, then poll hyprctl for a client that matches
  before firing layout-apply. Fall back to 'first non-empty class'
  when neither pattern is configured, so existing profiles keep working.
  Closes the security/correctness gap where any open window could trigger
  layout-apply against the desktop.

- New-team wizard (gui.rs ~1438): profile.characters.truncate is replaced
  with resize(slots, Character::default()) so a wizard with N members
  and zero existing characters now actually gets N Launch buttons.
  Character gains #[derive(... Default)].

- CHANGELOG entries.

cargo test 96+/0; clippy clean.
2026-09-16 05:58:27 +02:00
en
cece41d2ee Tighten IPC socket permissions (chmod 0o700/0o600)
fix(security): the daemon's IPC socket was world-accessible. Any local
user could speak the IPC protocol and type arbitrary text into game
windows via Command::Type.

- src/profile.rs: add chmod_runtime_dir() and chmod_socket(path) helpers
- src/session.rs: call chmod_runtime_dir() after create_dir_all, and
  chmod_socket() right after UnixListener::bind succeeds
- Tests: chmod_socket_sets_0o600, chmod_runtime_dir_sets_0o700 (saved/
  restored live dir perms to avoid clobbering a real session)
- CHANGELOG.md: security note

cargo test 96+/0; clippy clean.
2026-09-16 05:45:48 +02:00
en
20f0160799 Shell-quote bind_command args (Hyprland exec_cmd injection)
fix(hypr): Grok-flagged security finding: shell_single was applied to
bin and sock but args was interpolated raw. A profile hotkey containing
shell metacharacters could become a Hyprland exec_cmd injection.

- src/hypr.rs: bind_command now wraps args in shell_single
- New tests:
  - bind_command_quotes_args_with_metacharacters
  - shell_single_handles_inner_quote
- Doc comment updated to name the threat and what gets escaped

93+/0 cargo test; clippy clean.
2026-09-16 05:44:52 +02:00
en
8eb5346d92 Split smart interact shortcut from single Alt+J send
bind: "interact" now resolves to game_binds.interact (Alt+J) only —
a single keystroke. The full ISBoxer-style chain (CTM on -> Alt+J ->
sleep walk_delay_ms -> CTM off) lives at bind: "smart_interact".

Fixes the doubling bug Grok flagged: bind: "interact" previously
expanded into the full chain unconditionally, so the loot_manual and
interact_hold example maps fired ctm_on/ctm_off twice and produced
unwanted sleep delays.

- engine.rs: rename shortcut trigger (was: "interact")
- examples/profile.yaml: loot now uses smart_interact; loot_manual and
  interact_hold keep "interact" as a single Alt+J send
- docs/MACROS.md: heading + shortcut comment
- docs/NOTES.md: trigger name
- Add new test: interact_simple_sends_only_alt_j (1-line single-send)
- Existing smart shortcut tests renamed to bind: smart_interact
- CHANGELOG.md: trigger split note

93/93 cargo test pass; clippy clean.
2026-09-16 05:41:45 +02:00
10 changed files with 246 additions and 29 deletions

View File

@ -48,3 +48,36 @@
clippy clean. clippy clean.
- Docs: `docs/MACROS.md` "Smart interact shortcut" section, `docs/NOTES.md` - Docs: `docs/MACROS.md` "Smart interact shortcut" section, `docs/NOTES.md`
callout, `examples/profile.yaml` updated with comments. callout, `examples/profile.yaml` updated with comments.
> Note: After this change, `bind: "interact"` is a single Alt+J send (game_binds.interact).
> The full chain trigger is now `bind: "smart_interact"`. Examples and tests updated.
- **Security:** IPC runtime dir is now `chmod 0o700` and the Unix socket is
`chmod 0o600` immediately after `UnixListener::bind`. Any local user with
read access could speak our IPC protocol (`Command::Type` etc.); this is
the cheapest defense. Helpers: `profile::chmod_runtime_dir()` and
`profile::chmod_socket(path)`.
- **GUI:** New-team wizard now pads `profile.characters` to `slots` before
stamping `lutris_game`. A wizard with 5 members and an empty character
list previously saved a team with zero Launch rows; it now fills the
rows with `Character::default()` and applies the Lutris game to each.
- **GUI:** `arm_auto_apply` now waits for a client matching
`profile.window_match.class` / `.title` regexes before firing
`layout-apply`, instead of firing on the first non-empty client list.
With no patterns configured it still requires a non-empty class on the
matched client, so existing profiles behave the same. This prevents
`layout-apply` against the desktop when only a terminal (or any
unrelated window) is open.
- **Profile:** `Character` derives `Default` (needed for the padding).
- **Overlay:** `set_margin` arguments corrected to `(top, right, bottom,
left)`. The previous `(rect.y, rect.x, 0, 0)` passed `rect.x` as the
*right* margin, which is a no-op under TOP+LEFT anchoring — so the
overlay's x offset was silently dropped. Now `(y, 0, 0, x)` which
pushes the surface down by `y` and right by `x`.
- **Overlay:** Left-click on a slot no longer sets `state.exited = true`.
Doing so dropped the badge and left `OverlayHub.by_slot` still
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.

View File

@ -78,11 +78,11 @@ Do not mash. A second interact while they are still pathing is how characters ru
If they stop short, raise `walk_delay_ms` (25004000), or use `interact.style: auto` (CTM stays on), or `interact.style: hold`. If they stop short, raise `walk_delay_ms` (25004000), or use `interact.style: auto` (CTM stays on), or `interact.style: hold`.
## Smart interact shortcut (`bind: interact`) ## Smart interact shortcut (`bind: smart_interact`)
The example profile's `loot` map (Alt+G) and `interact` map (Alt+I) both use a **smart shortcut**: a single user keypress fires the full chain (CTM on → Interact with Target → sleep `walk_delay_ms` → CTM off). It is what ISBoxer did with a "Mapped Key" — you press one key, the multibox software sends the whole chain. The example profile's `loot` map (Alt+G) and `interact` map (Alt+I) both use a **smart shortcut**: a single user keypress fires the full chain (CTM on → Interact with Target → sleep `walk_delay_ms` → CTM off). It is what ISBoxer did with a "Mapped Key" — you press one key, the multibox software sends the whole chain.
In enBoxer the shortcut is `bind: interact` in a step: In enBoxer the shortcut is `bind: smart_interact` in a step:
```yaml ```yaml
- name: loot - name: loot
@ -90,7 +90,7 @@ In enBoxer the shortcut is `bind: interact` in a step:
steps: steps:
- bind: assist - bind: assist
target: others target: others
- bind: interact # <-- smart shortcut - bind: smart_interact # <-- smart shortcut
target: others target: others
``` ```

View File

@ -40,7 +40,7 @@ Visible-pixel capture: `grim` (default). Overlay: `mpv --wayland-app-id=enboxer-
## Smart interact shortcut ## Smart interact shortcut
`bind: interact` in any map step expands at compile time into the full chain `bind: smart_interact` in any map step expands at compile time into the full chain
(CTM on → Interact with Target → walk delay → CTM off), driven by (CTM on → Interact with Target → walk delay → CTM off), driven by
`profile.interact` (`style` + `walk_delay_ms`). This is the ISBoxer Mapped `profile.interact` (`style` + `walk_delay_ms`). This is the ISBoxer Mapped
Key analog — one user keypress, four keystrokes dispatched to every captured Key analog — one user keypress, four keystrokes dispatched to every captured

View File

@ -114,7 +114,7 @@ maps:
steps: steps:
- bind: assist - bind: assist
target: others target: others
- bind: interact - bind: smart_interact # full chain (use "interact" for a single Alt+J send)
target: others target: others
- name: loot_manual - name: loot_manual
# Same idea as `loot` but composed by hand. Pick this form if you want to # Same idea as `loot` but composed by hand. Pick this form if you want to
@ -127,7 +127,7 @@ maps:
target: others target: others
- bind: ctm_on - bind: ctm_on
target: others target: others
- bind: interact - bind: interact # single Alt+J send (NOT the smart shortcut)
target: others target: others
- delay_ms: 5000 - delay_ms: 5000
- bind: ctm_off - bind: ctm_off
@ -140,7 +140,7 @@ maps:
steps: steps:
- bind: ctm_on - bind: ctm_on
target: others target: others
- bind: interact - bind: interact # single Alt+J send (NOT the smart shortcut)
target: others target: others
release_steps: release_steps:
- bind: ctm_off - bind: ctm_off

View File

@ -205,11 +205,14 @@ impl Engine {
out.push(Action::Sleep(Duration::from_millis(ms))); out.push(Action::Sleep(Duration::from_millis(ms)));
continue; continue;
} }
// ISBoxer-style Mapped Key shortcut: `bind: "interact"` fires the // ISBoxer-style Mapped Key shortcut: `bind: "smart_interact"` fires
// full CTM-toggle + Interact-with-Target + (optional) wait + CTM-off // the full CTM-toggle + Interact-with-Target + (optional) wait +
// chain driven by profile.interact (style + walk_delay_ms). // CTM-off chain driven by profile.interact (style + walk_delay_ms).
// The user only pressed the map hotkey; the daemon composes the chain. // The user only pressed the map hotkey; the daemon composes the chain.
if step.bind.as_deref() == Some("interact") { // For per-step control, use `bind: "interact"` to send a single
// Interact-with-Target keystroke (game_binds.interact = Alt+J) and
// compose ctm_on / ctm_off / walk_delay_ms yourself.
if step.bind.as_deref() == Some("smart_interact") {
let slots = self.resolve_targets(&step.target, &map_name)?; let slots = self.resolve_targets(&step.target, &map_name)?;
let interact_key = self let interact_key = self
.profile .profile
@ -352,7 +355,7 @@ mod tests {
target: "others".into(), target: "others".into(),
}, },
Step { Step {
bind: Some("interact".into()), bind: Some("smart_interact".into()),
key: None, key: None,
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
@ -505,6 +508,37 @@ mod tests {
} }
} }
#[test]
fn interact_simple_sends_only_alt_j() {
// After the smart_interact split, `bind: "interact"` is a single
// Alt+J send (game_binds.interact = "g"). No CTM toggle, no sleep,
// no second send. Use this from manual chains or release_steps.
let mut e = sample();
let map = crate::profile::Map {
name: "interact_simple".into(),
hotkey: Hotkey("Alt+U".into()),
hold: false,
steps: vec![crate::profile::Step {
key: None,
bind: Some("interact".into()),
delay_ms: None,
target: "others".into(),
}],
release_steps: vec![],
};
e.profile.maps.push(map);
let acts = e.fire("Alt+U", Hold::Tap).unwrap();
assert_eq!(acts.len(), 1, "exactly one action: {acts:?}");
match &acts[0] {
Action::Send { key, slots, hold } => {
assert_eq!(key, "g");
assert_eq!(*hold, Hold::Tap);
assert_eq!(slots, &vec![2, 3]);
}
other => panic!("expected Send, got {other:?}"),
}
}
#[test] #[test]
fn interact_smart_shortcut_standard_emits_full_sequence() { fn interact_smart_shortcut_standard_emits_full_sequence() {
// bind: "interact" should fire CTM-on, Interact with Target, // bind: "interact" should fire CTM-on, Interact with Target,
@ -518,7 +552,7 @@ mod tests {
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("interact".into()), bind: Some("smart_interact".into()),
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
}], }],
@ -555,7 +589,7 @@ mod tests {
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("interact".into()), bind: Some("smart_interact".into()),
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
}], }],
@ -585,7 +619,7 @@ mod tests {
hold: true, hold: true,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("interact".into()), bind: Some("smart_interact".into()),
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
}], }],

View File

@ -3,8 +3,8 @@
use crate::hotkey::Hotkey; use crate::hotkey::Hotkey;
use crate::macros::print_macros; use crate::macros::print_macros;
use crate::profile::{ use crate::profile::{
default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile,
VideoFx, WindowMatch, Repeater, Step, VideoFx, WindowMatch,
}; };
use crate::session; use crate::session;
use anyhow::Result; use anyhow::Result;
@ -1438,9 +1438,22 @@ impl App {
let mut profile = self.profile.clone(); let mut profile = self.profile.clone();
profile.name = name.to_string(); profile.name = name.to_string();
profile.slots = wiz.members.max(1); profile.slots = wiz.members.max(1);
// Drop characters beyond the new member count. // Pad to slot count: truncate any extras, but extend with defaults
profile.characters.truncate(profile.slots as usize); // so a brand-new profile (members > 0, characters == 0) actually
// Apply the Lutris game pick to every character. // gets a Launch row per member.
profile.characters
.resize(profile.slots as usize, Character::default());
// Character::default() leaves slot = 0, which collides with the
// Launch button lookup (find(|c| c.slot == slot)). Assign slot 1..=N
// to any character still at 0, leaving properly-assigned entries
// untouched.
for (i, ch) in profile.characters.iter_mut().enumerate() {
if ch.slot == 0 {
ch.slot = (i as u32) + 1;
}
}
// Apply the Lutris game pick to every character (including the
// freshly-padded ones).
if let Some(slug_l) = wiz.lutris_slug.clone() { if let Some(slug_l) = wiz.lutris_slug.clone() {
for ch in &mut profile.characters { for ch in &mut profile.characters {
ch.lutris_game = Some(slug_l.clone()); ch.lutris_game = Some(slug_l.clone());
@ -1619,12 +1632,42 @@ impl App {
// and then sends the layout-apply IPC. Detached; logs on error. // and then sends the layout-apply IPC. Detached; logs on error.
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
// poll loop. If a pattern is malformed we treat it as "not
// configured" rather than crashing the auto-apply thread.
let class_pat = self
.profile
.window_match
.class
.as_deref()
.and_then(|p| regex::Regex::new(p).ok());
let title_pat = self
.profile
.window_match
.title
.as_deref()
.and_then(|p| regex::Regex::new(p).ok());
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())
.spawn(move || { .spawn(move || {
use std::process::Command as SyncCommand; use std::process::Command as SyncCommand;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let deadline = std::time::Duration::from_secs(30); let deadline = std::time::Duration::from_secs(30);
// Returns true if the client's class/title match the
// configured patterns. If neither pattern is configured
// (no `window_match` set on the profile), accept the
// first client with a non-empty class so existing
// profiles keep working.
let matched = |cls: &str, ttl: &str| -> bool {
let class_ok = class_pat.as_ref().is_none_or(|re| re.is_match(cls));
let title_ok = title_pat.as_ref().is_none_or(|re| re.is_match(ttl));
if !any_pattern {
!cls.is_empty()
} else {
class_ok && title_ok
}
};
while start.elapsed() < deadline { while start.elapsed() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1000)); std::thread::sleep(std::time::Duration::from_millis(1000));
let out = SyncCommand::new("hyprctl") let out = SyncCommand::new("hyprctl")
@ -1635,7 +1678,18 @@ impl App {
serde_json::from_slice::<serde_json::Value>(&out.stdout) serde_json::from_slice::<serde_json::Value>(&out.stdout)
{ {
if let Some(arr) = v.as_array() { if let Some(arr) = v.as_array() {
if !arr.is_empty() { let hit = arr.iter().any(|c| {
let cls = c
.get("class")
.and_then(|x| x.as_str())
.unwrap_or("");
let ttl = c
.get("title")
.and_then(|x| x.as_str())
.unwrap_or("");
matched(cls, ttl)
});
if hit {
let mut cmd = SyncCommand::new( let mut cmd = SyncCommand::new(
std::env::current_exe() std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("enboxer")), .unwrap_or_else(|_| std::path::PathBuf::from("enboxer")),

View File

@ -227,12 +227,15 @@ fn shell_single(s: &str) -> String {
} }
/// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`. /// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`.
/// `bin`, `sock`, and `args` are all single-quoted so a map hotkey containing
/// shell metacharacters (`;`, backticks, `$()`, quotes, spaces) cannot inject
/// into the compositor's `exec_cmd`.
pub fn bind_command(bin: &str, sock: &str, args: &str) -> String { pub fn bind_command(bin: &str, sock: &str, args: &str) -> String {
format!( format!(
"{} ipc --sock {} {}", "{} ipc --sock {} {}",
shell_single(bin), shell_single(bin),
shell_single(sock), shell_single(sock),
args shell_single(args)
) )
} }
@ -312,15 +315,30 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::bind_command; use super::*;
#[test] #[test]
fn bind_command_has_one_ipc() { fn bind_command_has_one_ipc() {
let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G"); let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G");
assert_eq!( assert_eq!(
cmd, cmd,
"'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' hotkey Alt+G" "'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' 'hotkey Alt+G'"
); );
assert_eq!(cmd.matches(" ipc ").count(), 1); assert_eq!(cmd.matches(" ipc ").count(), 1);
} }
#[test]
fn bind_command_quotes_args_with_metacharacters() {
// shell_single must escape a hostile arg (semicolon, backtick,
// dollar-paren) so it cannot inject into Hyprland's exec_cmd.
let s = bind_command(
"enboxer",
"/tmp/enboxer.sock",
"hotkey Alt+G; cat /etc/passwd",
);
assert!(s.starts_with("'enboxer' ipc --sock '/tmp/enboxer.sock' "));
assert!(s.contains("'hotkey Alt+G; cat /etc/passwd'"));
}
} }

View File

@ -157,7 +157,7 @@ pub enum InteractStyle {
Hold, Hold,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Character { pub struct Character {
pub slot: u32, pub slot: u32,
#[serde(default)] #[serde(default)]
@ -462,6 +462,38 @@ pub fn runtime_dir() -> PathBuf {
} }
} }
/// `chmod 0o700` the runtime dir so only this user can read/write.
///
/// Any local user with read access to the socket could speak our IPC
/// protocol (including `Command::Type`, which is wide-open text injection
/// into game windows). Permissions are the cheapest defense.
pub fn chmod_runtime_dir() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = runtime_dir();
let _ = std::fs::set_permissions(
&dir,
std::fs::Permissions::from_mode(0o700),
);
}
}
/// `chmod 0o600` the IPC socket so only this user can connect.
///
/// Belt-and-braces with `chmod_runtime_dir`: the dir alone is not enough
/// if other shared paths leaked earlier.
pub fn chmod_socket<P: AsRef<std::path::Path>>(path: P) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
path.as_ref(),
std::fs::Permissions::from_mode(0o600),
);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::NormRect; use super::NormRect;
@ -490,3 +522,39 @@ mod tests {
assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40)); assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40));
} }
} }
#[cfg(unix)]
#[test]
fn chmod_socket_sets_0o600() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("enboxer-test-chmod");
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("test.sock");
std::fs::write(&sock, b"").unwrap();
std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o644)).unwrap();
chmod_socket(&sock);
let m = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
assert_eq!(m, 0o600, "expected 0o600, got {m:o}");
std::fs::remove_file(&sock).ok();
std::fs::remove_dir(&dir).ok();
}
#[cfg(unix)]
#[test]
fn chmod_runtime_dir_sets_0o700() {
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();
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));
}
}

View File

@ -43,6 +43,7 @@ impl Session {
pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
std::fs::create_dir_all(runtime_dir()).ok(); std::fs::create_dir_all(runtime_dir()).ok();
crate::profile::chmod_runtime_dir();
if sock.exists() { if sock.exists() {
let _ = std::fs::remove_file(&sock); let _ = std::fs::remove_file(&sock);
} }
@ -69,6 +70,7 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
} }
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
crate::profile::chmod_socket(&sock);
tracing::info!("ipc {}", sock.display()); tracing::info!("ipc {}", sock.display());
let s1 = session.clone(); let s1 = session.clone();

View File

@ -229,9 +229,12 @@ fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> {
layer.set_anchor(zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Left); 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_size(rect.w.max(1) as u32, rect.h.max(1) as u32);
layer.set_exclusive_zone(-1); layer.set_exclusive_zone(-1);
// Margins encode the offset: with TOP+LEFT anchor, a positive top // zwlr_layer_surface::set_margin is (top, right, bottom, left).
// margin pushes the surface down; positive left pushes it right. // With TOP+LEFT anchor: top margin pushes the surface down, left
layer.set_margin(rect.y.max(0), rect.x.max(0), 0, 0); // 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); layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
state.surface = Some(surface.clone()); state.surface = Some(surface.clone());
state.layer_surface = Some(layer); state.layer_surface = Some(layer);
@ -463,7 +466,12 @@ impl Dispatch<wl_pointer::WlPointer, ()> for OverlayState {
&& matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed)) && matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed))
{ {
send_swap(state.slot, &state.ipc_sock); send_swap(state.slot, &state.ipc_sock);
state.exited = true; // 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.
} }
} }
} }