Merge branch 'master' into gui_search
This commit is contained in:
parent
b0a55c9b18
commit
51982dde62
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
1
.idea/theseus.iml
generated
@ -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
2458
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
34
flake.lock
generated
34
flake.lock
generated
@ -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": {
|
||||
|
||||
18
flake.nix
18
flake.nix
@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
rustc cargo clippy openssl pkg-config
|
||||
gtk4 gdk-pixbuf atk webkitgtk
|
||||
];
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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
100
theseus/src/api/auth.rs
Normal 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
19
theseus/src/api/mod.rs
Normal 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
243
theseus/src/api/profile.rs
Normal 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
22
theseus/src/config.rs
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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
103
theseus/src/error.rs
Normal 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>;
|
||||
@ -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()?)
|
||||
}
|
||||
|
||||
@ -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?)
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
@ -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?)
|
||||
}
|
||||
@ -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
130
theseus/src/state/dirs.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
95
theseus/src/state/metadata.rs
Normal file
95
theseus/src/state/metadata.rs
Normal 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
130
theseus/src/state/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
357
theseus/src/state/profiles.rs
Normal file
357
theseus/src/state/profiles.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
125
theseus/src/state/settings.rs
Normal file
125
theseus/src/state/settings.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
79
theseus/src/state/users.rs
Normal file
79
theseus/src/state/users.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
96
theseus/src/util/fetch.rs
Normal 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
23
theseus/src/util/mod.rs
Normal 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 {}
|
||||
}
|
||||
}
|
||||
60
theseus/src/util/platform.rs
Normal file
60
theseus/src/util/platform.rs
Normal 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 => ";",
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
178
theseus_cli/src/subcommands/user.rs
Normal file
178
theseus_cli/src/subcommands/user.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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!(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
3147
theseus_gui/pnpm-lock.yaml
generated
3147
theseus_gui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1 +1 @@
|
||||
module.exports = require('omorphia/config/postcss.config.cjs')
|
||||
module.exports = require('omorphia/config/postcss.cjs')
|
||||
@ -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');
|
||||
})();
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export async function handle({ event, resolve }) {
|
||||
return await resolve(event, {
|
||||
ssr: false,
|
||||
ssr: false
|
||||
});
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -75,6 +75,7 @@
|
||||
color: var(--color-text-lightest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.statuses {
|
||||
margin-left: auto;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
<script context="module" lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
</style>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
3
theseus_gui/src/stores/account.ts
Normal file
3
theseus_gui/src/stores/account.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const token = writable('');
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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%);
|
||||
}
|
||||
BIN
theseus_gui/static/fonts/InterBold.woff2
Normal file
BIN
theseus_gui/static/fonts/InterBold.woff2
Normal file
Binary file not shown.
BIN
theseus_gui/static/fonts/InterRegular.woff2
Normal file
BIN
theseus_gui/static/fonts/InterRegular.woff2
Normal file
Binary file not shown.
BIN
theseus_gui/static/fonts/InterSemiBold.woff2
Normal file
BIN
theseus_gui/static/fonts/InterSemiBold.woff2
Normal file
Binary file not shown.
@ -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')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
28
theseus_gui/vite.config.js
Normal file
28
theseus_gui/vite.config.js
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user