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.
228 lines
6.7 KiB
YAML
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")
|
|
}
|
|
}
|