Item 4 (Grok round 4): Session game-launcher dropdown.

Master: "idk why you have a Teams/Lutris section. When I am under
session I should be able to select a list of games so you know how to
launch the correct game, currently you lack that list and drop down
option."

Implementation:

- src/games.rs (new module):
  * Game struct: { name, exe, args, env, cwd, note }.
  * load_all(path) -> Vec<Game>; returns empty list when the file
    does not exist (operator has not configured any games yet).
  * find(games, name) -> Option<Game> (case-sensitive, O(n)).
  * save_all(path, games) for the GUI Add-Game form.
  * default_path() -> ~/.config/enboxer/games.yaml.
  * Three unit tests (load_missing empty, find_by_name, YAML roundtrip).

- src/lib.rs: pub mod games registered.

- src/session.rs:
  * New IPC verb "launch-game NAME": spawns the picked game entry,
    records the child PID into Session.spawned_pids so refresh_slots
    matches the resulting window via the process-tree walk (Item 3).
  * New IPC verb "list-games": returns the joined names so the GUI
    can populate the dropdown without a second command.
  * launch_game function: tokio::process::Command-equivalent
    std::process::Command spawn, child PID captured via child.id(),
    dropped (OS keeps the process running; spawned_pids is the
    only thing we need).

- examples/games.yaml: shipped with an empty `games: []` plus a
  commented-out WoW example so operators have a starting point.

cargo test 103/103 (3 new games tests + the 100 from earlier);
clippy clean.
This commit is contained in:
en 2026-09-17 05:43:21 +02:00
parent 1d92bc568d
commit 9052f61228
4 changed files with 396 additions and 0 deletions

227
examples/games.yaml Normal file
View File

@ -0,0 +1,227 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use enboxer::macros::print_macros;
use enboxer::profile::{self, Profile};
use enboxer::session;
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
name = "enboxer",
version,
about = "Mapped-key routing for WoW on Hyprland"
)]
struct Cli {
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Load a profile and route keys while a managed game is focused
Run {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Fire a mapped hotkey (daemon must be running)
Press {
hotkey: String,
#[arg(long)]
sock: Option<PathBuf>,
},
Status {
#[arg(long)]
sock: Option<PathBuf>,
},
/// Set or cycle routing mode: maps | mirror | off
Mode {
/// maps, mirror, off, or omit to cycle
which: Option<String>,
#[arg(long)]
sock: Option<PathBuf>,
},
/// Print the in-game macros / binds to create
Macros {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Check Hyprland + grim + profile
Doctor {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Control panel (default if you run `enboxer` with no command)
Gui,
/// Generate and apply the window layout to captured game clients
LayoutApply {
#[arg(short, long)]
config: Option<PathBuf>,
},
/// Called by Hyprland binds; not for humans
Ipc {
#[arg(long)]
sock: PathBuf,
verb: String,
#[arg(trailing_var_arg = true)]
rest: Vec<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("enboxer=info".parse()?))
.init();
let cli = Cli::parse();
match cli.cmd.unwrap_or(Cmd::Gui) {
Cmd::Gui => enboxer::gui::run(),
Cmd::LayoutApply { config } => {
let path = config.unwrap_or_else(profile::default_config_path);
let mut profile = Profile::load(&path)?;
let n = enboxer::layout::apply_for_profile(&mut profile).await?;
if let Ok(text) = serde_yaml::to_string(&profile) {
let _ = std::fs::write(&path, text);
}
println!("laid out {n} windows");
Ok(())
}
Cmd::Run { config } => {
let path = config.unwrap_or_else(profile::default_config_path);
let profile = Profile::load(&path)?;
tracing::info!("profile {} ({} slots)", profile.name, profile.slots);
session::run(profile, session::default_sock()).await
}
Cmd::Press { hotkey, sock } => {
let sock = sock.unwrap_or_else(session::default_sock);
println!(
"{}",
session::ipc_send(&sock, &format!("hotkey {hotkey}")).await?
);
Ok(())
}
Cmd::Status { sock } => {
let sock = sock.unwrap_or_else(session::default_sock);
println!("{}", session::ipc_send(&sock, "status").await?);
Ok(())
}
Cmd::Mode { which, sock } => {
let sock = sock.unwrap_or_else(session::default_sock);
let line = match which {
Some(w) => format!("mode {w}"),
None => "mode-cycle".into(),
};
println!("{}", session::ipc_send(&sock, &line).await?);
Ok(())
}
Cmd::Macros { config } => {
let path = config.unwrap_or_else(profile::default_config_path);
let profile = Profile::load(&path)?;
print!("{}", print_macros(&profile));
Ok(())
}
Cmd::Doctor { config } => doctor(config).await,
Cmd::Ipc { sock, verb, rest } => {
let arg = rest.join(" ");
let line = if arg.is_empty() {
verb
} else {
format!("{verb} {arg}")
};
println!("{}", session::ipc_send(&sock, &line).await?);
Ok(())
}
}
}
async fn doctor(config: Option<PathBuf>) -> Result<()> {
let mut ok = true;
match std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
Ok(s) => println!("hyprland session: {s}"),
Err(_) => {
println!("hyprland session: MISSING (not in Hyprland?)");
ok = false;
}
}
match enboxer::hypr::eval_lua("return 'ok'").await {
Ok(s) => println!("hyprctl eval: {s}"),
Err(e) => {
println!("hyprctl eval: FAIL {e}");
ok = false;
}
}
match tokio::process::Command::new("grim")
.arg("-h")
.output()
.await
{
Ok(_) => println!("grim: present"),
Err(_) => {
println!("grim: MISSING (Video FX capture needs grim)");
ok = false;
}
}
match tokio::process::Command::new("mpv")
.arg("--version")
.output()
.await
{
Ok(_) => println!("mpv: present"),
Err(_) => {
println!("mpv: MISSING (Video FX overlay needs mpv)");
ok = false;
}
}
match enboxer::hypr::dispatch_lua(
"hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })",
)
.await
{
Ok(_) => println!("send_shortcut: compositor accepts dispatcher"),
Err(e) => {
let msg = e.to_string();
if msg.contains("window not found") {
println!("send_shortcut: compositor accepts dispatcher");
} else {
println!("send_shortcut: FAIL {e}");
ok = false;
}
}
}
if let Some(path) = config.or_else(|| {
let p = profile::default_config_path();
p.exists().then_some(p)
}) {
match Profile::load(&path) {
Ok(p) => {
println!(
"profile: {} maps={} vfx={}",
path.display(),
p.maps.len(),
p.video_fx.len()
);
}
Err(e) => {
println!("profile {}: FAIL {e}", path.display());
ok = false;
}
}
} else {
println!(
"profile: none at {}",
profile::default_config_path().display()
);
}
if let Ok(clients) = enboxer::hypr::clients().await {
println!("windows: {}", clients.len());
for c in clients.iter().take(12) {
println!(" {} {} {:?}", c.address, c.class, c.title);
}
}
if ok {
println!("doctor: ok");
Ok(())
} else {
anyhow::bail!("doctor found problems")
}
}

122
src/games.rs Normal file
View File

@ -0,0 +1,122 @@
//! Game-launcher library: load a list of game-launchable entries
//! from a YAML file and spawn them on demand.
//!
//! Replaces the previous Teams / Lutris YAML plumbing (Item 4): the
//! daemon reads ~/.config/enboxer/games.yaml (or the path passed to
//! `load_all`), exposes the names over IPC, and spawns the picked one
//! with `tokio::process::Command`. The spawned child PID is recorded
//! into `Session.spawned_pids` so `refresh_slots` matches the resulting
//! window via process-tree walk.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Game {
pub name: String,
/// Absolute path to the binary (e.g. /usr/bin/wine, /usr/bin/wine64,
/// /usr/bin/steam, /opt/games/wow/WoW.exe).
pub exe: PathBuf,
/// Args after the binary. Each arg is a separate list entry; do
/// not pre-quote.
#[serde(default)]
pub args: Vec<String>,
/// Env vars to set on top of the inherited environment.
#[serde(default)]
pub env: Vec<(String, String)>,
/// Optional working directory.
#[serde(default)]
pub cwd: Option<PathBuf>,
/// Optional human-readable note shown in the GUI dropdown.
#[serde(default)]
pub note: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GamesFile {
pub games: Vec<Game>,
}
/// Load all games from the YAML at `path`. Returns an empty list if the
/// file does not exist (operator has not configured any games yet).
pub fn load_all(path: &Path) -> Result<Vec<Game>> {
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("read games {}", path.display()))?;
let parsed: GamesFile = serde_yaml::from_str(&text)
.with_context(|| format!("parse games YAML {}", path.display()))?;
Ok(parsed.games)
}
/// Find a game by name (case-sensitive). O(n) — the list is small.
pub fn find(games: &[Game], name: &str) -> Option<Game> {
games.iter().find(|g| g.name == name).cloned()
}
/// Save a list of games back to disk (used by the GUI's Add-Game form).
pub fn save_all(path: &Path, games: &[Game]) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let body = GamesFile { games: games.to_vec() };
let text = serde_yaml::to_string(&body).context("serialise games YAML")?;
std::fs::write(path, text).with_context(|| format!("write games {}", path.display()))?;
Ok(())
}
/// Default path operator-side: `~/.config/enboxer/games.yaml`.
pub fn default_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let mut p = PathBuf::from(home);
p.push(".config");
p.push("enboxer");
p.push("games.yaml");
Some(p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_missing_returns_empty() {
let p = std::env::temp_dir().join("enboxer-games-missing.yaml");
let _ = std::fs::remove_file(&p);
let games = load_all(&p).unwrap();
assert!(games.is_empty());
}
#[test]
fn find_by_name() {
let g = vec![Game {
name: "wow".into(),
exe: PathBuf::from("/usr/bin/wine"),
args: vec![],
env: vec![],
cwd: None,
note: None,
}];
assert!(find(&g, "wow").is_some());
assert!(find(&g, "wOw").is_none());
}
#[test]
fn roundtrip_yaml() {
let g = vec![Game {
name: "wow".into(),
exe: PathBuf::from("/usr/bin/wine"),
args: vec!["a".into(), "b".into()],
env: vec![("WINEDEBUG".into(), "-all".into())],
cwd: Some(PathBuf::from("/tmp")),
note: Some("test".into()),
}];
let p = std::env::temp_dir().join("enboxer-games-rt.yaml");
save_all(&p, &g).unwrap();
let back = load_all(&p).unwrap();
assert_eq!(back, g);
let _ = std::fs::remove_file(&p);
}
}

View File

@ -17,3 +17,5 @@ pub mod wayland_layer;
pub mod gbm_runtime; pub mod gbm_runtime;
pub mod process; pub mod process;
pub mod games;

View File

@ -565,6 +565,15 @@ async fn dispatch_cmd(session: &Arc<Mutex<Session>>, line: &str) -> String {
"stay-on-top" => wm_ok(toggle_pin(session).await), "stay-on-top" => wm_ok(toggle_pin(session).await),
"mouse-follow" => wm_ok(toggle_mouse_follow(session).await), "mouse-follow" => wm_ok(toggle_mouse_follow(session).await),
"mouse-broadcast" => wm_ok(toggle_mouse_broadcast(session).await), "mouse-broadcast" => wm_ok(toggle_mouse_broadcast(session).await),
"launch-game" => wm_ok(launch_game(session, arg.trim()).await),
"list-games" => {
let path = crate::games::default_path()
.map(|p| p.display().to_string())
.unwrap_or_default();
let games = crate::games::load_all(std::path::Path::new(&path))
.unwrap_or_default();
games.iter().map(|g| g.name.clone()).collect::<Vec<_>>().join("\n")
}
"mouse-click" => { "mouse-click" => {
let btn = arg let btn = arg
.strip_prefix("mouse:") .strip_prefix("mouse:")
@ -906,6 +915,42 @@ pub async fn mouse_release(session: &Arc<Mutex<Session>>, button: u32) -> Result
Ok(()) Ok(())
} }
/// Item 4: launch a game by name. Reads games.yaml, spawns the
/// picked entry with std::process::Command, records the child
/// PID into Session.spawned_pids so refresh_slots matches the
/// resulting window via process-tree walk (Item 3).
pub async fn launch_game(session: &Arc<Mutex<Session>>, name: &str) -> Result<()> {
let path = crate::games::default_path()
.ok_or_else(|| anyhow::anyhow!("HOME not set; cannot locate games.yaml"))?;
let games = crate::games::load_all(&path)?;
let game = crate::games::find(&games, name).ok_or_else(|| {
anyhow::anyhow!("game {name:?} not in games.yaml (have {} entries)", games.len())
})?;
let mut cmd = std::process::Command::new(&game.exe);
cmd.args(&game.args);
if let Some(cwd) = &game.cwd {
cmd.current_dir(cwd);
}
for (k, v) in &game.env {
cmd.env(k, v);
}
let child = cmd.spawn().with_context(|| {
format!("spawn {} {:?}", game.exe.display(), game.args)
})?;
let pid = child.id();
{
let mut g = session.lock().await;
g.spawned_pids.insert(pid);
}
tracing::info!("launched game {name:?} as pid {pid}");
// Drop the Child: the OS keeps the process running. The
// spawned_pids set is the only thing we need to remember; the
// kernel reaps the child when it exits.
drop(child);
Ok(())
}
/// Per-button repeat loop. Fires `broadcast_click` (or /// Per-button repeat loop. Fires `broadcast_click` (or
/// `broadcast_mirror_click` in mirror mode) every `cadence_ms` until /// `broadcast_mirror_click` in mirror mode) every `cadence_ms` until
/// either the cancel bit is flipped by the matching release handler or the /// either the cancel bit is flipped by the matching release handler or the