From d1070ca2137e85b0a9111c4faedd23e53202b02b Mon Sep 17 00:00:00 2001 From: Danielle Date: Mon, 28 Mar 2022 18:41:35 -0700 Subject: [PATCH] Initial draft of profile metadata format & CLI (#17) * Initial draft of profile metadata format * Remove records, add Clippy to Nix, fix Clippy error * Work on profile definition * BREAKING: Make global settings consistent with profile settings * Add builder methods & format * Integrate launching with profiles * Add profile loading * Launching via profile, API tweaks, and yak shaving * Incremental update, committing everything due to personal system maintainance * Prepare for review cycle * Remove reminents of experimental work * CLI: allow people to override the non-empty directory check * Fix mistake in previous commit * Handle trailing whitespace and newlines in prompts * Revamp prompt to use dialoguer and support defaults * Make requested changes --- .gitignore | 6 + Cargo.lock | 176 ++++++++- flake.lock | 103 +++++ flake.nix | 66 ++++ rustfmt.toml | 2 + shell.nix | 3 +- theseus/Cargo.toml | 1 + theseus/examples/download-pack.rs | 4 +- theseus/src/data/meta.rs | 19 +- theseus/src/data/mod.rs | 5 + theseus/src/data/profiles.rs | 502 +++++++++++++++++++++++++ theseus/src/data/settings.rs | 69 +++- theseus/src/launcher/args.rs | 51 +-- theseus/src/launcher/download.rs | 137 ++++--- theseus/src/launcher/java.rs | 17 - theseus/src/launcher/mod.rs | 225 +++++------ theseus/src/lib.rs | 30 +- theseus/src/modpack/mod.rs | 6 +- theseus/src/modpack/modrinth_api.rs | 25 +- theseus/src/modpack/pack.rs | 1 + theseus_cli/Cargo.toml | 11 +- theseus_cli/src/main.rs | 27 +- theseus_cli/src/subcommands/mod.rs | 17 + theseus_cli/src/subcommands/profile.rs | 418 ++++++++++++++++++++ theseus_cli/src/util.rs | 76 ++++ theseus_gui/src-tauri/build.rs | 2 +- theseus_gui/src-tauri/src/main.rs | 160 ++++---- 27 files changed, 1825 insertions(+), 334 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rustfmt.toml create mode 100644 theseus/src/data/profiles.rs delete mode 100644 theseus/src/launcher/java.rs create mode 100644 theseus_cli/src/subcommands/mod.rs create mode 100644 theseus_cli/src/subcommands/profile.rs create mode 100644 theseus_cli/src/util.rs diff --git a/.gitignore b/.gitignore index 559433632..61d861c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ node_modules/ .svelte-kit/ theseus_gui/build/ WixTools + +[#]*[#] + +# TEMPORARY: ignore my test instance and metadata +theseus_cli/launcher +theseus_cli/foo diff --git a/Cargo.lock b/Cargo.lock index a5df52823..baf65356a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,6 +529,21 @@ dependencies = [ "cache-padded", ] +[[package]] +name = "console" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b32d32ca44b70c3e4acd7db1babf555fa026e385fb95f18028f88848b3c31" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "regex", + "terminal_size", + "unicode-width", + "winapi", +] + [[package]] name = "const_format" version = "0.2.22" @@ -847,6 +862,23 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d6b4fabcd9e97e1df1ae15395ac7e49fb144946a0d453959dc2696273b9da" +dependencies = [ + "console", + "tempfile", + "zeroize", +] + +[[package]] +name = "diff" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" + [[package]] name = "digest" version = "0.8.1" @@ -878,6 +910,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -888,6 +929,17 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.0", + "winapi", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -938,6 +990,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.29" @@ -974,6 +1032,16 @@ version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" +[[package]] +name = "eyre" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9289ed2c0440a6536e65119725cf91fc2c6b5e513bfd2e36e1134d7cca6ca12f" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fake-simd" version = "0.1.2" @@ -1694,6 +1762,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "1.7.0" @@ -1898,7 +1972,7 @@ checksum = "3dfb6b71a9a89cd38b395d994214297447e8e63b1ba5708a9a2b0b1048ceda76" dependencies = [ "cc", "chrono", - "dirs", + "dirs 1.0.5", "objc-foundation", ] @@ -2313,6 +2387,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "pango" version = "0.15.6" @@ -2338,6 +2421,21 @@ dependencies = [ "system-deps 6.0.2", ] +[[package]] +name = "papergrid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c2b5b9ea36abb56e4f8a29889e67f878ebc4ab43c918abe32e85a012d27b819" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "paris" +version = "1.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69d19a208bba8b94bd27d4b7a06ad153cddc6b88cb2149a668e23ce7bdb67d5" + [[package]] name = "parking" version = "2.0.0" @@ -2624,6 +2722,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c038cb5319b9c704bf9c227c261d275bfec0ad438118a2787ce47944fb228b" +dependencies = [ + "ansi_term", + "ctor", + "diff", + "output_vt100", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3465,6 +3575,27 @@ dependencies = [ "version-compare 0.1.0", ] +[[package]] +name = "tabled" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fda0a52a50c85604747c6584cc79cfe6c7b6ee2349ffed082f965bdbef77ef" +dependencies = [ + "papergrid", + "tabled_derive", +] + +[[package]] +name = "tabled_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a224735cbc8c30f06e52dc3891dc4b8eed07e5d4c8fb6f4cb6a839458e5a6465" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tao" version = "0.6.2" @@ -3715,6 +3846,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "theseus" version = "0.1.0" @@ -3731,6 +3872,7 @@ dependencies = [ "log", "once_cell", "path-clean", + "pretty_assertions", "regex", "reqwest", "serde", @@ -3748,9 +3890,18 @@ dependencies = [ name = "theseus_cli" version = "0.1.0" dependencies = [ + "argh", + "daedalus", + "dialoguer", + "dirs 4.0.0", + "eyre", "futures", + "paris", + "tabled", "theseus", "tokio", + "tokio-stream", + "uuid", ] [[package]] @@ -3864,6 +4015,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.6.9" @@ -4002,6 +4164,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + [[package]] name = "unicode-xid" version = "0.2.2" @@ -4628,6 +4796,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zeroize" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb5728b8afd3f280a869ce1d4c554ffaed35f45c231fc41bfbd0381bef50317" + [[package]] name = "zip" version = "0.5.13" diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..70fbe9249 --- /dev/null +++ b/flake.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1646893503, + "narHash": "sha256-N4Wn8FUXUC1h1DkL8X9I7VMvIv0fLLLjeJX3uFyzvRQ=", + "owner": "nix-community", + "repo": "fenix", + "rev": "aad7f0a3e44ecfc9e2c5f1a45387d193c1c51aa6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "naersk": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1639947939, + "narHash": "sha256-pGsM8haJadVP80GFq4xhnSpNitYNQpaXk4cnA796Cso=", + "owner": "nix-community", + "repo": "naersk", + "rev": "2fc8ce9d3c025d59fee349c1f80be9785049d653", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "naersk", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646497237, + "narHash": "sha256-Ccpot1h/rV8MgcngDp5OrdmLTMaUTbStZTR5/sI7zW0=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "062a0c5437b68f950b081bbfc8a699d57a4ee026", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "naersk": "naersk", + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1646862342, + "narHash": "sha256-zXd3qsIcQFDFMB6p8bSpkOKjTuBTvYuM4GkPYxEfQdA=", + "owner": "rust-analyzer", + "repo": "rust-analyzer", + "rev": "5b51cb835a356cf79cba00cf5c65d51cadeea7f1", + "type": "github" + }, + "original": { + "owner": "rust-analyzer", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..746b13631 --- /dev/null +++ b/flake.nix @@ -0,0 +1,66 @@ +{ + description = "The official Modrinth launcher"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + utils.url = "github:numtide/flake-utils"; + naersk = { + url = "github:nix-community/naersk"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = inputs: + inputs.utils.lib.eachDefaultSystem (system: let + pkgs = import inputs.nixpkgs { inherit system; }; + fenix = inputs.fenix.packages.${system}; + utils = inputs.utils.lib; + + toolchain = with fenix; + combine [ + minimal.rustc minimal.cargo + ]; + + naersk = inputs.naersk.lib.${system}.override { + rustc = toolchain; + cargo = toolchain; + }; + + deps = with pkgs; { + global = [ + openssl pkg-config + ]; + gui = [ + gtk4 gdk-pixbuf atk webkitgtk + ]; + shell = [ + toolchain fenix.default.clippy git + jdk17 jdk8 + ]; + }; + in { + packages = { + theseus-cli = naersk.buildPackage { + pname = "theseus_cli"; + src = ./.; + buildInputs = deps.global; + cargoBuildOptions = x: x ++ ["-p" "theseus_cli"]; + }; + }; + + apps = { + theseus-cli = utils.mkApp { + drv = inputs.self.packages.${system}.theseus-cli; + }; + }; + + devShell = pkgs.mkShell { + buildInputs = with deps; + global ++ gui ++ shell; + }; + }); +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..f5a8b8674 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2018" +max_width = 80 \ No newline at end of file diff --git a/shell.nix b/shell.nix index a6e893830..69a7fa416 100644 --- a/shell.nix +++ b/shell.nix @@ -2,6 +2,7 @@ pkgs.mkShell { buildInputs = with pkgs; [ - rustc cargo openssl pkg-config + rustc cargo clippy openssl pkg-config + gtk4 gdk-pixbuf atk webkitgtk ]; } diff --git a/theseus/Cargo.toml b/theseus/Cargo.toml index 53f4a91c1..203053fca 100644 --- a/theseus/Cargo.toml +++ b/theseus/Cargo.toml @@ -38,6 +38,7 @@ once_cell = "1.9.0" [dev-dependencies] argh = "0.1.6" +pretty_assertions = "1.1.0" [[example]] name = "download-pack" diff --git a/theseus/examples/download-pack.rs b/theseus/examples/download-pack.rs index 3488fa1f1..50cba5307 100644 --- a/theseus/examples/download-pack.rs +++ b/theseus/examples/download-pack.rs @@ -45,7 +45,9 @@ pub async fn main() { ); let start = Instant::now(); - fetch_modpack(&args.url, args.hash.as_deref(), &dest, ModpackSide::Client).await.unwrap(); + fetch_modpack(&args.url, args.hash.as_deref(), &dest, ModpackSide::Client) + .await + .unwrap(); let end = start.elapsed(); println!("Download completed in {} seconds", end.as_secs_f32()); diff --git a/theseus/src/data/meta.rs b/theseus/src/data/meta.rs index 7e2bddeac..bd253e205 100644 --- a/theseus/src/data/meta.rs +++ b/theseus/src/data/meta.rs @@ -22,7 +22,8 @@ impl Metadata { let meta_path = Path::new(LAUNCHER_WORK_DIR).join(META_FILE); if meta_path.exists() { - let meta_data = std::fs::read_to_string(meta_path).ok() + let meta_data = std::fs::read_to_string(meta_path) + .ok() .and_then(|x| serde_json::from_str::(&x).ok()); if let Some(metadata) = meta_data { @@ -77,8 +78,14 @@ impl Metadata { "{}/minecraft/v0/manifest.json", META_URL ))), - daedalus::modded::fetch_manifest(&format!("{}/forge/v0/manifest.json", META_URL)), - daedalus::modded::fetch_manifest(&format!("{}/fabric/v0/manifest.json", META_URL)), + daedalus::modded::fetch_manifest(&format!( + "{}/forge/v0/manifest.json", + META_URL + )), + daedalus::modded::fetch_manifest(&format!( + "{}/fabric/v0/manifest.json", + META_URL + )), ) .await; @@ -90,10 +97,12 @@ impl Metadata { } pub async fn get<'a>() -> Result, DataError> { - Ok(METADATA + let res = METADATA .get() .ok_or_else(|| DataError::InitializedError("metadata".to_string()))? .read() - .await) + .await; + + Ok(res) } } diff --git a/theseus/src/data/mod.rs b/theseus/src/data/mod.rs index 9308f2377..fb307d8a5 100644 --- a/theseus/src/data/mod.rs +++ b/theseus/src/data/mod.rs @@ -1,9 +1,11 @@ use std::io; pub use meta::Metadata; +pub use profiles::{Profile, Profiles}; pub use settings::Settings; mod meta; +pub mod profiles; mod settings; #[derive(thiserror::Error, Debug)] @@ -14,6 +16,9 @@ pub enum DataError { #[error("Daedalus error: {0}")] DaedalusError(#[from] daedalus::Error), + #[error("Data format error: {0}")] + FormatError(String), + #[error("Attempted to access {0} without initializing it!")] InitializedError(String), diff --git a/theseus/src/data/profiles.rs b/theseus/src/data/profiles.rs new file mode 100644 index 000000000..5f9639200 --- /dev/null +++ b/theseus/src/data/profiles.rs @@ -0,0 +1,502 @@ +use super::DataError; +use crate::launcher::ModLoader; +use daedalus::modded::LoaderVersion; +use futures::*; +use once_cell::sync::OnceCell; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::{ + fs, + process::{Child, Command}, + sync::{Mutex, RwLock, RwLockReadGuard}, +}; + +static PROFILES: OnceCell> = OnceCell::new(); +pub const PROFILE_JSON_PATH: &str = "profile.json"; + +#[derive(Debug)] +pub struct Profiles(pub HashMap); + +// TODO: possibly add defaults to some of these values +pub const CURRENT_FORMAT_VERSION: u32 = 1; +pub const SUPPORTED_ICON_FORMATS: &[&'static str] = &[ + "bmp", "gif", "jpeg", "jpg", "jpe", "png", "svg", "svgz", "webp", "rgb", + "mp4", +]; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Profile { + #[serde(skip)] + pub path: PathBuf, + pub metadata: Metadata, + #[serde(skip_serializing_if = "Option::is_none")] + pub java: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hooks: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Metadata { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub game_version: String, + #[serde(default)] + pub loader: ModLoader, + #[serde(skip_serializing_if = "Option::is_none")] + pub loader_version: Option, + pub format_version: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct JavaSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub install: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_arguments: Option>, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub struct MemorySettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + pub maximum: u32, +} + +impl Default for MemorySettings { + fn default() -> Self { + Self { + minimum: None, + maximum: 2048, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub struct WindowSize(pub u16, pub u16); + +impl Default for WindowSize { + fn default() -> Self { + Self(854, 480) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ProfileHooks { + #[serde(skip_serializing_if = "HashSet::is_empty", default)] + pub pre_launch: HashSet, + #[serde(skip_serializing_if = "Option::is_none")] + pub wrapper: Option, + #[serde(skip_serializing_if = "HashSet::is_empty", default)] + pub post_exit: HashSet, +} + +impl Default for ProfileHooks { + fn default() -> Self { + Self { + pre_launch: HashSet::::new(), + wrapper: None, + post_exit: HashSet::::new(), + } + } +} + +impl Profile { + pub async fn new( + name: String, + version: String, + path: PathBuf, + ) -> Result { + if name.trim().is_empty() { + return Err(DataError::FormatError(String::from( + "Empty name for instance!", + ))); + } + + Ok(Self { + path: path.canonicalize()?, + metadata: Metadata { + name, + icon: None, + game_version: version, + loader: ModLoader::Vanilla, + loader_version: None, + format_version: CURRENT_FORMAT_VERSION, + }, + java: None, + memory: None, + resolution: None, + hooks: None, + }) + } + + pub async fn run( + &self, + credentials: &crate::launcher::Credentials, + ) -> Result { + use crate::launcher::LauncherError; + let (settings, version_info) = tokio::try_join! { + super::Settings::get(), + super::Metadata::get() + .and_then(|manifest| async move { + let version = manifest + .minecraft + .versions + .iter() + .find(|it| it.id == self.metadata.game_version.as_ref()) + .ok_or_else(|| DataError::FormatError(format!( + "invalid or unknown version: {}", + self.metadata.game_version + )))?; + + Ok(daedalus::minecraft::fetch_version_info(version) + .await?) + }) + }?; + + let ref pre_launch_hooks = + self.hooks.as_ref().unwrap_or(&settings.hooks).pre_launch; + for hook in pre_launch_hooks.iter() { + // TODO: hook parameters + let mut cmd = hook.split(' '); + let result = Command::new(cmd.next().unwrap()) + .args(&cmd.collect::>()) + .current_dir(&self.path) + .spawn()? + .wait() + .await?; + + if !result.success() { + return Err(LauncherError::ExitError( + result.code().unwrap_or(-1), + )); + } + } + + let java_install = match self.java { + Some(JavaSettings { + install: Some(ref install), + .. + }) => install, + _ => if version_info + .java_version + .as_ref() + .filter(|it| it.major_version >= 16) + .is_some() + { + settings.java_17_path.as_ref() + } else { + settings.java_8_path.as_ref() + } + .ok_or_else(|| { + LauncherError::JavaError(format!( + "No Java installed for version {}", + version_info.java_version.map_or(8, |it| it.major_version), + )) + })?, + }; + + if !java_install.exists() { + return Err(LauncherError::JavaError(format!( + "Could not find java install: {}", + java_install.display() + ))); + } + + let java_args = &self + .java + .as_ref() + .and_then(|it| it.extra_arguments.as_ref()) + .unwrap_or(&settings.custom_java_args); + + let wrapper = self + .hooks + .as_ref() + .map_or(&settings.hooks.wrapper, |it| &it.wrapper); + + let ref memory = self.memory.unwrap_or(settings.memory); + let ref resolution = + self.resolution.unwrap_or(settings.game_resolution); + + crate::launcher::launch_minecraft( + &self.metadata.game_version, + &self.metadata.loader_version, + &self.path, + &java_install, + &java_args, + &wrapper, + memory, + resolution, + credentials, + ) + .await + } + + pub async fn kill( + &self, + running: &mut Child, + ) -> Result<(), crate::launcher::LauncherError> { + running.kill().await?; + self.wait_for(running).await + } + + pub async fn wait_for( + &self, + running: &mut Child, + ) -> Result<(), crate::launcher::LauncherError> { + let result = running.wait().await.map_err(|err| { + crate::launcher::LauncherError::ProcessError { + inner: err, + process: String::from("minecraft"), + } + })?; + + match result.success() { + false => Err(crate::launcher::LauncherError::ExitError( + result.code().unwrap_or(-1), + )), + true => Ok(()), + } + } + + // TODO: deduplicate these builder methods + // They are flat like this in order to allow builder-style usage + pub fn with_name(&mut self, name: String) -> &mut Self { + self.metadata.name = name; + self + } + + pub async fn with_icon( + &mut self, + icon: &Path, + ) -> Result<&mut Self, DataError> { + let ext = icon + .extension() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or(""); + + if SUPPORTED_ICON_FORMATS.contains(&ext) { + let file_name = format!("icon.{ext}"); + fs::copy(icon, &self.path.join(&file_name)).await?; + self.metadata.icon = + Some(Path::new(&format!("./{file_name}")).to_owned()); + + Ok(self) + } else { + Err(DataError::FormatError(format!( + "Unsupported image type: {ext}" + ))) + } + } + + pub fn with_game_version(&mut self, version: String) -> &mut Self { + self.metadata.game_version = version; + self + } + + pub fn with_loader( + &mut self, + loader: ModLoader, + version: Option, + ) -> &mut Self { + self.metadata.loader = loader; + self.metadata.loader_version = version; + self + } + + pub fn with_java_settings( + &mut self, + settings: Option, + ) -> &mut Self { + self.java = settings; + self + } + + pub fn with_memory( + &mut self, + settings: Option, + ) -> &mut Self { + self.memory = settings; + self + } + + pub fn with_resolution( + &mut self, + resolution: Option, + ) -> &mut Self { + self.resolution = resolution; + self + } + + pub fn with_hooks(&mut self, hooks: Option) -> &mut Self { + self.hooks = hooks; + self + } +} + +impl Profiles { + pub async fn init() -> Result<(), DataError> { + let settings = super::Settings::get().await?; + let profiles = Arc::new(Mutex::new(HashMap::new())); + + let futures = settings.profiles.clone().into_iter().map(|path| async { + let profiles = Arc::clone(&profiles); + tokio::spawn(async move { + // TODO: handle missing profiles + let mut profiles = profiles.lock().await; + let profile = Self::read_profile_from_dir(path.clone()).await?; + + profiles.insert(path, profile); + Ok(()) as Result<_, DataError> + }) + .await + .unwrap() + }); + futures::future::try_join_all(futures).await?; + + PROFILES.get_or_init(|| { + RwLock::new(Profiles( + Arc::try_unwrap(profiles).unwrap().into_inner(), + )) + }); + Ok(()) + } + + pub async fn insert(profile: Profile) -> Result<(), DataError> { + let mut profiles = PROFILES + .get() + .ok_or_else(|| { + DataError::InitializedError(String::from("profiles")) + })? + .write() + .await; + + super::Settings::get_mut() + .await? + .profiles + .insert(profile.path.clone()); + profiles.0.insert(profile.path.clone(), profile); + Ok(()) + } + + pub async fn insert_from(path: PathBuf) -> Result<(), DataError> { + Self::read_profile_from_dir(path) + .and_then(Self::insert) + .await + } + + pub async fn remove(path: &Path) -> Result, DataError> { + let path = path.canonicalize()?; + let mut profiles = PROFILES.get().unwrap().write().await; + super::Settings::get_mut().await?.profiles.remove(&path); + Ok(profiles.0.remove(&path)) + } + + pub async fn save() -> Result<(), DataError> { + let profiles = Self::get().await?; + + let futures = profiles.0.clone().into_iter().map(|(path, profile)| { + tokio::spawn(async move { + let json = tokio::task::spawn_blocking(move || { + serde_json::to_vec_pretty(&profile) + }) + .await + .unwrap()?; + + let profile_json_path = path.join(PROFILE_JSON_PATH); + fs::write(profile_json_path, json).await?; + Ok(()) as Result<(), DataError> + }) + }); + futures::future::try_join_all(futures) + .await + .unwrap() + .into_iter() + .collect::>()?; + + Ok(()) + } + + pub async fn get<'a>() -> Result, DataError> { + Ok(PROFILES + .get() + .ok_or_else(|| DataError::InitializedError("profiles".to_string()))? + .read() + .await) + } + + async fn read_profile_from_dir( + path: PathBuf, + ) -> Result { + let json = fs::read(path.join(PROFILE_JSON_PATH)).await?; + let mut profile = serde_json::from_slice::(&json)?; + profile.path = path.clone(); + Ok(profile) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn profile_test() -> Result<(), serde_json::Error> { + let profile = Profile { + path: PathBuf::from("/tmp/nunya/beeswax"), + metadata: Metadata { + name: String::from("Example Pack"), + icon: None, + game_version: String::from("1.18.2"), + loader: ModLoader::Vanilla, + loader_version: None, + format_version: CURRENT_FORMAT_VERSION, + }, + java: JavaSettings { + install: PathBuf::from("/usr/bin/java"), + extra_arguments: Vec::new(), + }, + memory: MemorySettings { + minimum: None, + maximum: 8192, + }, + resolution: WindowSize(1920, 1080), + hooks: ProfileHooks { + pre_launch: HashSet::new(), + wrapper: None, + post_exit: HashSet::new(), + }, + }; + let json = serde_json::json!({ + "path": "/tmp/nunya/beeswax", + "metadata": { + "name": "Example Pack", + "game_version": "1.18.2", + "format_version": 1u32, + }, + "java": { + "install": "/usr/bin/java", + }, + "memory": { + "maximum": 8192u32, + }, + "resolution": (1920u16, 1080u16), + "hooks": {}, + }); + + assert_eq!(serde_json::to_value(profile.clone())?, json.clone()); + assert_str_eq!( + format!("{:?}", serde_json::from_value::(json)?), + format!("{:?}", profile), + ); + Ok(()) + } +} diff --git a/theseus/src/data/settings.rs b/theseus/src/data/settings.rs index 05a24f8ed..ec0643b2b 100644 --- a/theseus/src/data/settings.rs +++ b/theseus/src/data/settings.rs @@ -1,33 +1,51 @@ -use std::path::Path; +use super::profiles::*; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; use crate::{data::DataError, LAUNCHER_WORK_DIR}; use once_cell::sync; use serde::{Deserialize, Serialize}; -use tokio::sync::{RwLock, RwLockReadGuard}; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; const SETTINGS_FILE: &str = "settings.json"; +const ICONS_PATH: &str = "icons"; +const METADATA_DIR: &str = "meta"; static SETTINGS: sync::OnceCell> = sync::OnceCell::new(); +pub const FORMAT_VERSION: u32 = 1; #[derive(Clone, Serialize, Deserialize, Debug)] +#[serde(default)] pub struct Settings { - pub memory: i32, - pub game_resolution: (i32, i32), - pub custom_java_args: String, - pub java_8_path: Option, - pub java_17_path: Option, - pub wrapper_command: Option, + pub memory: MemorySettings, + pub game_resolution: WindowSize, + pub custom_java_args: Vec, + pub java_8_path: Option, + pub java_17_path: Option, + pub hooks: ProfileHooks, + pub icon_path: PathBuf, + pub metadata_dir: PathBuf, + pub profiles: HashSet, + pub max_concurrent_downloads: usize, + pub version: u32, } impl Default for Settings { fn default() -> Self { Self { - memory: 2048, - game_resolution: (854, 480), - custom_java_args: "".to_string(), + memory: MemorySettings::default(), + game_resolution: WindowSize::default(), + custom_java_args: Vec::new(), java_8_path: None, java_17_path: None, - wrapper_command: None, + hooks: ProfileHooks::default(), + icon_path: Path::new(LAUNCHER_WORK_DIR).join(ICONS_PATH), + metadata_dir: Path::new(LAUNCHER_WORK_DIR).join(METADATA_DIR), + profiles: HashSet::new(), + max_concurrent_downloads: 32, + version: FORMAT_VERSION, } } } @@ -50,10 +68,14 @@ impl Settings { if SETTINGS.get().is_none() { let new = Self::default(); - std::fs::write( + tokio::fs::rename(SETTINGS_FILE, format!("{SETTINGS_FILE}.bak")) + .await?; + + tokio::fs::write( Path::new(LAUNCHER_WORK_DIR).join(SETTINGS_FILE), &serde_json::to_string(&new)?, - )?; + ) + .await?; SETTINGS.get_or_init(|| RwLock::new(new)); } @@ -66,7 +88,7 @@ impl Settings { Path::new(LAUNCHER_WORK_DIR).join(SETTINGS_FILE), )?)?; - let write = &mut *SETTINGS + let mut write = SETTINGS .get() .ok_or_else(|| DataError::InitializedError("settings".to_string()))? .write() @@ -82,17 +104,24 @@ impl Settings { std::fs::write( Path::new(LAUNCHER_WORK_DIR).join(SETTINGS_FILE), - &serde_json::to_string(&*settings)?, + &serde_json::to_string_pretty(&*settings)?, )?; Ok(()) } pub async fn get<'a>() -> Result, DataError> { - Ok(SETTINGS + Ok(Self::get_or_uninit::<'a>()?.read().await) + } + + pub async fn get_mut<'a>() -> Result, DataError> + { + Ok(Self::get_or_uninit::<'a>()?.write().await) + } + + fn get_or_uninit<'a>() -> Result<&'a RwLock, DataError> { + SETTINGS .get() - .ok_or_else(|| DataError::InitializedError("settings".to_string()))? - .read() - .await) + .ok_or_else(|| DataError::InitializedError("settings".to_string())) } } diff --git a/theseus/src/launcher/args.rs b/theseus/src/launcher/args.rs index 65dc3feba..1a444d540 100644 --- a/theseus/src/launcher/args.rs +++ b/theseus/src/launcher/args.rs @@ -1,3 +1,4 @@ +use crate::data::profiles::*; use crate::launcher::auth::provider::Credentials; use crate::launcher::rules::parse_rules; use crate::launcher::LauncherError; @@ -21,19 +22,22 @@ pub fn get_class_paths( libraries: &[Library], client_path: &Path, ) -> Result { - let mut class_paths = libraries.iter().filter_map(|library| { - if let Some(rules) = &library.rules { - if !super::rules::parse_rules(rules.as_slice()) { + let mut class_paths = libraries + .iter() + .filter_map(|library| { + if let Some(rules) = &library.rules { + if !super::rules::parse_rules(rules.as_slice()) { + return None; + } + } + + if !library.include_in_classpath { return None; } - } - if !library.include_in_classpath { - return None; - } - - Some(get_lib_path(libraries_path, &library.name)) - }).collect::, _>>()?; + Some(get_lib_path(libraries_path, &library.name)) + }) + .collect::, _>>()?; class_paths.push( crate::util::absolute_path(&client_path) @@ -54,9 +58,10 @@ pub fn get_class_paths_jar>( libraries_path: &Path, libraries: &[T], ) -> Result { - let class_paths = libraries.iter().map(|library| { - get_lib_path(libraries_path, library.as_ref()) - }).collect::, _>>()?; + let class_paths = libraries + .iter() + .map(|library| get_lib_path(libraries_path, library.as_ref())) + .collect::, _>>()?; Ok(class_paths.join(get_cp_separator())) } @@ -90,7 +95,7 @@ pub fn get_jvm_arguments( libraries_path: &Path, class_paths: &str, version_name: &str, - memory: i32, + memory: MemorySettings, custom_args: Vec, ) -> Result, LauncherError> { let mut parsed_arguments = Vec::new(); @@ -120,7 +125,10 @@ pub fn get_jvm_arguments( parsed_arguments.push(class_paths.to_string()); } - parsed_arguments.push(format!("-Xmx{}M", memory)); + if let Some(minimum) = memory.minimum { + parsed_arguments.push(format!("-Xms{minimum}M")); + } + parsed_arguments.push(format!("-Xmx{}M", memory.maximum)); for arg in custom_args { if !arg.is_empty() { parsed_arguments.push(arg); @@ -148,8 +156,7 @@ fn parse_jvm_argument( natives_path.to_string_lossy() )) })? - .to_string_lossy() - .to_string(), + .to_string_lossy(), ) .replace( "${library_directory}", @@ -180,7 +187,7 @@ pub fn get_minecraft_arguments( game_directory: &Path, assets_directory: &Path, version_type: &VersionType, - resolution: (i32, i32), + resolution: WindowSize, ) -> Result, LauncherError> { if let Some(arguments) = arguments { let mut parsed_arguments = Vec::new(); @@ -234,7 +241,7 @@ fn parse_minecraft_argument( game_directory: &Path, assets_directory: &Path, version_type: &VersionType, - resolution: (i32, i32), + resolution: WindowSize, ) -> Result { Ok(argument .replace("${auth_access_token}", access_token) @@ -255,7 +262,7 @@ fn parse_minecraft_argument( )) })? .to_string_lossy() - .to_string(), + .to_owned(), ) .replace( "${assets_root}", @@ -267,7 +274,7 @@ fn parse_minecraft_argument( )) })? .to_string_lossy() - .to_string(), + .to_owned(), ) .replace( "${game_assets}", @@ -279,7 +286,7 @@ fn parse_minecraft_argument( )) })? .to_string_lossy() - .to_string(), + .to_owned(), ) .replace("${version_type}", version_type.as_str()) .replace("${resolution_width}", &resolution.0.to_string()) diff --git a/theseus/src/launcher/download.rs b/theseus/src/launcher/download.rs index 2cd76191d..0e0794e3c 100644 --- a/theseus/src/launcher/download.rs +++ b/theseus/src/launcher/download.rs @@ -1,15 +1,38 @@ -use crate::launcher::LauncherError; +use crate::{ + data::{DataError, Settings}, + launcher::LauncherError, +}; use daedalus::get_path_from_artifact; use daedalus::minecraft::{ - fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType, Library, Os, Version, - VersionInfo, + fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType, + Library, Os, Version, VersionInfo, +}; +use daedalus::modded::{ + fetch_partial_version, merge_partial_version, LoaderVersion, }; -use daedalus::modded::{fetch_partial_version, merge_partial_version, LoaderVersion}; use futures::future; -use std::fs::File; -use std::io::Write; use std::path::Path; use std::time::Duration; +use tokio::{ + fs::File, + io::AsyncWriteExt, + sync::{OnceCell, Semaphore}, +}; + +static DOWNLOADS_SEMAPHORE: OnceCell = OnceCell::const_new(); + +pub async fn init() -> Result<(), DataError> { + DOWNLOADS_SEMAPHORE + .get_or_try_init(|| async { + let settings = Settings::get().await?; + Ok::<_, DataError>(Semaphore::new( + settings.max_concurrent_downloads, + )) + }) + .await?; + + Ok(()) +} pub async fn download_version_info( client_path: &Path, @@ -22,8 +45,7 @@ pub async fn download_version_info( }; let mut path = client_path.join(id); - path.push(id); - path.set_extension("json"); + path.push(&format!("{id}.json")); if path.exists() { let contents = std::fs::read_to_string(path)?; @@ -37,7 +59,7 @@ pub async fn download_version_info( info.id = loader_version.id.clone(); } let info_s = serde_json::to_string(&info)?; - save_file(&path, &bytes::Bytes::from(info_s))?; + save_file(&path, &bytes::Bytes::from(info_s)).await?; Ok(info) } @@ -58,10 +80,14 @@ pub async fn download_client( })?; let mut path = client_path.join(version); - path.push(version); - path.set_extension("jar"); + path.push(&format!("{version}.jar")); - save_and_download_file(&path, &client_download.url, Some(&client_download.sha1)).await?; + save_and_download_file( + &path, + &client_download.url, + Some(&client_download.sha1), + ) + .await?; Ok(()) } @@ -69,7 +95,8 @@ pub async fn download_assets_index( assets_path: &Path, version: &VersionInfo, ) -> Result { - let path = assets_path.join(format!("indexes/{}.json", &version.asset_index.id)); + let path = + assets_path.join(format!("indexes/{}.json", &version.asset_index.id)); if path.exists() { let content = std::fs::read_to_string(path)?; @@ -77,7 +104,8 @@ pub async fn download_assets_index( } else { let index = fetch_assets_index(version).await?; - save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?))?; + save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?)) + .await?; Ok(index) } @@ -88,12 +116,9 @@ pub async fn download_assets( legacy_path: Option<&Path>, index: &AssetsIndex, ) -> Result<(), LauncherError> { - future::join_all( - index - .objects - .iter() - .map(|(name, asset)| download_asset(assets_path, legacy_path, name, asset)), - ) + future::join_all(index.objects.iter().map(|(name, asset)| { + download_asset(assets_path, legacy_path, name, asset) + })) .await .into_iter() .collect::, LauncherError>>()?; @@ -114,14 +139,16 @@ async fn download_asset( resource_path.push(sub_hash); resource_path.push(hash); - let url = format!("https://resources.download.minecraft.net/{sub_hash}/{hash}"); + let url = + format!("https://resources.download.minecraft.net/{sub_hash}/{hash}"); - let resource = save_and_download_file(&resource_path, &url, Some(hash)).await?; + let resource = + save_and_download_file(&resource_path, &url, Some(hash)).await?; if let Some(legacy_path) = legacy_path { - let resource_path = - legacy_path.join(name.replace('/', &std::path::MAIN_SEPARATOR.to_string())); - save_file(resource_path.as_path(), &resource)?; + let resource_path = legacy_path + .join(name.replace('/', &std::path::MAIN_SEPARATOR.to_string())); + save_file(resource_path.as_path(), &resource).await?; } Ok(()) @@ -132,11 +159,9 @@ pub async fn download_libraries( natives_path: &Path, libraries: &[Library], ) -> Result<(), LauncherError> { - future::join_all( - libraries - .iter() - .map(|library| download_library(libraries_path, natives_path, library)), - ) + future::join_all(libraries.iter().map(|library| { + download_library(libraries_path, natives_path, library) + })) .await .into_iter() .collect::, LauncherError>>()?; @@ -173,7 +198,8 @@ async fn download_library_jar( if let Some(downloads) = &library.downloads { if let Some(library) = &downloads.artifact { - save_and_download_file(&path, &library.url, Some(&library.sha1)).await?; + save_and_download_file(&path, &library.url, Some(&library.sha1)) + .await?; } } else { let url = format!( @@ -189,16 +215,21 @@ async fn download_library_jar( Ok(()) } -async fn download_native(natives_path: &Path, library: &Library) -> Result<(), LauncherError> { +async fn download_native( + natives_path: &Path, + library: &Library, +) -> Result<(), LauncherError> { use daedalus::minecraft::LibraryDownload; use std::collections::HashMap; // Try blocks in stable Rust when? - let optional_cascade = || -> Option<(&String, &HashMap)> { - let os_key = library.natives.as_ref()?.get(&get_os())?; - let classifiers = library.downloads.as_ref()?.classifiers.as_ref()?; - Some((os_key, classifiers)) - }; + let optional_cascade = + || -> Option<(&String, &HashMap)> { + let os_key = library.natives.as_ref()?.get(&get_os())?; + let classifiers = + library.downloads.as_ref()?.classifiers.as_ref()?; + Some((os_key, classifiers)) + }; if let Some((os_key, classifiers)) = optional_cascade() { #[cfg(target_pointer_width = "64")] @@ -227,19 +258,25 @@ async fn save_and_download_file( Ok(bytes) => Ok(bytes::Bytes::from(bytes)), Err(_) => { let file = download_file(url, sha1).await?; - save_file(path, &file)?; + save_file(path, &file).await?; Ok(file) } } } -fn save_file(path: &Path, bytes: &bytes::Bytes) -> std::io::Result<()> { +async fn save_file(path: &Path, bytes: &bytes::Bytes) -> std::io::Result<()> { + let _save_permit = DOWNLOADS_SEMAPHORE + .get() + .expect("File operation semaphore not initialized!") + .acquire() + .await + .unwrap(); if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + tokio::fs::create_dir_all(parent).await?; } - let mut file = File::create(path)?; - file.write_all(bytes)?; + let mut file = File::create(path).await?; + file.write_all(bytes).await?; Ok(()) } @@ -252,7 +289,17 @@ pub fn get_os() -> Os { } } -pub async fn download_file(url: &str, sha1: Option<&str>) -> Result { +pub async fn download_file( + url: &str, + sha1: Option<&str>, +) -> Result { + let _download_permit = DOWNLOADS_SEMAPHORE + .get() + .expect("File operation semaphore not initialized!") + .acquire() + .await + .unwrap(); + let client = reqwest::Client::builder() .tcp_keepalive(Some(Duration::from_secs(10))) .build() @@ -307,7 +354,9 @@ pub async fn download_file(url: &str, sha1: Option<&str>) -> Result Result { - let hash = tokio::task::spawn_blocking(|| sha1::Sha1::from(bytes).hexdigest()).await?; + let hash = + tokio::task::spawn_blocking(|| sha1::Sha1::from(bytes).hexdigest()) + .await?; Ok(hash) } diff --git a/theseus/src/launcher/java.rs b/theseus/src/launcher/java.rs deleted file mode 100644 index e703b17f3..000000000 --- a/theseus/src/launcher/java.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::launcher::LauncherError; -use std::process::Command; - - -pub fn check_java() -> Result { - let child = Command::new("java") - .arg("-version") - .output() - .map_err(|inner| LauncherError::ProcessError { - inner, - process: "java".into(), - })?; - - let output = String::from_utf8_lossy(&child.stderr); - let output = output.trim_matches('\"'); - Ok(output.into()) -} diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 6e2650d4e..2d54c5339 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -1,20 +1,22 @@ use daedalus::minecraft::{ArgumentType, VersionInfo}; +use daedalus::modded::LoaderVersion; use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::process::{Command, Stdio}; +use std::{path::Path, process::Stdio}; use thiserror::Error; +use tokio::process::{Child, Command}; pub use crate::launcher::auth::provider::Credentials; mod args; -mod auth; +pub mod auth; mod download; -mod java; mod rules; +pub(crate) use download::init as init_download_semaphore; + #[derive(Error, Debug)] pub enum LauncherError { - #[error("Failed to violate file checksum at url {url} with hash {hash} after {tries} tries")] + #[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")] ChecksumFailure { hash: String, url: String, @@ -56,8 +58,12 @@ pub enum LauncherError { #[error("Java error: {0}")] JavaError(String), + + #[error("Command exited with non-zero exit code: {0}")] + ExitError(i32), } +// TODO: this probably should be in crate::data #[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum ModLoader { @@ -72,80 +78,64 @@ impl Default for ModLoader { } } -pub async fn launch_minecraft( - version_name: &str, - mod_loader: Option, - root_dir: &Path, - credentials: &Credentials, -) -> Result<(), LauncherError> { - let metadata = crate::data::Metadata::get().await?; - let settings = crate::data::Settings::get().await?; +impl std::fmt::Display for ModLoader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let repr = match self { + &Self::Vanilla => "Vanilla", + &Self::Forge => "Forge", + &Self::Fabric => "Fabric", + }; - let versions_path = crate::util::absolute_path(root_dir.join("versions"))?; - let libraries_path = crate::util::absolute_path(root_dir.join("libraries"))?; - let assets_path = crate::util::absolute_path(root_dir.join("assets"))?; - let legacy_assets_path = crate::util::absolute_path(root_dir.join("resources"))?; + f.write_str(repr) + } +} + +pub async fn launch_minecraft( + game_version: &str, + loader_version: &Option, + root_dir: &Path, + java: &Path, + java_args: &Vec, + wrapper: &Option, + memory: &crate::data::profiles::MemorySettings, + resolution: &crate::data::profiles::WindowSize, + credentials: &Credentials, +) -> Result { + let (metadata, settings) = futures::try_join! { + crate::data::Metadata::get(), + crate::data::Settings::get(), + }?; + let root_dir = root_dir.canonicalize()?; + let metadata_dir = &settings.metadata_dir; + + let ( + versions_path, + libraries_path, + assets_path, + legacy_assets_path, + natives_path, + ) = ( + metadata_dir.join("versions"), + metadata_dir.join("libraries"), + metadata_dir.join("assets"), + metadata_dir.join("resources"), + metadata_dir.join("natives"), + ); let version = metadata .minecraft .versions .iter() - .find(|x| x.id == version_name) + .find(|it| it.id == game_version) .ok_or_else(|| { - LauncherError::InvalidInput(format!("Version {} does not exist", version_name)) + LauncherError::InvalidInput(format!( + "Invalid game version: {game_version}", + )) })?; - let loader_version = match mod_loader.unwrap_or_default() { - ModLoader::Vanilla => None, - ModLoader::Forge | ModLoader::Fabric => { - let loaders = if mod_loader.unwrap_or_default() == ModLoader::Forge { - &metadata - .forge - .game_versions - .iter() - .find(|x| x.id == version_name) - .ok_or_else(|| { - LauncherError::InvalidInput(format!( - "Version {} for mod loader Forge does not exist", - version_name - )) - })? - .loaders - } else { - &metadata - .fabric - .game_versions - .iter() - .find(|x| x.id == version_name) - .ok_or_else(|| { - LauncherError::InvalidInput(format!( - "Version {} for mod loader Fabric does not exist", - version_name - )) - })? - .loaders - }; - - let loader = if let Some(version) = loaders.iter().find(|x| x.stable) { - Some(version.clone()) - } else { - loaders.first().cloned() - }; - - Some(loader.ok_or_else(|| { - LauncherError::InvalidInput(format!( - "No mod loader version found for version {}", - version_name - )) - })?) - } - }; - - let version_jar_name = if let Some(loader) = &loader_version { - loader.id.clone() - } else { - version.id.clone() - }; + let version_jar = loader_version + .as_ref() + .map_or(version.id.clone(), |it| it.id.clone()); let mut version = download::download_version_info( &versions_path, @@ -154,23 +144,10 @@ pub async fn launch_minecraft( ) .await?; - let java_path = if let Some(java) = &version.java_version { - if java.major_version == 17 || java.major_version == 16 { - settings.java_17_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 17 or select your Java 17 installation settings before launching this version!".to_string()))? - } else { - &settings.java_8_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 8 or select your Java 8 installation settings before launching this version!".to_string()))? - } - } else { - &settings.java_8_path.as_deref().ok_or_else(|| LauncherError::JavaError("Please install Java 8 or select your Java 8 installation settings before launching this version!".to_string()))? - }; - - let client_path = crate::util::absolute_path( - root_dir - .join("versions") - .join(&version.id) - .join(format!("{}.jar", &version.id)), - )?; - let natives_path = crate::util::absolute_path(root_dir.join("natives").join(&version.id))?; + let client_path = versions_path + .join(&version.id) + .join(format!("{}.jar", &version_jar)); + let version_natives_path = natives_path.join(&version.id); download_minecraft( &version, @@ -178,7 +155,7 @@ pub async fn launch_minecraft( &assets_path, &legacy_assets_path, &libraries_path, - &natives_path, + &version_natives_path, ) .await?; @@ -201,7 +178,7 @@ pub async fn launch_minecraft( data.insert( "MINECRAFT_VERSION".to_string(), daedalus::modded::SidedDataEntry { - client: version_name.to_string(), + client: game_version.to_string(), server: "".to_string(), }, ); @@ -252,6 +229,7 @@ pub async fn launch_minecraft( data, )?) .output() + .await .map_err(|err| LauncherError::ProcessError { inner: err, process: "java".to_string(), @@ -266,60 +244,50 @@ pub async fn launch_minecraft( } } - let arguments = version.arguments.unwrap_or_default(); - - let mut command = Command::new(if let Some(wrapper) = &settings.wrapper_command { - wrapper.clone() - } else { - java_path.to_string() - }); - - if settings.wrapper_command.is_some() { - command.arg(java_path); - } + let arguments = version.arguments.clone().unwrap_or_default(); + let mut command = match wrapper { + Some(hook) => { + let mut cmd = Command::new(hook); + cmd.arg(java); + cmd + } + None => Command::new(java.to_string_lossy().to_string()), + }; command .args(args::get_jvm_arguments( arguments.get(&ArgumentType::Jvm).map(|x| x.as_slice()), - &natives_path, + &version_natives_path, &libraries_path, - &args::get_class_paths(&libraries_path, version.libraries.as_slice(), &client_path)?, - &version_jar_name, - settings.memory, - settings - .custom_java_args - .split(" ") - .into_iter() - .map(|x| x.to_string()) - .collect(), + &args::get_class_paths( + &libraries_path, + version.libraries.as_slice(), + &client_path, + )?, + &version_jar, + *memory, + java_args.clone(), )?) - .arg(version.main_class) + .arg(version.main_class.clone()) .args(args::get_minecraft_arguments( arguments.get(&ArgumentType::Game).map(|x| x.as_slice()), version.minecraft_arguments.as_deref(), credentials, &version.id, &version.asset_index.id, - root_dir, + &root_dir, &assets_path, &version.type_, - settings.game_resolution, + *resolution, )?) - .current_dir(root_dir) + .current_dir(root_dir.clone()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - let mut child = command.spawn().map_err(|err| LauncherError::ProcessError { + command.spawn().map_err(|err| LauncherError::ProcessError { inner: err, - process: "minecraft".to_string(), - })?; - - child.wait().map_err(|err| LauncherError::ProcessError { - inner: err, - process: "minecraft".to_string(), - })?; - - Ok(()) + process: format!("minecraft-{} @ {}", &version.id, root_dir.display()), + }) } pub async fn download_minecraft( @@ -330,7 +298,8 @@ pub async fn download_minecraft( libraries_dir: &Path, natives_dir: &Path, ) -> Result<(), LauncherError> { - let assets_index = download::download_assets_index(assets_dir, version).await?; + let assets_index = + download::download_assets_index(assets_dir, version).await?; let (a, b, c) = futures::future::join3( download::download_client(versions_dir, version), @@ -343,7 +312,11 @@ pub async fn download_minecraft( }, &assets_index, ), - download::download_libraries(libraries_dir, natives_dir, version.libraries.as_slice()), + download::download_libraries( + libraries_dir, + natives_dir, + version.libraries.as_slice(), + ), ) .await; diff --git a/theseus/src/lib.rs b/theseus/src/lib.rs index 412566204..5d0528c22 100644 --- a/theseus/src/lib.rs +++ b/theseus/src/lib.rs @@ -3,11 +3,11 @@ //! Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs, //! and launching Modrinth mod packs -#![warn(missing_docs, unused_import_braces, missing_debug_implementations)] +#![warn(unused_import_braces, missing_debug_implementations)] static LAUNCHER_WORK_DIR: &'static str = "./launcher"; -mod data; +pub mod data; pub mod launcher; pub mod modpack; mod util; @@ -25,9 +25,29 @@ pub enum Error { } pub async fn init() -> Result<(), Error> { - std::fs::create_dir_all(LAUNCHER_WORK_DIR).expect("Unable to create launcher root directory!"); - crate::data::Metadata::init().await?; - crate::data::Settings::init().await?; + std::fs::create_dir_all(LAUNCHER_WORK_DIR) + .expect("Unable to create launcher root directory!"); + + use crate::data::*; + Metadata::init().await?; + + Settings::init().await?; + + tokio::try_join! { + launcher::init_download_semaphore(), + Profiles::init(), + }?; + + Ok(()) +} + +pub async fn save() -> Result<(), Error> { + use crate::data::*; + + tokio::try_join! { + Settings::save(), + Profiles::save(), + }?; Ok(()) } diff --git a/theseus/src/modpack/mod.rs b/theseus/src/modpack/mod.rs index 74842c64c..c9655d34a 100644 --- a/theseus/src/modpack/mod.rs +++ b/theseus/src/modpack/mod.rs @@ -22,10 +22,12 @@ pub const COMPILED_ZIP: &str = "compiled.mrpack"; pub const MANIFEST_PATH: &str = "modrinth.index.json"; pub const OVERRIDES_PATH: &str = "overrides/"; pub const PACK_JSON5_PATH: &str = "modpack.json5"; -const PACK_GITIGNORE: &'static str = const_format::formatcp!(r#" +const PACK_GITIGNORE: &'static str = const_format::formatcp!( + r#" {COMPILED_PATH} {COMPILED_ZIP} -"#); +"# +); #[derive(thiserror::Error, Debug)] pub enum ModpackError { diff --git a/theseus/src/modpack/modrinth_api.rs b/theseus/src/modpack/modrinth_api.rs index a99fd9181..17013c649 100644 --- a/theseus/src/modpack/modrinth_api.rs +++ b/theseus/src/modpack/modrinth_api.rs @@ -21,7 +21,10 @@ pub trait ModrinthAPI { channel: &str, game: &ModpackGame, ) -> ModpackResult>; - async fn get_version(&self, version: &str) -> ModpackResult>; + async fn get_version( + &self, + version: &str, + ) -> ModpackResult>; } #[derive(Debug)] @@ -93,6 +96,8 @@ impl ModrinthAPI for ModrinthV1 { String::from("Modrinth V1 does not support vanilla projects"), )), ModpackGame::Minecraft(ref version, ref loader) => Ok((version, loader)), + // This guard is here for when Modrinth does support other games. + #[allow(unreachable_patterns)] _ => Err(ModpackError::VersionError(String::from( "Attempted to use Modrinth API V1 to install a non-Minecraft project!", ))), @@ -131,7 +136,8 @@ impl ModrinthAPI for ModrinthV1 { .map(ModpackFile::from) .collect::>(); - let dep_futures = version.dependencies.iter().map(|it| self.get_version(it)); + let dep_futures = + version.dependencies.iter().map(|it| self.get_version(it)); let deps = try_join_all(dep_futures) .await? .into_iter() @@ -148,12 +154,17 @@ impl ModrinthAPI for ModrinthV1 { .collect()) } - async fn get_version(&self, version: &str) -> ModpackResult> { + async fn get_version( + &self, + version: &str, + ) -> ModpackResult> { let domain = &self.0; - let version_json = try_get_json(format!("{domain}/api/v1/version/{version}")).await?; - let mut version_deserializer = serde_json::Deserializer::from_slice(&version_json); - let version = ModrinthV1ProjectVersion::deserialize(&mut version_deserializer)?; - let base_path = PathBuf::from("mods/"); + let version_json = + try_get_json(format!("{domain}/api/v1/version/{version}")).await?; + let mut version_deserializer = + serde_json::Deserializer::from_slice(&version_json); + let version = + ModrinthV1ProjectVersion::deserialize(&mut version_deserializer)?; Ok(version .files diff --git a/theseus/src/modpack/pack.rs b/theseus/src/modpack/pack.rs index 9e39acf95..2209f9a38 100644 --- a/theseus/src/modpack/pack.rs +++ b/theseus/src/modpack/pack.rs @@ -159,6 +159,7 @@ pub struct ModpackFile { pub downloads: HashSet, } +#[allow(clippy::derive_hash_xor_eq)] impl Hash for ModpackFile { fn hash(&self, state: &mut H) { if let Some(ref hashes) = self.hashes { diff --git a/theseus_cli/Cargo.toml b/theseus_cli/Cargo.toml index d979fb15c..e0ee01dba 100644 --- a/theseus_cli/Cargo.toml +++ b/theseus_cli/Cargo.toml @@ -8,5 +8,14 @@ edition = "2018" [dependencies] theseus = { path = "../theseus" } +daedalus = "0.1.12" tokio = { version = "1", features = ["full"] } -futures = "0.3" \ No newline at end of file +tokio-stream = { version = "0.1", features = ["fs"] } +futures = "0.3" +argh = "0.1" +paris = { version = "1.5", features = ["macros", "no_logger"] } +dialoguer = "0.10" +eyre = "0.6" +tabled = "0.5" +dirs = "4.0" +uuid = {version = "0.8", features = ["v4", "serde"]} \ No newline at end of file diff --git a/theseus_cli/src/main.rs b/theseus_cli/src/main.rs index 7f755fb76..c98db7e7f 100644 --- a/theseus_cli/src/main.rs +++ b/theseus_cli/src/main.rs @@ -1,2 +1,27 @@ +use eyre::Result; +use paris::*; + +mod subcommands; +mod util; + +#[derive(argh::FromArgs)] +/// The official Modrinth CLI +pub struct Args { + #[argh(subcommand)] + pub subcommand: subcommands::SubCommand, +} + #[tokio::main] -async fn main() {} +async fn main() -> Result<()> { + let args = argh::from_env::(); + theseus::init().await?; + + let res = args.dispatch().await; + if res.is_err() { + error!("An error has occurred!\n"); + } else { + theseus::save().await?; + } + + res +} diff --git a/theseus_cli/src/subcommands/mod.rs b/theseus_cli/src/subcommands/mod.rs new file mode 100644 index 000000000..573e20f6d --- /dev/null +++ b/theseus_cli/src/subcommands/mod.rs @@ -0,0 +1,17 @@ +use eyre::Result; + +mod profile; + +#[derive(argh::FromArgs)] +#[argh(subcommand)] +pub enum SubCommand { + Profile(profile::ProfileCommand), +} + +impl crate::Args { + pub async fn dispatch(&self) -> Result<()> { + match self.subcommand { + SubCommand::Profile(ref cmd) => cmd.dispatch(self).await, + } + } +} diff --git a/theseus_cli/src/subcommands/profile.rs b/theseus_cli/src/subcommands/profile.rs new file mode 100644 index 000000000..3aacbc4b5 --- /dev/null +++ b/theseus_cli/src/subcommands/profile.rs @@ -0,0 +1,418 @@ +//! Profile management subcommand +use crate::util::{ + confirm_async, prompt_async, select_async, table_path_display, +}; +use daedalus::modded::LoaderVersion; +use eyre::{ensure, Result}; +use paris::*; +use std::path::{Path, PathBuf}; +use tabled::{Table, Tabled}; +use theseus::{ + data::{profiles::PROFILE_JSON_PATH, Metadata, Profile, Profiles}, + launcher::ModLoader, +}; +use tokio::fs; +use tokio_stream::{wrappers::ReadDirStream, StreamExt}; +use uuid::Uuid; + +#[derive(argh::FromArgs)] +#[argh(subcommand, name = "profile")] +/// profile management +pub struct ProfileCommand { + #[argh(subcommand)] + action: ProfileSubcommand, +} + +#[derive(argh::FromArgs)] +#[argh(subcommand)] +pub enum ProfileSubcommand { + Add(ProfileAdd), + Init(ProfileInit), + List(ProfileList), + Remove(ProfileRemove), + Run(ProfileRun), +} + +#[derive(argh::FromArgs)] +#[argh(subcommand, name = "add")] +/// add a new profile to Theseus +pub struct ProfileAdd { + #[argh(positional, default = "std::env::current_dir().unwrap()")] + /// the profile to add + profile: PathBuf, +} + +impl ProfileAdd { + pub async fn run( + &self, + _args: &crate::Args, + _largs: &ProfileCommand, + ) -> Result<()> { + info!( + "Adding profile at path '{}' to Theseus", + self.profile.display() + ); + + let profile = self.profile.canonicalize()?; + let json_path = profile.join(PROFILE_JSON_PATH); + ensure!( + json_path.exists(), + "Profile json does not exist. Perhaps you wanted `profile init` or `profile fetch`?" + ); + ensure!( + Profiles::get().await.unwrap().0.get(&profile).is_none(), + "Profile already managed by Theseus. If the contents of the profile are invalid or missing, the profile can be regenerated using `profile init` or `profile fetch`" + ); + Profiles::insert_from(profile).await?; + success!("Profile added!"); + + Ok(()) + } +} + +#[derive(argh::FromArgs)] +#[argh(subcommand, name = "init")] +/// create a new profile and manage it with Theseus +pub struct ProfileInit { + #[argh(positional, default = "std::env::current_dir().unwrap()")] + /// the path of the newly created profile + path: PathBuf, + + #[argh(option)] + /// the name of the profile + name: Option, + + #[argh(option)] + /// the game version of the profile + game_version: Option, + + #[argh(option)] + /// the icon for the profile + icon: Option, + + #[argh(option, from_str_fn(modloader_from_str))] + /// the modloader to use + modloader: Option, + + #[argh(option)] + /// the modloader version to use, set to "latest", "stable", or the ID of your chosen loader + loader_version: Option, +} + +impl ProfileInit { + pub async fn run( + &self, + _args: &crate::Args, + _largs: &ProfileCommand, + ) -> Result<()> { + // TODO: validate inputs from args early + if self.path.exists() { + ensure!( + self.path.is_dir(), + "Attempted to create profile in something other than a folder!" + ); + ensure!( + !self.path.join(PROFILE_JSON_PATH).exists(), + "Profile already exists! Perhaps you want `profile add` instead?" + ); + if ReadDirStream::new(fs::read_dir(&self.path).await?) + .next() + .await + .is_some() + { + warn!("You are trying to create a profile in a non-empty directory. If this is an instance from another launcher, please be sure to properly fill the profile.json fields!"); + if !confirm_async( + String::from("Do you wish to continue"), + false, + ) + .await? + { + eyre::bail!("Aborted!"); + } + } + } else { + fs::create_dir_all(&self.path).await?; + } + info!( + "Creating profile at path {}", + &self.path.canonicalize()?.display() + ); + + let metadata = Metadata::get().await?; + + // TODO: abstract default prompting + let name = match &self.name { + Some(name) => name.clone(), + None => { + let default = self.path.file_name().unwrap().to_string_lossy(); + + prompt_async( + String::from("Instance name"), + Some(default.into_owned()), + ) + .await? + } + }; + + let game_version = match &self.game_version { + Some(version) => version.clone(), + None => { + let default = &metadata.minecraft.latest.release; + + prompt_async( + String::from("Game version"), + Some(default.clone()), + ) + .await? + } + }; + + let loader = match &self.modloader { + Some(loader) => *loader, + None => { + let choice = select_async( + "Modloader".to_owned(), + &["vanilla", "fabric", "forge"], + ) + .await?; + + match choice { + 0 => ModLoader::Vanilla, + 1 => ModLoader::Fabric, + 2 => ModLoader::Forge, + _ => eyre::bail!( + "Invalid modloader ID: {choice}. This is a bug in the launcher!" + ), + } + } + }; + + let loader = if loader != ModLoader::Vanilla { + let version = match &self.loader_version { + Some(version) => String::from(version), + None => prompt_async( + String::from( + "Modloader version (latest, stable, or a version ID)", + ), + Some(String::from("latest")), + ) + .await?, + }; + + let filter = |it: &LoaderVersion| match version.as_str() { + "latest" => true, + "stable" => it.stable, + id => it.id == String::from(id), + }; + + let loader_data = match loader { + ModLoader::Forge => &metadata.forge, + ModLoader::Fabric => &metadata.fabric, + _ => eyre::bail!("Could not get manifest for loader {loader}. This is a bug in the CLI!"), + }; + + let ref loaders = loader_data.game_versions + .iter() + .find(|it| it.id == game_version) + .ok_or_else(|| eyre::eyre!("Modloader {loader} unsupported for Minecraft version {game_version}"))? + .loaders; + + let loader_version = + loaders.iter().cloned().find(filter).ok_or_else(|| { + eyre::eyre!( + "Invalid version {version} for modloader {loader}" + ) + })?; + + Some((loader_version, loader)) + } else { + None + }; + + let icon = match &self.icon { + Some(icon) => Some(icon.clone()), + None => Some( + prompt_async("Icon".to_owned(), Some(String::new())).await?, + ) + .filter(|it| !it.trim().is_empty()) + .map(PathBuf::from), + }; + + // We don't really care if the profile already is managed, as getting this far means that the user probably wanted to re-create a profile + drop(metadata); + let mut profile = + Profile::new(name, game_version, self.path.clone()).await?; + + if let Some(ref icon) = icon { + profile.with_icon(icon).await?; + } + + if let Some((loader_version, loader)) = loader { + profile.with_loader(loader, Some(loader_version)); + } + + Profiles::insert(profile).await?; + Profiles::save().await?; + + success!( + "Successfully created instance, it is now available to use with Theseus!" + ); + Ok(()) + } +} + +#[derive(argh::FromArgs)] +/// list all managed profiles +#[argh(subcommand, name = "list")] +pub struct ProfileList {} + +#[derive(Tabled)] +struct ProfileRow<'a> { + name: &'a str, + #[field(display_with = "table_path_display")] + path: &'a Path, + #[header("game version")] + game_version: &'a str, + loader: &'a ModLoader, + #[header("loader version")] + loader_version: &'a str, +} + +impl<'a> From<&'a Profile> for ProfileRow<'a> { + fn from(it: &'a Profile) -> Self { + Self { + name: &it.metadata.name, + path: &it.path, + game_version: &it.metadata.game_version, + loader: &it.metadata.loader, + loader_version: it + .metadata + .loader_version + .as_ref() + .map_or("", |it| &it.id), + } + } +} + +impl ProfileList { + pub async fn run( + &self, + _args: &crate::Args, + _largs: &ProfileCommand, + ) -> Result<()> { + let profiles = Profiles::get().await?; + let profiles = profiles.0.values().map(ProfileRow::from); + + let table = Table::new(profiles).with(tabled::Style::psql()).with( + tabled::Modify::new(tabled::Column(1..=1)) + .with(tabled::MaxWidth::wrapping(40)), + ); + println!("{table}"); + Ok(()) + } +} + +#[derive(argh::FromArgs)] +/// unmanage a profile +#[argh(subcommand, name = "remove")] +pub struct ProfileRemove { + #[argh(positional, default = "std::env::current_dir().unwrap()")] + /// the profile to get rid of + profile: PathBuf, +} + +impl ProfileRemove { + pub async fn run( + &self, + _args: &crate::Args, + _largs: &ProfileCommand, + ) -> Result<()> { + let profile = self.profile.canonicalize()?; + info!("Removing profile {} from Theseus", self.profile.display()); + if confirm_async(String::from("Do you wish to continue"), true).await? { + if Profiles::remove(&profile).await?.is_none() { + warn!("Profile was not managed by Theseus!"); + } else { + success!("Profile removed!"); + } + } else { + error!("Aborted!"); + } + + Ok(()) + } +} + +#[derive(argh::FromArgs)] +/// run a profile +#[argh(subcommand, name = "run")] +pub struct ProfileRun { + #[argh(positional, default = "std::env::current_dir().unwrap()")] + /// the profile to run + profile: PathBuf, + + // TODO: auth + #[argh(option, short = 't')] + /// the Minecraft token to use for player login. Should be replaced by auth when that is a thing. + token: String, + + #[argh(option, short = 'n')] + /// the uername to use for running the game + name: String, + + #[argh(option, short = 'i')] + /// the account id to use for running the game + id: Uuid, +} + +impl ProfileRun { + pub async fn run( + &self, + _args: &crate::Args, + _largs: &ProfileCommand, + ) -> Result<()> { + info!("Starting profile at path {}...", self.profile.display()); + let ref profiles = Profiles::get().await?.0; + let path = self.profile.canonicalize()?; + let profile = profiles + .get(&path) + .ok_or( + eyre::eyre!( + "Profile not managed by Theseus (if it exists, try using `profile add` first!)" + ) + )?; + + let credentials = theseus::launcher::Credentials { + id: self.id.clone(), + username: self.name.clone(), + access_token: self.token.clone(), + }; + + let mut proc = profile.run(&credentials).await?; + profile.wait_for(&mut proc).await?; + + success!("Process exited successfully!"); + Ok(()) + } +} + +impl ProfileCommand { + pub async fn dispatch(&self, args: &crate::Args) -> Result<()> { + match &self.action { + ProfileSubcommand::Add(ref cmd) => cmd.run(args, self).await, + ProfileSubcommand::Init(ref cmd) => cmd.run(args, self).await, + ProfileSubcommand::List(ref cmd) => cmd.run(args, self).await, + ProfileSubcommand::Remove(ref cmd) => cmd.run(args, self).await, + ProfileSubcommand::Run(ref cmd) => cmd.run(args, self).await, + } + } +} + +fn modloader_from_str(it: &str) -> core::result::Result { + match it { + "vanilla" => Ok(ModLoader::Vanilla), + "forge" => Ok(ModLoader::Forge), + "fabric" => Ok(ModLoader::Fabric), + _ => Err(String::from("Invalid modloader: {it}")), + } +} diff --git a/theseus_cli/src/util.rs b/theseus_cli/src/util.rs new file mode 100644 index 000000000..b40391cd7 --- /dev/null +++ b/theseus_cli/src/util.rs @@ -0,0 +1,76 @@ +use dialoguer::{Confirm, Input, Select}; +use eyre::Result; +use std::{borrow::Cow, path::Path}; + +// TODO: make primarily async to avoid copies + +// Prompting helpers +pub fn prompt(prompt: &str, default: Option) -> Result { + let prompt = match default.as_deref() { + Some("") => Cow::Owned(format!("{prompt} (optional)")), + Some(default) => Cow::Owned(format!("{prompt} (default: {default})")), + None => Cow::Borrowed(prompt), + }; + print_prompt(&prompt); + + let mut input = Input::::new(); + input.with_prompt("").show_default(false); + + if let Some(default) = default { + input.default(default); + } + + Ok(input.interact_text()?.trim().to_owned()) +} + +pub async fn prompt_async( + text: String, + default: Option, +) -> Result { + tokio::task::spawn_blocking(move || prompt(&text, default)).await? +} + +// Selection helpers +pub fn select(prompt: &str, choices: &[&str]) -> Result { + print_prompt(prompt); + + let res = Select::new().items(choices).default(0).interact()?; + eprintln!("> {}", choices[res]); + Ok(res) +} + +pub async fn select_async( + prompt: String, + choices: &'static [&'static str], +) -> Result { + tokio::task::spawn_blocking(move || select(&prompt, choices)).await? +} + +// Confirmation helpers +pub fn confirm(prompt: &str, default: bool) -> Result { + print_prompt(prompt); + Ok(Confirm::new().default(default).interact()?) +} + +pub async fn confirm_async(prompt: String, default: bool) -> Result { + tokio::task::spawn_blocking(move || confirm(&prompt, default)).await? +} + +// Table display helpers +pub fn table_path_display(path: &Path) -> String { + let mut res = path.display().to_string(); + + if let Some(home_dir) = dirs::home_dir() { + res = res.replace(&home_dir.display().to_string(), "~"); + } + + res +} + +// Internal helpers +fn print_prompt(prompt: &str) { + println!( + "{}", + paris::formatter::colorize_string(format!("? {prompt}:")) + ); +} diff --git a/theseus_gui/src-tauri/build.rs b/theseus_gui/src-tauri/build.rs index 795b9b7c8..d860e1e6a 100644 --- a/theseus_gui/src-tauri/build.rs +++ b/theseus_gui/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/theseus_gui/src-tauri/src/main.rs b/theseus_gui/src-tauri/src/main.rs index 63481c4ad..508d67703 100644 --- a/theseus_gui/src-tauri/src/main.rs +++ b/theseus_gui/src-tauri/src/main.rs @@ -1,89 +1,89 @@ #![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" )] use tauri::api::shell; use tauri::{ - CustomMenuItem, Manager, Menu, MenuEntry, MenuItem, Submenu, WindowBuilder, WindowUrl, + CustomMenuItem, Manager, Menu, MenuEntry, MenuItem, Submenu, WindowBuilder, WindowUrl, }; fn main() { - let ctx = tauri::generate_context!(); + let ctx = tauri::generate_context!(); - tauri::Builder::default() - .invoke_handler(tauri::generate_handler![]) - .create_window("main", WindowUrl::default(), |win, webview| { - let win = win - .title("Modrinth") - .resizable(true) - .decorations(true) - .always_on_top(false) - .inner_size(800.0, 550.0) - .min_inner_size(400.0, 200.0) - .skip_taskbar(false) - .fullscreen(false); - return (win, webview); - }) - .menu(Menu::with_items([ - #[cfg(target_os = "macos")] - MenuEntry::Submenu(Submenu::new( - &ctx.package_info().name, - Menu::with_items([ - MenuItem::About(ctx.package_info().name.clone()).into(), - MenuItem::Separator.into(), - MenuItem::Services.into(), - MenuItem::Separator.into(), - MenuItem::Hide.into(), - MenuItem::HideOthers.into(), - MenuItem::ShowAll.into(), - MenuItem::Separator.into(), - MenuItem::Quit.into(), - ]), - )), - MenuEntry::Submenu(Submenu::new( - "File", - Menu::with_items([MenuItem::CloseWindow.into()]), - )), - MenuEntry::Submenu(Submenu::new( - "Edit", - Menu::with_items([ - MenuItem::Undo.into(), - MenuItem::Redo.into(), - MenuItem::Separator.into(), - MenuItem::Cut.into(), - MenuItem::Copy.into(), - MenuItem::Paste.into(), - #[cfg(not(target_os = "macos"))] - MenuItem::Separator.into(), - MenuItem::SelectAll.into(), - ]), - )), - MenuEntry::Submenu(Submenu::new( - "View", - Menu::with_items([MenuItem::EnterFullScreen.into()]), - )), - MenuEntry::Submenu(Submenu::new( - "Window", - Menu::with_items([MenuItem::Minimize.into(), MenuItem::Zoom.into()]), - )), - // You should always have a Help menu on macOS because it will automatically - // show a menu search field - MenuEntry::Submenu(Submenu::new( - "Help", - Menu::with_items([CustomMenuItem::new("Learn More", "Learn More").into()]), - )), - ])) - .on_menu_event(|event| { - let event_name = event.menu_item_id(); - match event_name { - "Learn More" => { - let url = "https://modrinth.com".to_string(); - shell::open(&event.window().shell_scope(), url, None).unwrap(); - } - _ => {} - } - }) - .run(ctx) - .expect("error while running tauri application"); -} \ No newline at end of file + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![]) + .create_window("main", WindowUrl::default(), |win, webview| { + let win = win + .title("Modrinth") + .resizable(true) + .decorations(true) + .always_on_top(false) + .inner_size(800.0, 550.0) + .min_inner_size(400.0, 200.0) + .skip_taskbar(false) + .fullscreen(false); + return (win, webview); + }) + .menu(Menu::with_items([ + #[cfg(target_os = "macos")] + MenuEntry::Submenu(Submenu::new( + &ctx.package_info().name, + Menu::with_items([ + MenuItem::About(ctx.package_info().name.clone()).into(), + MenuItem::Separator.into(), + MenuItem::Services.into(), + MenuItem::Separator.into(), + MenuItem::Hide.into(), + MenuItem::HideOthers.into(), + MenuItem::ShowAll.into(), + MenuItem::Separator.into(), + MenuItem::Quit.into(), + ]), + )), + MenuEntry::Submenu(Submenu::new( + "File", + Menu::with_items([MenuItem::CloseWindow.into()]), + )), + MenuEntry::Submenu(Submenu::new( + "Edit", + Menu::with_items([ + MenuItem::Undo.into(), + MenuItem::Redo.into(), + MenuItem::Separator.into(), + MenuItem::Cut.into(), + MenuItem::Copy.into(), + MenuItem::Paste.into(), + #[cfg(not(target_os = "macos"))] + MenuItem::Separator.into(), + MenuItem::SelectAll.into(), + ]), + )), + MenuEntry::Submenu(Submenu::new( + "View", + Menu::with_items([MenuItem::EnterFullScreen.into()]), + )), + MenuEntry::Submenu(Submenu::new( + "Window", + Menu::with_items([MenuItem::Minimize.into(), MenuItem::Zoom.into()]), + )), + // You should always have a Help menu on macOS because it will automatically + // show a menu search field + MenuEntry::Submenu(Submenu::new( + "Help", + Menu::with_items([CustomMenuItem::new("Learn More", "Learn More").into()]), + )), + ])) + .on_menu_event(|event| { + let event_name = event.menu_item_id(); + match event_name { + "Learn More" => { + let url = "https://github.com/probablykasper/tauri-template".to_string(); + shell::open(&event.window().shell_scope(), url, None).unwrap(); + } + _ => {} + } + }) + .run(ctx) + .expect("error while running tauri application"); +}