Skip to content

Commit

Permalink
Subcommand to launch Minecraft
Browse files Browse the repository at this point in the history
  • Loading branch information
fenhl committed Oct 3, 2024
1 parent f761b07 commit 5ea7389
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 120 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
257 changes: 141 additions & 116 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand All @@ -344,15 +245,111 @@ 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,
debug: String,
},
}

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),
Expand All @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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;
Expand All @@ -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()?),
Expand All @@ -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(())
}

0 comments on commit 5ea7389

Please sign in to comment.