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, #[serde(default = "default_mode")] pub mode_default: Mode, #[serde(default)] pub repeater: Repeater, #[serde(default)] pub game_binds: BTreeMap, #[serde(default)] pub interact: Interact, #[serde(default = "default_session_hotkeys")] pub session_hotkeys: BTreeMap, #[serde(default)] pub characters: Vec, #[serde(default)] pub groups: BTreeMap, #[serde(default)] pub maps: Vec, #[serde(default)] pub video_fx: Vec, #[serde(default)] pub layout: Layout, } fn default_client() -> String { "wow-retail".into() } fn default_slots() -> u32 { 2 } fn default_session_hotkeys() -> BTreeMap { 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 { 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, pub title: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Repeater { #[serde(default)] pub enabled: bool, #[serde(default)] pub keys: Vec, } #[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, /// 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, /// 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, /// 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, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Map { pub name: String, pub hotkey: Hotkey, #[serde(default)] pub hold: bool, #[serde(default)] pub steps: Vec, #[serde(default)] pub release_steps: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Step { #[serde(default)] pub key: Option, #[serde(default)] pub bind: Option, #[serde(default)] pub delay_ms: Option, #[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, } 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 { 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> { 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 { 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>(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)); } }