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
This commit is contained in:
Danielle 2022-03-28 18:41:35 -07:00 committed by GitHub
parent 98aa66f9d8
commit d1070ca213
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1825 additions and 334 deletions

6
.gitignore vendored
View File

@ -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

176
Cargo.lock generated
View File

@ -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"

103
flake.lock generated Normal file
View File

@ -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
}

66
flake.nix Normal file
View File

@ -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;
};
});
}

2
rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
edition = "2018"
max_width = 80

View File

@ -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
];
}

View File

@ -38,6 +38,7 @@ once_cell = "1.9.0"
[dev-dependencies]
argh = "0.1.6"
pretty_assertions = "1.1.0"
[[example]]
name = "download-pack"

View File

@ -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());

View File

@ -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::<Metadata>(&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<RwLockReadGuard<'a, Self>, DataError> {
Ok(METADATA
let res = METADATA
.get()
.ok_or_else(|| DataError::InitializedError("metadata".to_string()))?
.read()
.await)
.await;
Ok(res)
}
}

View File

@ -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),

View File

@ -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<RwLock<Profiles>> = OnceCell::new();
pub const PROFILE_JSON_PATH: &str = "profile.json";
#[derive(Debug)]
pub struct Profiles(pub HashMap<PathBuf, Profile>);
// 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<JavaSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<MemorySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<WindowSize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<ProfileHooks>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Metadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<PathBuf>,
pub game_version: String,
#[serde(default)]
pub loader: ModLoader,
#[serde(skip_serializing_if = "Option::is_none")]
pub loader_version: Option<LoaderVersion>,
pub format_version: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JavaSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub install: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_arguments: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub struct MemorySettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<u32>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wrapper: Option<String>,
#[serde(skip_serializing_if = "HashSet::is_empty", default)]
pub post_exit: HashSet<String>,
}
impl Default for ProfileHooks {
fn default() -> Self {
Self {
pre_launch: HashSet::<String>::new(),
wrapper: None,
post_exit: HashSet::<String>::new(),
}
}
}
impl Profile {
pub async fn new(
name: String,
version: String,
path: PathBuf,
) -> Result<Self, DataError> {
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<Child, crate::launcher::LauncherError> {
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::<Vec<&str>>())
.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<LoaderVersion>,
) -> &mut Self {
self.metadata.loader = loader;
self.metadata.loader_version = version;
self
}
pub fn with_java_settings(
&mut self,
settings: Option<JavaSettings>,
) -> &mut Self {
self.java = settings;
self
}
pub fn with_memory(
&mut self,
settings: Option<MemorySettings>,
) -> &mut Self {
self.memory = settings;
self
}
pub fn with_resolution(
&mut self,
resolution: Option<WindowSize>,
) -> &mut Self {
self.resolution = resolution;
self
}
pub fn with_hooks(&mut self, hooks: Option<ProfileHooks>) -> &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<Option<Profile>, 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::<Result<_, DataError>>()?;
Ok(())
}
pub async fn get<'a>() -> Result<RwLockReadGuard<'a, Self>, DataError> {
Ok(PROFILES
.get()
.ok_or_else(|| DataError::InitializedError("profiles".to_string()))?
.read()
.await)
}
async fn read_profile_from_dir(
path: PathBuf,
) -> Result<Profile, DataError> {
let json = fs::read(path.join(PROFILE_JSON_PATH)).await?;
let mut profile = serde_json::from_slice::<Profile>(&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::<Profile>(json)?),
format!("{:?}", profile),
);
Ok(())
}
}

View File

@ -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<RwLock<Settings>> = 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<String>,
pub java_17_path: Option<String>,
pub wrapper_command: Option<String>,
pub memory: MemorySettings,
pub game_resolution: WindowSize,
pub custom_java_args: Vec<String>,
pub java_8_path: Option<PathBuf>,
pub java_17_path: Option<PathBuf>,
pub hooks: ProfileHooks,
pub icon_path: PathBuf,
pub metadata_dir: PathBuf,
pub profiles: HashSet<PathBuf>,
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<RwLockReadGuard<'a, Self>, DataError> {
Ok(SETTINGS
Ok(Self::get_or_uninit::<'a>()?.read().await)
}
pub async fn get_mut<'a>() -> Result<RwLockWriteGuard<'a, Self>, DataError>
{
Ok(Self::get_or_uninit::<'a>()?.write().await)
}
fn get_or_uninit<'a>() -> Result<&'a RwLock<Self>, DataError> {
SETTINGS
.get()
.ok_or_else(|| DataError::InitializedError("settings".to_string()))?
.read()
.await)
.ok_or_else(|| DataError::InitializedError("settings".to_string()))
}
}

View File

@ -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,7 +22,9 @@ pub fn get_class_paths(
libraries: &[Library],
client_path: &Path,
) -> Result<String, LauncherError> {
let mut class_paths = libraries.iter().filter_map(|library| {
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;
@ -33,7 +36,8 @@ pub fn get_class_paths(
}
Some(get_lib_path(libraries_path, &library.name))
}).collect::<Result<Vec<_>, _>>()?;
})
.collect::<Result<Vec<_>, _>>()?;
class_paths.push(
crate::util::absolute_path(&client_path)
@ -54,9 +58,10 @@ pub fn get_class_paths_jar<T: AsRef<str>>(
libraries_path: &Path,
libraries: &[T],
) -> Result<String, LauncherError> {
let class_paths = libraries.iter().map(|library| {
get_lib_path(libraries_path, library.as_ref())
}).collect::<Result<Vec<_>, _>>()?;
let class_paths = libraries
.iter()
.map(|library| get_lib_path(libraries_path, library.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
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<String>,
) -> Result<Vec<String>, 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<Vec<String>, 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<String, LauncherError> {
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())

View File

@ -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<Semaphore> = 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<AssetsIndex, LauncherError> {
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::<Result<Vec<()>, 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::<Result<Vec<()>, 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,14 +215,19 @@ 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<String, LibraryDownload>)> {
let optional_cascade =
|| -> Option<(&String, &HashMap<String, LibraryDownload>)> {
let os_key = library.natives.as_ref()?.get(&get_os())?;
let classifiers = library.downloads.as_ref()?.classifiers.as_ref()?;
let classifiers =
library.downloads.as_ref()?.classifiers.as_ref()?;
Some((os_key, classifiers))
};
@ -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<bytes::Bytes, LauncherError> {
pub async fn download_file(
url: &str,
sha1: Option<&str>,
) -> Result<bytes::Bytes, LauncherError> {
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<bytes::Bytes
/// Computes a checksum of the input bytes
async fn get_hash(bytes: bytes::Bytes) -> Result<String, LauncherError> {
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)
}

View File

@ -1,17 +0,0 @@
use crate::launcher::LauncherError;
use std::process::Command;
pub fn check_java() -> Result<String, LauncherError> {
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())
}

View File

@ -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<ModLoader>,
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<LoaderVersion>,
root_dir: &Path,
java: &Path,
java_args: &Vec<String>,
wrapper: &Option<String>,
memory: &crate::data::profiles::MemorySettings,
resolution: &crate::data::profiles::WindowSize,
credentials: &Credentials,
) -> Result<Child, LauncherError> {
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")
let client_path = versions_path
.join(&version.id)
.join(format!("{}.jar", &version.id)),
)?;
let natives_path = crate::util::absolute_path(root_dir.join("natives").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;

View File

@ -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(())
}

View File

@ -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 {

View File

@ -21,7 +21,10 @@ pub trait ModrinthAPI {
channel: &str,
game: &ModpackGame,
) -> ModpackResult<HashSet<ModpackFile>>;
async fn get_version(&self, version: &str) -> ModpackResult<HashSet<ModpackFile>>;
async fn get_version(
&self,
version: &str,
) -> ModpackResult<HashSet<ModpackFile>>;
}
#[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::<HashSet<ModpackFile>>();
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<HashSet<ModpackFile>> {
async fn get_version(
&self,
version: &str,
) -> ModpackResult<HashSet<ModpackFile>> {
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

View File

@ -159,6 +159,7 @@ pub struct ModpackFile {
pub downloads: HashSet<String>,
}
#[allow(clippy::derive_hash_xor_eq)]
impl Hash for ModpackFile {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
if let Some(ref hashes) = self.hashes {

View File

@ -8,5 +8,14 @@ edition = "2018"
[dependencies]
theseus = { path = "../theseus" }
daedalus = "0.1.12"
tokio = { version = "1", features = ["full"] }
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"]}

View File

@ -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::<Args>();
theseus::init().await?;
let res = args.dispatch().await;
if res.is_err() {
error!("An error has occurred!\n");
} else {
theseus::save().await?;
}
res
}

View File

@ -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,
}
}
}

View File

@ -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<String>,
#[argh(option)]
/// the game version of the profile
game_version: Option<String>,
#[argh(option)]
/// the icon for the profile
icon: Option<PathBuf>,
#[argh(option, from_str_fn(modloader_from_str))]
/// the modloader to use
modloader: Option<ModLoader>,
#[argh(option)]
/// the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
loader_version: Option<String>,
}
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<ModLoader, String> {
match it {
"vanilla" => Ok(ModLoader::Vanilla),
"forge" => Ok(ModLoader::Forge),
"fabric" => Ok(ModLoader::Fabric),
_ => Err(String::from("Invalid modloader: {it}")),
}
}

76
theseus_cli/src/util.rs Normal file
View File

@ -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<String>) -> Result<String> {
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::<String>::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<String>,
) -> Result<String> {
tokio::task::spawn_blocking(move || prompt(&text, default)).await?
}
// Selection helpers
pub fn select(prompt: &str, choices: &[&str]) -> Result<usize> {
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<usize> {
tokio::task::spawn_blocking(move || select(&prompt, choices)).await?
}
// Confirmation helpers
pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
print_prompt(prompt);
Ok(Confirm::new().default(default).interact()?)
}
pub async fn confirm_async(prompt: String, default: bool) -> Result<bool> {
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!("<yellow>?</> {prompt}:"))
);
}

View File

@ -78,7 +78,7 @@ fn main() {
let event_name = event.menu_item_id();
match event_name {
"Learn More" => {
let url = "https://modrinth.com".to_string();
let url = "https://github.com/probablykasper/tauri-template".to_string();
shell::open(&event.window().shell_scope(), url, None).unwrap();
}
_ => {}