From 5ea7389ba2e0af28cf710844f63be239da2b7c01 Mon Sep 17 00:00:00 2001
From: Fenhl <fenhl@fenhl.net>
Date: Thu, 3 Oct 2024 14:06:14 +0000
Subject: [PATCH] Subcommand to launch Minecraft

---
 Cargo.lock  |   2 +-
 Cargo.toml  |   4 +-
 README.md   |   6 +-
 src/main.rs | 257 ++++++++++++++++++++++++++++------------------------
 4 files changed, 149 insertions(+), 120 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 52040eb..340491c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1657,7 +1657,7 @@ dependencies = [
 
 [[package]]
 name = "systray-wurstmineberg-status"
-version = "2.4.0"
+version = "2.5.0"
 dependencies = [
  "clap",
  "directories",
diff --git a/Cargo.toml b/Cargo.toml
index 1ecebeb..cde63aa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "systray-wurstmineberg-status"
-version = "2.4.0"
+version = "2.5.0"
 authors = ["Fenhl <fenhl@fenhl.net>"]
 edition = "2021"
 repository = "https://github.com/wurstmineberg/systray"
@@ -33,7 +33,7 @@ serde = { version = "1.0.196", features = ["derive"] }
 serde_json = { package = "serde_json_path_to_error", version = "0.1" }
 serenity = { version = "0.12.0", default-features = false }
 thiserror = "1.0.56"
-tokio = { version = "1.35.1", features = ["rt", "time"] }
+tokio = { version = "1.35.1", features = ["rt-multi-thread", "time"] }
 wheel = { git = "https://github.com/fenhl/wheel", branch = "main", features = ["serde", "serde_json", "reqwest"] }
 
 [build-dependencies]
diff --git a/README.md b/README.md
index 7673cbf..13beb1b 100644
--- a/README.md
+++ b/README.md
@@ -22,8 +22,12 @@ For an equivalent macOS app, see [bitbar-server-status](https://github.com/wurst
 # Usage
 
 * The icon only appears as long as someone is online on one of our worlds. You can hover over it to see how many people are online (and if it's only one player, their name).
-* You can left-click on the icon to start Minecraft. This supports both the official Minecraft launcher and [Prism Launcher](https://prismlauncher.org/). For Prism Launcher to be detected, it must be available on the `PATH`. If Prism Launcher is installed via [Scoop](https://scoop.sh/), this should be the case by default.
+* You can left-click on the icon to start Minecraft. This supports [portablemc](https://pypi.org/project/portablemc/), [Prism Launcher](https://prismlauncher.org/), and the official Minecraft launcher.
+    * For portablemc to be used, the `.portablemc.login` [configuration](#configuration) entry must be specified.
+    * For Prism Launcher to be used, it must be available on the `PATH`. If Prism Launcher is installed via [Scoop](https://scoop.sh/), this should be the case by default.
+    * The official Minecraft launcher is the fallback if the conditions for using neither portablemc nor Prism Launcher are met. Both the new Microsoft Store launcher and the old launcher are supported.
 * You can right-click on the icon to see the active worlds, their current versions (each with a link to the [Minecraft wiki](https://minecraft.fandom/) article about that version), as well as the full list of everyone who's online (with links to their Wurstmineberg profiles).
+* The app can be run from the command line with the `launch` subcommand to start Minecraft (same behavior as left-clicking on the system tray icon).
 
 ## Configuration
 
diff --git a/src/main.rs b/src/main.rs
index 5b9bdc9..72d889e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -142,7 +142,7 @@ impl SystemTray {
         let app = self.clone();
         if let Some(previous_event_handler) = self.event_handler.replace(Some(nwg::full_bind_event_handler(&self.window.handle, move |event, _, handle| match event {
             nwg::Event::OnMenuItemSelected => if handle == app.item_launch_minecraft.borrow().handle {
-                app.launch_minecraft().expect("failed to launch Minecraft");
+                lock!(@blocking lock = app.state; launch_minecraft(&app.config, lock.as_ref().expect("missing server state")).expect("failed to launch Minecraft"));
             } else if handle == app.item_exit.borrow().handle {
                 app.exit();
             } else {
@@ -230,109 +230,10 @@ impl SystemTray {
 
     fn click(&self) {
         if self.config.left_click_launch {
-            self.launch_minecraft().expect("failed to launch Minecraft");
+            lock!(@blocking lock = self.state; launch_minecraft(&self.config, lock.as_ref().expect("missing server state")).expect("failed to launch Minecraft"));
         }
     }
 
-    fn launch_minecraft(&self) -> Result<(), LaunchError> {
-        let game_version = if let Some(ref version_override) = self.config.ferium.version_override {
-            Some(version_override.clone())
-        } else {
-            lock!(@blocking lock = self.state; {
-                let (_, world_status) = lock.as_ref().ok_or(LaunchError::MissingState)?.as_ref().map_err(|e| LaunchError::State { display: e.to_string(), debug: format!("{e:?}") })?;
-                world_status.get(MAIN_WORLD).map(|world_status| world_status.version.clone())
-            })
-        };
-        let portablemc_work_dir = if let Some(ferium_profile) = self.config.ferium.profiles.get(MAIN_WORLD) {
-            if let Some(ref game_version) = game_version {
-                let previous_profile = self.config.ferium.command()
-                    .arg("profile")
-                    .release_create_no_window()
-                    .check("ferium profile")?
-                    .stdout;
-                let mut previous_profile = String::from_utf8(previous_profile)?;
-                previous_profile.truncate(previous_profile.find(" *").ok_or(LaunchError::FeriumProfileFormat)?);
-                self.config.ferium.command()
-                    .arg("profile")
-                    .arg("switch")
-                    .arg(ferium_profile)
-                    .release_create_no_window()
-                    .check("ferium profile switch")?;
-                let current_profile = self.config.ferium.command()
-                    .arg("profile")
-                    .release_create_no_window()
-                    .check("ferium profile")?
-                    .stdout;
-                self.config.ferium.command()
-                    .arg("profile")
-                    .arg("configure")
-                    .arg("--game-version")
-                    .arg(game_version)
-                    .release_create_no_window()
-                    .check("ferium profile configure --game-version")?;
-                self.config.ferium.command()
-                    .arg("upgrade")
-                    .release_create_no_window()
-                    .check("ferium upgrade")?;
-                self.config.ferium.command()
-                    .arg("profile")
-                    .arg("switch")
-                    .arg(previous_profile)
-                    .release_create_no_window()
-                    .check("ferium profile switch")?;
-                current_profile.lines().find_map(|line| line.ok().and_then(|line| line.strip_prefix("        \r  Output directory:   ").map(|dir| {
-                    let mut dir = PathBuf::from(dir);
-                    dir.pop();
-                    dir
-                })))
-            } else {
-                None
-            }
-        } else {
-            None
-        };
-        if let Some(ref portablemc_login) = self.config.portablemc.login {
-            let mut cmd = Command::new("python");
-            cmd.arg("-m");
-            cmd.arg("portablemc");
-            if let Some(work_dir) = portablemc_work_dir {
-                cmd.arg("--work-dir");
-                cmd.arg(work_dir);
-            }
-            cmd.arg("start");
-            cmd.arg(format!("fabric:{}", game_version.unwrap_or_default()));
-            cmd.arg("--server=wurstmineberg.de");
-            cmd.arg("--login");
-            cmd.arg(portablemc_login);
-            cmd.release_create_no_window();
-            cmd.spawn().at_command("python -m portablemc")?;
-        } else {
-            let mut prism_command = Command::new("prismlauncher");
-            if let Some(ref instance) = self.config.prism_instance {
-                prism_command.arg("--show");
-                prism_command.arg(instance);
-            }
-            match prism_command.release_create_no_window().spawn() {
-                Ok(_) => {}
-                Err(e) if e.kind() == io::ErrorKind::NotFound => match Command::new("C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe")
-                    .release_create_no_window()
-                    .spawn()
-                {
-                    Ok(_) => {}
-                    Err(e) if e.kind() == io::ErrorKind::NotFound => {
-                        Command::new("explorer")
-                            .arg("shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft")
-                            .release_create_no_window()
-                            .spawn().at_command("explorer shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft")?;
-                    }
-                    Err(e) => return Err(e).at_command("C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe").map_err(LaunchError::from),
-                },
-                Err(e) => return Err(e).at_command("prismlauncher").map_err(LaunchError::from),
-            }
-        }
-        Ok(())
-    }
-
     fn exit(&self) {
         nwg::stop_thread_dispatch();
     }
@@ -344,8 +245,6 @@ enum LaunchError {
     #[error(transparent)] Wheel(#[from] wheel::Error),
     #[error("failed to parse `ferium profile` command output")]
     FeriumProfileFormat,
-    #[error("missing server state")]
-    MissingState,
     #[error("{display}")]
     State {
         display: String,
@@ -353,6 +252,104 @@ enum LaunchError {
     },
 }
 
+fn launch_minecraft(config: &Config, state: &Result<State, Error>) -> Result<(), LaunchError> {
+    let game_version = if let Some(ref version_override) = config.ferium.version_override {
+        Some(version_override.clone())
+    } else {
+        let (_, world_status) = state.as_ref().map_err(|e| LaunchError::State { display: e.to_string(), debug: format!("{e:?}") })?;
+        world_status.get(MAIN_WORLD).map(|world_status| world_status.version.clone())
+    };
+    let portablemc_work_dir = if let Some(ferium_profile) = config.ferium.profiles.get(MAIN_WORLD) {
+        if let Some(ref game_version) = game_version {
+            let previous_profile = config.ferium.command()
+                .arg("profile")
+                .release_create_no_window()
+                .check("ferium profile")?
+                .stdout;
+            let mut previous_profile = String::from_utf8(previous_profile)?;
+            previous_profile.truncate(previous_profile.find(" *").ok_or(LaunchError::FeriumProfileFormat)?);
+            config.ferium.command()
+                .arg("profile")
+                .arg("switch")
+                .arg(ferium_profile)
+                .release_create_no_window()
+                .check("ferium profile switch")?;
+            let current_profile = config.ferium.command()
+                .arg("profile")
+                .release_create_no_window()
+                .check("ferium profile")?
+                .stdout;
+            config.ferium.command()
+                .arg("profile")
+                .arg("configure")
+                .arg("--game-version")
+                .arg(game_version)
+                .release_create_no_window()
+                .check("ferium profile configure --game-version")?;
+            config.ferium.command()
+                .arg("upgrade")
+                .release_create_no_window()
+                .check("ferium upgrade")?;
+            config.ferium.command()
+                .arg("profile")
+                .arg("switch")
+                .arg(previous_profile)
+                .release_create_no_window()
+                .check("ferium profile switch")?;
+            current_profile.lines().find_map(|line| line.ok().and_then(|line| line.strip_prefix("        \r  Output directory:   ").map(|dir| {
+                let mut dir = PathBuf::from(dir);
+                dir.pop();
+                dir
+            })))
+        } else {
+            None
+        }
+    } else {
+        None
+    };
+    if let Some(ref portablemc_login) = config.portablemc.login {
+        let mut cmd = Command::new("python");
+        cmd.arg("-m");
+        cmd.arg("portablemc");
+        if let Some(work_dir) = portablemc_work_dir {
+            cmd.arg("--work-dir");
+            cmd.arg(work_dir);
+        }
+        cmd.arg("start");
+        cmd.arg(format!("fabric:{}", game_version.unwrap_or_default()));
+        cmd.arg("--server=wurstmineberg.de");
+        cmd.arg("--login");
+        cmd.arg(portablemc_login);
+        cmd.release_create_no_window();
+        cmd.spawn().at_command("python -m portablemc")?;
+    } else {
+        let mut prism_command = Command::new("prismlauncher");
+        if let Some(ref instance) = config.prism_instance {
+            prism_command.arg("--show");
+            prism_command.arg(instance);
+        }
+        match prism_command.release_create_no_window().spawn() {
+            Ok(_) => {}
+            Err(e) if e.kind() == io::ErrorKind::NotFound => match Command::new("C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe")
+                .release_create_no_window()
+                .spawn()
+            {
+                Ok(_) => {}
+                Err(e) if e.kind() == io::ErrorKind::NotFound => {
+                    Command::new("explorer")
+                        .arg("shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft")
+                        .release_create_no_window()
+                        .spawn().at_command("explorer shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft")?;
+                }
+                Err(e) => return Err(e).at_command("C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe").map_err(LaunchError::from),
+            },
+            Err(e) => return Err(e).at_command("prismlauncher").map_err(LaunchError::from),
+        }
+    }
+    Ok(())
+}
+
+
 #[derive(Debug, thiserror::Error)]
 enum Error {
     #[error(transparent)] Config(#[from] config::Error),
@@ -378,6 +375,16 @@ impl IsNetworkError for Error {
     }
 }
 
+fn get_http_client() -> reqwest::Result<reqwest::Client> {
+    reqwest::Client::builder()
+        .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), " (", env!("CARGO_PKG_REPOSITORY"), ")"))
+        .timeout(Duration::from_secs(30))
+        .use_rustls_tls()
+        .https_only(true)
+        .http2_prior_knowledge()
+        .build()
+}
+
 async fn get_state(http_client: &reqwest::Client) -> Result<State, Error> {
     let people = http_client.get("https://wurstmineberg.de/api/v3/people.json")
         .send().await?
@@ -393,13 +400,7 @@ async fn get_state(http_client: &reqwest::Client) -> Result<State, Error> {
 }
 
 async fn maintain_inner(state: Arc<Mutex<Option<Result<State, Error>>>>, update_notifier: nwg::NoticeSender) -> Result<(), Error> {
-    let http_client = reqwest::Client::builder()
-        .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
-        .timeout(Duration::from_secs(30))
-        .use_rustls_tls()
-        .https_only(true)
-        .http2_prior_knowledge()
-        .build()?;
+    let http_client = get_http_client()?;
     loop {
         let config = Config::load().await?; //TODO update config field of app? (make sure to keep overrides from CLI args)
         let new_state = match get_state(&http_client).await {
@@ -447,7 +448,7 @@ async fn maintain(state: Arc<Mutex<Option<Result<State, Error>>>>, update_notifi
 }
 
 #[derive(Debug, thiserror::Error)]
-enum MainError {
+enum GuiMainError {
     #[error(transparent)] Config(#[from] config::Error),
     #[error(transparent)] Io(#[from] io::Error),
     #[error(transparent)] Nwg(#[from] nwg::NwgError),
@@ -457,11 +458,13 @@ enum MainError {
 struct Args {
     #[clap(long)]
     show_if_empty: bool,
+    #[clap(subcommand)]
+    subcommand: Option<Subcommand>,
 }
 
 impl Args {
     fn to_config(self) -> Result<Config, config::Error> {
-        let Self { show_if_empty } = self;
+        let Self { show_if_empty, subcommand: _ } = self;
         let mut config = Config::blocking_load()?;
         if show_if_empty {
             config.show_if_empty = true;
@@ -470,7 +473,12 @@ impl Args {
     }
 }
 
-fn gui_main(args: Args) -> Result<(), MainError> {
+#[derive(clap::Subcommand)]
+enum Subcommand {
+    Launch,
+}
+
+fn gui_main(args: Args) -> Result<(), GuiMainError> {
     nwg::init()?;
     let app = SystemTray::build_ui(SystemTray {
         runtime: Some(Runtime::new()?),
@@ -482,9 +490,26 @@ fn gui_main(args: Args) -> Result<(), MainError> {
     Ok(())
 }
 
+#[derive(Debug, thiserror::Error)]
+enum CliMainError {
+    #[error(transparent)] Config(#[from] config::Error),
+    #[error(transparent)] Http(#[from] reqwest::Error),
+    #[error(transparent)] Io(#[from] io::Error),
+    #[error(transparent)] Launch(#[from] LaunchError),
+    #[error(transparent)] State(#[from] Error),
+    #[error(transparent)] Utf8(#[from] std::string::FromUtf8Error),
+}
+
 #[wheel::main]
-fn main(args: Args) {
-    if let Err(e) = gui_main(args) {
-        nwg::fatal_message(concat!(env!("CARGO_PKG_NAME"), ": fatal error"), &format!("{e}\nDebug info: ctx = main, {e:?}"))
+fn main(args: Args) -> Result<(), CliMainError> {
+    match args.subcommand {
+        None => if let Err(e) = gui_main(args) {
+            nwg::fatal_message(concat!(env!("CARGO_PKG_NAME"), ": fatal error"), &format!("{e}\nDebug info: ctx = main, {e:?}"))
+        },
+        Some(Subcommand::Launch) => Runtime::new()?.block_on(async move {
+            launch_minecraft(&args.to_config()?, &Ok(get_state(&get_http_client()?).await?))?;
+            Ok::<_, CliMainError>(())
+        })?,
     }
+    Ok(())
 }