enBoxer/src/profile.rs
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

561 lines
15 KiB
Rust

use crate::hotkey::{passthrough_id, Hotkey};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
#[serde(default = "default_client")]
pub client: String,
#[serde(default = "default_slots")]
pub slots: u32,
#[serde(default)]
pub window_match: WindowMatch,
/// Keys that are never intercepted. Empty = no skip list.
#[serde(default)]
pub passthrough: Vec<String>,
#[serde(default = "default_mode")]
pub mode_default: Mode,
#[serde(default)]
pub repeater: Repeater,
#[serde(default)]
pub game_binds: BTreeMap<String, String>,
#[serde(default)]
pub interact: Interact,
#[serde(default = "default_session_hotkeys")]
pub session_hotkeys: BTreeMap<String, String>,
#[serde(default)]
pub characters: Vec<Character>,
#[serde(default)]
pub groups: BTreeMap<String, Group>,
#[serde(default)]
pub maps: Vec<Map>,
#[serde(default)]
pub video_fx: Vec<VideoFx>,
#[serde(default)]
pub layout: Layout,
}
fn default_client() -> String {
"wow-retail".into()
}
fn default_slots() -> u32 {
2
}
fn default_session_hotkeys() -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert("mode_cycle".into(), "Shift+Alt+M".into());
m.insert("swap_next".into(), "Ctrl+grave".into());
m.insert("swap_prev".into(), "Ctrl+Shift+grave".into());
m.insert("focus_next".into(), "Ctrl+Shift+N".into());
m.insert("focus_prev".into(), "Ctrl+Shift+P".into());
m.insert("focus_main".into(), "Ctrl+F1".into());
m.insert("reset_all".into(), "Ctrl+Shift+R".into());
m.insert("stay_on_top".into(), "Ctrl+Shift+T".into());
m.insert("mouse_follow".into(), "Ctrl+Shift+F".into());
m.insert("mouse_broadcast".into(), "Ctrl+Shift+B".into());
m
}
fn default_mode() -> Mode {
Mode::Maps
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
/// 1: only configured maps; everything else goes to the front window
Maps,
/// 2: clone keys to the other game windows (front window still gets the real key)
#[serde(alias = "repeater")]
Mirror,
/// 3: do not intercept; all keys go to the front window
#[serde(alias = "disabled")]
Off,
}
impl Mode {
pub fn parse_name(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"maps" | "1" => Some(Mode::Maps),
"mirror" | "repeater" | "2" => Some(Mode::Mirror),
"off" | "disabled" | "3" => Some(Mode::Off),
_ => None,
}
}
pub fn cycle(self) -> Self {
match self {
Mode::Maps => Mode::Mirror,
Mode::Mirror => Mode::Off,
Mode::Off => Mode::Maps,
}
}
pub fn as_str(self) -> &'static str {
match self {
Mode::Maps => "maps",
Mode::Mirror => "mirror",
Mode::Off => "off",
}
}
pub fn label(self) -> &'static str {
match self {
Mode::Maps => "maps (configured keys only)",
Mode::Mirror => "mirror (all windows)",
Mode::Off => "off (front window only)",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WindowMatch {
pub class: Option<String>,
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Repeater {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interact {
#[serde(default = "default_interact_style")]
pub style: InteractStyle,
#[serde(default = "default_walk_delay")]
pub walk_delay_ms: u64,
}
fn default_interact_style() -> InteractStyle {
InteractStyle::Standard
}
fn default_walk_delay() -> u64 {
2500
}
impl Default for Interact {
fn default() -> Self {
Self {
style: InteractStyle::Standard,
walk_delay_ms: 2500,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractStyle {
Standard,
Auto,
Hold,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Character {
pub slot: u32,
#[serde(default)]
pub name: String,
#[serde(default)]
pub match_title: Option<String>,
/// Key *everyone* binds to `/assist ThisName`. Sent to others when this slot is main.
#[serde(default)]
pub assist_key: String,
/// Key *everyone* binds to `/follow ThisName`.
#[serde(default)]
pub follow_key: String,
/// Lutris game slug (matches `LutrisGame::slug`) to launch for this
/// slot from the GUI's Launch menu. `None` = no Launch button.
#[serde(default)]
pub lutris_game: Option<String>,
/// Override the wine prefix for this character (else the team prefix
/// or the Lutris YAML's prefix is used).
#[serde(default)]
pub wine_prefix: Option<PathBuf>,
/// After spawn, poll `hyprctl -j clients` for the matched window
/// (up to 30 s) and call the existing layout-apply IPC. Off by
/// default; this is the only path that auto-moves windows.
#[serde(default)]
pub auto_apply: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub slots: Vec<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Map {
pub name: String,
pub hotkey: Hotkey,
#[serde(default)]
pub hold: bool,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub release_steps: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub bind: Option<String>,
#[serde(default)]
pub delay_ms: Option<u64>,
#[serde(default = "default_target")]
pub target: String,
}
fn default_target() -> String {
"others".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoFx {
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
pub source_slot: u32,
pub source: NormRect,
#[serde(default)]
pub viewer: NormRect,
#[serde(default = "default_true")]
pub pass_through: bool,
#[serde(default = "default_fps")]
pub fps: u32,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LayoutPreset {
Stacked,
Grid,
#[default]
MainStrip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Layout {
#[serde(default)]
pub preset: LayoutPreset,
#[serde(default)]
pub same_size: bool,
#[serde(default)]
pub main_at_bottom: bool,
#[serde(default = "default_true")]
pub one_row: bool,
#[serde(default)]
pub pin: bool,
#[serde(default)]
pub auto_apply: bool,
#[serde(default)]
pub borderless: bool,
/// Hyprland monitor name, empty = largest.
#[serde(default)]
pub monitor: String,
#[serde(default)]
pub slots: Vec<LayoutSlot>,
}
impl Default for Layout {
fn default() -> Self {
Self {
preset: LayoutPreset::MainStrip,
same_size: false,
main_at_bottom: false,
one_row: true,
pin: false,
auto_apply: true,
borderless: false,
monitor: String::new(),
slots: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutSlot {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
#[serde(default)]
pub pin: bool,
}
fn default_fps() -> u32 {
12
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct NormRect {
#[serde(default)]
pub x: f64,
#[serde(default)]
pub y: f64,
#[serde(default = "default_one")]
pub w: f64,
#[serde(default = "default_one")]
pub h: f64,
}
fn default_one() -> f64 {
1.0
}
impl Default for NormRect {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
w: 0.28,
h: 0.28,
}
}
}
impl NormRect {
/// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.
pub fn is_pixels(&self) -> bool {
self.x.abs() > 1.0 || self.y.abs() > 1.0 || self.w > 1.0 || self.h > 1.0
}
pub fn to_pixels(
&self,
win_x: i32,
win_y: i32,
win_w: i32,
win_h: i32,
) -> (i32, i32, i32, i32) {
if self.is_pixels() {
(
win_x + self.x.round() as i32,
win_y + self.y.round() as i32,
self.w.round() as i32,
self.h.round() as i32,
)
} else {
let x = win_x + (self.x * win_w as f64).round() as i32;
let y = win_y + (self.y * win_h as f64).round() as i32;
let w = (self.w * win_w as f64).round() as i32;
let h = (self.h * win_h as f64).round() as i32;
(x, y, w.max(1), h.max(1))
}
}
pub fn from_global_pixels(gx: i32, gy: i32, gw: i32, gh: i32, win_x: i32, win_y: i32) -> Self {
Self {
x: (gx - win_x) as f64,
y: (gy - win_y) as f64,
w: gw as f64,
h: gh as f64,
}
}
}
impl Profile {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read profile {}", path.display()))?;
let mut profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?;
for (k, v) in default_session_hotkeys() {
profile.session_hotkeys.entry(k).or_insert(v);
}
profile.validate()?;
Ok(profile)
}
pub fn validate(&self) -> Result<()> {
if self.slots < 1 {
bail!("slots must be >= 1");
}
let mut seen = HashSet::new();
for m in &self.maps {
let id = m.hotkey.normalized()?;
if !seen.insert(id.clone()) {
bail!("duplicate map hotkey {id}");
}
if m.steps.is_empty() && m.release_steps.is_empty() {
bail!("map {} has no steps", m.name);
}
for s in m.steps.iter().chain(m.release_steps.iter()) {
s.validate()?;
}
}
for name in self.game_binds.keys() {
if name.trim().is_empty() {
bail!("empty game_binds key");
}
}
for fx in &self.video_fx {
if fx.source_slot < 1 || fx.source_slot > self.slots {
bail!("video_fx {} source_slot out of range", fx.name);
}
}
Ok(())
}
pub fn passthrough_set(&self) -> Result<HashSet<String>> {
let mut set = HashSet::new();
for k in &self.passthrough {
set.insert(passthrough_id(k)?);
}
Ok(set)
}
pub fn resolve_bind(&self, name: &str) -> Result<String> {
self.game_binds
.get(name)
.cloned()
.with_context(|| format!("unknown game_bind {name:?}"))
}
pub fn map_by_hotkey(&self, canonical: &str) -> Option<&Map> {
self.maps.iter().find(|m| {
m.hotkey
.normalized()
.ok()
.is_some_and(|n| n.eq_ignore_ascii_case(canonical))
})
}
}
impl Step {
fn validate(&self) -> Result<()> {
let kinds = [
self.key.is_some(),
self.bind.is_some(),
self.delay_ms.is_some(),
]
.into_iter()
.filter(|b| *b)
.count();
if kinds != 1 {
bail!("step must be exactly one of key, bind, delay_ms");
}
Ok(())
}
}
pub fn default_config_path() -> PathBuf {
directories::ProjectDirs::from("de", "nettsi", "enboxer")
.map(|p| p.config_dir().join("profile.yaml"))
.unwrap_or_else(|| PathBuf::from("profile.yaml"))
}
pub fn runtime_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(xdg).join("enboxer")
} else {
std::env::temp_dir().join("enboxer")
}
}
/// `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;
#[test]
fn fraction_rect() {
let r = NormRect {
x: 0.25,
y: 0.0,
w: 0.5,
h: 1.0,
};
assert!(!r.is_pixels());
assert_eq!(r.to_pixels(100, 50, 200, 100), (150, 50, 100, 100));
}
#[test]
fn pixel_rect() {
let r = NormRect {
x: 10.0,
y: 20.0,
w: 80.0,
h: 40.0,
};
assert!(r.is_pixels());
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));
}
}