Compare commits
5 Commits
1ec8d78f71
...
6028987a6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6028987a6e | ||
|
|
1b8b2cdf24 | ||
|
|
cece41d2ee | ||
|
|
20f0160799 | ||
|
|
8eb5346d92 |
33
CHANGELOG.md
33
CHANGELOG.md
@ -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.
|
||||||
|
|||||||
@ -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`.
|
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: 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
}],
|
}],
|
||||||
|
|||||||
66
src/gui.rs
66
src/gui.rs
@ -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")),
|
||||||
|
|||||||
24
src/hypr.rs
24
src/hypr.rs
@ -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'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user