Merge branch 'master' into gui_search

This commit is contained in:
venashial 2022-08-03 00:44:17 -07:00
parent b0a55c9b18
commit 51982dde62
79 changed files with 6320 additions and 6336 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

8
.gitignore vendored
View File

@ -2,13 +2,13 @@
node_modules/
.svelte-kit/
theseus_gui/build/
theseus_gui/generated/
WixTools
.direnv/
.DS_Store
.pnpm-debug.log
[#]*[#]
# TEMPORARY: ignore my test instance and metadata
theseus_cli/launcher
theseus_cli/foo
.DS_Store
.pnpm-debug.log
generated/

1
.idea/theseus.iml generated
View File

@ -47,6 +47,7 @@
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/crossbeam-utils-bab62be590a5955d/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/memoffset-235ac8b3550fb50a/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/theseus/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/theseus_gui/src-tauri/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
<excludeFolder url="file://$MODULE_DIR$/theseus_gui/.svelte-kit" />
<excludeFolder url="file://$MODULE_DIR$/theseus_gui/build" />

2458
Cargo.lock generated

File diff suppressed because it is too large Load Diff

34
flake.lock generated
View File

@ -8,11 +8,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1646893503,
"narHash": "sha256-N4Wn8FUXUC1h1DkL8X9I7VMvIv0fLLLjeJX3uFyzvRQ=",
"lastModified": 1655706580,
"narHash": "sha256-7DshIT1Ya5W9NAW7UdnYCHsGmXfOXJZCEHbbB/cCX7g=",
"owner": "nix-community",
"repo": "fenix",
"rev": "aad7f0a3e44ecfc9e2c5f1a45387d193c1c51aa6",
"rev": "d895003d8e03ac2fc8ffe2aa898299cbef1a7048",
"type": "github"
},
"original": {
@ -28,11 +28,11 @@
]
},
"locked": {
"lastModified": 1639947939,
"narHash": "sha256-pGsM8haJadVP80GFq4xhnSpNitYNQpaXk4cnA796Cso=",
"lastModified": 1655042882,
"narHash": "sha256-9BX8Fuez5YJlN7cdPO63InoyBy7dm3VlJkkmTt6fS1A=",
"owner": "nix-community",
"repo": "naersk",
"rev": "2fc8ce9d3c025d59fee349c1f80be9785049d653",
"rev": "cddffb5aa211f50c4b8750adbec0bbbdfb26bb9f",
"type": "github"
},
"original": {
@ -43,11 +43,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1646497237,
"narHash": "sha256-Ccpot1h/rV8MgcngDp5OrdmLTMaUTbStZTR5/sI7zW0=",
"lastModified": 1655624069,
"narHash": "sha256-7g1zwTdp35GMTERnSzZMWJ7PG3QdDE8VOX3WsnOkAtM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062a0c5437b68f950b081bbfc8a699d57a4ee026",
"rev": "0d68d7c857fe301d49cdcd56130e0beea4ecd5aa",
"type": "github"
},
"original": {
@ -68,15 +68,15 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1646862342,
"narHash": "sha256-zXd3qsIcQFDFMB6p8bSpkOKjTuBTvYuM4GkPYxEfQdA=",
"owner": "rust-analyzer",
"lastModified": 1655654433,
"narHash": "sha256-auHQ0XPCiaTPSn+R3Yu4J7oZ5Zq/FS5/Da1ivvdYb/Y=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "5b51cb835a356cf79cba00cf5c65d51cadeea7f1",
"rev": "427061da19723f2206fe4dcb175c9c43b9a6193d",
"type": "github"
},
"original": {
"owner": "rust-analyzer",
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
@ -84,11 +84,11 @@
},
"utils": {
"locked": {
"lastModified": 1644229661,
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
"lastModified": 1653893745,
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
"type": "github"
},
"original": {

View File

@ -14,7 +14,7 @@
};
};
outputs = inputs:
outputs = inputs@{self, ...}:
inputs.utils.lib.eachDefaultSystem (system: let
pkgs = import inputs.nixpkgs { inherit system; };
fenix = inputs.fenix.packages.${system};
@ -32,13 +32,14 @@
deps = with pkgs; {
global = [
openssl pkg-config
openssl pkg-config gcc
];
gui = [
gtk4 gdk-pixbuf atk webkitgtk
gtk4 gdk-pixbuf atk webkitgtk dbus
];
shell = [
toolchain fenix.default.clippy git
(with fenix; combine [toolchain default.clippy complete.rust-src rust-analyzer])
git
jdk17 jdk8
];
};
@ -53,8 +54,13 @@
};
apps = {
theseus-cli = utils.mkApp {
drv = inputs.self.packages.${system}.theseus-cli;
cli = utils.mkApp {
drv = self.packages.${system}.theseus-cli;
};
cli-dev = utils.mkApp {
drv = self.packages.${system}.theseus-cli.overrideAttrs (old: old // {
release = false;
});
};
};

View File

@ -1,8 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
rustc cargo clippy openssl pkg-config
gtk4 gdk-pixbuf atk webkitgtk
];
}

View File

@ -7,42 +7,35 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
thiserror = "1.0"
async-trait = "0.1.51"
daedalus = "0.1.12"
reqwest = { version = "0.11", features = ["json"] }
bytes = "1"
bincode = { version = "2.0.0-rc.1", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
json5 = "0.4.1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "0.8", features = ["serde", "v4"] }
bytes = "1"
zip = "0.5"
zip-extensions = "0.6"
sha1 = { version = "0.6.0", features = ["std"]}
path-clean = "0.1.0"
fs_extra = "1.2.0"
sled = { version = "0.34.7", features = ["compression"] }
url = "2.2"
uuid = { version = "1.1", features = ["serde", "v4"] }
zip = "0.5"
chrono = { version = "0.4.19", features = ["serde"] }
daedalus = { version = "0.1.16", features = ["bincode"] }
dirs = "4.0"
regex = "1.5"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
sys-info = "0.9.0"
# TODO: possibly replace with tracing to have structured logging
log = "0.4.14"
const_format = "0.2.22"
regex = "1.5"
sys-info = "0.9.0"
thiserror = "1.0"
tracing = "0.1"
tracing-error = "0.2"
async-tungstenite = { version = "0.17", features = ["tokio-runtime", "tokio-native-tls"] }
futures = "0.3"
once_cell = "1.9.0"
lazy_static = "1.4"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
argh = "0.1.6"
pretty_assertions = "1.1.0"
[[example]]
name = "download-pack"
[features]

View File

@ -1,55 +0,0 @@
use std::{path::PathBuf, time::Instant};
use argh::FromArgs;
use theseus::modpack::{fetch_modpack, pack::ModpackSide};
#[derive(FromArgs)]
/// Simple modpack download
struct ModpackDownloader {
/// where to download to
#[argh(positional)]
url: String,
/// where to put the resulting pack
#[argh(option, short = 'o')]
output: Option<PathBuf>,
/// the sha1 hash, if you want it checked
#[argh(option, short = 'c')]
hash: Option<String>,
/// use verbose logging
#[argh(switch, short = 'v')]
verbose: bool,
}
// Simple logging helper
fn debug(msg: &str, verbose: bool) {
if verbose {
println!("{}", msg);
}
}
#[tokio::main]
pub async fn main() {
let args = argh::from_env::<ModpackDownloader>();
let dest = args.output.unwrap_or(PathBuf::from("./pack-download/"));
debug(
&format!(
"Downloading pack {} to {}",
args.url,
dest.to_str().unwrap_or("?")
),
args.verbose,
);
let start = Instant::now();
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());
debug("Done!", args.verbose);
}

100
theseus/src/api/auth.rs Normal file
View File

@ -0,0 +1,100 @@
//! Authentication flow interface
use crate::{launcher::auth as inner, State};
use futures::prelude::*;
use tokio::sync::oneshot;
pub use inner::Credentials;
/// Authenticate a user with Hydra
/// To run this, you need to first spawn this function as a task, then
/// open a browser to the given URL and finally wait on the spawned future
/// with the ability to cancel in case the browser is closed before finishing
#[tracing::instrument]
pub async fn authenticate(
browser_url: oneshot::Sender<url::Url>,
) -> crate::Result<Credentials> {
let mut flow = inner::HydraAuthFlow::new().await?;
let state = State::get().await?;
let mut users = state.users.write().await;
let url = flow.prepare_login_url().await?;
browser_url.send(url).map_err(|url| {
crate::ErrorKind::OtherError(format!(
"Error sending browser url to parent: {url}"
))
})?;
let credentials = flow.extract_credentials().await?;
users.insert(&credentials)?;
if state.settings.read().await.default_user.is_none() {
let mut settings = state.settings.write().await;
settings.default_user = Some(credentials.id);
}
Ok(credentials)
}
/// Refresh some credentials using Hydra, if needed
#[tracing::instrument]
pub async fn refresh(
user: uuid::Uuid,
update_name: bool,
) -> crate::Result<Credentials> {
let state = State::get().await?;
let mut users = state.users.write().await;
futures::future::ready(users.get(user)?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to refresh nonexistent user with ID {user}"
))
.as_error()
}))
.and_then(|mut credentials| async move {
if chrono::offset::Utc::now() > credentials.expires {
inner::refresh_credentials(&mut credentials).await?;
if update_name {
inner::refresh_username(&mut credentials).await?;
}
}
users.insert(&credentials)?;
Ok(credentials)
})
.await
}
/// Remove a user account from the database
#[tracing::instrument]
pub async fn remove_user(user: uuid::Uuid) -> crate::Result<()> {
let state = State::get().await?;
let mut users = state.users.write().await;
if state.settings.read().await.default_user == Some(user) {
let mut settings = state.settings.write().await;
settings.default_user = users
.0
.first()?
.map(|it| uuid::Uuid::from_slice(&it.0))
.transpose()?;
}
users.remove(user)?;
Ok(())
}
/// Check if a user exists in Theseus
#[tracing::instrument]
pub async fn has_user(user: uuid::Uuid) -> crate::Result<bool> {
let state = State::get().await?;
let users = state.users.read().await;
Ok(users.contains(user)?)
}
/// Get a copy of the list of all user credentials
#[tracing::instrument]
pub async fn users() -> crate::Result<Box<[Credentials]>> {
let state = State::get().await?;
let users = state.users.read().await;
users.iter().collect()
}

19
theseus/src/api/mod.rs Normal file
View File

@ -0,0 +1,19 @@
//! API for interacting with Theseus
pub mod auth;
pub mod profile;
pub mod data {
pub use crate::state::{
DirectoryInfo, Hooks, JavaSettings, MemorySettings, ModLoader,
ProfileMetadata, Settings, WindowSize,
};
}
pub mod prelude {
pub use crate::{
auth::{self, Credentials},
data::*,
profile::{self, Profile},
State,
};
}

243
theseus/src/api/profile.rs Normal file
View File

@ -0,0 +1,243 @@
//! Theseus profile management interface
pub use crate::{
state::{JavaSettings, Profile},
State,
};
use daedalus as d;
use std::{
future::Future,
path::{Path, PathBuf},
};
use tokio::process::{Child, Command};
/// Add a profile to the in-memory state
#[tracing::instrument]
pub async fn add(profile: Profile) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.insert(profile)?;
Ok(())
}
/// Add a path as a profile in-memory
#[tracing::instrument]
pub async fn add_path(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.insert_from(path).await?;
Ok(())
}
/// Remove a profile
#[tracing::instrument]
pub async fn remove(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
profiles.remove(path)?;
Ok(())
}
/// Get a profile by path,
#[tracing::instrument]
pub async fn get(path: &Path) -> crate::Result<Option<Profile>> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
profiles.0.get(path).map_or(Ok(None), |prof| match prof {
Some(prof) => Ok(Some(prof.clone())),
None => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)
.as_error()),
})
}
/// Check if a profile is already managed by Theseus
#[tracing::instrument]
pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.contains_key(profile))
}
/// Check if a profile is loaded
#[tracing::instrument]
pub async fn is_loaded(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles
.0
.get(profile)
.map(Option::as_ref)
.flatten()
.is_some())
}
/// Edit a profile using a given asynchronous closure
pub async fn edit<Fut>(
path: &Path,
action: impl Fn(&mut Profile) -> Fut,
) -> crate::Result<()>
where
Fut: Future<Output = crate::Result<()>>,
{
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
match profiles.0.get_mut(path) {
Some(&mut Some(ref mut profile)) => action(profile).await,
Some(&mut None) => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)
.as_error()),
None => Err(crate::ErrorKind::UnmanagedProfileError(
path.display().to_string(),
)
.as_error()),
}
}
/// Get a copy of the profile set
#[tracing::instrument]
pub async fn list(
) -> crate::Result<std::collections::HashMap<PathBuf, Option<Profile>>> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.clone())
}
/// Run Minecraft using a profile
#[tracing::instrument(skip_all)]
pub async fn run(
path: &Path,
credentials: &crate::auth::Credentials,
) -> crate::Result<Child> {
let state = State::get().await.unwrap();
let settings = state.settings.read().await;
let profile = get(path).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to run a nonexistent or unloaded profile at path {}!",
path.display()
))
})?;
let version = state
.metadata
.minecraft
.versions
.iter()
.find(|it| it.id == profile.metadata.game_version.as_ref())
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Invalid or unknown Minecraft version: {}",
profile.metadata.game_version
))
})?;
let version_info = d::minecraft::fetch_version_info(version).await?;
let ref pre_launch_hooks =
profile.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(path)
.spawn()?
.wait()
.await?;
if !result.success() {
return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1)
))
.as_error());
}
}
let java_install = match profile.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(|| {
crate::ErrorKind::LauncherError(format!(
"No Java installed for version {}",
version_info.java_version.map_or(8, |it| it.major_version),
))
})?,
};
if !java_install.exists() {
return Err(crate::ErrorKind::LauncherError(format!(
"Could not find Java install: {}",
java_install.display()
))
.as_error());
}
let ref java_args = profile
.java
.as_ref()
.and_then(|it| it.extra_arguments.as_ref())
.unwrap_or(&settings.custom_java_args);
let wrapper = profile
.hooks
.as_ref()
.map_or(&settings.hooks.wrapper, |it| &it.wrapper);
let ref memory = profile.memory.unwrap_or(settings.memory);
let ref resolution = profile.resolution.unwrap_or(settings.game_resolution);
crate::launcher::launch_minecraft(
&profile.metadata.game_version,
&profile.metadata.loader_version,
&profile.path,
&java_install,
&java_args,
&wrapper,
memory,
resolution,
credentials,
)
.await
}
#[tracing::instrument]
pub async fn kill(running: &mut Child) -> crate::Result<()> {
running.kill().await?;
wait_for(running).await
}
#[tracing::instrument]
pub async fn wait_for(running: &mut Child) -> crate::Result<()> {
let result = running.wait().await.map_err(|err| {
crate::ErrorKind::LauncherError(format!(
"Error running minecraft: {err}"
))
})?;
match result.success() {
false => Err(crate::ErrorKind::LauncherError(format!(
"Minecraft exited with non-zero code {}",
result.code().unwrap_or(-1)
))
.as_error()),
true => Ok(()),
}
}

22
theseus/src/config.rs Normal file
View File

@ -0,0 +1,22 @@
//! Configuration structs
use once_cell::sync::Lazy;
use std::time;
pub static BINCODE_CONFIG: Lazy<bincode::config::Configuration> =
Lazy::new(|| {
bincode::config::standard()
.with_little_endian()
.with_no_limit()
});
pub static REQWEST_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
.build()
.unwrap()
});
pub fn sled_config() -> sled::Config {
sled::Config::default().use_compression(true)
}

View File

@ -1,107 +0,0 @@
use crate::{data::DataError, LAUNCHER_WORK_DIR};
use once_cell::sync;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, RwLockReadGuard};
const META_FILE: &str = "meta.json";
const META_URL: &str = "https://meta.modrinth.com/gamedata";
static METADATA: sync::OnceCell<RwLock<Metadata>> = sync::OnceCell::new();
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Metadata {
pub minecraft: daedalus::minecraft::VersionManifest,
pub forge: daedalus::modded::Manifest,
pub fabric: daedalus::modded::Manifest,
}
impl Metadata {
pub async fn init() -> Result<(), DataError> {
let meta_path = LAUNCHER_WORK_DIR.join(META_FILE);
if meta_path.exists() {
let meta_data = tokio::fs::read_to_string(meta_path)
.await
.ok()
.and_then(|it| serde_json::from_str::<Metadata>(&it).ok());
if let Some(metadata) = meta_data {
METADATA.get_or_init(|| RwLock::new(metadata));
}
}
let future = async {
for attempt in 0..=3 {
let res = async {
let new = Self::fetch().await?;
std::fs::write(
LAUNCHER_WORK_DIR.join(META_FILE),
&serde_json::to_string(&new)?,
)?;
if let Some(metadata) = METADATA.get() {
*metadata.write().await = new;
} else {
METADATA.get_or_init(|| RwLock::new(new));
}
Ok::<(), DataError>(())
}
.await;
match res {
Ok(_) => {
break;
}
Err(_) if attempt <= 3 => continue,
Err(err) => {
log::warn!("Unable to fetch launcher metadata: {}", err)
}
};
}
};
if METADATA.get().is_some() {
tokio::task::spawn(future);
} else {
future.await;
}
Ok(())
}
pub async fn fetch() -> Result<Self, DataError> {
let (game, forge, fabric) = futures::future::join3(
daedalus::minecraft::fetch_version_manifest(Some(&format!(
"{}/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
)),
)
.await;
Ok(Self {
minecraft: game?,
forge: forge?,
fabric: fabric?,
})
}
pub async fn get<'a>() -> Result<RwLockReadGuard<'a, Self>, DataError> {
let res = METADATA
.get()
.ok_or_else(|| DataError::InitializedError("metadata".to_string()))?
.read()
.await;
Ok(res)
}
}

View File

@ -1,27 +0,0 @@
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)]
pub enum DataError {
#[error("I/O error while reading data: {0}")]
IOError(#[from] io::Error),
#[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),
#[error("Error while serializing/deserializing data")]
SerdeError(#[from] serde_json::Error),
}

View File

@ -1,502 +0,0 @@
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,124 +0,0 @@
use super::profiles::*;
use std::{collections::HashSet, path::PathBuf};
use crate::{data::DataError, LAUNCHER_WORK_DIR};
use once_cell::sync;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
const SETTINGS_FILE: &str = "settings.json";
const ICONS_PATH: &str = "icons";
const METADATA_DIR: &str = "meta";
const SETTINGS_PATH_ENV: &str = "THESEUS_CONFIG_DIR";
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: 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: MemorySettings::default(),
game_resolution: WindowSize::default(),
custom_java_args: Vec::new(),
java_8_path: None,
java_17_path: None,
hooks: ProfileHooks::default(),
icon_path: LAUNCHER_WORK_DIR.join(ICONS_PATH),
metadata_dir: LAUNCHER_WORK_DIR.join(METADATA_DIR),
profiles: HashSet::new(),
max_concurrent_downloads: 32,
version: FORMAT_VERSION,
}
}
}
impl Settings {
pub async fn init() -> Result<(), DataError> {
let settings_path = std::env::var_os(SETTINGS_PATH_ENV)
.map_or(LAUNCHER_WORK_DIR.join(SETTINGS_FILE), PathBuf::from);
if settings_path.exists() {
let settings_data = tokio::fs::read_to_string(settings_path)
.await
.map(|x| serde_json::from_str::<Settings>(&x).ok())
.ok()
.flatten();
if let Some(settings) = settings_data {
SETTINGS.get_or_init(|| RwLock::new(settings));
}
}
if SETTINGS.get().is_none() {
let new = Self::default();
tokio::fs::write(
LAUNCHER_WORK_DIR.join(SETTINGS_FILE),
&serde_json::to_string(&new)?,
)
.await?;
SETTINGS.get_or_init(|| RwLock::new(new));
}
Ok(())
}
pub async fn load() -> Result<(), DataError> {
let new = serde_json::from_str::<Settings>(&std::fs::read_to_string(
LAUNCHER_WORK_DIR.join(SETTINGS_FILE),
)?)?;
let mut write = SETTINGS
.get()
.ok_or_else(|| DataError::InitializedError("settings".to_string()))?
.write()
.await;
*write = new;
Ok(())
}
pub async fn save() -> Result<(), DataError> {
let settings = Self::get().await?;
std::fs::write(
LAUNCHER_WORK_DIR.join(SETTINGS_FILE),
&serde_json::to_string_pretty(&*settings)?,
)?;
Ok(())
}
pub async fn get<'a>() -> Result<RwLockReadGuard<'a, Self>, DataError> {
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()))
}
}

103
theseus/src/error.rs Normal file
View File

@ -0,0 +1,103 @@
//! Theseus error type
use tracing_error::InstrumentError;
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
#[error("Filesystem error: {0}")]
FSError(String),
#[error("Serialization error (JSON): {0}")]
JSONError(#[from] serde_json::Error),
#[error("Error parsing UUID: {0}")]
UUIDError(#[from] uuid::Error),
#[error("Serialization error (Bincode): {0}")]
EncodeError(#[from] bincode::error::EncodeError),
#[error("Deserialization error (Bincode): {0}")]
DecodeError(#[from] bincode::error::DecodeError),
#[error("Error parsing URL: {0}")]
URLError(#[from] url::ParseError),
#[error("Database error: {0}")]
DBError(#[from] sled::Error),
#[error("Unable to read {0} from any source")]
NoValueFor(String),
#[error("Metadata error: {0}")]
MetadataError(#[from] daedalus::Error),
#[error("Minecraft authentication error: {0}")]
HydraError(String),
#[error("I/O error: {0}")]
IOError(#[from] std::io::Error),
#[error("Error launching Minecraft: {0}")]
LauncherError(String),
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Websocket error: {0}")]
WSError(#[from] async_tungstenite::tungstenite::Error),
#[error("Websocket closed before {0} could be received!")]
WSClosedError(String),
#[error("Incorrect Sha1 hash for download: {0} != {1}")]
HashError(String, String),
#[error("Paths stored in the database need to be valid UTF-8: {0}")]
UTFError(std::path::PathBuf),
#[error("Invalid input: {0}")]
InputError(String),
#[error(
"Tried to access unloaded profile {0}, loading it probably failed"
)]
UnloadedProfileError(String),
#[error("Profile {0} is not managed by Theseus!")]
UnmanagedProfileError(String),
#[error("Error: {0}")]
OtherError(String),
}
#[derive(Debug)]
pub struct Error {
source: tracing_error::TracedError<ErrorKind>,
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.source()
}
}
impl std::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(fmt, "{}", self.source)
}
}
impl<E: Into<ErrorKind>> From<E> for Error {
fn from(source: E) -> Self {
Self {
source: Into::<ErrorKind>::into(source).in_current_span(),
}
}
}
impl ErrorKind {
pub fn as_error(self) -> Error {
self.into()
}
}
pub type Result<T> = core::result::Result<T, Error>;

View File

@ -1,32 +1,29 @@
use crate::data::profiles::*;
use crate::launcher::auth::provider::Credentials;
use crate::launcher::rules::parse_rules;
use crate::launcher::LauncherError;
use daedalus::get_path_from_artifact;
use daedalus::minecraft::{Argument, ArgumentValue, Library, Os, VersionType};
use daedalus::modded::SidedDataEntry;
use std::collections::HashMap;
//! Minecraft CLI argument logic
// TODO: Rafactor this section
use super::{auth::Credentials, parse_rule};
use crate::{
state::{MemorySettings, WindowSize},
util::platform::classpath_separator,
};
use daedalus::{
get_path_from_artifact,
minecraft::{Argument, ArgumentValue, Library, VersionType},
modded::SidedDataEntry,
};
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::{collections::HashMap, path::Path};
use uuid::Uuid;
fn get_cp_separator() -> &'static str {
match super::download::get_os() {
Os::Osx | Os::Linux | Os::Unknown => ":",
Os::Windows => ";",
}
}
pub fn get_class_paths(
libraries_path: &Path,
libraries: &[Library],
client_path: &Path,
) -> Result<String, LauncherError> {
let mut class_paths = libraries
) -> crate::Result<String> {
let mut cps = libraries
.iter()
.filter_map(|library| {
if let Some(rules) = &library.rules {
if !super::rules::parse_rules(rules.as_slice()) {
if !rules.iter().all(parse_rule) {
return None;
}
}
@ -39,56 +36,50 @@ pub fn get_class_paths(
})
.collect::<Result<Vec<_>, _>>()?;
class_paths.push(
crate::util::absolute_path(&client_path)
cps.push(
client_path
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
client_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
);
Ok(class_paths.join(get_cp_separator()))
Ok(cps.join(classpath_separator()))
}
pub fn get_class_paths_jar<T: AsRef<str>>(
libraries_path: &Path,
libraries: &[T],
) -> Result<String, LauncherError> {
let class_paths = libraries
) -> crate::Result<String> {
let cps = libraries
.iter()
.map(|library| get_lib_path(libraries_path, library.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
Ok(class_paths.join(get_cp_separator()))
Ok(cps.join(classpath_separator()))
}
pub fn get_lib_path(libraries_path: &Path, lib: &str) -> Result<String, LauncherError> {
pub fn get_lib_path(libraries_path: &Path, lib: &str) -> crate::Result<String> {
let mut path = libraries_path.to_path_buf();
path.push(get_path_from_artifact(lib.as_ref())?);
let path = crate::util::absolute_path(&path).map_err(|_| {
LauncherError::InvalidInput(format!(
let path = &path.canonicalize().map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Library file at path {} does not exist",
path.to_string_lossy()
))
.as_error()
})?;
/*if !path.exists() {
if let Some(parent) = &path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::File::create(&path)?;
}*/
Ok(path.to_string_lossy().to_string())
}
pub fn get_jvm_arguments(
arguments: Option<&[Argument]>,
natives_path: &Path,
@ -97,7 +88,7 @@ pub fn get_jvm_arguments(
version_name: &str,
memory: MemorySettings,
custom_args: Vec<String>,
) -> Result<Vec<String>, LauncherError> {
) -> crate::Result<Vec<String>> {
let mut parsed_arguments = Vec::new();
if let Some(args) = arguments {
@ -113,11 +104,13 @@ pub fn get_jvm_arguments(
} else {
parsed_arguments.push(format!(
"-Djava.library.path={}",
&crate::util::absolute_path(natives_path)
.map_err(|_| LauncherError::InvalidInput(format!(
&natives_path
.canonicalize()
.map_err(|_| crate::ErrorKind::LauncherError(format!(
"Specified natives path {} does not exist",
natives_path.to_string_lossy()
)))?
))
.as_error())?
.to_string_lossy()
.to_string()
));
@ -144,33 +137,37 @@ fn parse_jvm_argument(
libraries_path: &Path,
class_paths: &str,
version_name: &str,
) -> Result<String, LauncherError> {
) -> crate::Result<String> {
argument.retain(|c| !c.is_whitespace());
Ok(argument
.replace(
"${natives_directory}",
&crate::util::absolute_path(natives_path)
&natives_path
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified natives path {} does not exist",
natives_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy(),
)
.replace(
"${library_directory}",
&crate::util::absolute_path(libraries_path)
&libraries_path
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified libraries path {} does not exist",
libraries_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
)
.replace("${classpath_separator}", get_cp_separator())
.replace("${classpath_separator}", classpath_separator())
.replace("${launcher_name}", "theseus")
.replace("${launcher_version}", env!("CARGO_PKG_VERSION"))
.replace("${version_name}", version_name)
@ -188,7 +185,7 @@ pub fn get_minecraft_arguments(
assets_directory: &Path,
version_type: &VersionType,
resolution: WindowSize,
) -> Result<Vec<String>, LauncherError> {
) -> crate::Result<Vec<String>> {
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
@ -242,48 +239,54 @@ fn parse_minecraft_argument(
assets_directory: &Path,
version_type: &VersionType,
resolution: WindowSize,
) -> Result<String, LauncherError> {
) -> crate::Result<String> {
Ok(argument
.replace("${auth_access_token}", access_token)
.replace("${auth_session}", access_token)
.replace("${auth_player_name}", username)
.replace("${auth_uuid}", &uuid.to_hyphenated().to_string())
.replace("${auth_uuid}", &uuid.hyphenated().to_string())
.replace("${user_properties}", "{}")
.replace("${user_type}", "mojang")
.replace("${version_name}", version)
.replace("${assets_index_name}", asset_index_name)
.replace(
"${game_directory}",
&crate::util::absolute_path(game_directory)
&game_directory
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified game directory {} does not exist",
game_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
)
.replace(
"${assets_root}",
&crate::util::absolute_path(assets_directory)
&assets_directory
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified assets directory {} does not exist",
assets_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
)
.replace(
"${game_assets}",
&crate::util::absolute_path(assets_directory)
&assets_directory
.canonicalize()
.map_err(|_| {
LauncherError::InvalidInput(format!(
crate::ErrorKind::LauncherError(format!(
"Specified assets directory {} does not exist",
assets_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
@ -297,9 +300,9 @@ fn parse_arguments<F>(
arguments: &[Argument],
parsed_arguments: &mut Vec<String>,
parse_function: F,
) -> Result<(), LauncherError>
) -> crate::Result<()>
where
F: Fn(&str) -> Result<String, LauncherError>,
F: Fn(&str) -> crate::Result<String>,
{
for argument in arguments {
match argument {
@ -311,7 +314,7 @@ where
}
}
Argument::Ruled { rules, value } => {
if parse_rules(rules.as_slice()) {
if rules.iter().all(parse_rule) {
match value {
ArgumentValue::Single(arg) => {
parsed_arguments.push(parse_function(arg)?);
@ -334,7 +337,7 @@ pub fn get_processor_arguments<T: AsRef<str>>(
libraries_path: &Path,
arguments: &[T],
data: &HashMap<String, SidedDataEntry>,
) -> Result<Vec<String>, LauncherError> {
) -> crate::Result<Vec<String>> {
let mut new_arguments = Vec::new();
for argument in arguments {
@ -342,7 +345,10 @@ pub fn get_processor_arguments<T: AsRef<str>>(
if argument.as_ref().starts_with('{') {
if let Some(entry) = data.get(trimmed_arg) {
new_arguments.push(if entry.client.starts_with('[') {
get_lib_path(libraries_path, &entry.client[1..entry.client.len() - 1])?
get_lib_path(
libraries_path,
&entry.client[1..entry.client.len() - 1],
)?
} else {
entry.client.clone()
})
@ -357,15 +363,25 @@ pub fn get_processor_arguments<T: AsRef<str>>(
Ok(new_arguments)
}
pub async fn get_processor_main_class(path: String) -> Result<Option<String>, LauncherError> {
pub async fn get_processor_main_class(
path: String,
) -> crate::Result<Option<String>> {
Ok(tokio::task::spawn_blocking(move || {
let zipfile = std::fs::File::open(&path)?;
let mut archive = zip::ZipArchive::new(zipfile).map_err(|_| {
LauncherError::ProcessorError(format!("Cannot read processor at {}", path))
crate::ErrorKind::LauncherError(format!(
"Cannot read processor at {}",
path
))
.as_error()
})?;
let file = archive.by_name("META-INF/MANIFEST.MF").map_err(|_| {
LauncherError::ProcessorError(format!("Cannot read processor manifest at {}", path))
crate::ErrorKind::LauncherError(format!(
"Cannot read processor manifest at {}",
path
))
.as_error()
})?;
let reader = BufReader::new(file);
@ -381,7 +397,8 @@ pub async fn get_processor_main_class(path: String) -> Result<Option<String>, La
}
}
Ok::<Option<String>, LauncherError>(None)
Ok::<Option<String>, crate::Error>(None)
})
.await??)
.await
.unwrap()?)
}

View File

@ -1,205 +1,168 @@
pub mod api {
use serde::{Deserialize, Serialize};
use uuid::Uuid;
//! Authentication flow based on Hydra
use async_tungstenite as ws;
use bincode::{Decode, Encode};
use chrono::{prelude::*, Duration};
use futures::prelude::*;
use once_cell::sync::*;
use serde::Deserialize;
use url::Url;
#[derive(Debug, Serialize, Deserialize)]
pub struct GameProfile {
pub id: Uuid,
pub name: String,
pub const HYDRA_URL: Lazy<Url> =
Lazy::new(|| Url::parse("https://hydra.modrinth.com").unwrap());
// Socket messages
#[derive(Deserialize)]
struct ErrorJSON {
error: String,
}
#[derive(Debug, Deserialize)]
pub struct UserProperty {
pub name: String,
pub value: String,
impl ErrorJSON {
pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result<T> {
if let Ok(err) = serde_json::from_slice::<Self>(data) {
Err(crate::ErrorKind::HydraError(err.error).as_error())
} else {
Ok(serde_json::from_slice::<T>(data)?)
}
#[derive(Debug, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
pub properties: Option<Vec<UserProperty>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticateResponse {
pub user: Option<User>,
pub client_token: Uuid,
pub access_token: String,
pub available_profiles: Vec<GameProfile>,
pub selected_profile: Option<GameProfile>,
}
pub async fn login(
username: &str,
password: &str,
request_user: bool,
) -> Result<AuthenticateResponse, reqwest::Error> {
let client = reqwest::Client::new();
client
.post("https://authserver.mojang.com/authenticate")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!(
{
"agent": {
"name": "Minecraft",
"version": 1
},
"username": username,
"password": password,
"clientToken": Uuid::new_v4(),
"requestUser": request_user
}
)
.to_string(),
)
.send()
.await?
.json()
.await
}
pub async fn sign_out(username: &str, password: &str) -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
client
.post("https://authserver.mojang.com/signout")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!(
{
"username": username,
"password": password
}
)
.to_string(),
)
.send()
.await?;
Ok(())
}
pub async fn validate(access_token: &str, client_token: &str) -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
client
.post("https://authserver.mojang.com/validate")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!(
{
"accessToken": access_token,
"clientToken": client_token
}
)
.to_string(),
)
.send()
.await?;
Ok(())
}
pub async fn invalidate(access_token: &str, client_token: &str) -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
client
.post("https://authserver.mojang.com/invalidate")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!(
{
"accessToken": access_token,
"clientToken": client_token
}
)
.to_string(),
)
.send()
.await?;
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshResponse {
pub user: Option<User>,
pub client_token: Uuid,
pub access_token: String,
pub selected_profile: Option<GameProfile>,
}
pub async fn refresh(
access_token: &str,
client_token: &str,
selected_profile: &GameProfile,
request_user: bool,
) -> Result<RefreshResponse, reqwest::Error> {
let client = reqwest::Client::new();
client
.post("https://authserver.mojang.com/refresh")
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::json!(
{
"accessToken": access_token,
"clientToken": client_token,
"selectedProfile": {
"id": selected_profile.id,
"name": selected_profile.name,
},
"requestUser": request_user,
}
)
.to_string(),
)
.send()
.await?
.json()
.await
}
}
pub mod provider {
use crate::launcher::auth::api::login;
use crate::launcher::LauncherError;
use uuid::Uuid;
#[derive(Deserialize)]
struct LoginCodeJSON {
login_code: String,
}
#[derive(Debug)]
/// The credentials of a user
#[derive(Deserialize)]
struct TokenJSON {
token: String,
refresh_token: String,
expires_after: u32,
}
#[derive(Deserialize)]
struct ProfileInfoJSON {
id: uuid::Uuid,
name: String,
}
// Login information
#[derive(Encode, Decode)]
pub struct Credentials {
/// The user UUID the credentials belong to
pub id: Uuid,
/// The username of the user
#[bincode(with_serde)]
pub id: uuid::Uuid,
pub username: String,
/// The access token associated with the credentials
pub access_token: String,
pub refresh_token: String,
#[bincode(with_serde)]
pub expires: DateTime<Utc>,
_ctor_scope: std::marker::PhantomData<()>,
}
impl Credentials {
/// Gets a credentials instance from a user's login
pub async fn from_login(username: &str, password: &str) -> Result<Self, LauncherError> {
let login =
login(username, password, true)
.await
.map_err(|err| LauncherError::FetchError {
inner: err,
item: "authentication credentials".to_string(),
})?;
// Implementation
pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
socket: ws::WebSocketStream<S>,
}
let profile = login.selected_profile.unwrap();
impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn new() -> crate::Result<Self> {
let sock_url = wrap_ref_builder!(
it = HYDRA_URL =>
{ it.set_scheme("wss").ok() }
);
let (socket, _) = ws::tokio::connect_async(sock_url.clone()).await?;
Ok(Self { socket })
}
pub async fn prepare_login_url(&mut self) -> crate::Result<Url> {
let code_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let code = ErrorJSON::unwrap::<LoginCodeJSON>(&code_resp)?;
Ok(wrap_ref_builder!(
it = HYDRA_URL.join("login")? =>
{ it.query_pairs_mut().append_pair("id", &code.login_code); }
))
}
pub async fn extract_credentials(&mut self) -> crate::Result<Credentials> {
// Minecraft bearer token
let token_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let token = ErrorJSON::unwrap::<TokenJSON>(&token_resp)?;
let expires =
Utc::now() + Duration::seconds(token.expires_after.into());
// Get account credentials
let info = fetch_info(&token.token).await?;
// Return structure from response
Ok(Credentials {
id: profile.id,
username: profile.name,
access_token: login.access_token,
username: info.name,
id: info.id,
refresh_token: token.refresh_token,
access_token: token.token,
expires,
_ctor_scope: std::marker::PhantomData,
})
}
}
pub async fn refresh_credentials(
credentials: &mut Credentials,
) -> crate::Result<()> {
let resp = crate::config::REQWEST_CLIENT
.post(HYDRA_URL.join("/refresh")?)
.json(
&serde_json::json!({ "refresh_token": credentials.refresh_token }),
)
.send()
.await?
.error_for_status()?
.json::<TokenJSON>()
.await?;
credentials.access_token = resp.token;
credentials.refresh_token = resp.refresh_token;
credentials.expires =
Utc::now() + Duration::seconds(resp.expires_after.into());
Ok(())
}
pub async fn refresh_username(
credentials: &mut Credentials,
) -> crate::Result<()> {
let info = fetch_info(&credentials.access_token).await?;
credentials.username = info.name;
Ok(())
}
// Helpers
async fn fetch_info(token: &str) -> crate::Result<ProfileInfoJSON> {
let url =
Url::parse("https://api.minecraftservices.com/minecraft/profile")?;
Ok(crate::config::REQWEST_CLIENT
.get(url)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?
.error_for_status()?
.json::<ProfileInfoJSON>()
.await?)
}

View File

@ -1,362 +1,286 @@
//! Downloader for Minecraft data
use crate::{
data::{DataError, Settings},
launcher::LauncherError,
state::State,
util::{fetch::*, platform::OsExt},
};
use daedalus::get_path_from_artifact;
use daedalus::minecraft::{
fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType,
Library, Os, Version, VersionInfo,
};
use daedalus::modded::{
fetch_partial_version, merge_partial_version, LoaderVersion,
};
use futures::future;
use std::path::Path;
use std::time::Duration;
use tokio::{
fs::File,
io::AsyncWriteExt,
sync::{OnceCell, Semaphore},
use daedalus::{
self as d,
minecraft::{
Asset, AssetsIndex, Library, Os, Version as GameVersion,
VersionInfo as GameVersionInfo,
},
modded::LoaderVersion,
};
use futures::prelude::*;
use tokio::{fs, sync::OnceCell};
static DOWNLOADS_SEMAPHORE: OnceCell<Semaphore> = OnceCell::const_new();
#[tracing::instrument(skip_all)]
pub async fn download_minecraft(
st: &State,
version: &GameVersionInfo,
) -> crate::Result<()> {
log::info!("Downloading Minecraft version {}", version.id);
let assets_index = download_assets_index(st, version).await?;
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?;
tokio::try_join! {
download_client(st, version),
download_assets(st, version.assets == "legacy", &assets_index),
download_libraries(st, version.libraries.as_slice(), &version.id)
}?;
log::info!("Done downloading Minecraft!");
Ok(())
}
#[tracing::instrument(skip_all, fields(version = version.id.as_str(), loader = ?loader))]
pub async fn download_version_info(
client_path: &Path,
version: &Version,
loader_version: Option<&LoaderVersion>,
) -> Result<VersionInfo, LauncherError> {
let id = match loader_version {
Some(x) => &x.id,
None => &version.id,
};
st: &State,
version: &GameVersion,
loader: Option<&LoaderVersion>,
) -> crate::Result<GameVersionInfo> {
let version_id = loader.map_or(&version.id, |it| &it.id);
log::debug!("Loading version info for Minecraft {version_id}");
let path = st
.directories
.version_dir(version_id)
.join(format!("{version_id}.json"));
let mut path = client_path.join(id);
path.push(&format!("{id}.json"));
if path.exists() {
let contents = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&contents)?)
let res = if path.exists() {
fs::read(path)
.err_into::<crate::Error>()
.await
.and_then(|ref it| Ok(serde_json::from_slice(it)?))
} else {
let mut info = fetch_version_info(version).await?;
log::info!("Downloading version info for version {}", &version.id);
let mut info = d::minecraft::fetch_version_info(version).await?;
if let Some(loader_version) = loader_version {
let partial = fetch_partial_version(&loader_version.url).await?;
info = merge_partial_version(partial, info);
info.id = loader_version.id.clone();
if let Some(loader) = loader {
let partial = d::modded::fetch_partial_version(&loader.url).await?;
info = d::modded::merge_partial_version(partial, info);
info.id = loader.id.clone();
}
let info_s = serde_json::to_string(&info)?;
save_file(&path, &bytes::Bytes::from(info_s)).await?;
let permit = st.io_semaphore.acquire().await.unwrap();
write(&path, &serde_json::to_vec(&info)?, &permit).await?;
Ok(info)
}
}?;
log::debug!("Loaded version info for Minecraft {version_id}");
Ok(res)
}
#[tracing::instrument(skip_all)]
pub async fn download_client(
client_path: &Path,
version_info: &VersionInfo,
) -> Result<(), LauncherError> {
let version = &version_info.id;
st: &State,
version_info: &GameVersionInfo,
) -> crate::Result<()> {
let ref version = version_info.id;
log::debug!("Locating client for version {version}");
let client_download = version_info
.downloads
.get(&DownloadType::Client)
.ok_or_else(|| {
LauncherError::InvalidInput(format!(
"Version {version} does not have any client downloads"
.get(&d::minecraft::DownloadType::Client)
.ok_or(
crate::ErrorKind::LauncherError(format!(
"No client downloads exist for version {version}"
))
})?;
.as_error(),
)?;
let path = st
.directories
.version_dir(version)
.join(format!("{version}.jar"));
let mut path = client_path.join(version);
path.push(&format!("{version}.jar"));
save_and_download_file(
&path,
&client_download.url,
Some(&client_download.sha1),
)
if !path.exists() {
let permit = st.io_semaphore.acquire().await.unwrap();
let bytes =
fetch(&client_download.url, Some(&client_download.sha1), &permit)
.await?;
write(&path, &bytes, &permit).await?;
log::info!("Fetched client version {version}");
}
log::debug!("Client loaded for version {version}!");
Ok(())
}
#[tracing::instrument(skip_all)]
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));
st: &State,
version: &GameVersionInfo,
) -> crate::Result<AssetsIndex> {
log::debug!("Loading assets index");
let path = st
.directories
.assets_index_dir()
.join(format!("{}.json", &version.asset_index.id));
if path.exists() {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
let res = if path.exists() {
fs::read(path)
.err_into::<crate::Error>()
.await
.and_then(|ref it| Ok(serde_json::from_slice(it)?))
} else {
let index = fetch_assets_index(version).await?;
save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?))
.await?;
let index = d::minecraft::fetch_assets_index(version).await?;
let permit = st.io_semaphore.acquire().await.unwrap();
write(&path, &serde_json::to_vec(&index)?, &permit).await?;
log::info!("Fetched assets index");
Ok(index)
}
}?;
log::debug!("Assets index successfully loaded!");
Ok(res)
}
#[tracing::instrument(skip(st, index))]
pub async fn download_assets(
assets_path: &Path,
legacy_path: Option<&Path>,
st: &State,
with_legacy: bool,
index: &AssetsIndex,
) -> Result<(), LauncherError> {
future::join_all(index.objects.iter().map(|(name, asset)| {
download_asset(assets_path, legacy_path, name, asset)
}))
.await
.into_iter()
.collect::<Result<Vec<()>, LauncherError>>()?;
Ok(())
}
async fn download_asset(
assets_path: &Path,
legacy_path: Option<&Path>,
name: &str,
asset: &Asset,
) -> Result<(), LauncherError> {
let hash = &asset.hash;
let sub_hash = &hash[..2];
let mut resource_path = assets_path.join("objects");
resource_path.push(sub_hash);
resource_path.push(hash);
let url =
format!("https://resources.download.minecraft.net/{sub_hash}/{hash}");
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).await?;
}
) -> crate::Result<()> {
log::debug!("Loading assets");
stream::iter(index.objects.iter())
.map(Ok::<(&String, &Asset), crate::Error>)
.try_for_each_concurrent(None, |(name, asset)| async move {
let ref hash = asset.hash;
let resource_path = st.directories.object_dir(hash);
let url = format!(
"https://resources.download.minecraft.net/{sub_hash}/{hash}",
sub_hash = &hash[..2]
);
let fetch_cell = OnceCell::<bytes::Bytes>::new();
tokio::try_join! {
async {
if !resource_path.exists() {
let permit = st.io_semaphore.acquire().await.unwrap();
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &permit))
.await?;
write(&resource_path, &resource, &permit).await?;
log::info!("Fetched asset with hash {hash}");
}
Ok::<_, crate::Error>(())
},
async {
if with_legacy {
let permit = st.io_semaphore.acquire().await.unwrap();
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &permit))
.await?;
let resource_path = st.directories.legacy_assets_dir().join(
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
);
write(&resource_path, &resource, &permit).await?;
log::info!("Fetched legacy asset with hash {hash}");
}
Ok::<_, crate::Error>(())
},
}?;
log::debug!("Loaded asset with hash {hash}");
Ok(())
}).await?;
log::debug!("Done loading assets!");
Ok(())
}
#[tracing::instrument(skip(st, libraries))]
pub async fn download_libraries(
libraries_path: &Path,
natives_path: &Path,
st: &State,
libraries: &[Library],
) -> Result<(), LauncherError> {
future::join_all(libraries.iter().map(|library| {
download_library(libraries_path, natives_path, library)
}))
.await
.into_iter()
.collect::<Result<Vec<()>, LauncherError>>()?;
version: &str,
) -> crate::Result<()> {
log::debug!("Loading libraries");
Ok(())
}
tokio::try_join! {
fs::create_dir_all(st.directories.libraries_dir()),
fs::create_dir_all(st.directories.version_natives_dir(version))
}?;
async fn download_library(
libraries_path: &Path,
natives_path: &Path,
library: &Library,
) -> Result<(), LauncherError> {
stream::iter(libraries.iter())
.map(Ok::<&Library, crate::Error>)
.try_for_each_concurrent(None, |library| async move {
if let Some(rules) = &library.rules {
if !super::rules::parse_rules(rules) {
if !rules.iter().all(super::parse_rule) {
return Ok(());
}
}
tokio::try_join! {
async {
let artifact_path = d::get_path_from_artifact(&library.name)?;
let path = st.directories.libraries_dir().join(&artifact_path);
future::try_join(
download_library_jar(libraries_path, library),
download_native(natives_path, library),
)
match library.downloads {
_ if path.exists() => Ok(()),
Some(d::minecraft::LibraryDownloads {
artifact: Some(ref artifact),
..
}) => {
let permit = st.io_semaphore.acquire().await.unwrap();
let bytes = fetch(&artifact.url, Some(&artifact.sha1), &permit)
.await?;
Ok(())
write(&path, &bytes, &permit).await?;
log::info!("Fetched library {}", &library.name);
Ok::<_, crate::Error>(())
}
async fn download_library_jar(
libraries_path: &Path,
library: &Library,
) -> Result<(), LauncherError> {
let artifact_path = get_path_from_artifact(&library.name)?;
let path = libraries_path.join(&artifact_path);
if let Some(downloads) = &library.downloads {
if let Some(library) = &downloads.artifact {
save_and_download_file(&path, &library.url, Some(&library.sha1))
.await?;
}
} else {
let url = format!(
"{}{artifact_path}",
None => {
let url = [
library
.url
.as_deref()
.unwrap_or("https://libraries.minecraft.net/"),
.unwrap_or("https://libraries.minecraft.net"),
&artifact_path
].concat();
let permit = st.io_semaphore.acquire().await.unwrap();
let bytes = fetch(&url, None, &permit).await?;
write(&path, &bytes, &permit).await?;
log::info!("Fetched library {}", &library.name);
Ok::<_, crate::Error>(())
}
_ => Ok(())
}
},
async {
// HACK: pseudo try block using or else
if let Some((os_key, classifiers)) = None.or_else(|| Some((
library
.natives
.as_ref()?
.get(&Os::native())?,
library
.downloads
.as_ref()?
.classifiers
.as_ref()?
))) {
let parsed_key = os_key.replace(
"${arch}",
crate::util::platform::ARCH_WIDTH,
);
save_and_download_file(&path, &url, None).await?;
}
Ok(())
}
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 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")]
let parsed_key = os_key.replace("${arch}", "64");
#[cfg(target_pointer_width = "32")]
let parsed_key = os_key.replace("${arch}", "32");
if let Some(native) = classifiers.get(&parsed_key) {
let file = download_file(&native.url, Some(&native.sha1)).await?;
let reader = std::io::Cursor::new(&file);
let permit = st.io_semaphore.acquire().await.unwrap();
let data = fetch(&native.url, Some(&native.sha1), &permit).await?;
let reader = std::io::Cursor::new(&data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
archive.extract(natives_path).unwrap();
archive.extract(&st.directories.version_natives_dir(version)).unwrap();
log::info!("Fetched native {}", &library.name);
}
}
Ok(())
}
}?;
async fn save_and_download_file(
path: &Path,
url: &str,
sha1: Option<&str>,
) -> Result<bytes::Bytes, LauncherError> {
match std::fs::read(path) {
Ok(bytes) => Ok(bytes::Bytes::from(bytes)),
Err(_) => {
let file = download_file(url, sha1).await?;
save_file(path, &file).await?;
Ok(file)
}
}
}
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() {
tokio::fs::create_dir_all(parent).await?;
}
let mut file = File::create(path).await?;
file.write_all(bytes).await?;
log::debug!("Loaded library {}", library.name);
Ok(())
}
).await?;
pub fn get_os() -> Os {
match std::env::consts::OS {
"windows" => Os::Windows,
"macos" => Os::Osx,
"linux" => Os::Linux,
_ => Os::Unknown,
}
}
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()
.map_err(|err| LauncherError::FetchError {
inner: err,
item: url.to_string(),
})?;
for attempt in 1..=4 {
let result = client.get(url).send().await;
match result {
Ok(x) => {
let bytes = x.bytes().await;
if let Ok(bytes) = bytes {
if let Some(sha1) = sha1 {
if &get_hash(bytes.clone()).await? != sha1 {
if attempt <= 3 {
continue;
} else {
return Err(LauncherError::ChecksumFailure {
hash: sha1.to_string(),
url: url.to_string(),
tries: attempt,
});
}
}
}
return Ok(bytes);
} else if attempt <= 3 {
continue;
} else if let Err(err) = bytes {
return Err(LauncherError::FetchError {
inner: err,
item: url.to_string(),
});
}
}
Err(_) if attempt <= 3 => continue,
Err(err) => {
return Err(LauncherError::FetchError {
inner: err,
item: url.to_string(),
})
}
}
}
unreachable!()
}
/// 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?;
Ok(hash)
log::debug!("Done loading libraries!");
Ok(())
}

View File

@ -1,328 +1,214 @@
use daedalus::minecraft::{ArgumentType, VersionInfo};
use daedalus::modded::LoaderVersion;
use serde::{Deserialize, Serialize};
//! Logic for launching Minecraft
use crate::state as st;
use daedalus as d;
use std::{path::Path, process::Stdio};
use thiserror::Error;
use tokio::process::{Child, Command};
pub use crate::launcher::auth::provider::Credentials;
mod args;
pub mod auth;
mod download;
mod rules;
pub(crate) use download::init as init_download_semaphore;
#[tracing::instrument]
pub fn parse_rule(rule: &d::minecraft::Rule) -> bool {
use d::minecraft::{Rule, RuleAction};
#[derive(Error, Debug)]
pub enum LauncherError {
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
ChecksumFailure {
hash: String,
url: String,
tries: u32,
},
#[error("Failed to run processor: {0}")]
ProcessorError(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Error while managing asynchronous tasks")]
TaskError(#[from] tokio::task::JoinError),
#[error("Error while reading/writing to the disk: {0}")]
IoError(#[from] std::io::Error),
#[error("Error while spawning child process {process}")]
ProcessError {
inner: std::io::Error,
process: String,
},
#[error("Error while deserializing JSON")]
SerdeError(#[from] serde_json::Error),
#[error("Unable to fetch {item}")]
FetchError { inner: reqwest::Error, item: String },
#[error("{0}")]
ParseError(String),
#[error("Error while fetching metadata: {0}")]
DaedalusError(#[from] daedalus::Error),
#[error("Error while reading metadata: {0}")]
MetaError(#[from] crate::data::DataError),
#[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 {
Vanilla,
Forge,
Fabric,
}
impl Default for ModLoader {
fn default() -> Self {
ModLoader::Vanilla
}
}
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 res = match rule {
Rule {
os: Some(ref os), ..
} => crate::util::platform::os_rule(os),
Rule {
features: Some(ref features),
..
} => features.has_demo_resolution.unwrap_or(false),
_ => true,
};
f.write_str(repr)
match rule.action {
RuleAction::Allow => res,
RuleAction::Disallow => !res,
}
}
macro_rules! processor_rules {
($dest:expr; $($name:literal : client => $client:expr, server => $server:expr;)+) => {
$(std::collections::HashMap::insert(
$dest,
String::from($name),
daedalus::modded::SidedDataEntry {
client: String::from($client),
server: String::from($server),
},
);)+
}
}
#[tracing::instrument(skip_all, fields(path = ?instance_path))]
pub async fn launch_minecraft(
game_version: &str,
loader_version: &Option<LoaderVersion>,
root_dir: &Path,
java: &Path,
java_args: &Vec<String>,
loader_version: &Option<d::modded::LoaderVersion>,
instance_path: &Path,
java_install: &Path,
java_args: &[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;
memory: &st::MemorySettings,
resolution: &st::WindowSize,
credentials: &auth::Credentials,
) -> crate::Result<Child> {
let state = st::State::get().await?;
let instance_path = instance_path.canonicalize()?;
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
let version = state
.metadata
.minecraft
.versions
.iter()
.find(|it| it.id == game_version)
.ok_or_else(|| {
LauncherError::InvalidInput(format!(
"Invalid game version: {game_version}",
))
})?;
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {game_version}"
)))?;
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,
version,
let mut version_info = download::download_version_info(
&state,
&version,
loader_version.as_ref(),
)
.await?;
let client_path = versions_path
.join(&version.id)
.join(format!("{}.jar", &version_jar));
let version_natives_path = natives_path.join(&version.id);
let client_path = state
.directories
.version_dir(&version.id)
.join(format!("{version_jar}.jar"));
download_minecraft(
&version,
&versions_path,
&assets_path,
&legacy_assets_path,
&libraries_path,
&version_natives_path,
)
.await?;
download::download_minecraft(&state, &version_info).await?;
st::State::sync().await?;
if let Some(processors) = &version.processors {
if let Some(ref mut data) = version.data {
data.insert(
"SIDE".to_string(),
daedalus::modded::SidedDataEntry {
client: "client".to_string(),
server: "".to_string(),
},
);
data.insert(
"MINECRAFT_JAR".to_string(),
daedalus::modded::SidedDataEntry {
client: client_path.to_string_lossy().to_string(),
server: "".to_string(),
},
);
data.insert(
"MINECRAFT_VERSION".to_string(),
daedalus::modded::SidedDataEntry {
client: game_version.to_string(),
server: "".to_string(),
},
);
data.insert(
"ROOT".to_string(),
daedalus::modded::SidedDataEntry {
client: root_dir.to_string_lossy().to_string(),
server: "".to_string(),
},
);
data.insert(
"LIBRARY_DIR".to_string(),
daedalus::modded::SidedDataEntry {
client: libraries_path.to_string_lossy().to_string(),
server: "".to_string(),
},
);
if let Some(processors) = &version_info.processors {
if let Some(ref mut data) = version_info.data {
processor_rules! {
data;
"SIDE":
client => "client",
server => "";
"MINECRAFT_JAR" :
client => client_path.to_string_lossy(),
server => "";
"MINECRAFT_VERSION":
client => game_version,
server => "";
"ROOT":
client => instance_path.to_string_lossy(),
server => "";
"LIBRARY_DIR":
client => state.directories.libraries_dir().to_string_lossy(),
server => "";
}
for processor in processors {
if let Some(sides) = &processor.sides {
if !sides.contains(&"client".to_string()) {
if !sides.contains(&String::from("client")) {
continue;
}
}
let mut cp = processor.classpath.clone();
cp.push(processor.jar.clone());
let cp = wrap_ref_builder!(cp = processor.classpath.clone() => {
cp.push(processor.jar.clone())
});
let child = Command::new("java")
.arg("-cp")
.arg(args::get_class_paths_jar(&libraries_path, &cp)?)
.arg(args::get_class_paths_jar(
&state.directories.libraries_dir(),
&cp,
)?)
.arg(
args::get_processor_main_class(args::get_lib_path(
&libraries_path,
&state.directories.libraries_dir(),
&processor.jar,
)?)
.await?
.ok_or_else(|| {
LauncherError::ProcessorError(format!(
crate::ErrorKind::LauncherError(format!(
"Could not find processor main class for {}",
processor.jar
))
})?,
)
.args(args::get_processor_arguments(
&libraries_path,
&state.directories.libraries_dir(),
&processor.args,
data,
)?)
.output()
.await
.map_err(|err| LauncherError::ProcessError {
inner: err,
process: "java".to_string(),
.map_err(|err| {
crate::ErrorKind::LauncherError(format!(
"Error running processor: {err}",
))
})?;
if !child.status.success() {
return Err(LauncherError::ProcessorError(
String::from_utf8_lossy(&child.stderr).to_string(),
));
return Err(crate::ErrorKind::LauncherError(format!(
"Processor error: {}",
String::from_utf8_lossy(&child.stderr)
))
.as_error());
}
}
}
}
let arguments = version.arguments.clone().unwrap_or_default();
let args = version_info.arguments.clone().unwrap_or_default();
let mut command = match wrapper {
Some(hook) => {
let mut cmd = Command::new(hook);
cmd.arg(java);
cmd
wrap_ref_builder!(it = Command::new(hook) => {it.arg(java_install)})
}
None => Command::new(java.to_string_lossy().to_string()),
None => Command::new(String::from(java_install.to_string_lossy())),
};
command
.args(args::get_jvm_arguments(
arguments.get(&ArgumentType::Jvm).map(|x| x.as_slice()),
&version_natives_path,
&libraries_path,
args.get(&d::minecraft::ArgumentType::Jvm)
.map(|x| x.as_slice()),
&state.directories.version_natives_dir(&version.id),
&state.directories.libraries_dir(),
&args::get_class_paths(
&libraries_path,
version.libraries.as_slice(),
&state.directories.libraries_dir(),
version_info.libraries.as_slice(),
&client_path,
)?,
&version_jar,
*memory,
java_args.clone(),
Vec::from(java_args),
)?)
.arg(version.main_class.clone())
.arg(version_info.main_class.clone())
.args(args::get_minecraft_arguments(
arguments.get(&ArgumentType::Game).map(|x| x.as_slice()),
version.minecraft_arguments.as_deref(),
args.get(&d::minecraft::ArgumentType::Game)
.map(|x| x.as_slice()),
version_info.minecraft_arguments.as_deref(),
credentials,
&version.id,
&version.asset_index.id,
&root_dir,
&assets_path,
&version_info.asset_index.id,
&instance_path,
&state.directories.assets_dir(),
&version.type_,
*resolution,
)?)
.current_dir(root_dir.clone())
.current_dir(instance_path.clone())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
command.spawn().map_err(|err| LauncherError::ProcessError {
inner: err,
process: format!("minecraft-{} @ {}", &version.id, root_dir.display()),
command.spawn().map_err(|err| {
crate::ErrorKind::LauncherError(format!(
"Error running Minecraft (minecraft-{} @ {}): {err}",
&version.id,
instance_path.display()
))
.as_error()
})
}
pub async fn download_minecraft(
version: &VersionInfo,
versions_dir: &Path,
assets_dir: &Path,
legacy_assets_dir: &Path,
libraries_dir: &Path,
natives_dir: &Path,
) -> Result<(), LauncherError> {
let assets_index =
download::download_assets_index(assets_dir, version).await?;
let (a, b, c) = futures::future::join3(
download::download_client(versions_dir, version),
download::download_assets(
assets_dir,
if version.assets == "legacy" {
Some(legacy_assets_dir)
} else {
None
},
&assets_index,
),
download::download_libraries(
libraries_dir,
natives_dir,
version.libraries.as_slice(),
),
)
.await;
a?;
b?;
c?;
Ok(())
}

View File

@ -1,55 +0,0 @@
use crate::launcher::download::get_os;
use daedalus::minecraft::{OsRule, Rule, RuleAction};
use regex::Regex;
pub fn parse_rules(rules: &[Rule]) -> bool {
rules.iter().all(|x| parse_rule(x))
}
pub fn parse_rule(rule: &Rule) -> bool {
let result = if let Some(os) = &rule.os {
parse_os_rule(os)
} else if let Some(features) = &rule.features {
features.has_demo_resolution.unwrap_or(false)
} else {
true
};
match rule.action {
RuleAction::Allow => result,
RuleAction::Disallow => !result,
}
}
pub fn parse_os_rule(rule: &OsRule) -> bool {
if let Some(arch) = &rule.arch {
match arch.as_str() {
"x86" => {
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
return false;
}
"arm" => {
#[cfg(not(target_arch = "arm"))]
return false;
}
_ => {}
}
}
if let Some(name) = &rule.name {
if &get_os() != name {
return false;
}
}
if let Some(version) = &rule.version {
let regex = Regex::new(version.as_str());
if let Ok(regex) = regex {
if !regex.is_match(&sys_info::os_release().unwrap_or_default()) {
return false;
}
}
}
true
}

View File

@ -1,57 +1,21 @@
//! # Theseus
//!
//! Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs,
//! and launching Modrinth mod packs
/*!
# Theseus
#![warn(unused_import_braces, missing_debug_implementations)]
Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs,
and launching Modrinth mod packs
*/
#![warn(unused_import_braces)]
#![deny(unused_must_use)]
// TODO: make non-hardcoded
lazy_static::lazy_static! {
static ref LAUNCHER_WORK_DIR: std::path::PathBuf = dirs::config_dir().expect("Could not find config dir").join("theseus");
}
pub mod data;
pub mod launcher;
pub mod modpack;
#[macro_use]
mod util;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Launcher error: {0}")]
LauncherError(#[from] launcher::LauncherError),
mod api;
mod config;
mod error;
mod launcher;
mod state;
#[error("Modpack error: {0}")]
ModpackError(#[from] modpack::ModpackError),
#[error("Data error: {0}")]
DaedalusError(#[from] data::DataError),
}
pub async fn init() -> Result<(), Error> {
tokio::fs::create_dir_all(LAUNCHER_WORK_DIR.as_path())
.await
.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(())
}
pub use api::*;
pub use error::*;
pub use state::State;

View File

@ -1,446 +0,0 @@
use std::path::PathBuf;
use std::convert::TryFrom;
use crate::launcher::ModLoader;
use super::pack::ModpackGame;
use super::{pack, ModpackError, ModpackResult};
use serde::{Deserialize, Serialize};
pub const DEFAULT_FORMAT_VERSION: u32 = 1;
const MODRINTH_GAMEDATA_URL: &str = "https://staging-cdn.modrinth.com/gamedata";
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub format_version: u32,
pub game: String,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub files: Vec<ManifestFile>,
pub dependencies: ManifestDeps,
}
impl TryFrom<Manifest> for pack::Modpack {
type Error = ModpackError;
fn try_from(manifest: Manifest) -> Result<Self, Self::Error> {
let files = manifest
.files
.into_iter()
.map(pack::ModpackFile::try_from)
.collect::<ModpackResult<_>>()?;
Ok(Self {
name: manifest.name,
version: manifest.version_id,
summary: manifest.summary,
game: ModpackGame::from(manifest.dependencies),
files,
})
}
}
fn get_loader_version(loader: ModLoader, version: &str) -> ModpackResult<String> {
let source = match loader {
ModLoader::Vanilla => Err(ModpackError::VersionError(String::from(
"Attempted to get mod loader version of Vanilla",
))),
ModLoader::Forge => Ok(format!("{MODRINTH_GAMEDATA_URL}/forge/v0/manifest.json")),
ModLoader::Fabric => Ok(format!("{MODRINTH_GAMEDATA_URL}/fabric/v0/manifest.json")),
}?;
let manifest = futures::executor::block_on(daedalus::modded::fetch_manifest(&source))?;
let version = manifest
.game_versions
.iter()
.find(|&it| it.id == version)
.map(|x| x.loaders.first())
.flatten()
.ok_or_else(|| {
ModpackError::VersionError(format!(
"No versions of modloader {loader:?} exist for Minecraft {version}",
))
})?;
Ok(version.id.clone())
}
impl TryFrom<pack::Modpack> for Manifest {
type Error = ModpackError;
fn try_from(pack: pack::Modpack) -> Result<Self, Self::Error> {
let pack::Modpack {
game,
version,
name,
summary,
files,
} = pack;
let game_name = match &game {
ModpackGame::Minecraft(..) => "minecraft".into(),
};
let files: Vec<_> = files.into_iter().map(ManifestFile::from).collect();
Ok(Manifest {
format_version: DEFAULT_FORMAT_VERSION,
game: game_name,
version_id: version,
name,
summary,
files,
dependencies: ManifestDeps::try_from(game)?,
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ManifestFile {
pub path: PathBuf,
pub hashes: Option<ManifestHashes>,
#[serde(default)]
pub env: ManifestEnvs,
pub downloads: Vec<String>,
}
impl TryFrom<ManifestFile> for pack::ModpackFile {
type Error = ModpackError;
fn try_from(file: ManifestFile) -> Result<Self, Self::Error> {
Ok(Self {
path: file.path,
hashes: file.hashes.map(pack::ModpackFileHashes::from),
env: pack::ModpackEnv::try_from(file.env)?,
downloads: file.downloads.into_iter().collect(),
})
}
}
impl From<pack::ModpackFile> for ManifestFile {
fn from(file: pack::ModpackFile) -> Self {
Self {
path: file.path,
hashes: file.hashes.map(ManifestHashes::from),
env: file.env.into(),
downloads: file.downloads.into_iter().collect(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct ManifestHashes {
pub sha1: String,
}
impl From<ManifestHashes> for pack::ModpackFileHashes {
fn from(hashes: ManifestHashes) -> Self {
Self { sha1: hashes.sha1 }
}
}
impl From<pack::ModpackFileHashes> for ManifestHashes {
fn from(hashes: pack::ModpackFileHashes) -> Self {
Self { sha1: hashes.sha1 }
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
pub struct ManifestEnvs {
pub client: ManifestEnv,
pub server: ManifestEnv,
}
impl Default for ManifestEnvs {
fn default() -> Self {
Self {
client: ManifestEnv::Optional,
server: ManifestEnv::Optional,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ManifestEnv {
Required,
Optional,
Unsupported,
}
impl TryFrom<ManifestEnvs> for pack::ModpackEnv {
type Error = ModpackError;
fn try_from(envs: ManifestEnvs) -> Result<Self, Self::Error> {
use ManifestEnv::*;
match (envs.client, envs.server) {
(Required, Unsupported) => Ok(Self::ClientOnly),
(Unsupported, Required) => Ok(Self::ServerOnly),
(Optional, Optional) => Ok(Self::Both),
_ => Err(ModpackError::FormatError(format!(
"Invalid environment specification: {:?}",
envs
))),
}
}
}
impl From<pack::ModpackEnv> for ManifestEnvs {
fn from(envs: pack::ModpackEnv) -> Self {
use super::pack::ModpackEnv::*;
let (client, server) = match envs {
ClientOnly => (ManifestEnv::Required, ManifestEnv::Unsupported),
ServerOnly => (ManifestEnv::Unsupported, ManifestEnv::Required),
Both => (ManifestEnv::Optional, ManifestEnv::Optional),
};
Self { client, server }
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(untagged)]
pub enum ManifestDeps {
MinecraftFabric {
minecraft: String,
#[serde(rename = "fabric-loader")]
fabric_loader: String,
},
MinecraftForge {
minecraft: String,
forge: String,
},
MinecraftVanilla {
minecraft: String,
},
}
impl From<ManifestDeps> for pack::ModpackGame {
fn from(deps: ManifestDeps) -> Self {
use ManifestDeps::*;
match deps {
MinecraftVanilla { minecraft } => Self::Minecraft(minecraft, ModLoader::Vanilla),
MinecraftFabric { minecraft, .. } => Self::Minecraft(minecraft, ModLoader::Fabric),
MinecraftForge { minecraft, .. } => Self::Minecraft(minecraft, ModLoader::Forge),
}
}
}
impl TryFrom<pack::ModpackGame> for ManifestDeps {
type Error = ModpackError;
fn try_from(game: pack::ModpackGame) -> Result<Self, Self::Error> {
use super::pack::ModpackGame::*;
Ok(match game {
Minecraft(minecraft, ModLoader::Vanilla) => Self::MinecraftVanilla { minecraft },
Minecraft(minecraft, ModLoader::Fabric) => Self::MinecraftFabric {
fabric_loader: get_loader_version(ModLoader::Fabric, &minecraft)?,
minecraft,
},
Minecraft(minecraft, ModLoader::Forge) => Self::MinecraftForge {
forge: get_loader_version(ModLoader::Fabric, &minecraft)?,
minecraft,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple() -> ModpackResult<()> {
const PACK_JSON: &'static str = r#"
{
"formatVersion": 1,
"game": "minecraft",
"versionId": "deadbeef",
"name": "Example Pack",
"files": [],
"dependencies": {
"minecraft": "1.17.1"
}
}
"#;
let expected_manifest = Manifest {
format_version: 1,
game: "minecraft".into(),
version_id: "deadbeef".into(),
name: "Example Pack".into(),
summary: None,
files: vec![],
dependencies: ManifestDeps::MinecraftVanilla {
minecraft: "1.17.1".into(),
},
};
let manifest: Manifest = serde_json::from_str(PACK_JSON).expect("Error parsing pack JSON");
assert_eq!(expected_manifest, manifest);
Ok(())
}
#[test]
fn parse_forge() -> ModpackResult<()> {
const PACK_JSON: &'static str = r#"
{
"formatVersion": 1,
"game": "minecraft",
"versionId": "deadbeef",
"name": "Example Pack",
"files": [
{
"path": "mods/testmod.jar",
"hashes": {
"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"downloads": [
"https://example.com/testmod.jar"
]
}
],
"dependencies": {
"minecraft": "1.17.1",
"forge": "37.0.110"
}
}
"#;
let expected_manifest = Manifest {
format_version: 1,
game: "minecraft".into(),
version_id: "deadbeef".into(),
name: "Example Pack".into(),
summary: None,
files: vec![ManifestFile {
path: "mods/testmod.jar".into(),
hashes: Some(ManifestHashes {
sha1: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
}),
env: ManifestEnvs::default(),
downloads: vec!["https://example.com/testmod.jar".into()],
}],
dependencies: ManifestDeps::MinecraftForge {
minecraft: "1.17.1".into(),
forge: "37.0.110".into(),
},
};
let manifest: Manifest = serde_json::from_str(PACK_JSON).expect("Error parsing pack JSON");
assert_eq!(expected_manifest, manifest);
Ok(())
}
#[test]
fn parse_fabric() -> ModpackResult<()> {
const PACK_JSON: &'static str = r#"
{
"formatVersion": 1,
"game": "minecraft",
"versionId": "deadbeef",
"name": "Example Pack",
"files": [
{
"path": "mods/testmod.jar",
"hashes": {
"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"downloads": [
"https://example.com/testmod.jar"
]
}
],
"dependencies": {
"minecraft": "1.17.1",
"fabric-loader": "0.9.0"
}
}
"#;
let expected_manifest = Manifest {
format_version: 1,
game: "minecraft".into(),
version_id: "deadbeef".into(),
name: "Example Pack".into(),
summary: None,
files: vec![ManifestFile {
path: "mods/testmod.jar".into(),
hashes: Some(ManifestHashes {
sha1: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
}),
env: ManifestEnvs::default(),
downloads: vec!["https://example.com/testmod.jar".into()],
}],
dependencies: ManifestDeps::MinecraftFabric {
minecraft: "1.17.1".into(),
fabric_loader: "0.9.0".into(),
},
};
let manifest: Manifest = serde_json::from_str(PACK_JSON).expect("Error parsing pack JSON");
assert_eq!(expected_manifest, manifest);
Ok(())
}
#[test]
fn parse_complete() -> ModpackResult<()> {
const PACK_JSON: &'static str = r#"
{
"formatVersion": 1,
"game": "minecraft",
"versionId": "deadbeef",
"name": "Example Pack",
"summary": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"files": [
{
"path": "mods/testmod.jar",
"hashes": {
"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"env": {
"client": "required",
"server": "unsupported"
},
"downloads": [
"https://example.com/testmod.jar"
]
}
],
"dependencies": {
"minecraft": "1.17.1",
"forge": "37.0.110"
}
}
"#;
let expected_manifest = Manifest {
format_version: 1,
game: "minecraft".into(),
version_id: "deadbeef".into(),
name: "Example Pack".into(),
summary: Some("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.".into()),
files: vec![ManifestFile {
path: "mods/testmod.jar".into(),
hashes: Some(ManifestHashes {
sha1: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
}),
env: ManifestEnvs {
client: ManifestEnv::Required,
server: ManifestEnv::Unsupported,
},
downloads: vec!["https://example.com/testmod.jar".into()],
}],
dependencies: ManifestDeps::MinecraftForge {
minecraft: "1.17.1".into(),
forge: "37.0.110".into(),
},
};
let manifest: Manifest = serde_json::from_str(PACK_JSON).expect("Error parsing pack JSON");
assert_eq!(expected_manifest, manifest);
Ok(())
}
}

View File

@ -1,194 +0,0 @@
//! Provides utilties for downloading and parsing modpacks
use daedalus::download_file;
use fs_extra::dir::CopyOptions;
use std::{convert::TryFrom, env, io, path::Path};
use tokio::{fs, try_join};
use uuid::Uuid;
use zip::ZipArchive;
use zip_extensions::ZipWriterExtensions;
use self::{
manifest::Manifest,
pack::{Modpack, ModpackGame},
};
pub mod manifest;
pub mod modrinth_api;
pub mod pack;
pub const COMPILED_PATH: &str = "compiled/";
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#"
{COMPILED_PATH}
{COMPILED_ZIP}
"#
);
#[derive(thiserror::Error, Debug)]
pub enum ModpackError {
#[error("I/O error while reading modpack: {0}")]
IOError(#[from] io::Error),
#[error("I/O error while reading modpack: {0}")]
FSExtraError(#[from] fs_extra::error::Error),
#[error("Error extracting archive: {0}")]
ZipError(#[from] zip::result::ZipError),
#[error("Invalid modpack format: {0}")]
FormatError(String),
#[error("Invalid output directory: {0}")]
InvalidDirectory(String),
#[error("Error parsing manifest: {0}")]
ManifestError(String),
#[error("Daedalus error: {0}")]
DaedalusError(#[from] daedalus::Error),
#[error("Error parsing json: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Error parsing json5: {0}")]
Json5Error(#[from] json5::Error),
#[error("Error joining futures: {0}")]
JoinError(#[from] tokio::task::JoinError),
#[error("Versioning Error: {0}")]
VersionError(String),
#[error("Error downloading file: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Invalid modpack source: {0} (set the WHITELISTED_MODPACK_DOMAINS environment variable to override)")]
SourceWhitelistError(String),
}
type ModpackResult<T> = Result<T, ModpackError>;
/// Realise a modpack from a given URL
pub async fn fetch_modpack(
url: &str,
sha1: Option<&str>,
dest: &Path,
side: pack::ModpackSide,
) -> ModpackResult<()> {
let bytes = download_file(url, sha1).await?;
let mut archive = ZipArchive::new(io::Cursor::new(&bytes as &[u8]))?;
realise_modpack_zip(&mut archive, dest, side).await
}
/// Realise a given modpack from a zip archive
pub async fn realise_modpack_zip(
archive: &mut ZipArchive<impl io::Read + io::Seek>,
dest: &Path,
side: pack::ModpackSide,
) -> ModpackResult<()> {
let mut tmp = env::temp_dir();
tmp.push(format!("theseus-{}/", Uuid::new_v4()));
archive.extract(&tmp)?;
realise_modpack(&tmp, dest, side).await
}
/// Realise a given modpack into an instance
pub async fn realise_modpack(
dir: &Path,
dest: &Path,
side: pack::ModpackSide,
) -> ModpackResult<()> {
if dest.is_file() {
return Err(ModpackError::InvalidDirectory(String::from(
"Output is not a directory",
)));
}
if std::fs::read_dir(dest).map_or(false, |it| it.count() != 0) {
return Err(ModpackError::InvalidDirectory(String::from(
"Output directory is non-empty",
)));
}
if !dest.exists() {
fs::create_dir_all(dest).await?;
}
// Copy overrides
let overrides = dir.join(OVERRIDES_PATH);
if overrides.is_dir() {
fs_extra::dir::copy(overrides, dest, &CopyOptions::new())?;
}
// Parse manifest
// NOTE: I'm using standard files here, since Serde does not support async readers
let manifest_path = Some(dir.join(MANIFEST_PATH))
.filter(|it| it.is_file())
.ok_or_else(|| {
ModpackError::ManifestError(String::from("Manifest missing or is not a file"))
})?;
let manifest_file = std::fs::File::open(manifest_path)?;
let reader = io::BufReader::new(manifest_file);
let manifest: Manifest = serde_json::from_reader(reader)?;
let modpack = Modpack::try_from(manifest)?;
// Realise modpack
modpack.download_files(dest, side).await?;
Ok(())
}
pub fn to_pack_json5(pack: &Modpack) -> ModpackResult<String> {
let json5 = json5::to_string(pack)?;
Ok(format!("// This modpack is managed using Theseus. It can be edited using either a Theseus-compatible launcher or manually.\n{json5}"))
}
pub async fn create_modpack(
name: &str,
game: ModpackGame,
summary: Option<&str>,
) -> ModpackResult<()> {
let output_dir = Path::new("./").join(name);
let pack = Modpack::new(game, "0.1.0", name, summary);
try_join!(
fs::create_dir(&output_dir),
fs::create_dir(output_dir.join(OVERRIDES_PATH)),
fs::write(output_dir.join(".gitignore"), PACK_GITIGNORE),
fs::write(output_dir.join(PACK_JSON5_PATH), to_pack_json5(&pack)?),
)?;
Ok(())
}
pub async fn compile_modpack(dir: &Path) -> ModpackResult<()> {
let result_dir = dir.join(COMPILED_PATH);
let pack: Modpack = json5::from_str(&fs::read_to_string(dir.join(PACK_JSON5_PATH)).await?)?;
fs::create_dir(&result_dir).await?;
if dir.join(OVERRIDES_PATH).exists() {
fs_extra::dir::copy(
dir.join(OVERRIDES_PATH),
result_dir.join(OVERRIDES_PATH),
&CopyOptions::new(),
)?;
}
let manifest = Manifest::try_from(pack)?;
fs::write(
result_dir.join(MANIFEST_PATH),
serde_json::to_string(&manifest)?,
)
.await?;
let result_zip = fs::File::create(dir.join(COMPILED_ZIP))
.await?
.into_std()
.await;
let mut zip = zip::ZipWriter::new(&result_zip);
zip.create_from_directory(&result_dir)?;
Ok(())
}

View File

@ -1,180 +0,0 @@
use std::{collections::HashSet, convert::TryFrom, path::PathBuf};
use crate::launcher::ModLoader;
use super::{
manifest::{ManifestEnvs, ManifestHashes},
pack::{ModpackEnv, ModpackFile, ModpackFileHashes, ModpackGame},
ModpackError, ModpackResult,
};
use async_trait::async_trait;
use bytes::Bytes;
use futures::future::try_join_all;
use serde::Deserialize;
use tokio::try_join;
#[async_trait]
pub trait ModrinthAPI {
async fn get_latest_version(
&self,
project: &str,
channel: &str,
game: &ModpackGame,
) -> ModpackResult<HashSet<ModpackFile>>;
async fn get_version(
&self,
version: &str,
) -> ModpackResult<HashSet<ModpackFile>>;
}
#[derive(Debug)]
pub struct ModrinthV1(pub String);
#[derive(Debug, Deserialize)]
struct ModrinthV1Project {
title: String,
client_side: String,
server_side: String,
}
#[derive(Debug, Deserialize)]
struct ModrinthV1ProjectVersion {
dependencies: HashSet<String>,
game_versions: HashSet<String>,
version_type: String,
files: Vec<ModrinthV1ProjectVersionFile>,
loaders: HashSet<String>,
}
#[derive(Clone, Debug, Deserialize)]
struct ModrinthV1ProjectVersionFile {
hashes: ManifestHashes,
url: String,
filename: String,
}
impl From<ModrinthV1ProjectVersionFile> for ModpackFile {
fn from(file: ModrinthV1ProjectVersionFile) -> Self {
Self {
hashes: Some(ModpackFileHashes::from(file.hashes)),
downloads: HashSet::from([file.url]),
path: PathBuf::from(file.filename),
// WARNING: Since the sidedness of version 1 API requests is unknown, the environment is
// set here as both.
env: ModpackEnv::Both,
}
}
}
#[async_trait]
impl ModrinthAPI for ModrinthV1 {
async fn get_latest_version(
&self,
project: &str,
channel: &str,
game: &ModpackGame,
) -> ModpackResult<HashSet<ModpackFile>> {
let domain = &self.0;
// Fetch metadata
let (project_json, versions_json): (Bytes, Bytes) = try_join!(
try_get_json(format!("{domain}/api/v1/mod/{project}")),
try_get_json(format!("{domain}/api/v1/mod/{project}/version")),
)?;
let (mut project_deserializer, mut versions_deserializer) = (
serde_json::Deserializer::from_slice(&project_json),
serde_json::Deserializer::from_slice(&versions_json),
);
let (project, versions) = (
ModrinthV1Project::deserialize(&mut project_deserializer)?,
Vec::deserialize(&mut versions_deserializer)?,
);
let (game_version, loader) = match game {
ModpackGame::Minecraft(_, ModLoader::Vanilla) => Err(ModpackError::VersionError(
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!",
))),
}?;
let version: ModrinthV1ProjectVersion = versions
.into_iter()
.find(|it: &ModrinthV1ProjectVersion| {
let loader_str = match loader {
ModLoader::Fabric => "fabric",
ModLoader::Forge => "forge",
ModLoader::Vanilla => unreachable!(),
};
it.version_type == channel
&& it.game_versions.contains(game_version)
&& it.loaders.contains(loader_str)
})
.ok_or_else(|| {
ModpackError::VersionError(format!(
"Unable to find compatible version of mod {}",
project.title
))
})?;
// Project fields
let envs = ModpackEnv::try_from(ManifestEnvs {
client: serde_json::from_str(&project.client_side)?,
server: serde_json::from_str(&project.server_side)?,
})?;
// Conversions
let files = version
.files
.iter()
.cloned()
.map(ModpackFile::from)
.collect::<HashSet<ModpackFile>>();
let dep_futures =
version.dependencies.iter().map(|it| self.get_version(it));
let deps = try_join_all(dep_futures)
.await?
.into_iter()
.flatten()
.collect::<HashSet<ModpackFile>>();
Ok(files
.into_iter()
.chain(deps.into_iter())
.map(|mut it| {
it.env = envs;
it
})
.collect())
}
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)?;
Ok(version
.files
.into_iter()
.map(ModpackFile::from)
.collect::<HashSet<_>>())
}
}
// Helpers
async fn try_get_json(url: String) -> ModpackResult<Bytes> {
Ok(reqwest::get(url).await?.error_for_status()?.bytes().await?)
}

View File

@ -1,277 +0,0 @@
use daedalus::download_file_mirrors;
use futures::future;
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
hash::Hash,
iter::FromIterator,
path::{Path, PathBuf},
};
use tokio::fs;
use super::{
modrinth_api::{self, ModrinthV1},
ModpackError, ModpackResult,
};
use crate::launcher::ModLoader;
pub const MODRINTH_DEFAULT_MODPACK_DOMAINS: &[&str] = &[
"cdn.modrinth.com",
"edge.forgecdn.net",
"github.com",
"raw.githubusercontent.com",
];
pub const MODRINTH_MODPACK_DOMAIN_WHITELIST_VAR: &str = "WHITELISTED_MODPACK_DOMAINS";
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct Modpack {
pub game: ModpackGame,
pub version: String,
pub name: String,
pub summary: Option<String>,
pub files: HashSet<ModpackFile>,
}
impl Modpack {
pub fn new(game: ModpackGame, version: &str, name: &str, summary: Option<&str>) -> Self {
Self {
game,
version: String::from(version),
name: String::from(name),
summary: summary.map(String::from),
files: HashSet::new(),
}
}
/// Download a modpack's files for a given side to a given destination
/// Assumes the destination exists and is a directory
pub async fn download_files(&self, dest: &Path, side: ModpackSide) -> ModpackResult<()> {
let handles = self.files.iter().cloned().map(move |file| {
let (dest, side) = (dest.to_owned(), side);
tokio::spawn(async move { file.fetch(&dest, side).await })
});
future::try_join_all(handles)
.await?
.into_iter()
.collect::<ModpackResult<_>>()?;
// TODO Integrate instance format to save other metadata
Ok(())
}
pub async fn add_project(
&mut self,
project: &str,
base_path: &Path,
source: Option<&dyn modrinth_api::ModrinthAPI>,
channel: Option<&str>,
) -> ModpackResult<()> {
let default_api = ModrinthV1(String::from("https://api.modrinth.com"));
let channel = channel.unwrap_or("release");
let source = source.unwrap_or(&default_api);
let files = source
.get_latest_version(project, channel, &self.game)
.await?
.into_iter()
.map(|mut it: ModpackFile| {
it.path = base_path.join(it.path);
it
});
self.files.extend(files);
Ok(())
}
pub async fn add_version(
&mut self,
version: &str,
base_path: &Path,
source: Option<&dyn modrinth_api::ModrinthAPI>,
) -> ModpackResult<()> {
let default_api = ModrinthV1(String::from("https://api.modrinth.com"));
let source = source.unwrap_or(&default_api);
let files = source
.get_version(version)
.await?
.into_iter()
.map(|mut it: ModpackFile| {
it.path = base_path.join(it.path);
it
});
self.files.extend(files);
Ok(())
}
pub async fn add_file(
&mut self,
source: reqwest::Url,
dest: &Path,
hashes: Option<ModpackFileHashes>,
env: Option<ModpackEnv>,
) -> ModpackResult<()> {
let whitelisted_domains = std::env::var(MODRINTH_MODPACK_DOMAIN_WHITELIST_VAR)
.map(|it| serde_json::from_str::<Vec<String>>(&it).ok().unwrap())
.unwrap_or_else(|_| {
MODRINTH_DEFAULT_MODPACK_DOMAINS
.iter()
.cloned()
.map(String::from)
.collect::<Vec<String>>()
});
if !whitelisted_domains
.iter()
.any(|it| it == source.host_str().unwrap())
{
return Err(ModpackError::SourceWhitelistError(String::from(
source.host_str().unwrap(),
)));
}
let file = ModpackFile {
path: PathBuf::from(dest),
hashes,
env: env.unwrap_or(ModpackEnv::Both),
downloads: HashSet::from_iter([String::from(source)].iter().cloned()),
};
self.files.insert(file);
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ModpackGame {
// TODO: Currently, the launcher does not support specifying mod loader versions, so I just
// store the loader here.
Minecraft(String, ModLoader),
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ModpackFile {
pub path: PathBuf,
pub hashes: Option<ModpackFileHashes>,
pub env: ModpackEnv,
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 {
hashes.sha1.hash(state);
}
self.path.hash(state);
}
}
impl ModpackFile {
pub async fn fetch(&self, dest: &Path, side: ModpackSide) -> ModpackResult<()> {
if !self.env.supports(side) {
return Ok(());
}
let output = dest.join(&self.path);
// HACK: Since Daedalus appends a file name to all mirrors and the manifest supplies full
// URLs, I'm supplying it with an empty string to avoid reinventing the wheel.
let bytes = download_file_mirrors(
"",
self.downloads
.iter()
.map(|it| it.as_str())
.collect::<Vec<&str>>()
.as_slice(),
self.hashes.as_ref().map(|it| it.sha1.as_str()),
)
.await?;
fs::create_dir_all(output.parent().unwrap()).await?;
fs::write(output, bytes).await?;
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModpackEnv {
ClientOnly,
ServerOnly,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModpackSide {
Client,
Server,
}
impl ModpackEnv {
pub fn supports(&self, side: ModpackSide) -> bool {
match self {
Self::ClientOnly => side == ModpackSide::Client,
Self::ServerOnly => side == ModpackSide::Server,
Self::Both => true,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ModpackFileHashes {
pub sha1: String,
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use super::*;
use crate::launcher::ModLoader;
#[tokio::test]
async fn add_version() -> ModpackResult<()> {
const TEST_VERSION: &'static str = "TpnSObJ7";
let mut test_pack = Modpack::new(
ModpackGame::Minecraft(String::from("1.16.5"), ModLoader::Fabric),
"0.1.0",
"Example Modpack",
None,
);
test_pack
.add_version(TEST_VERSION, Path::new("mods/"), None)
.await?;
assert_eq!(
test_pack,
Modpack {
game: ModpackGame::Minecraft(String::from("1.16.5"), ModLoader::Fabric),
version: String::from("0.1.0"),
name: String::from("Example Modpack"),
summary: None,
files: {
let mut files = HashSet::new();
files.insert(ModpackFile {
path: PathBuf::from("mods/gravestones-v1.9.jar"),
hashes: Some(ModpackFileHashes {
sha1: String::from("3f0f6d523d218460310b345be03ab3f1d294e04d"),
}),
env: ModpackEnv::Both,
downloads: {
let mut downloads = HashSet::new();
downloads.insert(String::from("https://cdn.modrinth.com/data/ssUbhMkL/versions/v1.9/gravestones-v1.9.jar"));
downloads
}
});
files
},
},
);
Ok(())
}
}

130
theseus/src/state/dirs.rs Normal file
View File

@ -0,0 +1,130 @@
//! Theseus directory information
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug)]
pub struct DirectoryInfo {
pub config_dir: PathBuf,
pub working_dir: PathBuf,
}
impl DirectoryInfo {
/// Get all paths needed for Theseus to operate properly
#[tracing::instrument]
pub async fn init() -> crate::Result<Self> {
// Working directory
let working_dir = std::env::current_dir().map_err(|err| {
crate::ErrorKind::FSError(format!(
"Could not open working directory: {err}"
))
})?;
// Config directory
let config_dir = Self::env_path("THESEUS_CONFIG_DIR")
.or_else(|| Some(dirs::config_dir()?.join("theseus")))
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(),
))?;
fs::create_dir_all(&config_dir).await.map_err(|err| {
crate::ErrorKind::FSError(format!(
"Error creating Theseus config directory: {err}"
))
})?;
Ok(Self {
config_dir,
working_dir,
})
}
/// Get the Minecraft instance metadata directory
#[inline]
pub fn metadata_dir(&self) -> PathBuf {
self.config_dir.join("meta")
}
/// Get the Minecraft versions metadata directory
#[inline]
pub fn versions_dir(&self) -> PathBuf {
self.metadata_dir().join("versions")
}
/// Get the metadata directory for a given version
#[inline]
pub fn version_dir(&self, version: &str) -> PathBuf {
self.versions_dir().join(version)
}
/// Get the Minecraft libraries metadata directory
#[inline]
pub fn libraries_dir(&self) -> PathBuf {
self.metadata_dir().join("libraries")
}
/// Get the Minecraft assets metadata directory
#[inline]
pub fn assets_dir(&self) -> PathBuf {
self.metadata_dir().join("assets")
}
/// Get the assets index directory
#[inline]
pub fn assets_index_dir(&self) -> PathBuf {
self.assets_dir().join("indexes")
}
/// Get the assets objects directory
#[inline]
pub fn objects_dir(&self) -> PathBuf {
self.assets_dir().join("objects")
}
/// Get the directory for a specific object
#[inline]
pub fn object_dir(&self, hash: &str) -> PathBuf {
self.objects_dir().join(&hash[..2]).join(hash)
}
/// Get the Minecraft legacy assets metadata directory
#[inline]
pub fn legacy_assets_dir(&self) -> PathBuf {
self.metadata_dir().join("resources")
}
/// Get the Minecraft legacy assets metadata directory
#[inline]
pub fn natives_dir(&self) -> PathBuf {
self.metadata_dir().join("natives")
}
/// Get the natives directory for a version of Minecraft
#[inline]
pub fn version_natives_dir(&self, version: &str) -> PathBuf {
self.natives_dir().join(version)
}
/// Get the directory containing instance icons
#[inline]
pub fn icon_dir(&self) -> PathBuf {
self.config_dir.join("icons")
}
/// Get the file containing the global database
#[inline]
pub fn database_file(&self) -> PathBuf {
self.config_dir.join("data.bin")
}
/// Get the settings file for Theseus
#[inline]
pub fn settings_file(&self) -> PathBuf {
self.config_dir.join("settings.json")
}
/// Get path from environment variable
#[inline]
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from)
}
}

View File

@ -0,0 +1,95 @@
//! Theseus metadata
use crate::config::BINCODE_CONFIG;
use bincode::{Decode, Encode};
use daedalus::{
minecraft::{fetch_version_manifest, VersionManifest as MinecraftManifest},
modded::{
fetch_manifest as fetch_loader_manifest, Manifest as LoaderManifest,
},
};
use futures::prelude::*;
use std::collections::LinkedList;
const METADATA_URL: &str = "https://meta.modrinth.com/gamedata";
const METADATA_DB_FIELD: &[u8] = b"metadata";
// TODO: store as subtree in database
#[derive(Encode, Decode, Debug)]
pub struct Metadata {
pub minecraft: MinecraftManifest,
pub forge: LoaderManifest,
pub fabric: LoaderManifest,
}
impl Metadata {
fn get_manifest(name: &str) -> String {
format!("{METADATA_URL}/{name}/v0/manifest.json")
}
async fn fetch() -> crate::Result<Self> {
let (minecraft, forge, fabric) = tokio::try_join! {
async {
let url = Self::get_manifest("minecraft");
fetch_version_manifest(Some(&url)).await
},
async {
let url = Self::get_manifest("forge");
fetch_loader_manifest(&url).await
},
async {
let url = Self::get_manifest("fabric");
fetch_loader_manifest(&url).await
}
}?;
Ok(Self {
minecraft,
forge,
fabric,
})
}
#[tracing::instrument(skip_all)]
pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let mut metadata = None;
if let Some(ref meta_bin) = db.get(METADATA_DB_FIELD)? {
match bincode::decode_from_slice::<Self, _>(
&meta_bin,
*BINCODE_CONFIG,
) {
Ok((meta, _)) => metadata = Some(meta),
Err(err) => {
log::warn!("Could not read launcher metadata: {err}")
}
}
}
let mut fetch_futures = LinkedList::new();
for _ in 0..3 {
fetch_futures.push_back(Self::fetch().boxed());
}
match future::select_ok(fetch_futures).await {
Ok(meta) => metadata = Some(meta.0),
Err(err) => log::warn!("Unable to fetch launcher metadata: {err}"),
}
if let Some(meta) = metadata {
db.insert(
METADATA_DB_FIELD,
sled::IVec::from(bincode::encode_to_vec(
&meta,
*BINCODE_CONFIG,
)?),
)?;
db.flush_async().await?;
Ok(meta)
} else {
Err(
crate::ErrorKind::NoValueFor(String::from("launcher metadata"))
.as_error(),
)
}
}
}

130
theseus/src/state/mod.rs Normal file
View File

@ -0,0 +1,130 @@
//! Theseus state management system
use crate::config::sled_config;
use std::sync::Arc;
use tokio::sync::{Mutex, OnceCell, RwLock, Semaphore};
// Submodules
mod dirs;
pub use self::dirs::*;
mod metadata;
pub use self::metadata::*;
mod profiles;
pub use self::profiles::*;
mod settings;
pub use self::settings::*;
mod users;
pub use self::users::*;
// Global state
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
pub struct State {
/// Database, used to store some information
pub(self) database: sled::Db,
/// Information on the location of files used in the launcher
pub directories: DirectoryInfo,
/// Semaphore used to limit concurrent I/O and avoid errors
pub io_semaphore: Semaphore,
/// Launcher metadata
pub metadata: Metadata,
// TODO: settings API
/// Launcher configuration
pub settings: RwLock<Settings>,
/// Launcher profile metadata
pub(crate) profiles: RwLock<Profiles>,
/// Launcher user account info
pub(crate) users: RwLock<Users>,
}
impl State {
#[tracing::instrument]
/// Get the current launcher state, initializing it if needed
pub async fn get() -> crate::Result<Arc<Self>> {
LAUNCHER_STATE
.get_or_try_init(|| {
async {
// Directories
let directories = DirectoryInfo::init().await?;
// Database
// TODO: make database versioned
let database = sled_config()
.path(directories.database_file())
.open()?;
// Settings
let settings =
Settings::init(&directories.settings_file()).await?;
// Launcher data
let (metadata, profiles) = tokio::try_join! {
Metadata::init(&database),
Profiles::init(&database),
}?;
let users = Users::init(&database)?;
// Loose initializations
let io_semaphore =
Semaphore::new(settings.max_concurrent_downloads);
Ok(Arc::new(Self {
database,
directories,
io_semaphore,
metadata,
settings: RwLock::new(settings),
profiles: RwLock::new(profiles),
users: RwLock::new(users),
}))
}
})
.await
.map(Arc::clone)
}
#[tracing::instrument]
/// Synchronize in-memory state with persistent state
pub async fn sync() -> crate::Result<()> {
let state = Self::get().await?;
let batch = Arc::new(Mutex::new(sled::Batch::default()));
let sync_settings = async {
let state = Arc::clone(&state);
tokio::spawn(async move {
let reader = state.settings.read().await;
reader.sync(&state.directories.settings_file()).await?;
Ok::<_, crate::Error>(())
})
.await
.unwrap()
};
let sync_profiles = async {
let state = Arc::clone(&state);
let batch = Arc::clone(&batch);
tokio::spawn(async move {
let profiles = state.profiles.read().await;
let mut batch = batch.lock().await;
profiles.sync(&mut batch).await?;
Ok::<_, crate::Error>(())
})
.await
.unwrap()
};
tokio::try_join!(sync_settings, sync_profiles)?;
state
.database
.apply_batch(Arc::try_unwrap(batch).unwrap().into_inner())?;
state.database.flush_async().await?;
Ok(())
}
}

View File

@ -0,0 +1,357 @@
use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::config::BINCODE_CONFIG;
use daedalus::modded::LoaderVersion;
use futures::prelude::*;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use tokio::fs;
const PROFILE_JSON_PATH: &str = "profile.json";
const PROFILE_SUBTREE: &[u8] = b"profiles";
pub(crate) struct Profiles(pub HashMap<PathBuf, Option<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: ProfileMetadata,
#[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<Hooks>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ProfileMetadata {
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,
}
// TODO: Quilt?
#[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ModLoader {
Vanilla,
Forge,
Fabric,
}
impl Default for ModLoader {
fn default() -> Self {
ModLoader::Vanilla
}
}
impl std::fmt::Display for ModLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
&Self::Vanilla => "Vanilla",
&Self::Forge => "Forge",
&Self::Fabric => "Fabric",
})
}
}
#[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>>,
}
impl Profile {
#[tracing::instrument]
pub async fn new(
name: String,
version: String,
path: PathBuf,
) -> crate::Result<Self> {
if name.trim().is_empty() {
return Err(crate::ErrorKind::InputError(String::from(
"Empty name for instance!",
))
.into());
}
Ok(Self {
path: path.canonicalize()?,
metadata: ProfileMetadata {
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,
})
}
// TODO: deduplicate these builder methods
// They are flat like this in order to allow builder-style usage
#[tracing::instrument]
pub fn with_name(&mut self, name: String) -> &mut Self {
self.metadata.name = name;
self
}
#[tracing::instrument]
pub async fn with_icon<'a>(
&'a mut self,
icon: &'a Path,
) -> crate::Result<&'a mut Self> {
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(crate::ErrorKind::InputError(format!(
"Unsupported image type: {ext}"
))
.into())
}
}
#[tracing::instrument]
pub fn with_game_version(&mut self, version: String) -> &mut Self {
self.metadata.game_version = version;
self
}
#[tracing::instrument]
pub fn with_loader(
&mut self,
loader: ModLoader,
version: Option<LoaderVersion>,
) -> &mut Self {
self.metadata.loader = loader;
self.metadata.loader_version = version;
self
}
#[tracing::instrument]
pub fn with_java_settings(
&mut self,
settings: Option<JavaSettings>,
) -> &mut Self {
self.java = settings;
self
}
#[tracing::instrument]
pub fn with_memory(
&mut self,
settings: Option<MemorySettings>,
) -> &mut Self {
self.memory = settings;
self
}
#[tracing::instrument]
pub fn with_resolution(
&mut self,
resolution: Option<WindowSize>,
) -> &mut Self {
self.resolution = resolution;
self
}
#[tracing::instrument]
pub fn with_hooks(&mut self, hooks: Option<Hooks>) -> &mut Self {
self.hooks = hooks;
self
}
}
impl Profiles {
#[tracing::instrument(skip(db))]
pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let profile_db = db.get(PROFILE_SUBTREE)?.map_or(
Ok(Default::default()),
|bytes| {
bincode::decode_from_slice::<Box<[PathBuf]>, _>(
&bytes,
*BINCODE_CONFIG,
)
.map(|it| it.0)
},
)?;
let profiles = stream::iter(profile_db.iter())
.then(|it| async move {
let path = PathBuf::from(it);
let prof = match Self::read_profile_from_dir(&path).await {
Ok(prof) => Some(prof),
Err(err) => {
log::warn!("Error loading profile: {err}. Skipping...");
None
}
};
(path, prof)
})
.collect::<HashMap<PathBuf, Option<Profile>>>()
.await;
Ok(Self(profiles))
}
#[tracing::instrument(skip(self))]
pub fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
self.0.insert(
profile
.path
.canonicalize()?
.to_str()
.ok_or(
crate::ErrorKind::UTFError(profile.path.clone()).as_error(),
)?
.into(),
Some(profile),
);
Ok(self)
}
#[tracing::instrument(skip(self))]
pub async fn insert_from<'a>(
&'a mut self,
path: &'a Path,
) -> crate::Result<&Self> {
self.insert(Self::read_profile_from_dir(&path.canonicalize()?).await?)
}
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> {
let path = PathBuf::from(path.canonicalize()?.to_str().unwrap());
self.0.remove(&path);
Ok(self)
}
#[tracing::instrument(skip_all)]
pub async fn sync<'a>(
&'a self,
batch: &'a mut sled::Batch,
) -> crate::Result<&Self> {
stream::iter(self.0.iter())
.map(Ok::<_, crate::Error>)
.try_for_each_concurrent(None, |(path, profile)| async move {
let json = serde_json::to_vec_pretty(&profile)?;
let json_path =
Path::new(path.to_str().unwrap()).join(PROFILE_JSON_PATH);
fs::write(json_path, json).await?;
Ok::<_, crate::Error>(())
})
.await?;
batch.insert(
PROFILE_SUBTREE,
bincode::encode_to_vec(
self.0.keys().collect::<Box<[_]>>(),
*BINCODE_CONFIG,
)?,
);
Ok(self)
}
async fn read_profile_from_dir(path: &Path) -> crate::Result<Profile> {
let json = fs::read(path.join(PROFILE_JSON_PATH)).await?;
let mut profile = serde_json::from_slice::<Profile>(&json)?;
profile.path = PathBuf::from(path);
Ok(profile)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::{assert_eq, assert_str_eq};
use std::collections::HashSet;
#[test]
fn profile_test() -> Result<(), serde_json::Error> {
let profile = Profile {
path: PathBuf::new(),
metadata: ProfileMetadata {
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: Some(JavaSettings {
install: Some(PathBuf::from("/usr/bin/java")),
extra_arguments: Some(Vec::new()),
}),
memory: Some(MemorySettings {
minimum: None,
maximum: 8192,
}),
resolution: Some(WindowSize(1920, 1080)),
hooks: Some(Hooks {
pre_launch: HashSet::new(),
wrapper: None,
post_exit: HashSet::new(),
}),
};
let json = serde_json::json!({
"metadata": {
"name": "Example Pack",
"game_version": "1.18.2",
"format_version": 1u32,
"loader": "vanilla",
},
"java": {
"extra_arguments": [],
"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

@ -0,0 +1,125 @@
//! Theseus settings file
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use tokio::fs;
// TODO: convert to semver?
const CURRENT_FORMAT_VERSION: u32 = 1;
// Types
/// Global Theseus settings
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)]
pub struct Settings {
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 default_user: Option<uuid::Uuid>,
pub hooks: Hooks,
pub max_concurrent_downloads: usize,
pub version: u32,
}
impl Default for Settings {
fn default() -> Self {
Self {
memory: MemorySettings::default(),
game_resolution: WindowSize::default(),
custom_java_args: Vec::new(),
java_8_path: None,
java_17_path: None,
default_user: None,
hooks: Hooks::default(),
max_concurrent_downloads: 64,
version: CURRENT_FORMAT_VERSION,
}
}
}
impl Settings {
#[tracing::instrument]
pub async fn init(file: &Path) -> crate::Result<Self> {
if file.exists() {
fs::read(&file)
.await
.map_err(|err| {
crate::ErrorKind::FSError(format!(
"Error reading settings file: {err}"
))
.as_error()
})
.and_then(|it| {
serde_json::from_slice::<Settings>(&it)
.map_err(crate::Error::from)
})
} else {
Ok(Settings::default())
}
}
#[tracing::instrument(skip(self))]
pub async fn sync(&self, to: &Path) -> crate::Result<()> {
fs::write(to, serde_json::to_vec_pretty(self)?)
.await
.map_err(|err| {
crate::ErrorKind::FSError(format!(
"Error saving settings to file: {err}"
))
.as_error()
})
}
}
/// Minecraft memory settings
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
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,
}
}
}
/// Game window size
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub struct WindowSize(pub u16, pub u16);
impl Default for WindowSize {
fn default() -> Self {
Self(854, 480)
}
}
/// Game initialization hooks
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)]
pub struct Hooks {
#[serde(skip_serializing_if = "HashSet::is_empty")]
pub pre_launch: HashSet<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wrapper: Option<String>,
#[serde(skip_serializing_if = "HashSet::is_empty")]
pub post_exit: HashSet<String>,
}
impl Default for Hooks {
fn default() -> Self {
Self {
pre_launch: HashSet::<String>::new(),
wrapper: None,
post_exit: HashSet::<String>::new(),
}
}
}

View File

@ -0,0 +1,79 @@
//! User login info
use crate::{auth::Credentials, config::BINCODE_CONFIG};
const USER_DB_TREE: &[u8] = b"users";
/// The set of users stored in the launcher
#[derive(Clone)]
pub(crate) struct Users(pub(crate) sled::Tree);
impl Users {
#[tracing::instrument(skip(db))]
pub fn init(db: &sled::Db) -> crate::Result<Self> {
Ok(Self(db.open_tree(USER_DB_TREE)?))
}
#[tracing::instrument(skip_all)]
pub fn insert(
&mut self,
credentials: &Credentials,
) -> crate::Result<&Self> {
let id = credentials.id.as_bytes();
self.0.insert(
id,
bincode::encode_to_vec(credentials, *BINCODE_CONFIG)?,
)?;
Ok(self)
}
#[tracing::instrument(skip(self))]
pub fn contains(&self, id: uuid::Uuid) -> crate::Result<bool> {
Ok(self.0.contains_key(id.as_bytes())?)
}
#[tracing::instrument(skip(self))]
pub fn get(&self, id: uuid::Uuid) -> crate::Result<Option<Credentials>> {
self.0.get(id.as_bytes())?.map_or(Ok(None), |prof| {
bincode::decode_from_slice(&prof, *BINCODE_CONFIG)
.map_err(crate::Error::from)
.map(|it| Some(it.0))
})
}
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, id: uuid::Uuid) -> crate::Result<&Self> {
self.0.remove(id.as_bytes())?;
Ok(self)
}
pub fn iter(&self) -> UserIter<impl UserInnerIter> {
UserIter(self.0.iter().values(), false)
}
}
alias_trait! {
pub UserInnerIter: Iterator<Item = sled::Result<sled::IVec>>, Send, Sync
}
/// An iterator over the set of users
#[derive(Debug)]
pub struct UserIter<I: UserInnerIter>(I, bool);
impl<I: UserInnerIter> Iterator for UserIter<I> {
type Item = crate::Result<Credentials>;
#[tracing::instrument(skip(self))]
fn next(&mut self) -> Option<Self::Item> {
if self.1 {
return None;
}
let it = self.0.next()?;
let res = it.map_err(crate::Error::from).and_then(|it| {
Ok(bincode::decode_from_slice(&it, *BINCODE_CONFIG)?.0)
});
self.1 = res.is_err();
Some(res)
}
}

View File

@ -1,18 +0,0 @@
use std::path::{Path, PathBuf};
use std::{env, io};
use path_clean::PathClean;
// https://stackoverflow.com/a/54817755
pub fn absolute_path(path: impl AsRef<Path>) -> io::Result<PathBuf> {
let path = path.as_ref();
let absolute_path = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()?.join(path)
}
.clean();
Ok(absolute_path)
}

96
theseus/src/util/fetch.rs Normal file
View File

@ -0,0 +1,96 @@
//! Functions for fetching infromation from the Internet
use crate::config::REQWEST_CLIENT;
use futures::prelude::*;
use std::{collections::LinkedList, convert::TryInto, path::Path, sync::Arc};
use tokio::{
fs::{self, File},
io::AsyncWriteExt,
sync::{Semaphore, SemaphorePermit},
};
const FETCH_ATTEMPTS: usize = 3;
#[tracing::instrument(skip(_permit))]
pub async fn fetch<'a>(
url: &str,
sha1: Option<&str>,
_permit: &SemaphorePermit<'a>,
) -> crate::Result<bytes::Bytes> {
let mut attempts = LinkedList::new();
for _ in 0..FETCH_ATTEMPTS {
attempts.push_back(
async {
let content = REQWEST_CLIENT.get(url).send().await?;
let bytes = content.bytes().await?;
if let Some(hash) = sha1 {
let actual_hash = sha1_async(bytes.clone()).await;
if actual_hash != hash {
return Err(crate::ErrorKind::HashError(
actual_hash,
String::from(hash),
)
.into());
}
}
Ok(bytes)
}
.boxed(),
)
}
log::debug!("Done downloading URL {url}");
future::select_ok(attempts).map_ok(|it| it.0).await
}
// This is implemented, as it will be useful in porting modpacks
// For now, allow it to be dead code
#[allow(dead_code)]
#[tracing::instrument(skip(sem))]
pub async fn fetch_mirrors(
urls: &[&str],
sha1: Option<&str>,
permits: u32,
sem: &Semaphore,
) -> crate::Result<bytes::Bytes> {
let _permits = sem.acquire_many(permits).await.unwrap();
let sem = Arc::new(Semaphore::new(permits.try_into().unwrap()));
future::select_ok(urls.into_iter().map(|url| {
let sha1 = sha1.map(String::from);
let url = String::from(*url);
let sem = Arc::clone(&sem);
tokio::spawn(async move {
let permit = sem.acquire().await.unwrap();
fetch(&url, sha1.as_deref(), &permit).await
})
.map(Result::unwrap)
.boxed()
}))
.await
.map(|it| it.0)
}
#[tracing::instrument(skip(bytes, _permit))]
pub async fn write<'a>(
path: &Path,
bytes: &[u8],
_permit: &SemaphorePermit<'a>,
) -> crate::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let mut file = File::create(path).await?;
log::debug!("Done writing file {}", path.display());
file.write_all(bytes).await?;
Ok(())
}
async fn sha1_async(bytes: bytes::Bytes) -> String {
tokio::task::spawn_blocking(move || sha1::Sha1::from(bytes).hexdigest())
.await
.unwrap()
}

23
theseus/src/util/mod.rs Normal file
View File

@ -0,0 +1,23 @@
//! Theseus utility functions
pub mod fetch;
pub mod platform;
/// Wrap a builder which uses a mut reference into one which outputs an owned value
macro_rules! wrap_ref_builder {
($id:ident = $init:expr => $transform:block) => {{
let mut it = $init;
{
let $id = &mut it;
$transform;
}
it
}};
}
/// Alias a trait, used to avoid needing nightly features
macro_rules! alias_trait {
($scope:vis $name:ident : $bound:path $(, $bounds:path)*) => {
$scope trait $name: $bound $(+ $bounds)* {}
impl<T: $bound $(+ $bounds)*> $name for T {}
}
}

View File

@ -0,0 +1,60 @@
//! Platform-related code
use daedalus::minecraft::{Os, OsRule};
use regex::Regex;
// OS detection
pub trait OsExt {
/// Get the OS of the current system
fn native() -> Self;
}
impl OsExt for Os {
fn native() -> Self {
match std::env::consts::OS {
"windows" => Self::Windows,
"macos" => Self::Osx,
"linux" => Self::Linux,
_ => Self::Unknown,
}
}
}
// Bit width
#[cfg(target_pointer_width = "64")]
pub const ARCH_WIDTH: &str = "64";
#[cfg(target_pointer_width = "32")]
pub const ARCH_WIDTH: &str = "32";
// Platform rule handling
pub fn os_rule(rule: &OsRule) -> bool {
let mut rule_match = true;
if let Some(ref arch) = rule.arch {
rule_match &= match arch.as_str() {
"x86" => cfg!(any(target_arch = "x86", target_arch = "x86_64")),
"arm" => cfg!(target_arch = "arm"),
_ => true,
};
}
if let Some(name) = &rule.name {
rule_match &= &Os::native() == name;
}
if let Some(version) = &rule.version {
if let Ok(regex) = Regex::new(version.as_str()) {
rule_match &=
regex.is_match(&sys_info::os_release().unwrap_or_default());
}
}
rule_match
}
pub fn classpath_separator() -> &'static str {
match Os::native() {
Os::Osx | Os::Linux | Os::Unknown => ":",
Os::Windows => ";",
}
}

View File

@ -8,14 +8,23 @@ edition = "2018"
[dependencies]
theseus = { path = "../theseus" }
daedalus = "0.1.12"
daedalus = {version = "0.1.15", features = ["bincode"]}
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"]}
uuid = {version = "1.1", features = ["v4", "serde"]}
url = "2.2"
color-eyre = "0.6"
eyre = "0.6"
tracing = "0.1"
tracing-error = "0.2"
tracing-futures = "0.2"
tracing-subscriber = {version = "0.3", features = ["env-filter"]}
webbrowser = "0.7"

View File

@ -1,24 +1,52 @@
use eyre::Result;
use futures::TryFutureExt;
use paris::*;
use tracing_error::ErrorLayer;
use tracing_futures::WithSubscriber;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
mod subcommands;
#[macro_use]
mod util;
#[derive(argh::FromArgs)]
mod subcommands;
#[derive(argh::FromArgs, Debug)]
/// The official Modrinth CLI
pub struct Args {
#[argh(subcommand)]
pub subcommand: subcommands::SubCommand,
pub subcommand: subcommands::Subcommand,
}
#[tokio::main]
async fn main() -> Result<()> {
#[tracing::instrument]
fn main() -> Result<()> {
let args = argh::from_env::<Args>();
theseus::init().await?;
color_eyre::install()?;
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))?;
let format = fmt::layer()
.without_time()
.with_writer(std::io::stderr)
.with_target(false)
.compact();
tracing_subscriber::registry()
.with(format)
.with(filter)
.with(ErrorLayer::default())
.init();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(
async move {
args.dispatch()
.inspect_err(|_| error!("An error has occurred!\n"))
.and_then(|_| async { Ok(theseus::save().await?) })
.await
}
.with_current_subscriber(),
)
}

View File

@ -1,17 +1,20 @@
use eyre::Result;
mod profile;
mod user;
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum SubCommand {
pub enum Subcommand {
Profile(profile::ProfileCommand),
User(user::UserCommand),
}
impl crate::Args {
pub async fn dispatch(&self) -> Result<()> {
match self.subcommand {
SubCommand::Profile(ref cmd) => cmd.dispatch(self).await,
}
dispatch!(self.subcommand, (self) => {
Subcommand::Profile,
Subcommand::User
})
}
}

View File

@ -1,29 +1,26 @@
//! Profile management subcommand
use crate::util::{
confirm_async, prompt_async, select_async, table_path_display,
confirm_async, prompt_async, select_async, table, table_path_display,
};
use daedalus::modded::LoaderVersion;
use eyre::{ensure, Result};
use futures::prelude::*;
use paris::*;
use std::path::{Path, PathBuf};
use tabled::{Table, Tabled};
use theseus::{
data::{profiles::PROFILE_JSON_PATH, Metadata, Profile, Profiles},
launcher::ModLoader,
};
use tabled::Tabled;
use theseus::prelude::*;
use tokio::fs;
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
use uuid::Uuid;
use tokio_stream::wrappers::ReadDirStream;
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "profile")]
/// profile management
/// manage Minecraft instances
pub struct ProfileCommand {
#[argh(subcommand)]
action: ProfileSubcommand,
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum ProfileSubcommand {
Add(ProfileAdd),
@ -33,7 +30,7 @@ pub enum ProfileSubcommand {
Run(ProfileRun),
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "add")]
/// add a new profile to Theseus
pub struct ProfileAdd {
@ -54,23 +51,26 @@ impl ProfileAdd {
);
let profile = self.profile.canonicalize()?;
let json_path = profile.join(PROFILE_JSON_PATH);
let json_path = profile.join("profile.json");
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::is_managed(&profile).await?,
"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?;
profile::add_path(&profile).await?;
State::sync().await?;
success!("Profile added!");
Ok(())
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "init")]
/// create a new profile and manage it with Theseus
pub struct ProfileInit {
@ -106,13 +106,15 @@ impl ProfileInit {
_largs: &ProfileCommand,
) -> Result<()> {
// TODO: validate inputs from args early
let state = State::get().await?;
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(),
!self.path.join("profile.json").exists(),
"Profile already exists! Perhaps you want `profile add` instead?"
);
if ReadDirStream::new(fs::read_dir(&self.path).await?)
@ -138,8 +140,6 @@ impl ProfileInit {
&self.path.canonicalize()?.display()
);
let metadata = Metadata::get().await?;
// TODO: abstract default prompting
let name = match &self.name {
Some(name) => name.clone(),
@ -157,7 +157,7 @@ impl ProfileInit {
let game_version = match &self.game_version {
Some(version) => version.clone(),
None => {
let default = &metadata.minecraft.latest.release;
let default = &state.metadata.minecraft.latest.release;
prompt_async(
String::from("Game version"),
@ -206,8 +206,8 @@ impl ProfileInit {
};
let loader_data = match loader {
ModLoader::Forge => &metadata.forge,
ModLoader::Fabric => &metadata.fabric,
ModLoader::Forge => &state.metadata.forge,
ModLoader::Fabric => &state.metadata.fabric,
_ => eyre::bail!("Could not get manifest for loader {loader}. This is a bug in the CLI!"),
};
@ -238,8 +238,6 @@ impl ProfileInit {
.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?;
@ -251,8 +249,8 @@ impl ProfileInit {
profile.with_loader(loader, Some(loader_version));
}
Profiles::insert(profile).await?;
Profiles::save().await?;
profile::add(profile).await?;
State::sync().await?;
success!(
"Successfully created instance, it is now available to use with Theseus!"
@ -261,7 +259,7 @@ impl ProfileInit {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// list all managed profiles
#[argh(subcommand, name = "list")]
pub struct ProfileList {}
@ -294,25 +292,43 @@ impl<'a> From<&'a Profile> for ProfileRow<'a> {
}
}
impl<'a> From<&'a Path> for ProfileRow<'a> {
fn from(it: &'a Path) -> Self {
Self {
name: "?",
path: it,
game_version: "?",
loader: &ModLoader::Vanilla,
loader_version: "?",
}
}
}
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 profiles = profile::list().await?;
let rows = profiles.iter().map(|(path, prof)| {
prof.as_ref().map_or_else(
|| ProfileRow::from(path.as_path()),
ProfileRow::from,
)
});
let table = Table::new(profiles).with(tabled::Style::psql()).with(
let table = table(rows).with(
tabled::Modify::new(tabled::Column(1..=1))
.with(tabled::MaxWidth::wrapping(40)),
);
println!("{table}");
Ok(())
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// unmanage a profile
#[argh(subcommand, name = "remove")]
pub struct ProfileRemove {
@ -329,10 +345,13 @@ impl ProfileRemove {
) -> 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() {
if !profile::is_managed(&profile).await? {
warn!("Profile was not managed by Theseus!");
} else {
profile::remove(&profile).await?;
State::sync().await?;
success!("Profile removed!");
}
} else {
@ -343,7 +362,7 @@ impl ProfileRemove {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// run a profile
#[argh(subcommand, name = "run")]
pub struct ProfileRun {
@ -351,18 +370,9 @@ pub struct ProfileRun {
/// 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,
#[argh(option)]
/// the user to authenticate with
user: Option<uuid::Uuid>,
}
impl ProfileRun {
@ -372,24 +382,28 @@ impl ProfileRun {
_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(),
};
ensure!(
profile::is_managed(&path).await?,
"Profile not managed by Theseus (if it exists, try using `profile add` first!)",
);
let mut proc = profile.run(&credentials).await?;
profile.wait_for(&mut proc).await?;
let id = future::ready(self.user.ok_or(()))
.or_else(|_| async move {
let state = State::get().await?;
let settings = state.settings.read().await;
settings.default_user
.ok_or(eyre::eyre!(
"Could not find any users, please add one using the `user add` command."
))
})
.await?;
let credentials = auth::refresh(id, false).await?;
let mut proc = profile::run(&path, &credentials).await?;
profile::wait_for(&mut proc).await?;
success!("Process exited successfully!");
Ok(())
@ -397,14 +411,14 @@ impl ProfileRun {
}
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,
}
pub async fn run(&self, args: &crate::Args) -> Result<()> {
dispatch!(&self.action, (args, self) => {
ProfileSubcommand::Add,
ProfileSubcommand::Init,
ProfileSubcommand::List,
ProfileSubcommand::Remove,
ProfileSubcommand::Run
})
}
}

View File

@ -0,0 +1,178 @@
//! User management subcommand
use crate::util::{confirm_async, table};
use eyre::Result;
use paris::*;
use tabled::Tabled;
use theseus::prelude::*;
use tokio::sync::oneshot;
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "user")]
/// manage Minecraft accounts
pub struct UserCommand {
#[argh(subcommand)]
action: UserSubcommand,
}
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum UserSubcommand {
Add(UserAdd),
List(UserList),
Remove(UserRemove),
SetDefault(UserDefault),
}
#[derive(argh::FromArgs, Debug)]
/// add a new user to Theseus
#[argh(subcommand, name = "add")]
pub struct UserAdd {
#[argh(option)]
/// the browser to authenticate using
browser: Option<webbrowser::Browser>,
}
impl UserAdd {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Adding new user account to Theseus");
info!("A browser window will now open, follow the login flow there.");
let (tx, rx) = oneshot::channel::<url::Url>();
let flow = tokio::spawn(auth::authenticate(tx));
let url = rx.await?;
match self.browser {
Some(browser) => webbrowser::open_browser(browser, url.as_str()),
None => webbrowser::open(url.as_str()),
}?;
let credentials = flow.await??;
State::sync().await?;
success!("Logged in user {}.", credentials.username);
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// list all known users
#[argh(subcommand, name = "list")]
pub struct UserList {}
#[derive(Tabled)]
struct UserRow<'a> {
username: &'a str,
id: uuid::Uuid,
default: bool,
}
impl<'a> UserRow<'a> {
pub fn from(
credentials: &'a Credentials,
default: Option<uuid::Uuid>,
) -> Self {
Self {
username: &credentials.username,
id: credentials.id,
default: Some(credentials.id) == default,
}
}
}
impl UserList {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
let state = State::get().await?;
let default = state.settings.read().await.default_user;
let users = auth::users().await?;
let rows = users.iter().map(|user| UserRow::from(user, default));
let table = table(rows);
println!("{table}");
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// remove a user
#[argh(subcommand, name = "remove")]
pub struct UserRemove {
/// the user to remove
#[argh(positional)]
user: uuid::Uuid,
}
impl UserRemove {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Removing user {}", self.user.as_hyphenated());
if confirm_async(String::from("Do you wish to continue"), true).await? {
if !auth::has_user(self.user).await? {
warn!("Profile was not managed by Theseus!");
} else {
auth::remove_user(self.user).await?;
State::sync().await?;
success!("User removed!");
}
} else {
error!("Aborted!");
}
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// set the default user
#[argh(subcommand, name = "set-default")]
pub struct UserDefault {
/// the user to set as default
#[argh(positional)]
user: uuid::Uuid,
}
impl UserDefault {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Setting user {} as default", self.user.as_hyphenated());
// TODO: settings API
let state: std::sync::Arc<State> = State::get().await?;
let mut settings = state.settings.write().await;
if settings.default_user == Some(self.user) {
warn!("User is already the default!");
} else {
settings.default_user = Some(self.user);
success!("User set as default!");
}
Ok(())
}
}
impl UserCommand {
pub async fn run(&self, args: &crate::Args) -> Result<()> {
dispatch!(&self.action, (args, self) => {
UserSubcommand::Add,
UserSubcommand::List,
UserSubcommand::Remove,
UserSubcommand::SetDefault
})
}
}

View File

@ -1,6 +1,7 @@
use dialoguer::{Confirm, Input, Select};
use eyre::Result;
use std::{borrow::Cow, path::Path};
use tabled::{Table, Tabled};
// TODO: make primarily async to avoid copies
@ -56,7 +57,11 @@ pub async fn confirm_async(prompt: String, default: bool) -> Result<bool> {
tokio::task::spawn_blocking(move || confirm(&prompt, default)).await?
}
// Table display helpers
// Table helpers
pub fn table<T: Tabled>(rows: impl IntoIterator<Item = T>) -> Table {
Table::new(rows).with(tabled::Style::psql())
}
pub fn table_path_display(path: &Path) -> String {
let mut res = path.display().to_string();
@ -67,6 +72,20 @@ pub fn table_path_display(path: &Path) -> String {
res
}
// Dispatch macros
macro_rules! dispatch {
($on:expr, $args:tt => {$($option:path),+}) => {
match $on {
$($option (ref cmd) => dispatch!(@apply cmd => $args)),+
}
};
(@apply $cmd:expr => ($($args:expr),*)) => {{
use tracing_futures::WithSubscriber;
$cmd.run($($args),*).with_current_subscriber().await
}};
}
// Internal helpers
fn print_prompt(prompt: &str) {
println!(

View File

@ -1,130 +0,0 @@
{
"pages": {
"description": "Description",
"gallery": "Gallery",
"changelog": "Changelog",
"versions": "Versions",
"settings": "Settings",
"reports": "Reports",
"moderation": "Moderation",
"notifications": "Notifications",
"about": "About",
"following": "Following",
"all": "All"
},
"generic": {
"labels": {
"license": "License",
"project_id": "Project ID",
"project_status": "Project status"
},
"actions": {
"edit": "Edit",
"follow": "Follow",
"unfollow": "Unfollow",
"report": "Report",
"new_project": "New project",
"download": "Download",
"save": "Save",
"cancel": "Cancel",
"discard": "Discard changes"
},
"external": {
"github_profile": "GitHub profile",
"discord": "Discord",
"github": "GitHub",
"issues": "Issues",
"source": "Source",
"wiki": "Wiki",
"patreon": "Patreon",
"paypal": "PayPal",
"buy_me_a_coffee": "Buy Me a Coffee",
"github_sponsors": "GitHub Sponsors",
"donate": "Donate",
"kofi": "Ko-Fi"
},
"environments": {
"label": "Environment",
"server_side": "Server side",
"client_side": "Client side",
"values": {
"required": "Required",
"unsupported": "Unsupported",
"optional": "Optional",
"unknown": "Unknown"
}
},
"byline": "by <u>{author}</u>"
},
"project": {
"types": {
"mod": {
"singular": "Mod",
"plural": "Mods",
"search": "Search mods..."
},
"modpack": {
"singular": "Modpack",
"plural": "Modpacks",
"search": "Search modpacks..."
}
},
"sidebar_headings": {
"external_resources": "External resources",
"featured_versions": "Featured versions",
"project_members": "Project members",
"technical_information": "Technical information"
}
},
"status": {
"approved": "Approved",
"rejected": "Rejected",
"draft": "Draft",
"unlisted": "Unlisted",
"processing": "Under review",
"unknown": "Unknown"
},
"release_channels": {
"release": "Release",
"beta": "Beta",
"alpha": "Alpha"
},
"roles": {
"developer": "Developer",
"admin": "Admin",
"moderator": "Moderator"
},
"stats": {
"joined": "Joined {ago}",
"notified": "Notified {ago}",
"downloads": "{downloads, plural, one {<b>1</b> download} other {<b>#</b> downloads}}",
"followers_of_projects": "{followers, plural, one {<b>1</b> follower of projects} other {<b>#</b> followers of projects}}",
"followers": "{followers, plural, one {<b>1</b> follower} other {<b>#</b> followers}}",
"user_id": "User ID: {id}",
"created": "Created {ago}",
"updated": "Updated {ago}"
},
"tags": {
"technology": "Technology",
"adventure": "Adventure",
"magic": "Magic",
"utility": "Utility",
"decoration": "Decoration",
"library": "Library",
"cursed": "Cursed",
"worldgen": "World generation",
"storage": "Storage",
"food": "Food",
"equipment": "Equipment",
"miscellaneous": "Miscellaneous",
"optimization": "Optimization",
"fabric": "Fabric",
"quilt": "Quilt",
"forge": "Forge",
"server": "Server",
"client": "Client",
"good": "Good",
"trash": "Trash",
"misc": "Miscellaneous"
}
}

View File

@ -1,21 +1,19 @@
{
"scripts": {
"dev": "tauri dev",
"dev:web": "svelte-kit dev",
"kill:web": "kill-port 3000",
"dev:web": "vite",
"kill:web": "kill-port 5173",
"tauri": "tauri",
"build": "tauri build",
"build:web": "svelte-kit build",
"build:web": "vite build",
"test": "cargo ../test --manifest-path ./src-tauri/Cargo.toml",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. src",
"check": "cargo check --manifest-path src-tauri/Cargo.toml && svelte-check --tsconfig ./tsconfig.json",
"generate": "node ./scripts/generate.js",
"postinstall": "pnpm generate"
"check": "cargo check --manifest-path src-tauri/Cargo.toml && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "next",
"@sveltejs/kit": "next",
"@tauri-apps/cli": "^1.0.0-rc.5",
"@tauri-apps/cli": "^1.0.4",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"eslint": "^7.32.0",
@ -27,8 +25,10 @@
"prettier-plugin-svelte": "^2.5.0",
"svelte": "^3.48.0",
"svelte-check": "^2.2.6",
"svelte-intl-precompile": "^0.11.1",
"tslib": "^2.3.1",
"typescript": "~4.5.4"
"typescript": "~4.5.4",
"vite": "^3.0.0"
},
"type": "module",
"dependencies": {
@ -38,10 +38,10 @@
"@iconify-json/heroicons-outline": "^1.1.1",
"@iconify-json/heroicons-solid": "^1.1.1",
"@iconify-json/lucide": "^1.1.5",
"@tauri-apps/api": "^1.0.0-rc.1",
"omorphia": "0.0.19",
"svelte-intl-precompile": "^0.9.2",
"svrollbar": "^0.10.4",
"unplugin-icons": "^0.13.2"
"@tauri-apps/api": "^1.0.2",
"omorphia": "0.0.67",
"svrollbar": "^0.12.0",
"unplugin-icons": "^0.14.7",
"highlight.js": "11.5.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
module.exports = require('omorphia/config/postcss.config.cjs')
module.exports = require('omorphia/config/postcss.cjs')

View File

@ -1,56 +0,0 @@
import fetch from 'node-fetch';
import { promises as fs } from 'fs';
const API_URL =
process.env.VITE_API_URL || process.env?.NODE_ENV === 'development'
? 'https://staging-api.modrinth.com/v2/'
: 'https://api.modrinth.com/v2/';
const GENERATED_PATH = './generated/';
(async () => {
/* GAME VERSIONS */
// Fetch data
let gameVersions = await (await fetch(API_URL + 'tag/game_version')).json();
// Write JSON file
await fs.writeFile(GENERATED_PATH + 'gameVersions.json', JSON.stringify(gameVersions));
console.log('Generated gameVersions.json');
/* TAGS */
// Fetch data
let [categories, loaders, licenses, donationPlatforms] = await Promise.all([
await (await fetch(API_URL + 'tag/category')).json(),
await (await fetch(API_URL + 'tag/loader')).json(),
await (await fetch(API_URL + 'tag/license')).json(),
await (await fetch(API_URL + 'tag/donation_platform')).json(),
]);
// Create single object with icons
let tagIcons = {
...categories.reduce((a, v) => ({ ...a, [v.name]: v.icon }), {}),
...loaders.reduce((a, v) => ({ ...a, [v.name]: v.icon }), {}),
};
// Add icon class
tagIcons = Object.fromEntries(Object.entries(tagIcons).map(([k, v]) => [k, v.replace('<svg', '<svg class="icon"')]));
// Delete icons from original arrays
// eslint-disable-next-line @typescript-eslint/no-unused-vars
categories = categories.map(({ icon, ...rest }) => rest);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loaders = loaders.map(({ icon, ...rest }) => rest);
// Set project types
const projectTypes = ['mod', 'modpack'];
// Write JSON file
await fs.writeFile(
GENERATED_PATH + 'tags.json',
JSON.stringify({ categories, loaders, projectTypes, licenses, donationPlatforms, tagIcons })
);
console.log('Generated tags.json');
})();

View File

@ -8,12 +8,12 @@ rust-version = "1.57"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.0.0-rc.3", features = [] }
tauri-build = { version = "1.0.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.0-rc.3", features = ["api-all"] }
tauri = { version = "1.0.4", features = ["api-all"] }
[features]
# by default Tauri runs in production mode

View File

@ -9,12 +9,12 @@ use tauri::{
};
fn main() {
let ctx = tauri::generate_context!();
let ctx = tauri::generate_context!(); // Run `pnpm build:web` (builds the web app) to get rid of the error.
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![])
.create_window("main", WindowUrl::default(), |win, webview| {
let win = win
.setup(|app| {
let _win = WindowBuilder::new(app, "main", WindowUrl::default())
.title("Modrinth")
.resizable(true)
.decorations(true)
@ -22,15 +22,16 @@ fn main() {
.inner_size(800.0, 550.0)
.min_inner_size(400.0, 200.0)
.skip_taskbar(false)
.fullscreen(false);
return (win, webview);
.fullscreen(false)
.build()?;
Ok(())
})
.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::About(ctx.package_info().name.clone()).into(),
MenuItem::Separator.into(),
MenuItem::Services.into(),
MenuItem::Separator.into(),

View File

@ -4,7 +4,7 @@
},
"build": {
"distDir": "../build",
"devPath": "http://localhost:3000",
"devPath": "http://localhost:5173",
"beforeDevCommand": "pnpm dev:web",
"beforeBuildCommand": "pnpm run build:web"
},
@ -32,13 +32,11 @@
"shortDescription": "",
"longDescription": "",
"deb": {
"depends": [],
"useBootstrapper": false
"depends": []
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"useBootstrapper": false,
"exceptionDomain": "",
"signingIdentity": null,
"providerShortName": null,

View File

@ -3,9 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head%
<link rel="preload" href="/fonts/InterRegular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/InterBold.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/InterSemiBold.woff2" as="font" type="font/woff2" crossorigin />
%sveltekit.head%
</head>
<body style="background-color: hsl(220, 13%, 15%)">
%svelte.body%
<body class="base theme-dark" style="background-color: hsl(220, 13%, 15%)">
%sveltekit.body%
</body>
</html>

View File

@ -9,7 +9,6 @@
</div>
</div>
<style lang="postcss">
.card-row {
display: flex;
@ -25,7 +24,7 @@
&::after {
flex: 1 1;
content: " ";
content: ' ';
background-color: hsla(0, 0%, 100%, 0.2);
height: 0.2rem;
border-radius: var(--rounded-max);

View File

@ -1,57 +1,57 @@
<script lang="ts">
import { FormField, Slider, TextInput, Button } from "omorphia"
import TitledSection from "$components/TitledSection.svelte"
import WindowSettings from "$components/WindowSettings.svelte"
import { Field, Slider, TextInput, Button } from 'omorphia';
import TitledSection from '$components/TitledSection.svelte';
import WindowSettings from '$components/WindowSettings.svelte';
export let maxConcurrentDownloads: number = 20;
</script>
<div class="section">
<TitledSection title="Downloads">
<FormField label="Max concurrent downloads">
<Slider min=1 max=64 bind:value={maxConcurrentDownloads} />
</FormField>
<Field label="Max concurrent downloads">
<Slider min="1" max="64" bind:value={maxConcurrentDownloads} />
</Field>
</TitledSection>
<TitledSection title="Override game resolution" toggleable=true>
<TitledSection title="Override game resolution" toggleable="true">
<WindowSettings />
</TitledSection>
<TitledSection title="Profile hooks">
<FormField label="Pre-launch">
<Field label="Pre-launch">
<TextInput />
</FormField>
<FormField label="Wrapper">
</Field>
<Field label="Wrapper">
<TextInput />
</FormField>
<FormField label="Post-exit">
</Field>
<Field label="Post-exit">
<TextInput />
</FormField>
</Field>
</TitledSection>
<TitledSection title="Java">
<FormField label="Java 8 installation">
<Field label="Java 8 installation">
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home" />
<div class="button-group">
<Button>Auto-detect</Button>
<Button>Browse installations</Button>
<Button>Test</Button>
</div>
</FormField>
<FormField label="Java 17 installation">
</Field>
<Field label="Java 17 installation">
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" />
<div class="button-group">
<Button>Auto-detect</Button>
<Button>Browse installations</Button>
<Button>Test</Button>
</div>
</FormField>
<FormField label="Minimum memory allocatted (in MB)">
</Field>
<Field label="Minimum memory allocatted (in MB)">
<TextInput />
</FormField>
<FormField label="Maximum memory allocatted (in MB)">
</Field>
<Field label="Maximum memory allocatted (in MB)">
<TextInput value="2048" />
</FormField>
<FormField label="Arguments">
</Field>
<Field label="Arguments">
<TextInput />
</FormField>
</Field>
</TitledSection>
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import IconPlayFilled from "virtual:icons/carbon/play-filled-alt"
import IconBadgeCheck from "virtual:icons/heroicons-solid/badge-check"
import IconPlayFilled from 'virtual:icons/carbon/play-filled-alt';
import IconBadgeCheck from 'virtual:icons/heroicons-solid/badge-check';
export let title: string;
export let id: string;
@ -9,9 +9,14 @@
export let image: string;
</script>
<a class="instance" href="/library/instance/{id}/settings"
style:background-image="linear-gradient(5deg, hsla(0,0%,0%,0.8) 0%, hsla(0,0%,0%,0) 100%), url('{image}')">
<div class="instance__version">{version}{#if modpack}<IconBadgeCheck />{/if}</div>
<a
class="instance"
href="/library/instance/{id}/settings"
style:background-image="linear-gradient(5deg, hsla(0,0%,0%,0.8) 0%, hsla(0,0%,0%,0) 100%), url('{image}')"
>
<div class="instance__version">
{version}{#if modpack}<IconBadgeCheck />{/if}
</div>
<div class="instance__title">{title}</div>
<button class="play-button">
<IconPlayFilled />

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { Checkbox } from "omorphia"
import { Checkbox } from 'omorphia';
export let title: string
export let toggleable: boolean = false
export let title: string;
export let toggleable: boolean = false;
export let enabled: boolean = false
export let enabled: boolean = false;
</script>
<div class="section">
@ -32,7 +32,7 @@
&::after {
flex: 1 1;
content: " ";
content: ' ';
background-color: hsla(0, 0%, 100%, 0.2);
height: 0.2rem;
border-radius: var(--rounded-max);

View File

@ -1,26 +1,29 @@
<script lang="ts">
import { page } from "$app/stores";
import type { SvelteComponent } from 'svelte';
import { page } from '$app/stores';
export let items: {
label: string,
label: string;
/** Page href, without slash prefix */
href: string,
/** An icon, as a svelte component */
icon: any, // `SvelteComponentDev` has type errors
href: string;
icon: SvelteComponent;
}[];
/** Path level in URL, zero-indexed */
export let level = 0;
let path: string[];
$: path = $page.url.pathname
.substring(1)
.split('/')
$: path = $page.url.pathname.substring(1).split('/');
</script>
<div class="vertical-nav">
{#each items as item (item.href)}
<a class="nav-item" href="/{item.href}" class:active={path[level] === item.href} sveltekit:prefetch>
<a
class="nav-item"
href="/{item.href}"
class:active={path[level] === item.href}
sveltekit:prefetch
>
<svelte:component this={item.icon} />
{item.label}
</a>

View File

@ -1,14 +1,14 @@
<script lang="ts">
import { FormField, TextInput } from "omorphia"
import { Field, TextInput } from 'omorphia';
</script>
<div class="section">
<FormField label="Window width">
<Field label="Window width">
<TextInput />
</FormField>
<FormField label="Window height">
</Field>
<Field label="Window height">
<TextInput />
</FormField>
</Field>
</div>
<style lang="postcss">

View File

@ -1,6 +1,6 @@
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
return await resolve(event, {
ssr: false,
ssr: false
});
}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { Svrollbar } from 'svrollbar'
import { Svrollbar } from 'svrollbar';
let viewport: Element
let contents: Element
let viewport: Element;
let contents: Element;
</script>
<div class="page">

View File

@ -1,12 +1,12 @@
<script lang="ts">
import VerticalNav from '../components/VerticalNav.svelte'
import IconHome from 'virtual:icons/lucide/home'
import IconSearch from 'virtual:icons/heroicons-outline/search'
import IconPlus from 'virtual:icons/heroicons-outline/plus'
import VerticalNav from '../components/VerticalNav.svelte';
import IconHome from 'virtual:icons/lucide/home';
import IconSearch from 'virtual:icons/heroicons-outline/search';
import IconPlus from 'virtual:icons/heroicons-outline/plus';
import IconLibrary from 'virtual:icons/lucide/library';
import IconSettings from 'virtual:icons/lucide/settings'
import { page } from "$app/stores";
import { Button } from 'omorphia'
import IconSettings from 'virtual:icons/lucide/settings';
import { page } from '$app/stores';
import { Button } from 'omorphia';
</script>
<div class="sidebar">
@ -21,23 +21,25 @@
</a>
</div>
<VerticalNav items={[
<VerticalNav
items={[
{
label: 'Home',
href: '',
icon: IconHome,
icon: IconHome
},
{
label: 'Search',
href: 'search',
icon: IconSearch,
icon: IconSearch
},
{
label: 'Library',
href: 'library',
icon: IconLibrary,
icon: IconLibrary
}
]}/>
]}
/>
<div class="instance-list">
<div class="instance-list__title">Instances</div>
@ -120,6 +122,34 @@
}
}
&__create {
margin-top: 16px;
display: flex;
align-items: center;
grid-gap: 8px;
&__title {
text-transform: uppercase;
letter-spacing: 1px;
font-size: 11px;
line-height: 100%;
}
&__container {
max-height: calc(100vh - 400px);
overflow-y: auto;
mask-image: linear-gradient(to bottom, transparent, hsla(0, 0%, 0%, 1) 5% 95%, transparent);
scrollbar-width: none;
padding: 8px 0;
display: flex;
flex-direction: column;
gap: 4px;
&::-webkit-scrollbar {
display: none;
}
}
&__create {
margin-top: 16px;
display: flex;
@ -140,5 +170,20 @@
margin-top: auto;
margin-bottom: 0;
}
:global(button) {
width: 34px;
}
}
}
> :global(*) {
margin-bottom: 24px;
}
> *:last-child {
margin-top: auto;
margin-bottom: 0;
}
}
</style>

View File

@ -75,6 +75,7 @@
color: var(--color-text-lightest);
}
}
}
.statuses {
margin-left: auto;

View File

@ -1,28 +1,27 @@
<script context="module" lang="ts">
import { init, waitLocale } from 'svelte-intl-precompile'
import { registerAll } from '$locales'
import { init, waitLocale, t, getLocaleFromAcceptLanguageHeader } from 'svelte-intl-precompile';
import { registerAll, availableLocales } from '$locales';
registerAll()
registerAll();
/** @type {import('@sveltejs/kit').Load} */
export async function load({fetch, session, stuff}) {
export const load: import('@sveltejs/kit').Load = async ({ session }) => {
init({
fallbackLocale: 'en',
initialLocale: session.acceptedLanguage,
})
await waitLocale()
initialLocale: getLocaleFromAcceptLanguageHeader(session.acceptLanguage, availableLocales)
});
await waitLocale();
return {}
}
return {};
};
</script>
<script lang="ts">
import '@fontsource/inter'
import 'omorphia/styles.postcss'
import '$styles/global.postcss'
import Sidebar from '$layout/Sidebar.svelte'
import StatusBar from "$layout/StatusBar.svelte";
import Page from "$layout/Page.svelte";
import '@fontsource/inter';
import 'omorphia/styles.postcss';
import '$styles/global.postcss';
import Sidebar from '$layout/Sidebar.svelte';
import StatusBar from '$layout/StatusBar.svelte';
import Page from '$layout/Page.svelte';
</script>
<div class="app base theme-dark">
@ -39,8 +38,8 @@
width: 100%;
display: grid;
grid-template-areas:
"sidebar status-bar"
"sidebar page";
'sidebar status-bar'
'sidebar page';
grid-template-rows: 2.5rem 1fr;
grid-template-columns: 14rem 1fr;
}

View File

@ -1,26 +1,41 @@
<script lang="ts">
import Instance from "$components/Instance.svelte";
import CardRow from "$components/CardRow.svelte";
import Instance from '$components/Instance.svelte';
import CardRow from '$components/CardRow.svelte';
</script>
<CardRow title="Jump back in">
{#each Array(5) as _, i}
<Instance title="New Caves" id="234" version="1.18" image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg" />
<Instance
title="New Caves"
id="234"
version="1.18"
image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg"
/>
{/each}
</CardRow>
<CardRow title="Popular packs">
{#each Array(5) as _, i}
<Instance title="All of Fabric 5" id="567" version="1.18.1" image="https://media.forgecdn.net/avatars/458/829/637733746768258525.png" modpack />
<Instance
title="All of Fabric 5"
id="567"
version="1.18.1"
image="https://media.forgecdn.net/avatars/458/829/637733746768258525.png"
modpack
/>
{/each}
</CardRow>
<CardRow title="New releases">
{#each Array(5) as _, i}
<Instance title="New Caves" id="234" version="1.18.2" image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg" />
<Instance
title="New Caves"
id="234"
version="1.18.2"
image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg"
/>
{/each}
</CardRow>
<style lang="postcss">
</style>

View File

@ -1,11 +1,8 @@
<script context="module" lang="ts">
</script>
<script lang="ts">
</script>
<style lang="postcss">
</style>

View File

@ -1,29 +1,32 @@
<script lang="ts">
import VerticalNav from "$components/VerticalNav.svelte";
import IconPackage from 'virtual:icons/lucide/package'
import IconAdjustments from 'virtual:icons/heroicons-outline/adjustments'
import IconFileText from 'virtual:icons/lucide/file-text'
import VerticalNav from '$components/VerticalNav.svelte';
import IconPackage from 'virtual:icons/lucide/package';
import IconAdjustments from 'virtual:icons/heroicons-outline/adjustments';
import IconFileText from 'virtual:icons/lucide/file-text';
</script>
<div class="layout-instance">
<div class="instance-sidebar">
<VerticalNav level={3} items={[
<VerticalNav
level={3}
items={[
{
label: 'Mods',
href: 'mods',
icon: IconPackage,
icon: IconPackage
},
{
label: 'Settings',
href: 'settings',
icon: IconAdjustments,
icon: IconAdjustments
},
{
label: 'Logs',
href: 'logs',
icon: IconFileText,
icon: IconFileText
}
]}/>
]}
/>
</div>
<div class="layout-instance__page">

View File

@ -1,9 +1,11 @@
<script lang="ts">
import { Checkbox, FormField, TextInput, Button } from "omorphia"
import GlobalSettings from "$components/GlobalSettings.svelte"
import TitledSection from "$components/TitledSection.svelte"
<script context="module" lang="ts">
</script>
export let overrideGlobalSettings = false
<script lang="ts">
import GlobalSettings from '$components/GlobalSettings.svelte';
import TitledSection from '$components/TitledSection.svelte';
export let overrideGlobalSettings = false;
</script>
<div class="section">
@ -26,7 +28,7 @@
&::after {
flex: 1 1;
content: " ";
content: ' ';
background-color: hsla(0, 0%, 100%, 0.2);
height: 0.2rem;
border-radius: var(--rounded-max);

View File

@ -1,10 +1,8 @@
<script context="module" lang="ts">
</script>
<script lang="ts">
import { Checkbox, FormField, TextInput, Button } from "omorphia"
import GlobalSettings from "$components/GlobalSettings.svelte"
import GlobalSettings from '$components/GlobalSettings.svelte';
</script>
<div class="section">

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const token = writable('');

View File

@ -5,11 +5,11 @@
padding: 0.5rem 0.75rem;
border-radius: var(--rounded-sm);
box-shadow: var(--shadow-inset-sm) var(--shadow-raised);
transition: background-color 0.2s ease-in-out,
color 0.1s ease-in-out;
transition: background-color 0.2s ease-in-out, color 0.1s ease-in-out;
color: var(--color-text-light);
&:hover, &.active {
&:hover,
&.active {
color: var(--color-text);
}

View File

@ -16,5 +16,5 @@
--color-bg: hsl(217, 9%, 18%);
--color-brand: hsl(145, 75%, 45%);
--color-brand-contrast: black;
--sub-page-bg: hsl(220, 9%, 13%)
--sub-page-bg: hsl(220, 9%, 13%);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,37 +1,23 @@
import adapter from '@sveltejs/adapter-static';
import path from "path";
import { preprocess, plugins } from 'omorphia/config/svelte.config'
import precompileIntl from "svelte-intl-precompile/sveltekit-plugin";
import { preprocess } from 'omorphia/config/svelte';
import path from 'path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess,
preprocess: [preprocess],
kit: {
adapter: adapter({
fallback: '200.html',
fallback: '200.html'
}),
vite: {
plugins: [
...plugins,
precompileIntl('locales'),
],
resolve: {
alias: {
$generated: path.resolve('./generated'),
$stores: path.resolve('./src/stores'),
$assets: path.resolve('./src/assets'),
$components: path.resolve('./src/components'),
$layout: path.resolve('./src/layout'),
$lib: path.resolve('./src/lib'),
$stores: path.resolve('./src/stores'),
$styles: path.resolve('./src/styles'),
$generated: path.resolve('./generated'),
},
},
server: {
fs: {
// Allow serving files from one level up to the project root
allow: ['..', './generated/*.json'],
}
}
$styles: path.resolve('./src/styles')
}
}
};

View File

@ -0,0 +1,28 @@
import { sveltekit } from '@sveltejs/kit/vite';
import path from 'path';
import { plugins } from 'omorphia/config/vite';
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin';
import { Generator } from 'omorphia/plugins';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [
sveltekit(),
...plugins,
precompileIntl('locales'),
Generator({
gameVersions: true,
openapi: true
})
],
optimizeDeps: {
include: ['highlight.js/lib/core']
},
server: {
fs: {
allow: ['generated']
}
}
};
export default config;