Compare commits

..

No commits in common. "6028987a6eb89facd7a071be347bf22f7d182d0c" and "1ec8d78f7148a0658a67235280e7c63c3928a795" have entirely different histories.

10 changed files with 29 additions and 246 deletions

View File

@ -48,36 +48,3 @@
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: smart_interact`) ## Smart interact shortcut (`bind: 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: smart_interact` in a step: In enBoxer the shortcut is `bind: interact` in a step:
```yaml ```yaml
- name: loot - name: loot
@ -90,7 +90,7 @@ In enBoxer the shortcut is `bind: smart_interact` in a step:
steps: steps:
- bind: assist - bind: assist
target: others target: others
- bind: smart_interact # <-- smart shortcut - bind: 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: smart_interact` in any map step expands at compile time into the full chain `bind: 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: smart_interact # full chain (use "interact" for a single Alt+J send) - bind: interact
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 # single Alt+J send (NOT the smart shortcut) - bind: interact
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 # single Alt+J send (NOT the smart shortcut) - bind: interact
target: others target: others
release_steps: release_steps:
- bind: ctm_off - bind: ctm_off

View File

@ -205,14 +205,11 @@ 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: "smart_interact"` fires // ISBoxer-style Mapped Key shortcut: `bind: "interact"` fires the
// the full CTM-toggle + Interact-with-Target + (optional) wait + // full CTM-toggle + Interact-with-Target + (optional) wait + CTM-off
// CTM-off chain driven by profile.interact (style + walk_delay_ms). // 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.
// For per-step control, use `bind: "interact"` to send a single if step.bind.as_deref() == Some("interact") {
// 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
@ -355,7 +352,7 @@ mod tests {
target: "others".into(), target: "others".into(),
}, },
Step { Step {
bind: Some("smart_interact".into()), bind: Some("interact".into()),
key: None, key: None,
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
@ -508,37 +505,6 @@ 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,
@ -552,7 +518,7 @@ mod tests {
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("smart_interact".into()), bind: Some("interact".into()),
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
}], }],
@ -589,7 +555,7 @@ mod tests {
hold: false, hold: false,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("smart_interact".into()), bind: Some("interact".into()),
delay_ms: None, delay_ms: None,
target: "others".into(), target: "others".into(),
}], }],
@ -619,7 +585,7 @@ mod tests {
hold: true, hold: true,
steps: vec![crate::profile::Step { steps: vec![crate::profile::Step {
key: None, key: None,
bind: Some("smart_interact".into()), bind: Some("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::{
Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step,
Repeater, Step, VideoFx, WindowMatch, VideoFx, WindowMatch,
}; };
use crate::session; use crate::session;
use anyhow::Result; use anyhow::Result;
@ -1438,22 +1438,9 @@ 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);
// Pad to slot count: truncate any extras, but extend with defaults // Drop characters beyond the new member count.
// so a brand-new profile (members > 0, characters == 0) actually profile.characters.truncate(profile.slots as usize);
// gets a Launch row per member. // Apply the Lutris game pick to every character.
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());
@ -1632,42 +1619,12 @@ 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")
@ -1678,18 +1635,7 @@ 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() {
let hit = arr.iter().any(|c| { if !arr.is_empty() {
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,15 +227,12 @@ 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),
shell_single(args) args
) )
} }
@ -315,30 +312,15 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::bind_command;
#[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, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character { pub struct Character {
pub slot: u32, pub slot: u32,
#[serde(default)] #[serde(default)]
@ -462,38 +462,6 @@ 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;
@ -522,39 +490,3 @@ 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,7 +43,6 @@ 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);
} }
@ -70,7 +69,6 @@ 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,12 +229,9 @@ 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);
// zwlr_layer_surface::set_margin is (top, right, bottom, left). // Margins encode the offset: with TOP+LEFT anchor, a positive top
// With TOP+LEFT anchor: top margin pushes the surface down, left // margin pushes the surface down; positive left pushes it right.
// margin pushes it right. (Earlier versions of this code passed layer.set_margin(rect.y.max(0), rect.x.max(0), 0, 0);
// 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);
@ -466,12 +463,7 @@ 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);
// NOTE: do not flip `state.exited = true` here. Doing so state.exited = true;
// 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.
} }
} }
} }