diff --git a/README.md b/README.md index 090fec1..72ff2ac 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,21 @@ first launch. ## Usage -| Command | What it does | -| --------------------------------- | ------------------------------------------------------- | -| `battlenet-manager` | Start the tray daemon (default) | -| `battlenet-manager launch` | Launch Battle.net and return (for `.desktop` shortcuts) | -| `battlenet-manager kill` | SIGTERM → wait 3 s → SIGKILL on all Battle.net procs | -| `battlenet-manager diagnose` | Run environment health checks | -| `battlenet-manager update-proton` | Interactive GE-Proton picker | -| `update-proton --latest` | Install newest GE-Proton release | -| `update-proton --version X` | Install a specific tag (e.g. `GE-Proton10-34`) | -| `update-proton --list` | Show recent releases without installing | +| Command | What it does | +| --------------------------------- | -------------------------------------------------------- | +| `battlenet-manager` | Start the tray daemon (default) | +| `battlenet-manager launch` | Launch Battle.net and return (for `.desktop` shortcuts) | +| `battlenet-manager kill` | SIGTERM → wait 3 s → SIGKILL on all Battle.net procs | +| `battlenet-manager diagnose` | Run environment health checks | +| `battlenet-manager update-proton` | Interactive GE-Proton picker | +| `update-proton --latest` | Install newest GE-Proton release | +| `update-proton --version X` | Install a specific tag (e.g. `GE-Proton10-34`) | +| `update-proton --list` | Show recent releases without installing | +| `config show` / `config path` | Print current config or its path | +| `config edit` | Open config in `$EDITOR` | +| `config set --prefix PATH` | Change the Wine prefix (also `--compat-dir`, `--gameid`) | +| `service install` | Write + enable a `systemd --user` unit for autostart | +| `service uninstall` / `status` | Remove the unit / show its status | ## Config diff --git a/src/config.rs b/src/config.rs index e8e1285..17bac9a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,12 +36,65 @@ fn home_dir() -> PathBuf { } impl Config { - fn config_path() -> Result { + pub fn config_path() -> Result { let dirs = ProjectDirs::from("co.aleshym", "", "battlenet-manager") .context("Could not determine config directory")?; Ok(dirs.config_dir().join("config.toml")) } + /// Print the config path followed by the serialised current config. + pub fn show(&self) -> Result<()> { + let path = Self::config_path()?; + println!("# {}", path.display()); + let s = toml::to_string_pretty(self)?; + print!("{s}"); + Ok(()) + } + + /// Open the config file in $EDITOR (falling back to nano). + /// Ensures the file exists first so the editor isn't launched on nothing. + pub fn edit() -> Result<()> { + let _ = Self::load()?; // writes defaults if missing + let path = Self::config_path()?; + let editor = std::env::var("EDITOR") + .or_else(|_| std::env::var("VISUAL")) + .unwrap_or_else(|_| "nano".into()); + let status = std::process::Command::new(&editor) + .arg(&path) + .status() + .with_context(|| format!("Failed to spawn editor '{editor}'"))?; + if !status.success() { + anyhow::bail!("Editor '{editor}' exited non-zero"); + } + Ok(()) + } + + /// Update individual fields non-interactively, then save. + pub fn set_fields( + &mut self, + prefix: Option, + compat_dir: Option, + gameid: Option, + ) -> Result<()> { + if prefix.is_none() && compat_dir.is_none() && gameid.is_none() { + anyhow::bail!("nothing to set — pass at least one of --prefix / --compat-dir / --gameid"); + } + if let Some(p) = prefix { + self.prefix_dir = p; + } + if let Some(p) = compat_dir { + self.proton_compat_dir = p; + } + if let Some(g) = gameid { + self.gameid = g; + } + self.save()?; + println!("\x1b[1;32m✓\x1b[0m Config saved."); + println!(); + self.show()?; + Ok(()) + } + /// Load config from disk, creating a default one if it doesn't exist. pub fn load() -> Result { let path = Self::config_path()?; diff --git a/src/main.rs b/src/main.rs index be63123..d600241 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,12 @@ mod config; mod diagnose; mod launcher; mod proton; +mod service; mod tray; use anyhow::Result; use clap::{Parser, Subcommand}; +use std::path::PathBuf; /// Battle.net launcher manager for Linux via umu/Proton-GE. /// @@ -46,6 +48,52 @@ enum Commands { #[arg(long)] list: bool, }, + + /// Show or modify configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, + + /// Manage the systemd --user service that autostarts the tray + Service { + #[command(subcommand)] + action: ServiceAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// Print the config file path and current values + Show, + /// Print just the config file path + Path, + /// Open the config file in $EDITOR + Edit, + /// Update one or more fields non-interactively + Set { + /// Wine prefix directory + #[arg(long, value_name = "PATH")] + prefix: Option, + + /// GE-Proton install directory + #[arg(long, value_name = "PATH")] + compat_dir: Option, + + /// umu GAMEID (used for protonfixes lookup) + #[arg(long, value_name = "ID")] + gameid: Option, + }, +} + +#[derive(Subcommand)] +enum ServiceAction { + /// Write the unit, daemon-reload, and enable+start the service + Install, + /// Stop, disable, and remove the unit file + Uninstall, + /// Show `systemctl --user status` for the service + Status, } fn main() -> Result<()> { @@ -60,6 +108,22 @@ fn main() -> Result<()> { Commands::UpdateProton { latest, version, list } => { proton::run(&config, latest, version, list)?; } + Commands::Config { action } => match action { + ConfigAction::Show => config.show()?, + ConfigAction::Path => { + println!("{}", config::Config::config_path()?.display()); + } + ConfigAction::Edit => config::Config::edit()?, + ConfigAction::Set { prefix, compat_dir, gameid } => { + let mut c = config; + c.set_fields(prefix, compat_dir, gameid)?; + } + }, + Commands::Service { action } => match action { + ServiceAction::Install => service::install()?, + ServiceAction::Uninstall => service::uninstall()?, + ServiceAction::Status => service::status()?, + }, } Ok(()) diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..33647d8 --- /dev/null +++ b/src/service.rs @@ -0,0 +1,98 @@ +use anyhow::{bail, Context, Result}; +use std::path::PathBuf; +use std::process::Command; + +const UNIT_NAME: &str = "battlenet-manager.service"; + +fn unit_path() -> Result { + let home = std::env::var("HOME").context("$HOME is not set")?; + Ok(PathBuf::from(home) + .join(".config/systemd/user") + .join(UNIT_NAME)) +} + +fn render_unit(exe: &std::path::Path) -> String { + format!( + "[Unit]\n\ + Description=Battle.net tray manager\n\ + After=graphical-session.target\n\ + PartOf=graphical-session.target\n\ + \n\ + [Service]\n\ + ExecStart={exe}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + \n\ + [Install]\n\ + WantedBy=graphical-session.target\n", + exe = exe.display(), + ) +} + +fn systemctl(args: &[&str]) -> Result<()> { + let status = Command::new("systemctl") + .arg("--user") + .args(args) + .status() + .context("Failed to invoke systemctl --user (is systemd installed?)")?; + if !status.success() { + bail!("systemctl --user {} exited non-zero", args.join(" ")); + } + Ok(()) +} + +/// Write the unit, reload systemd, and enable+start the service. +pub fn install() -> Result<()> { + let exe = std::env::current_exe() + .context("Cannot determine path to own executable")?; + let path = unit_path()?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {parent:?}"))?; + } + + let contents = render_unit(&exe); + std::fs::write(&path, &contents) + .with_context(|| format!("Failed to write unit file {path:?}"))?; + println!("Wrote unit: {}", path.display()); + println!("ExecStart: {}", exe.display()); + println!(); + + systemctl(&["daemon-reload"])?; + systemctl(&["enable", "--now", UNIT_NAME])?; + + println!(); + println!("\x1b[1;32m✓\x1b[0m Service installed and started."); + println!(" Status: systemctl --user status {UNIT_NAME}"); + println!(" Logs: journalctl --user -u {UNIT_NAME} -f"); + Ok(()) +} + +/// Stop, disable, and remove the unit file. +pub fn uninstall() -> Result<()> { + let path = unit_path()?; + + // Ignore failures: the unit may already be stopped or unknown to systemd. + let _ = systemctl(&["disable", "--now", UNIT_NAME]); + + if path.exists() { + std::fs::remove_file(&path) + .with_context(|| format!("Failed to remove {path:?}"))?; + println!("Removed {}", path.display()); + } else { + println!("No unit file at {}", path.display()); + } + + let _ = systemctl(&["daemon-reload"]); + println!("\x1b[1;32m✓\x1b[0m Service removed."); + Ok(()) +} + +/// Pass through `systemctl --user status`. +pub fn status() -> Result<()> { + let _ = Command::new("systemctl") + .args(["--user", "status", UNIT_NAME]) + .status(); + Ok(()) +}