Compare commits
No commits in common. "6028987a6eb89facd7a071be347bf22f7d182d0c" and "1ec8d78f7148a0658a67235280e7c63c3928a795" have entirely different histories.
6028987a6e
...
1ec8d78f71
33
CHANGELOG.md
33
CHANGELOG.md
@ -48,36 +48,3 @@
|
||||
clippy clean.
|
||||
- Docs: `docs/MACROS.md` "Smart interact shortcut" section, `docs/NOTES.md`
|
||||
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.
|
||||
|
||||
@ -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` (2500–4000), 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.
|
||||
|
||||
In enBoxer the shortcut is `bind: smart_interact` in a step:
|
||||
In enBoxer the shortcut is `bind: interact` in a step:
|
||||
|
||||
```yaml
|
||||
- name: loot
|
||||
@ -90,7 +90,7 @@ In enBoxer the shortcut is `bind: smart_interact` in a step:
|
||||
steps:
|
||||
- bind: assist
|
||||
target: others
|
||||
- bind: smart_interact # <-- smart shortcut
|
||||
- bind: interact # <-- smart shortcut
|
||||
target: others
|
||||
```
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ Visible-pixel capture: `grim` (default). Overlay: `mpv --wayland-app-id=enboxer-
|
||||
|
||||
## 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
|
||||
`profile.interact` (`style` + `walk_delay_ms`). This is the ISBoxer Mapped
|
||||
Key analog — one user keypress, four keystrokes dispatched to every captured
|
||||
|
||||
@ -114,7 +114,7 @@ maps:
|
||||
steps:
|
||||
- bind: assist
|
||||
target: others
|
||||
- bind: smart_interact # full chain (use "interact" for a single Alt+J send)
|
||||
- bind: interact
|
||||
target: others
|
||||
- name: loot_manual
|
||||
# Same idea as `loot` but composed by hand. Pick this form if you want to
|
||||
@ -127,7 +127,7 @@ maps:
|
||||
target: others
|
||||
- bind: ctm_on
|
||||
target: others
|
||||
- bind: interact # single Alt+J send (NOT the smart shortcut)
|
||||
- bind: interact
|
||||
target: others
|
||||
- delay_ms: 5000
|
||||
- bind: ctm_off
|
||||
@ -140,7 +140,7 @@ maps:
|
||||
steps:
|
||||
- bind: ctm_on
|
||||
target: others
|
||||
- bind: interact # single Alt+J send (NOT the smart shortcut)
|
||||
- bind: interact
|
||||
target: others
|
||||
release_steps:
|
||||
- bind: ctm_off
|
||||
|
||||
@ -205,14 +205,11 @@ impl Engine {
|
||||
out.push(Action::Sleep(Duration::from_millis(ms)));
|
||||
continue;
|
||||
}
|
||||
// ISBoxer-style Mapped Key shortcut: `bind: "smart_interact"` fires
|
||||
// the full CTM-toggle + Interact-with-Target + (optional) wait +
|
||||
// CTM-off chain driven by profile.interact (style + walk_delay_ms).
|
||||
// ISBoxer-style Mapped Key shortcut: `bind: "interact"` fires the
|
||||
// full CTM-toggle + Interact-with-Target + (optional) wait + CTM-off
|
||||
// chain driven by profile.interact (style + walk_delay_ms).
|
||||
// The user only pressed the map hotkey; the daemon composes the chain.
|
||||
// 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") {
|
||||
if step.bind.as_deref() == Some("interact") {
|
||||
let slots = self.resolve_targets(&step.target, &map_name)?;
|
||||
let interact_key = self
|
||||
.profile
|
||||
@ -355,7 +352,7 @@ mod tests {
|
||||
target: "others".into(),
|
||||
},
|
||||
Step {
|
||||
bind: Some("smart_interact".into()),
|
||||
bind: Some("interact".into()),
|
||||
key: None,
|
||||
delay_ms: None,
|
||||
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]
|
||||
fn interact_smart_shortcut_standard_emits_full_sequence() {
|
||||
// bind: "interact" should fire CTM-on, Interact with Target,
|
||||
@ -552,7 +518,7 @@ mod tests {
|
||||
hold: false,
|
||||
steps: vec![crate::profile::Step {
|
||||
key: None,
|
||||
bind: Some("smart_interact".into()),
|
||||
bind: Some("interact".into()),
|
||||
delay_ms: None,
|
||||
target: "others".into(),
|
||||
}],
|
||||
@ -589,7 +555,7 @@ mod tests {
|
||||
hold: false,
|
||||
steps: vec![crate::profile::Step {
|
||||
key: None,
|
||||
bind: Some("smart_interact".into()),
|
||||
bind: Some("interact".into()),
|
||||
delay_ms: None,
|
||||
target: "others".into(),
|
||||
}],
|
||||
@ -619,7 +585,7 @@ mod tests {
|
||||
hold: true,
|
||||
steps: vec![crate::profile::Step {
|
||||
key: None,
|
||||
bind: Some("smart_interact".into()),
|
||||
bind: Some("interact".into()),
|
||||
delay_ms: None,
|
||||
target: "others".into(),
|
||||
}],
|
||||
|
||||
66
src/gui.rs
66
src/gui.rs
@ -3,8 +3,8 @@
|
||||
use crate::hotkey::Hotkey;
|
||||
use crate::macros::print_macros;
|
||||
use crate::profile::{
|
||||
Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile,
|
||||
Repeater, Step, VideoFx, WindowMatch,
|
||||
default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step,
|
||||
VideoFx, WindowMatch,
|
||||
};
|
||||
use crate::session;
|
||||
use anyhow::Result;
|
||||
@ -1438,22 +1438,9 @@ impl App {
|
||||
let mut profile = self.profile.clone();
|
||||
profile.name = name.to_string();
|
||||
profile.slots = wiz.members.max(1);
|
||||
// Pad to slot count: truncate any extras, but extend with defaults
|
||||
// so a brand-new profile (members > 0, characters == 0) actually
|
||||
// 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).
|
||||
// Drop characters beyond the new member count.
|
||||
profile.characters.truncate(profile.slots as usize);
|
||||
// Apply the Lutris game pick to every character.
|
||||
if let Some(slug_l) = wiz.lutris_slug.clone() {
|
||||
for ch in &mut profile.characters {
|
||||
ch.lutris_game = Some(slug_l.clone());
|
||||
@ -1632,42 +1619,12 @@ impl App {
|
||||
// and then sends the layout-apply IPC. Detached; logs on error.
|
||||
let profile_path = self.path.clone();
|
||||
let allow = self.allow_layout;
|
||||
// Compile the profile's window_match patterns once, outside the
|
||||
// poll loop. If a pattern is malformed we treat it as "not
|
||||
// configured" rather than crashing the auto-apply thread.
|
||||
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()
|
||||
.name("enboxer-auto-apply".into())
|
||||
.spawn(move || {
|
||||
use std::process::Command as SyncCommand;
|
||||
let start = std::time::Instant::now();
|
||||
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 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
let out = SyncCommand::new("hyprctl")
|
||||
@ -1678,18 +1635,7 @@ impl App {
|
||||
serde_json::from_slice::<serde_json::Value>(&out.stdout)
|
||||
{
|
||||
if let Some(arr) = v.as_array() {
|
||||
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 {
|
||||
if !arr.is_empty() {
|
||||
let mut cmd = SyncCommand::new(
|
||||
std::env::current_exe()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("enboxer")),
|
||||
|
||||
24
src/hypr.rs
24
src/hypr.rs
@ -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`.
|
||||
/// `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 {
|
||||
format!(
|
||||
"{} ipc --sock {} {}",
|
||||
shell_single(bin),
|
||||
shell_single(sock),
|
||||
shell_single(args)
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
@ -315,30 +312,15 @@ where
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::bind_command;
|
||||
|
||||
#[test]
|
||||
fn bind_command_has_one_ipc() {
|
||||
let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G");
|
||||
assert_eq!(
|
||||
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);
|
||||
}
|
||||
|
||||
#[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'"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -157,7 +157,7 @@ pub enum InteractStyle {
|
||||
Hold,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Character {
|
||||
pub slot: u32,
|
||||
#[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)]
|
||||
mod tests {
|
||||
use super::NormRect;
|
||||
@ -522,39 +490,3 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,6 @@ impl Session {
|
||||
|
||||
pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
||||
std::fs::create_dir_all(runtime_dir()).ok();
|
||||
crate::profile::chmod_runtime_dir();
|
||||
if sock.exists() {
|
||||
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()))?;
|
||||
crate::profile::chmod_socket(&sock);
|
||||
tracing::info!("ipc {}", sock.display());
|
||||
|
||||
let s1 = session.clone();
|
||||
|
||||
@ -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_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));
|
||||
// Margins encode the offset: with TOP+LEFT anchor, a positive top
|
||||
// margin pushes the surface down; positive left pushes it right.
|
||||
layer.set_margin(rect.y.max(0), rect.x.max(0), 0, 0);
|
||||
layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
|
||||
state.surface = Some(surface.clone());
|
||||
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))
|
||||
{
|
||||
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.
|
||||
state.exited = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user