enBoxer/examples/games.yaml
en 9052f61228 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.
2026-09-17 05:43:21 +02:00

228 lines
6.7 KiB
YAML

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")
}
}