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/
|
node_modules/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
theseus_gui/build/
|
theseus_gui/build/
|
||||||
|
theseus_gui/generated/
|
||||||
WixTools
|
WixTools
|
||||||
|
.direnv/
|
||||||
|
.DS_Store
|
||||||
|
.pnpm-debug.log
|
||||||
|
|
||||||
[#]*[#]
|
[#]*[#]
|
||||||
|
|
||||||
# TEMPORARY: ignore my test instance and metadata
|
# TEMPORARY: ignore my test instance and metadata
|
||||||
theseus_cli/launcher
|
|
||||||
theseus_cli/foo
|
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/crossbeam-utils-bab62be590a5955d/out" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/memoffset-235ac8b3550fb50a/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/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$/target" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/theseus_gui/.svelte-kit" />
|
<excludeFolder url="file://$MODULE_DIR$/theseus_gui/.svelte-kit" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/theseus_gui/build" />
|
<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"
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1646893503,
|
"lastModified": 1655706580,
|
||||||
"narHash": "sha256-N4Wn8FUXUC1h1DkL8X9I7VMvIv0fLLLjeJX3uFyzvRQ=",
|
"narHash": "sha256-7DshIT1Ya5W9NAW7UdnYCHsGmXfOXJZCEHbbB/cCX7g=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "fenix",
|
"repo": "fenix",
|
||||||
"rev": "aad7f0a3e44ecfc9e2c5f1a45387d193c1c51aa6",
|
"rev": "d895003d8e03ac2fc8ffe2aa898299cbef1a7048",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -28,11 +28,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1639947939,
|
"lastModified": 1655042882,
|
||||||
"narHash": "sha256-pGsM8haJadVP80GFq4xhnSpNitYNQpaXk4cnA796Cso=",
|
"narHash": "sha256-9BX8Fuez5YJlN7cdPO63InoyBy7dm3VlJkkmTt6fS1A=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "naersk",
|
"repo": "naersk",
|
||||||
"rev": "2fc8ce9d3c025d59fee349c1f80be9785049d653",
|
"rev": "cddffb5aa211f50c4b8750adbec0bbbdfb26bb9f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -43,11 +43,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1646497237,
|
"lastModified": 1655624069,
|
||||||
"narHash": "sha256-Ccpot1h/rV8MgcngDp5OrdmLTMaUTbStZTR5/sI7zW0=",
|
"narHash": "sha256-7g1zwTdp35GMTERnSzZMWJ7PG3QdDE8VOX3WsnOkAtM=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "062a0c5437b68f950b081bbfc8a699d57a4ee026",
|
"rev": "0d68d7c857fe301d49cdcd56130e0beea4ecd5aa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -68,15 +68,15 @@
|
|||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1646862342,
|
"lastModified": 1655654433,
|
||||||
"narHash": "sha256-zXd3qsIcQFDFMB6p8bSpkOKjTuBTvYuM4GkPYxEfQdA=",
|
"narHash": "sha256-auHQ0XPCiaTPSn+R3Yu4J7oZ5Zq/FS5/Da1ivvdYb/Y=",
|
||||||
"owner": "rust-analyzer",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"rev": "5b51cb835a356cf79cba00cf5c65d51cadeea7f1",
|
"rev": "427061da19723f2206fe4dcb175c9c43b9a6193d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "rust-analyzer",
|
"owner": "rust-lang",
|
||||||
"ref": "nightly",
|
"ref": "nightly",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
@ -84,11 +84,11 @@
|
|||||||
},
|
},
|
||||||
"utils": {
|
"utils": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1644229661,
|
"lastModified": 1653893745,
|
||||||
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
|
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
|
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
18
flake.nix
18
flake.nix
@ -14,7 +14,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = inputs:
|
outputs = inputs@{self, ...}:
|
||||||
inputs.utils.lib.eachDefaultSystem (system: let
|
inputs.utils.lib.eachDefaultSystem (system: let
|
||||||
pkgs = import inputs.nixpkgs { inherit system; };
|
pkgs = import inputs.nixpkgs { inherit system; };
|
||||||
fenix = inputs.fenix.packages.${system};
|
fenix = inputs.fenix.packages.${system};
|
||||||
@ -32,13 +32,14 @@
|
|||||||
|
|
||||||
deps = with pkgs; {
|
deps = with pkgs; {
|
||||||
global = [
|
global = [
|
||||||
openssl pkg-config
|
openssl pkg-config gcc
|
||||||
];
|
];
|
||||||
gui = [
|
gui = [
|
||||||
gtk4 gdk-pixbuf atk webkitgtk
|
gtk4 gdk-pixbuf atk webkitgtk dbus
|
||||||
];
|
];
|
||||||
shell = [
|
shell = [
|
||||||
toolchain fenix.default.clippy git
|
(with fenix; combine [toolchain default.clippy complete.rust-src rust-analyzer])
|
||||||
|
git
|
||||||
jdk17 jdk8
|
jdk17 jdk8
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@ -53,8 +54,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
apps = {
|
apps = {
|
||||||
theseus-cli = utils.mkApp {
|
cli = utils.mkApp {
|
||||||
drv = inputs.self.packages.${system}.theseus-cli;
|
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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
thiserror = "1.0"
|
bytes = "1"
|
||||||
async-trait = "0.1.51"
|
bincode = { version = "2.0.0-rc.1", features = ["serde"] }
|
||||||
|
|
||||||
daedalus = "0.1.12"
|
|
||||||
|
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
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"]}
|
sha1 = { version = "0.6.0", features = ["std"]}
|
||||||
path-clean = "0.1.0"
|
sled = { version = "0.34.7", features = ["compression"] }
|
||||||
fs_extra = "1.2.0"
|
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"
|
dirs = "4.0"
|
||||||
|
# TODO: possibly replace with tracing to have structured logging
|
||||||
regex = "1.5"
|
|
||||||
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
futures = "0.3"
|
|
||||||
|
|
||||||
sys-info = "0.9.0"
|
|
||||||
|
|
||||||
log = "0.4.14"
|
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"
|
once_cell = "1.9.0"
|
||||||
lazy_static = "1.4"
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
argh = "0.1.6"
|
argh = "0.1.6"
|
||||||
pretty_assertions = "1.1.0"
|
pretty_assertions = "1.1.0"
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "download-pack"
|
|
||||||
|
|
||||||
[features]
|
[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::*;
|
//! Minecraft CLI argument logic
|
||||||
use crate::launcher::auth::provider::Credentials;
|
// TODO: Rafactor this section
|
||||||
use crate::launcher::rules::parse_rules;
|
use super::{auth::Credentials, parse_rule};
|
||||||
use crate::launcher::LauncherError;
|
use crate::{
|
||||||
use daedalus::get_path_from_artifact;
|
state::{MemorySettings, WindowSize},
|
||||||
use daedalus::minecraft::{Argument, ArgumentValue, Library, Os, VersionType};
|
util::platform::classpath_separator,
|
||||||
use daedalus::modded::SidedDataEntry;
|
};
|
||||||
use std::collections::HashMap;
|
use daedalus::{
|
||||||
|
get_path_from_artifact,
|
||||||
|
minecraft::{Argument, ArgumentValue, Library, VersionType},
|
||||||
|
modded::SidedDataEntry,
|
||||||
|
};
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::Path;
|
use std::{collections::HashMap, path::Path};
|
||||||
use uuid::Uuid;
|
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(
|
pub fn get_class_paths(
|
||||||
libraries_path: &Path,
|
libraries_path: &Path,
|
||||||
libraries: &[Library],
|
libraries: &[Library],
|
||||||
client_path: &Path,
|
client_path: &Path,
|
||||||
) -> Result<String, LauncherError> {
|
) -> crate::Result<String> {
|
||||||
let mut class_paths = libraries
|
let mut cps = libraries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|library| {
|
.filter_map(|library| {
|
||||||
if let Some(rules) = &library.rules {
|
if let Some(rules) = &library.rules {
|
||||||
if !super::rules::parse_rules(rules.as_slice()) {
|
if !rules.iter().all(parse_rule) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,56 +36,50 @@ pub fn get_class_paths(
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
class_paths.push(
|
cps.push(
|
||||||
crate::util::absolute_path(&client_path)
|
client_path
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified class path {} does not exist",
|
"Specified class path {} does not exist",
|
||||||
client_path.to_string_lossy()
|
client_path.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(class_paths.join(get_cp_separator()))
|
Ok(cps.join(classpath_separator()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_class_paths_jar<T: AsRef<str>>(
|
pub fn get_class_paths_jar<T: AsRef<str>>(
|
||||||
libraries_path: &Path,
|
libraries_path: &Path,
|
||||||
libraries: &[T],
|
libraries: &[T],
|
||||||
) -> Result<String, LauncherError> {
|
) -> crate::Result<String> {
|
||||||
let class_paths = libraries
|
let cps = libraries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|library| get_lib_path(libraries_path, library.as_ref()))
|
.map(|library| get_lib_path(libraries_path, library.as_ref()))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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();
|
let mut path = libraries_path.to_path_buf();
|
||||||
|
|
||||||
path.push(get_path_from_artifact(lib.as_ref())?);
|
path.push(get_path_from_artifact(lib.as_ref())?);
|
||||||
|
|
||||||
let path = crate::util::absolute_path(&path).map_err(|_| {
|
let path = &path.canonicalize().map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Library file at path {} does not exist",
|
"Library file at path {} does not exist",
|
||||||
path.to_string_lossy()
|
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())
|
Ok(path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_jvm_arguments(
|
pub fn get_jvm_arguments(
|
||||||
arguments: Option<&[Argument]>,
|
arguments: Option<&[Argument]>,
|
||||||
natives_path: &Path,
|
natives_path: &Path,
|
||||||
@ -97,7 +88,7 @@ pub fn get_jvm_arguments(
|
|||||||
version_name: &str,
|
version_name: &str,
|
||||||
memory: MemorySettings,
|
memory: MemorySettings,
|
||||||
custom_args: Vec<String>,
|
custom_args: Vec<String>,
|
||||||
) -> Result<Vec<String>, LauncherError> {
|
) -> crate::Result<Vec<String>> {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
|
|
||||||
if let Some(args) = arguments {
|
if let Some(args) = arguments {
|
||||||
@ -113,11 +104,13 @@ pub fn get_jvm_arguments(
|
|||||||
} else {
|
} else {
|
||||||
parsed_arguments.push(format!(
|
parsed_arguments.push(format!(
|
||||||
"-Djava.library.path={}",
|
"-Djava.library.path={}",
|
||||||
&crate::util::absolute_path(natives_path)
|
&natives_path
|
||||||
.map_err(|_| LauncherError::InvalidInput(format!(
|
.canonicalize()
|
||||||
|
.map_err(|_| crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified natives path {} does not exist",
|
"Specified natives path {} does not exist",
|
||||||
natives_path.to_string_lossy()
|
natives_path.to_string_lossy()
|
||||||
)))?
|
))
|
||||||
|
.as_error())?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string()
|
.to_string()
|
||||||
));
|
));
|
||||||
@ -144,33 +137,37 @@ fn parse_jvm_argument(
|
|||||||
libraries_path: &Path,
|
libraries_path: &Path,
|
||||||
class_paths: &str,
|
class_paths: &str,
|
||||||
version_name: &str,
|
version_name: &str,
|
||||||
) -> Result<String, LauncherError> {
|
) -> crate::Result<String> {
|
||||||
argument.retain(|c| !c.is_whitespace());
|
argument.retain(|c| !c.is_whitespace());
|
||||||
Ok(argument
|
Ok(argument
|
||||||
.replace(
|
.replace(
|
||||||
"${natives_directory}",
|
"${natives_directory}",
|
||||||
&crate::util::absolute_path(natives_path)
|
&natives_path
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified natives path {} does not exist",
|
"Specified natives path {} does not exist",
|
||||||
natives_path.to_string_lossy()
|
natives_path.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy(),
|
.to_string_lossy(),
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"${library_directory}",
|
"${library_directory}",
|
||||||
&crate::util::absolute_path(libraries_path)
|
&libraries_path
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified libraries path {} does not exist",
|
"Specified libraries path {} does not exist",
|
||||||
libraries_path.to_string_lossy()
|
libraries_path.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
.replace("${classpath_separator}", get_cp_separator())
|
.replace("${classpath_separator}", classpath_separator())
|
||||||
.replace("${launcher_name}", "theseus")
|
.replace("${launcher_name}", "theseus")
|
||||||
.replace("${launcher_version}", env!("CARGO_PKG_VERSION"))
|
.replace("${launcher_version}", env!("CARGO_PKG_VERSION"))
|
||||||
.replace("${version_name}", version_name)
|
.replace("${version_name}", version_name)
|
||||||
@ -188,7 +185,7 @@ pub fn get_minecraft_arguments(
|
|||||||
assets_directory: &Path,
|
assets_directory: &Path,
|
||||||
version_type: &VersionType,
|
version_type: &VersionType,
|
||||||
resolution: WindowSize,
|
resolution: WindowSize,
|
||||||
) -> Result<Vec<String>, LauncherError> {
|
) -> crate::Result<Vec<String>> {
|
||||||
if let Some(arguments) = arguments {
|
if let Some(arguments) = arguments {
|
||||||
let mut parsed_arguments = Vec::new();
|
let mut parsed_arguments = Vec::new();
|
||||||
|
|
||||||
@ -242,48 +239,54 @@ fn parse_minecraft_argument(
|
|||||||
assets_directory: &Path,
|
assets_directory: &Path,
|
||||||
version_type: &VersionType,
|
version_type: &VersionType,
|
||||||
resolution: WindowSize,
|
resolution: WindowSize,
|
||||||
) -> Result<String, LauncherError> {
|
) -> crate::Result<String> {
|
||||||
Ok(argument
|
Ok(argument
|
||||||
.replace("${auth_access_token}", access_token)
|
.replace("${auth_access_token}", access_token)
|
||||||
.replace("${auth_session}", access_token)
|
.replace("${auth_session}", access_token)
|
||||||
.replace("${auth_player_name}", username)
|
.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_properties}", "{}")
|
||||||
.replace("${user_type}", "mojang")
|
.replace("${user_type}", "mojang")
|
||||||
.replace("${version_name}", version)
|
.replace("${version_name}", version)
|
||||||
.replace("${assets_index_name}", asset_index_name)
|
.replace("${assets_index_name}", asset_index_name)
|
||||||
.replace(
|
.replace(
|
||||||
"${game_directory}",
|
"${game_directory}",
|
||||||
&crate::util::absolute_path(game_directory)
|
&game_directory
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified game directory {} does not exist",
|
"Specified game directory {} does not exist",
|
||||||
game_directory.to_string_lossy()
|
game_directory.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"${assets_root}",
|
"${assets_root}",
|
||||||
&crate::util::absolute_path(assets_directory)
|
&assets_directory
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified assets directory {} does not exist",
|
"Specified assets directory {} does not exist",
|
||||||
assets_directory.to_string_lossy()
|
assets_directory.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"${game_assets}",
|
"${game_assets}",
|
||||||
&crate::util::absolute_path(assets_directory)
|
&assets_directory
|
||||||
|
.canonicalize()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Specified assets directory {} does not exist",
|
"Specified assets directory {} does not exist",
|
||||||
assets_directory.to_string_lossy()
|
assets_directory.to_string_lossy()
|
||||||
))
|
))
|
||||||
|
.as_error()
|
||||||
})?
|
})?
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
@ -297,9 +300,9 @@ fn parse_arguments<F>(
|
|||||||
arguments: &[Argument],
|
arguments: &[Argument],
|
||||||
parsed_arguments: &mut Vec<String>,
|
parsed_arguments: &mut Vec<String>,
|
||||||
parse_function: F,
|
parse_function: F,
|
||||||
) -> Result<(), LauncherError>
|
) -> crate::Result<()>
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> Result<String, LauncherError>,
|
F: Fn(&str) -> crate::Result<String>,
|
||||||
{
|
{
|
||||||
for argument in arguments {
|
for argument in arguments {
|
||||||
match argument {
|
match argument {
|
||||||
@ -311,7 +314,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Argument::Ruled { rules, value } => {
|
Argument::Ruled { rules, value } => {
|
||||||
if parse_rules(rules.as_slice()) {
|
if rules.iter().all(parse_rule) {
|
||||||
match value {
|
match value {
|
||||||
ArgumentValue::Single(arg) => {
|
ArgumentValue::Single(arg) => {
|
||||||
parsed_arguments.push(parse_function(arg)?);
|
parsed_arguments.push(parse_function(arg)?);
|
||||||
@ -334,7 +337,7 @@ pub fn get_processor_arguments<T: AsRef<str>>(
|
|||||||
libraries_path: &Path,
|
libraries_path: &Path,
|
||||||
arguments: &[T],
|
arguments: &[T],
|
||||||
data: &HashMap<String, SidedDataEntry>,
|
data: &HashMap<String, SidedDataEntry>,
|
||||||
) -> Result<Vec<String>, LauncherError> {
|
) -> crate::Result<Vec<String>> {
|
||||||
let mut new_arguments = Vec::new();
|
let mut new_arguments = Vec::new();
|
||||||
|
|
||||||
for argument in arguments {
|
for argument in arguments {
|
||||||
@ -342,7 +345,10 @@ pub fn get_processor_arguments<T: AsRef<str>>(
|
|||||||
if argument.as_ref().starts_with('{') {
|
if argument.as_ref().starts_with('{') {
|
||||||
if let Some(entry) = data.get(trimmed_arg) {
|
if let Some(entry) = data.get(trimmed_arg) {
|
||||||
new_arguments.push(if entry.client.starts_with('[') {
|
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 {
|
} else {
|
||||||
entry.client.clone()
|
entry.client.clone()
|
||||||
})
|
})
|
||||||
@ -357,15 +363,25 @@ pub fn get_processor_arguments<T: AsRef<str>>(
|
|||||||
Ok(new_arguments)
|
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 || {
|
Ok(tokio::task::spawn_blocking(move || {
|
||||||
let zipfile = std::fs::File::open(&path)?;
|
let zipfile = std::fs::File::open(&path)?;
|
||||||
let mut archive = zip::ZipArchive::new(zipfile).map_err(|_| {
|
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(|_| {
|
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);
|
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 {
|
//! Authentication flow based on Hydra
|
||||||
use serde::{Deserialize, Serialize};
|
use async_tungstenite as ws;
|
||||||
use uuid::Uuid;
|
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 const HYDRA_URL: Lazy<Url> =
|
||||||
pub struct GameProfile {
|
Lazy::new(|| Url::parse("https://hydra.modrinth.com").unwrap());
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
// Socket messages
|
||||||
pub struct UserProperty {
|
#[derive(Deserialize)]
|
||||||
pub name: String,
|
struct ErrorJSON {
|
||||||
pub value: String,
|
error: String,
|
||||||
}
|
|
||||||
|
|
||||||
#[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 {
|
impl ErrorJSON {
|
||||||
use crate::launcher::auth::api::login;
|
pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result<T> {
|
||||||
use crate::launcher::LauncherError;
|
if let Ok(err) = serde_json::from_slice::<Self>(data) {
|
||||||
use uuid::Uuid;
|
Err(crate::ErrorKind::HydraError(err.error).as_error())
|
||||||
|
} else {
|
||||||
#[derive(Debug)]
|
Ok(serde_json::from_slice::<T>(data)?)
|
||||||
/// The credentials of a user
|
|
||||||
pub struct Credentials {
|
|
||||||
/// The user UUID the credentials belong to
|
|
||||||
pub id: Uuid,
|
|
||||||
/// The username of the user
|
|
||||||
pub username: String,
|
|
||||||
/// The access token associated with the credentials
|
|
||||||
pub access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let profile = login.selected_profile.unwrap();
|
|
||||||
|
|
||||||
Ok(Credentials {
|
|
||||||
id: profile.id,
|
|
||||||
username: profile.name,
|
|
||||||
access_token: login.access_token,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginCodeJSON {
|
||||||
|
login_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
#[bincode(with_serde)]
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
#[bincode(with_serde)]
|
||||||
|
pub expires: DateTime<Utc>,
|
||||||
|
_ctor_scope: std::marker::PhantomData<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementation
|
||||||
|
pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
|
||||||
|
socket: ws::WebSocketStream<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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::{
|
use crate::{
|
||||||
data::{DataError, Settings},
|
state::State,
|
||||||
launcher::LauncherError,
|
util::{fetch::*, platform::OsExt},
|
||||||
};
|
};
|
||||||
use daedalus::get_path_from_artifact;
|
use daedalus::{
|
||||||
use daedalus::minecraft::{
|
self as d,
|
||||||
fetch_assets_index, fetch_version_info, Asset, AssetsIndex, DownloadType,
|
minecraft::{
|
||||||
Library, Os, Version, VersionInfo,
|
Asset, AssetsIndex, Library, Os, Version as GameVersion,
|
||||||
};
|
VersionInfo as GameVersionInfo,
|
||||||
use daedalus::modded::{
|
},
|
||||||
fetch_partial_version, merge_partial_version, LoaderVersion,
|
modded::LoaderVersion,
|
||||||
};
|
|
||||||
use futures::future;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::{
|
|
||||||
fs::File,
|
|
||||||
io::AsyncWriteExt,
|
|
||||||
sync::{OnceCell, Semaphore},
|
|
||||||
};
|
};
|
||||||
|
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> {
|
tokio::try_join! {
|
||||||
DOWNLOADS_SEMAPHORE
|
download_client(st, version),
|
||||||
.get_or_try_init(|| async {
|
download_assets(st, version.assets == "legacy", &assets_index),
|
||||||
let settings = Settings::get().await?;
|
download_libraries(st, version.libraries.as_slice(), &version.id)
|
||||||
Ok::<_, DataError>(Semaphore::new(
|
}?;
|
||||||
settings.max_concurrent_downloads,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
|
log::info!("Done downloading Minecraft!");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all, fields(version = version.id.as_str(), loader = ?loader))]
|
||||||
pub async fn download_version_info(
|
pub async fn download_version_info(
|
||||||
client_path: &Path,
|
st: &State,
|
||||||
version: &Version,
|
version: &GameVersion,
|
||||||
loader_version: Option<&LoaderVersion>,
|
loader: Option<&LoaderVersion>,
|
||||||
) -> Result<VersionInfo, LauncherError> {
|
) -> crate::Result<GameVersionInfo> {
|
||||||
let id = match loader_version {
|
let version_id = loader.map_or(&version.id, |it| &it.id);
|
||||||
Some(x) => &x.id,
|
log::debug!("Loading version info for Minecraft {version_id}");
|
||||||
None => &version.id,
|
let path = st
|
||||||
};
|
.directories
|
||||||
|
.version_dir(version_id)
|
||||||
|
.join(format!("{version_id}.json"));
|
||||||
|
|
||||||
let mut path = client_path.join(id);
|
let res = if path.exists() {
|
||||||
path.push(&format!("{id}.json"));
|
fs::read(path)
|
||||||
|
.err_into::<crate::Error>()
|
||||||
if path.exists() {
|
.await
|
||||||
let contents = std::fs::read_to_string(path)?;
|
.and_then(|ref it| Ok(serde_json::from_slice(it)?))
|
||||||
Ok(serde_json::from_str(&contents)?)
|
|
||||||
} else {
|
} 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 {
|
if let Some(loader) = loader {
|
||||||
let partial = fetch_partial_version(&loader_version.url).await?;
|
let partial = d::modded::fetch_partial_version(&loader.url).await?;
|
||||||
info = merge_partial_version(partial, info);
|
info = d::modded::merge_partial_version(partial, info);
|
||||||
info.id = loader_version.id.clone();
|
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)
|
Ok(info)
|
||||||
}
|
}?;
|
||||||
|
|
||||||
|
log::debug!("Loaded version info for Minecraft {version_id}");
|
||||||
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
pub async fn download_client(
|
pub async fn download_client(
|
||||||
client_path: &Path,
|
st: &State,
|
||||||
version_info: &VersionInfo,
|
version_info: &GameVersionInfo,
|
||||||
) -> Result<(), LauncherError> {
|
) -> crate::Result<()> {
|
||||||
let version = &version_info.id;
|
let ref version = version_info.id;
|
||||||
|
log::debug!("Locating client for version {version}");
|
||||||
let client_download = version_info
|
let client_download = version_info
|
||||||
.downloads
|
.downloads
|
||||||
.get(&DownloadType::Client)
|
.get(&d::minecraft::DownloadType::Client)
|
||||||
.ok_or_else(|| {
|
.ok_or(
|
||||||
LauncherError::InvalidInput(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Version {version} does not have any client downloads"
|
"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);
|
if !path.exists() {
|
||||||
path.push(&format!("{version}.jar"));
|
let permit = st.io_semaphore.acquire().await.unwrap();
|
||||||
|
let bytes =
|
||||||
save_and_download_file(
|
fetch(&client_download.url, Some(&client_download.sha1), &permit)
|
||||||
&path,
|
|
||||||
&client_download.url,
|
|
||||||
Some(&client_download.sha1),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
let content = std::fs::read_to_string(path)?;
|
|
||||||
Ok(serde_json::from_str(&content)?)
|
|
||||||
} else {
|
|
||||||
let index = fetch_assets_index(version).await?;
|
|
||||||
|
|
||||||
save_file(&path, &bytes::Bytes::from(serde_json::to_string(&index)?))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn download_assets(
|
|
||||||
assets_path: &Path,
|
|
||||||
legacy_path: Option<&Path>,
|
|
||||||
index: &AssetsIndex,
|
|
||||||
) -> Result<(), LauncherError> {
|
|
||||||
future::join_all(index.objects.iter().map(|(name, asset)| {
|
|
||||||
download_asset(assets_path, legacy_path, name, asset)
|
|
||||||
}))
|
|
||||||
.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?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn download_libraries(
|
|
||||||
libraries_path: &Path,
|
|
||||||
natives_path: &Path,
|
|
||||||
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>>()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_library(
|
|
||||||
libraries_path: &Path,
|
|
||||||
natives_path: &Path,
|
|
||||||
library: &Library,
|
|
||||||
) -> Result<(), LauncherError> {
|
|
||||||
if let Some(rules) = &library.rules {
|
|
||||||
if !super::rules::parse_rules(rules) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
future::try_join(
|
|
||||||
download_library_jar(libraries_path, library),
|
|
||||||
download_native(natives_path, library),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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?;
|
.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(
|
||||||
|
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));
|
||||||
|
|
||||||
|
let res = if path.exists() {
|
||||||
|
fs::read(path)
|
||||||
|
.err_into::<crate::Error>()
|
||||||
|
.await
|
||||||
|
.and_then(|ref it| Ok(serde_json::from_slice(it)?))
|
||||||
} else {
|
} else {
|
||||||
let url = format!(
|
let index = d::minecraft::fetch_assets_index(version).await?;
|
||||||
"{}{artifact_path}",
|
let permit = st.io_semaphore.acquire().await.unwrap();
|
||||||
library
|
write(&path, &serde_json::to_vec(&index)?, &permit).await?;
|
||||||
.url
|
log::info!("Fetched assets index");
|
||||||
.as_deref()
|
Ok(index)
|
||||||
.unwrap_or("https://libraries.minecraft.net/"),
|
}?;
|
||||||
);
|
|
||||||
save_and_download_file(&path, &url, None).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
log::debug!("Assets index successfully loaded!");
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(st, index))]
|
||||||
|
pub async fn download_assets(
|
||||||
|
st: &State,
|
||||||
|
with_legacy: bool,
|
||||||
|
index: &AssetsIndex,
|
||||||
|
) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_native(
|
#[tracing::instrument(skip(st, libraries))]
|
||||||
natives_path: &Path,
|
pub async fn download_libraries(
|
||||||
library: &Library,
|
st: &State,
|
||||||
) -> Result<(), LauncherError> {
|
libraries: &[Library],
|
||||||
use daedalus::minecraft::LibraryDownload;
|
version: &str,
|
||||||
use std::collections::HashMap;
|
) -> crate::Result<()> {
|
||||||
|
log::debug!("Loading libraries");
|
||||||
|
|
||||||
// Try blocks in stable Rust when?
|
tokio::try_join! {
|
||||||
let optional_cascade =
|
fs::create_dir_all(st.directories.libraries_dir()),
|
||||||
|| -> Option<(&String, &HashMap<String, LibraryDownload>)> {
|
fs::create_dir_all(st.directories.version_natives_dir(version))
|
||||||
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() {
|
stream::iter(libraries.iter())
|
||||||
#[cfg(target_pointer_width = "64")]
|
.map(Ok::<&Library, crate::Error>)
|
||||||
let parsed_key = os_key.replace("${arch}", "64");
|
.try_for_each_concurrent(None, |library| async move {
|
||||||
#[cfg(target_pointer_width = "32")]
|
if let Some(rules) = &library.rules {
|
||||||
let parsed_key = os_key.replace("${arch}", "32");
|
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);
|
||||||
|
|
||||||
if let Some(native) = classifiers.get(&parsed_key) {
|
match library.downloads {
|
||||||
let file = download_file(&native.url, Some(&native.sha1)).await?;
|
_ 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?;
|
||||||
|
write(&path, &bytes, &permit).await?;
|
||||||
|
log::info!("Fetched library {}", &library.name);
|
||||||
|
Ok::<_, crate::Error>(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let url = [
|
||||||
|
library
|
||||||
|
.url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("https://libraries.minecraft.net"),
|
||||||
|
&artifact_path
|
||||||
|
].concat();
|
||||||
|
|
||||||
let reader = std::io::Cursor::new(&file);
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
let mut archive = zip::ZipArchive::new(reader).unwrap();
|
if let Some(native) = classifiers.get(&parsed_key) {
|
||||||
archive.extract(natives_path).unwrap();
|
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);
|
||||||
Ok(())
|
let mut archive = zip::ZipArchive::new(reader).unwrap();
|
||||||
}
|
archive.extract(&st.directories.version_natives_dir(version)).unwrap();
|
||||||
|
log::info!("Fetched native {}", &library.name);
|
||||||
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?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
Ok(())
|
||||||
} 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) => {
|
log::debug!("Loaded library {}", library.name);
|
||||||
return Err(LauncherError::FetchError {
|
Ok(())
|
||||||
inner: err,
|
|
||||||
item: url.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
).await?;
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes a checksum of the input bytes
|
log::debug!("Done loading libraries!");
|
||||||
async fn get_hash(bytes: bytes::Bytes) -> Result<String, LauncherError> {
|
Ok(())
|
||||||
let hash =
|
|
||||||
tokio::task::spawn_blocking(|| sha1::Sha1::from(bytes).hexdigest())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(hash)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,328 +1,214 @@
|
|||||||
use daedalus::minecraft::{ArgumentType, VersionInfo};
|
//! Logic for launching Minecraft
|
||||||
use daedalus::modded::LoaderVersion;
|
use crate::state as st;
|
||||||
use serde::{Deserialize, Serialize};
|
use daedalus as d;
|
||||||
use std::{path::Path, process::Stdio};
|
use std::{path::Path, process::Stdio};
|
||||||
use thiserror::Error;
|
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
pub use crate::launcher::auth::provider::Credentials;
|
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|
||||||
mod download;
|
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)]
|
let res = match rule {
|
||||||
pub enum LauncherError {
|
Rule {
|
||||||
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
|
os: Some(ref os), ..
|
||||||
ChecksumFailure {
|
} => crate::util::platform::os_rule(os),
|
||||||
hash: String,
|
Rule {
|
||||||
url: String,
|
features: Some(ref features),
|
||||||
tries: u32,
|
..
|
||||||
},
|
} => features.has_demo_resolution.unwrap_or(false),
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
#[error("Failed to run processor: {0}")]
|
match rule.action {
|
||||||
ProcessorError(String),
|
RuleAction::Allow => res,
|
||||||
|
RuleAction::Disallow => !res,
|
||||||
#[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 {
|
macro_rules! processor_rules {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
($dest:expr; $($name:literal : client => $client:expr, server => $server:expr;)+) => {
|
||||||
let repr = match self {
|
$(std::collections::HashMap::insert(
|
||||||
&Self::Vanilla => "Vanilla",
|
$dest,
|
||||||
&Self::Forge => "Forge",
|
String::from($name),
|
||||||
&Self::Fabric => "Fabric",
|
daedalus::modded::SidedDataEntry {
|
||||||
};
|
client: String::from($client),
|
||||||
|
server: String::from($server),
|
||||||
f.write_str(repr)
|
},
|
||||||
|
);)+
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all, fields(path = ?instance_path))]
|
||||||
pub async fn launch_minecraft(
|
pub async fn launch_minecraft(
|
||||||
game_version: &str,
|
game_version: &str,
|
||||||
loader_version: &Option<LoaderVersion>,
|
loader_version: &Option<d::modded::LoaderVersion>,
|
||||||
root_dir: &Path,
|
instance_path: &Path,
|
||||||
java: &Path,
|
java_install: &Path,
|
||||||
java_args: &Vec<String>,
|
java_args: &[String],
|
||||||
wrapper: &Option<String>,
|
wrapper: &Option<String>,
|
||||||
memory: &crate::data::profiles::MemorySettings,
|
memory: &st::MemorySettings,
|
||||||
resolution: &crate::data::profiles::WindowSize,
|
resolution: &st::WindowSize,
|
||||||
credentials: &Credentials,
|
credentials: &auth::Credentials,
|
||||||
) -> Result<Child, LauncherError> {
|
) -> crate::Result<Child> {
|
||||||
let (metadata, settings) = futures::try_join! {
|
let state = st::State::get().await?;
|
||||||
crate::data::Metadata::get(),
|
let instance_path = instance_path.canonicalize()?;
|
||||||
crate::data::Settings::get(),
|
|
||||||
}?;
|
|
||||||
let root_dir = root_dir.canonicalize()?;
|
|
||||||
let metadata_dir = &settings.metadata_dir;
|
|
||||||
|
|
||||||
let (
|
let version = state
|
||||||
versions_path,
|
.metadata
|
||||||
libraries_path,
|
|
||||||
assets_path,
|
|
||||||
legacy_assets_path,
|
|
||||||
natives_path,
|
|
||||||
) = (
|
|
||||||
metadata_dir.join("versions"),
|
|
||||||
metadata_dir.join("libraries"),
|
|
||||||
metadata_dir.join("assets"),
|
|
||||||
metadata_dir.join("resources"),
|
|
||||||
metadata_dir.join("natives"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let version = metadata
|
|
||||||
.minecraft
|
.minecraft
|
||||||
.versions
|
.versions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|it| it.id == game_version)
|
.find(|it| it.id == game_version)
|
||||||
.ok_or_else(|| {
|
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||||
LauncherError::InvalidInput(format!(
|
"Invalid game version: {game_version}"
|
||||||
"Invalid game version: {game_version}",
|
)))?;
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let version_jar = loader_version
|
let version_jar = loader_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(version.id.clone(), |it| it.id.clone());
|
.map_or(version.id.clone(), |it| it.id.clone());
|
||||||
|
|
||||||
let mut version = download::download_version_info(
|
let mut version_info = download::download_version_info(
|
||||||
&versions_path,
|
&state,
|
||||||
version,
|
&version,
|
||||||
loader_version.as_ref(),
|
loader_version.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let client_path = versions_path
|
let client_path = state
|
||||||
.join(&version.id)
|
.directories
|
||||||
.join(format!("{}.jar", &version_jar));
|
.version_dir(&version.id)
|
||||||
let version_natives_path = natives_path.join(&version.id);
|
.join(format!("{version_jar}.jar"));
|
||||||
|
|
||||||
download_minecraft(
|
download::download_minecraft(&state, &version_info).await?;
|
||||||
&version,
|
st::State::sync().await?;
|
||||||
&versions_path,
|
|
||||||
&assets_path,
|
|
||||||
&legacy_assets_path,
|
|
||||||
&libraries_path,
|
|
||||||
&version_natives_path,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(processors) = &version.processors {
|
if let Some(processors) = &version_info.processors {
|
||||||
if let Some(ref mut data) = version.data {
|
if let Some(ref mut data) = version_info.data {
|
||||||
data.insert(
|
processor_rules! {
|
||||||
"SIDE".to_string(),
|
data;
|
||||||
daedalus::modded::SidedDataEntry {
|
"SIDE":
|
||||||
client: "client".to_string(),
|
client => "client",
|
||||||
server: "".to_string(),
|
server => "";
|
||||||
},
|
"MINECRAFT_JAR" :
|
||||||
);
|
client => client_path.to_string_lossy(),
|
||||||
data.insert(
|
server => "";
|
||||||
"MINECRAFT_JAR".to_string(),
|
"MINECRAFT_VERSION":
|
||||||
daedalus::modded::SidedDataEntry {
|
client => game_version,
|
||||||
client: client_path.to_string_lossy().to_string(),
|
server => "";
|
||||||
server: "".to_string(),
|
"ROOT":
|
||||||
},
|
client => instance_path.to_string_lossy(),
|
||||||
);
|
server => "";
|
||||||
data.insert(
|
"LIBRARY_DIR":
|
||||||
"MINECRAFT_VERSION".to_string(),
|
client => state.directories.libraries_dir().to_string_lossy(),
|
||||||
daedalus::modded::SidedDataEntry {
|
server => "";
|
||||||
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(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for processor in processors {
|
for processor in processors {
|
||||||
if let Some(sides) = &processor.sides {
|
if let Some(sides) = &processor.sides {
|
||||||
if !sides.contains(&"client".to_string()) {
|
if !sides.contains(&String::from("client")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cp = processor.classpath.clone();
|
let cp = wrap_ref_builder!(cp = processor.classpath.clone() => {
|
||||||
cp.push(processor.jar.clone());
|
cp.push(processor.jar.clone())
|
||||||
|
});
|
||||||
|
|
||||||
let child = Command::new("java")
|
let child = Command::new("java")
|
||||||
.arg("-cp")
|
.arg("-cp")
|
||||||
.arg(args::get_class_paths_jar(&libraries_path, &cp)?)
|
.arg(args::get_class_paths_jar(
|
||||||
|
&state.directories.libraries_dir(),
|
||||||
|
&cp,
|
||||||
|
)?)
|
||||||
.arg(
|
.arg(
|
||||||
args::get_processor_main_class(args::get_lib_path(
|
args::get_processor_main_class(args::get_lib_path(
|
||||||
&libraries_path,
|
&state.directories.libraries_dir(),
|
||||||
&processor.jar,
|
&processor.jar,
|
||||||
)?)
|
)?)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
LauncherError::ProcessorError(format!(
|
crate::ErrorKind::LauncherError(format!(
|
||||||
"Could not find processor main class for {}",
|
"Could not find processor main class for {}",
|
||||||
processor.jar
|
processor.jar
|
||||||
))
|
))
|
||||||
})?,
|
})?,
|
||||||
)
|
)
|
||||||
.args(args::get_processor_arguments(
|
.args(args::get_processor_arguments(
|
||||||
&libraries_path,
|
&state.directories.libraries_dir(),
|
||||||
&processor.args,
|
&processor.args,
|
||||||
data,
|
data,
|
||||||
)?)
|
)?)
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| LauncherError::ProcessError {
|
.map_err(|err| {
|
||||||
inner: err,
|
crate::ErrorKind::LauncherError(format!(
|
||||||
process: "java".to_string(),
|
"Error running processor: {err}",
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !child.status.success() {
|
if !child.status.success() {
|
||||||
return Err(LauncherError::ProcessorError(
|
return Err(crate::ErrorKind::LauncherError(format!(
|
||||||
String::from_utf8_lossy(&child.stderr).to_string(),
|
"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 {
|
let mut command = match wrapper {
|
||||||
Some(hook) => {
|
Some(hook) => {
|
||||||
let mut cmd = Command::new(hook);
|
wrap_ref_builder!(it = Command::new(hook) => {it.arg(java_install)})
|
||||||
cmd.arg(java);
|
|
||||||
cmd
|
|
||||||
}
|
}
|
||||||
None => Command::new(java.to_string_lossy().to_string()),
|
None => Command::new(String::from(java_install.to_string_lossy())),
|
||||||
};
|
};
|
||||||
|
|
||||||
command
|
command
|
||||||
.args(args::get_jvm_arguments(
|
.args(args::get_jvm_arguments(
|
||||||
arguments.get(&ArgumentType::Jvm).map(|x| x.as_slice()),
|
args.get(&d::minecraft::ArgumentType::Jvm)
|
||||||
&version_natives_path,
|
.map(|x| x.as_slice()),
|
||||||
&libraries_path,
|
&state.directories.version_natives_dir(&version.id),
|
||||||
|
&state.directories.libraries_dir(),
|
||||||
&args::get_class_paths(
|
&args::get_class_paths(
|
||||||
&libraries_path,
|
&state.directories.libraries_dir(),
|
||||||
version.libraries.as_slice(),
|
version_info.libraries.as_slice(),
|
||||||
&client_path,
|
&client_path,
|
||||||
)?,
|
)?,
|
||||||
&version_jar,
|
&version_jar,
|
||||||
*memory,
|
*memory,
|
||||||
java_args.clone(),
|
Vec::from(java_args),
|
||||||
)?)
|
)?)
|
||||||
.arg(version.main_class.clone())
|
.arg(version_info.main_class.clone())
|
||||||
.args(args::get_minecraft_arguments(
|
.args(args::get_minecraft_arguments(
|
||||||
arguments.get(&ArgumentType::Game).map(|x| x.as_slice()),
|
args.get(&d::minecraft::ArgumentType::Game)
|
||||||
version.minecraft_arguments.as_deref(),
|
.map(|x| x.as_slice()),
|
||||||
|
version_info.minecraft_arguments.as_deref(),
|
||||||
credentials,
|
credentials,
|
||||||
&version.id,
|
&version.id,
|
||||||
&version.asset_index.id,
|
&version_info.asset_index.id,
|
||||||
&root_dir,
|
&instance_path,
|
||||||
&assets_path,
|
&state.directories.assets_dir(),
|
||||||
&version.type_,
|
&version.type_,
|
||||||
*resolution,
|
*resolution,
|
||||||
)?)
|
)?)
|
||||||
.current_dir(root_dir.clone())
|
.current_dir(instance_path.clone())
|
||||||
.stdout(Stdio::inherit())
|
.stdout(Stdio::inherit())
|
||||||
.stderr(Stdio::inherit());
|
.stderr(Stdio::inherit());
|
||||||
|
|
||||||
command.spawn().map_err(|err| LauncherError::ProcessError {
|
command.spawn().map_err(|err| {
|
||||||
inner: err,
|
crate::ErrorKind::LauncherError(format!(
|
||||||
process: format!("minecraft-{} @ {}", &version.id, root_dir.display()),
|
"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
|
||||||
//! Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs,
|
|
||||||
//! and launching Modrinth mod packs
|
|
||||||
|
|
||||||
#![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
|
#[macro_use]
|
||||||
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;
|
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
mod api;
|
||||||
pub enum Error {
|
mod config;
|
||||||
#[error("Launcher error: {0}")]
|
mod error;
|
||||||
LauncherError(#[from] launcher::LauncherError),
|
mod launcher;
|
||||||
|
mod state;
|
||||||
|
|
||||||
#[error("Modpack error: {0}")]
|
pub use api::*;
|
||||||
ModpackError(#[from] modpack::ModpackError),
|
pub use error::*;
|
||||||
|
pub use state::State;
|
||||||
#[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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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]
|
[dependencies]
|
||||||
theseus = { path = "../theseus" }
|
theseus = { path = "../theseus" }
|
||||||
daedalus = "0.1.12"
|
daedalus = {version = "0.1.15", features = ["bincode"]}
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-stream = { version = "0.1", features = ["fs"] }
|
tokio-stream = { version = "0.1", features = ["fs"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
argh = "0.1"
|
argh = "0.1"
|
||||||
paris = { version = "1.5", features = ["macros", "no_logger"] }
|
paris = { version = "1.5", features = ["macros", "no_logger"] }
|
||||||
dialoguer = "0.10"
|
dialoguer = "0.10"
|
||||||
eyre = "0.6"
|
|
||||||
tabled = "0.5"
|
tabled = "0.5"
|
||||||
dirs = "4.0"
|
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 eyre::Result;
|
||||||
use futures::TryFutureExt;
|
use futures::TryFutureExt;
|
||||||
use paris::*;
|
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;
|
mod util;
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
mod subcommands;
|
||||||
|
|
||||||
|
#[derive(argh::FromArgs, Debug)]
|
||||||
/// The official Modrinth CLI
|
/// The official Modrinth CLI
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
#[argh(subcommand)]
|
#[argh(subcommand)]
|
||||||
pub subcommand: subcommands::SubCommand,
|
pub subcommand: subcommands::Subcommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tracing::instrument]
|
||||||
async fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = argh::from_env::<Args>();
|
let args = argh::from_env::<Args>();
|
||||||
theseus::init().await?;
|
|
||||||
|
|
||||||
args.dispatch()
|
color_eyre::install()?;
|
||||||
.inspect_err(|_| error!("An error has occurred!\n"))
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.and_then(|_| async { Ok(theseus::save().await?) })
|
.or_else(|_| EnvFilter::try_new("info"))?;
|
||||||
.await
|
|
||||||
|
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"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.with_current_subscriber(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
use eyre::Result;
|
use eyre::Result;
|
||||||
|
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod user;
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
#[argh(subcommand)]
|
#[argh(subcommand)]
|
||||||
pub enum SubCommand {
|
pub enum Subcommand {
|
||||||
Profile(profile::ProfileCommand),
|
Profile(profile::ProfileCommand),
|
||||||
|
User(user::UserCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::Args {
|
impl crate::Args {
|
||||||
pub async fn dispatch(&self) -> Result<()> {
|
pub async fn dispatch(&self) -> Result<()> {
|
||||||
match self.subcommand {
|
dispatch!(self.subcommand, (self) => {
|
||||||
SubCommand::Profile(ref cmd) => cmd.dispatch(self).await,
|
Subcommand::Profile,
|
||||||
}
|
Subcommand::User
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,26 @@
|
|||||||
//! Profile management subcommand
|
//! Profile management subcommand
|
||||||
use crate::util::{
|
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 daedalus::modded::LoaderVersion;
|
||||||
use eyre::{ensure, Result};
|
use eyre::{ensure, Result};
|
||||||
|
use futures::prelude::*;
|
||||||
use paris::*;
|
use paris::*;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tabled::{Table, Tabled};
|
use tabled::Tabled;
|
||||||
use theseus::{
|
use theseus::prelude::*;
|
||||||
data::{profiles::PROFILE_JSON_PATH, Metadata, Profile, Profiles},
|
|
||||||
launcher::ModLoader,
|
|
||||||
};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
|
use tokio_stream::wrappers::ReadDirStream;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
#[argh(subcommand, name = "profile")]
|
#[argh(subcommand, name = "profile")]
|
||||||
/// profile management
|
/// manage Minecraft instances
|
||||||
pub struct ProfileCommand {
|
pub struct ProfileCommand {
|
||||||
#[argh(subcommand)]
|
#[argh(subcommand)]
|
||||||
action: ProfileSubcommand,
|
action: ProfileSubcommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
#[argh(subcommand)]
|
#[argh(subcommand)]
|
||||||
pub enum ProfileSubcommand {
|
pub enum ProfileSubcommand {
|
||||||
Add(ProfileAdd),
|
Add(ProfileAdd),
|
||||||
@ -33,7 +30,7 @@ pub enum ProfileSubcommand {
|
|||||||
Run(ProfileRun),
|
Run(ProfileRun),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
#[argh(subcommand, name = "add")]
|
#[argh(subcommand, name = "add")]
|
||||||
/// add a new profile to Theseus
|
/// add a new profile to Theseus
|
||||||
pub struct ProfileAdd {
|
pub struct ProfileAdd {
|
||||||
@ -54,23 +51,26 @@ impl ProfileAdd {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let profile = self.profile.canonicalize()?;
|
let profile = self.profile.canonicalize()?;
|
||||||
let json_path = profile.join(PROFILE_JSON_PATH);
|
let json_path = profile.join("profile.json");
|
||||||
|
|
||||||
ensure!(
|
ensure!(
|
||||||
json_path.exists(),
|
json_path.exists(),
|
||||||
"Profile json does not exist. Perhaps you wanted `profile init` or `profile fetch`?"
|
"Profile json does not exist. Perhaps you wanted `profile init` or `profile fetch`?"
|
||||||
);
|
);
|
||||||
ensure!(
|
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`"
|
"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!");
|
success!("Profile added!");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
#[argh(subcommand, name = "init")]
|
#[argh(subcommand, name = "init")]
|
||||||
/// create a new profile and manage it with Theseus
|
/// create a new profile and manage it with Theseus
|
||||||
pub struct ProfileInit {
|
pub struct ProfileInit {
|
||||||
@ -106,13 +106,15 @@ impl ProfileInit {
|
|||||||
_largs: &ProfileCommand,
|
_largs: &ProfileCommand,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// TODO: validate inputs from args early
|
// TODO: validate inputs from args early
|
||||||
|
let state = State::get().await?;
|
||||||
|
|
||||||
if self.path.exists() {
|
if self.path.exists() {
|
||||||
ensure!(
|
ensure!(
|
||||||
self.path.is_dir(),
|
self.path.is_dir(),
|
||||||
"Attempted to create profile in something other than a folder!"
|
"Attempted to create profile in something other than a folder!"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!self.path.join(PROFILE_JSON_PATH).exists(),
|
!self.path.join("profile.json").exists(),
|
||||||
"Profile already exists! Perhaps you want `profile add` instead?"
|
"Profile already exists! Perhaps you want `profile add` instead?"
|
||||||
);
|
);
|
||||||
if ReadDirStream::new(fs::read_dir(&self.path).await?)
|
if ReadDirStream::new(fs::read_dir(&self.path).await?)
|
||||||
@ -138,8 +140,6 @@ impl ProfileInit {
|
|||||||
&self.path.canonicalize()?.display()
|
&self.path.canonicalize()?.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let metadata = Metadata::get().await?;
|
|
||||||
|
|
||||||
// TODO: abstract default prompting
|
// TODO: abstract default prompting
|
||||||
let name = match &self.name {
|
let name = match &self.name {
|
||||||
Some(name) => name.clone(),
|
Some(name) => name.clone(),
|
||||||
@ -157,7 +157,7 @@ impl ProfileInit {
|
|||||||
let game_version = match &self.game_version {
|
let game_version = match &self.game_version {
|
||||||
Some(version) => version.clone(),
|
Some(version) => version.clone(),
|
||||||
None => {
|
None => {
|
||||||
let default = &metadata.minecraft.latest.release;
|
let default = &state.metadata.minecraft.latest.release;
|
||||||
|
|
||||||
prompt_async(
|
prompt_async(
|
||||||
String::from("Game version"),
|
String::from("Game version"),
|
||||||
@ -206,8 +206,8 @@ impl ProfileInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let loader_data = match loader {
|
let loader_data = match loader {
|
||||||
ModLoader::Forge => &metadata.forge,
|
ModLoader::Forge => &state.metadata.forge,
|
||||||
ModLoader::Fabric => &metadata.fabric,
|
ModLoader::Fabric => &state.metadata.fabric,
|
||||||
_ => eyre::bail!("Could not get manifest for loader {loader}. This is a bug in the CLI!"),
|
_ => 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),
|
.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 =
|
let mut profile =
|
||||||
Profile::new(name, game_version, self.path.clone()).await?;
|
Profile::new(name, game_version, self.path.clone()).await?;
|
||||||
|
|
||||||
@ -251,8 +249,8 @@ impl ProfileInit {
|
|||||||
profile.with_loader(loader, Some(loader_version));
|
profile.with_loader(loader, Some(loader_version));
|
||||||
}
|
}
|
||||||
|
|
||||||
Profiles::insert(profile).await?;
|
profile::add(profile).await?;
|
||||||
Profiles::save().await?;
|
State::sync().await?;
|
||||||
|
|
||||||
success!(
|
success!(
|
||||||
"Successfully created instance, it is now available to use with Theseus!"
|
"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
|
/// list all managed profiles
|
||||||
#[argh(subcommand, name = "list")]
|
#[argh(subcommand, name = "list")]
|
||||||
pub struct ProfileList {}
|
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 {
|
impl ProfileList {
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
&self,
|
&self,
|
||||||
_args: &crate::Args,
|
_args: &crate::Args,
|
||||||
_largs: &ProfileCommand,
|
_largs: &ProfileCommand,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let profiles = Profiles::get().await?;
|
let profiles = profile::list().await?;
|
||||||
let profiles = profiles.0.values().map(ProfileRow::from);
|
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))
|
tabled::Modify::new(tabled::Column(1..=1))
|
||||||
.with(tabled::MaxWidth::wrapping(40)),
|
.with(tabled::MaxWidth::wrapping(40)),
|
||||||
);
|
);
|
||||||
println!("{table}");
|
println!("{table}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
/// unmanage a profile
|
/// unmanage a profile
|
||||||
#[argh(subcommand, name = "remove")]
|
#[argh(subcommand, name = "remove")]
|
||||||
pub struct ProfileRemove {
|
pub struct ProfileRemove {
|
||||||
@ -329,10 +345,13 @@ impl ProfileRemove {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let profile = self.profile.canonicalize()?;
|
let profile = self.profile.canonicalize()?;
|
||||||
info!("Removing profile {} from Theseus", self.profile.display());
|
info!("Removing profile {} from Theseus", self.profile.display());
|
||||||
|
|
||||||
if confirm_async(String::from("Do you wish to continue"), true).await? {
|
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!");
|
warn!("Profile was not managed by Theseus!");
|
||||||
} else {
|
} else {
|
||||||
|
profile::remove(&profile).await?;
|
||||||
|
State::sync().await?;
|
||||||
success!("Profile removed!");
|
success!("Profile removed!");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -343,7 +362,7 @@ impl ProfileRemove {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(argh::FromArgs)]
|
#[derive(argh::FromArgs, Debug)]
|
||||||
/// run a profile
|
/// run a profile
|
||||||
#[argh(subcommand, name = "run")]
|
#[argh(subcommand, name = "run")]
|
||||||
pub struct ProfileRun {
|
pub struct ProfileRun {
|
||||||
@ -351,18 +370,9 @@ pub struct ProfileRun {
|
|||||||
/// the profile to run
|
/// the profile to run
|
||||||
profile: PathBuf,
|
profile: PathBuf,
|
||||||
|
|
||||||
// TODO: auth
|
#[argh(option)]
|
||||||
#[argh(option, short = 't')]
|
/// the user to authenticate with
|
||||||
/// the Minecraft token to use for player login. Should be replaced by auth when that is a thing.
|
user: Option<uuid::Uuid>,
|
||||||
token: String,
|
|
||||||
|
|
||||||
#[argh(option, short = 'n')]
|
|
||||||
/// the uername to use for running the game
|
|
||||||
name: String,
|
|
||||||
|
|
||||||
#[argh(option, short = 'i')]
|
|
||||||
/// the account id to use for running the game
|
|
||||||
id: Uuid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileRun {
|
impl ProfileRun {
|
||||||
@ -372,24 +382,28 @@ impl ProfileRun {
|
|||||||
_largs: &ProfileCommand,
|
_largs: &ProfileCommand,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Starting profile at path {}...", self.profile.display());
|
info!("Starting profile at path {}...", self.profile.display());
|
||||||
let ref profiles = Profiles::get().await?.0;
|
|
||||||
let path = self.profile.canonicalize()?;
|
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 {
|
ensure!(
|
||||||
id: self.id.clone(),
|
profile::is_managed(&path).await?,
|
||||||
username: self.name.clone(),
|
"Profile not managed by Theseus (if it exists, try using `profile add` first!)",
|
||||||
access_token: self.token.clone(),
|
);
|
||||||
};
|
|
||||||
|
|
||||||
let mut proc = profile.run(&credentials).await?;
|
let id = future::ready(self.user.ok_or(()))
|
||||||
profile.wait_for(&mut proc).await?;
|
.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!");
|
success!("Process exited successfully!");
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -397,14 +411,14 @@ impl ProfileRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileCommand {
|
impl ProfileCommand {
|
||||||
pub async fn dispatch(&self, args: &crate::Args) -> Result<()> {
|
pub async fn run(&self, args: &crate::Args) -> Result<()> {
|
||||||
match &self.action {
|
dispatch!(&self.action, (args, self) => {
|
||||||
ProfileSubcommand::Add(ref cmd) => cmd.run(args, self).await,
|
ProfileSubcommand::Add,
|
||||||
ProfileSubcommand::Init(ref cmd) => cmd.run(args, self).await,
|
ProfileSubcommand::Init,
|
||||||
ProfileSubcommand::List(ref cmd) => cmd.run(args, self).await,
|
ProfileSubcommand::List,
|
||||||
ProfileSubcommand::Remove(ref cmd) => cmd.run(args, self).await,
|
ProfileSubcommand::Remove,
|
||||||
ProfileSubcommand::Run(ref cmd) => cmd.run(args, self).await,
|
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 dialoguer::{Confirm, Input, Select};
|
||||||
use eyre::Result;
|
use eyre::Result;
|
||||||
use std::{borrow::Cow, path::Path};
|
use std::{borrow::Cow, path::Path};
|
||||||
|
use tabled::{Table, Tabled};
|
||||||
|
|
||||||
// TODO: make primarily async to avoid copies
|
// 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?
|
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 {
|
pub fn table_path_display(path: &Path) -> String {
|
||||||
let mut res = path.display().to_string();
|
let mut res = path.display().to_string();
|
||||||
|
|
||||||
@ -67,6 +72,20 @@ pub fn table_path_display(path: &Path) -> String {
|
|||||||
res
|
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
|
// Internal helpers
|
||||||
fn print_prompt(prompt: &str) {
|
fn print_prompt(prompt: &str) {
|
||||||
println!(
|
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": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"dev:web": "svelte-kit dev",
|
"dev:web": "vite",
|
||||||
"kill:web": "kill-port 3000",
|
"kill:web": "kill-port 5173",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"build": "tauri build",
|
"build": "tauri build",
|
||||||
"build:web": "svelte-kit build",
|
"build:web": "vite build",
|
||||||
"test": "cargo ../test --manifest-path ./src-tauri/Cargo.toml",
|
"test": "cargo ../test --manifest-path ./src-tauri/Cargo.toml",
|
||||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. src",
|
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. src",
|
||||||
"check": "cargo check --manifest-path src-tauri/Cargo.toml && svelte-check --tsconfig ./tsconfig.json",
|
"check": "cargo check --manifest-path src-tauri/Cargo.toml && svelte-check --tsconfig ./tsconfig.json"
|
||||||
"generate": "node ./scripts/generate.js",
|
|
||||||
"postinstall": "pnpm generate"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "next",
|
"@sveltejs/adapter-static": "next",
|
||||||
"@sveltejs/kit": "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/eslint-plugin": "^5.10.1",
|
||||||
"@typescript-eslint/parser": "^5.10.1",
|
"@typescript-eslint/parser": "^5.10.1",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
@ -27,8 +25,10 @@
|
|||||||
"prettier-plugin-svelte": "^2.5.0",
|
"prettier-plugin-svelte": "^2.5.0",
|
||||||
"svelte": "^3.48.0",
|
"svelte": "^3.48.0",
|
||||||
"svelte-check": "^2.2.6",
|
"svelte-check": "^2.2.6",
|
||||||
|
"svelte-intl-precompile": "^0.11.1",
|
||||||
"tslib": "^2.3.1",
|
"tslib": "^2.3.1",
|
||||||
"typescript": "~4.5.4"
|
"typescript": "~4.5.4",
|
||||||
|
"vite": "^3.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -38,10 +38,10 @@
|
|||||||
"@iconify-json/heroicons-outline": "^1.1.1",
|
"@iconify-json/heroicons-outline": "^1.1.1",
|
||||||
"@iconify-json/heroicons-solid": "^1.1.1",
|
"@iconify-json/heroicons-solid": "^1.1.1",
|
||||||
"@iconify-json/lucide": "^1.1.5",
|
"@iconify-json/lucide": "^1.1.5",
|
||||||
"@tauri-apps/api": "^1.0.0-rc.1",
|
"@tauri-apps/api": "^1.0.2",
|
||||||
"omorphia": "0.0.19",
|
"omorphia": "0.0.67",
|
||||||
"svelte-intl-precompile": "^0.9.2",
|
"svrollbar": "^0.12.0",
|
||||||
"svrollbar": "^0.10.4",
|
"unplugin-icons": "^0.14.7",
|
||||||
"unplugin-icons": "^0.13.2"
|
"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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.0.0-rc.3", features = [] }
|
tauri-build = { version = "1.0.4", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
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]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
|||||||
@ -9,12 +9,12 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
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()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![])
|
.invoke_handler(tauri::generate_handler![])
|
||||||
.create_window("main", WindowUrl::default(), |win, webview| {
|
.setup(|app| {
|
||||||
let win = win
|
let _win = WindowBuilder::new(app, "main", WindowUrl::default())
|
||||||
.title("Modrinth")
|
.title("Modrinth")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
@ -22,15 +22,16 @@ fn main() {
|
|||||||
.inner_size(800.0, 550.0)
|
.inner_size(800.0, 550.0)
|
||||||
.min_inner_size(400.0, 200.0)
|
.min_inner_size(400.0, 200.0)
|
||||||
.skip_taskbar(false)
|
.skip_taskbar(false)
|
||||||
.fullscreen(false);
|
.fullscreen(false)
|
||||||
return (win, webview);
|
.build()?;
|
||||||
|
Ok(())
|
||||||
})
|
})
|
||||||
.menu(Menu::with_items([
|
.menu(Menu::with_items([
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
MenuEntry::Submenu(Submenu::new(
|
MenuEntry::Submenu(Submenu::new(
|
||||||
&ctx.package_info().name,
|
&ctx.package_info().name,
|
||||||
Menu::with_items([
|
Menu::with_items([
|
||||||
MenuItem::About(ctx.package_info().name.clone()).into(),
|
// MenuItem::About(ctx.package_info().name.clone()).into(),
|
||||||
MenuItem::Separator.into(),
|
MenuItem::Separator.into(),
|
||||||
MenuItem::Services.into(),
|
MenuItem::Services.into(),
|
||||||
MenuItem::Separator.into(),
|
MenuItem::Separator.into(),
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"distDir": "../build",
|
"distDir": "../build",
|
||||||
"devPath": "http://localhost:3000",
|
"devPath": "http://localhost:5173",
|
||||||
"beforeDevCommand": "pnpm dev:web",
|
"beforeDevCommand": "pnpm dev:web",
|
||||||
"beforeBuildCommand": "pnpm run build:web"
|
"beforeBuildCommand": "pnpm run build:web"
|
||||||
},
|
},
|
||||||
@ -32,13 +32,11 @@
|
|||||||
"shortDescription": "",
|
"shortDescription": "",
|
||||||
"longDescription": "",
|
"longDescription": "",
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [],
|
"depends": []
|
||||||
"useBootstrapper": false
|
|
||||||
},
|
},
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"frameworks": [],
|
"frameworks": [],
|
||||||
"minimumSystemVersion": "",
|
"minimumSystemVersion": "",
|
||||||
"useBootstrapper": false,
|
|
||||||
"exceptionDomain": "",
|
"exceptionDomain": "",
|
||||||
"signingIdentity": null,
|
"signingIdentity": null,
|
||||||
"providerShortName": null,
|
"providerShortName": null,
|
||||||
|
|||||||
8
theseus_gui/src/app.d.ts
vendored
8
theseus_gui/src/app.d.ts
vendored
@ -3,11 +3,11 @@
|
|||||||
// See https://kit.svelte.dev/docs/typescript
|
// See https://kit.svelte.dev/docs/typescript
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare namespace App {
|
declare namespace App {
|
||||||
interface Locals {}
|
interface Locals {}
|
||||||
|
|
||||||
interface Platform {}
|
interface Platform {}
|
||||||
|
|
||||||
interface Session {}
|
interface Session {}
|
||||||
|
|
||||||
interface Stuff {}
|
interface Stuff {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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 />
|
||||||
</head>
|
<link rel="preload" href="/fonts/InterBold.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<body style="background-color: hsl(220, 13%, 15%)">
|
<link rel="preload" href="/fonts/InterSemiBold.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
%svelte.body%
|
%sveltekit.head%
|
||||||
</body>
|
</head>
|
||||||
|
<body class="base theme-dark" style="background-color: hsl(220, 13%, 15%)">
|
||||||
|
%sveltekit.body%
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,54 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let title: string;
|
export let title: string;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<div class="card-row__title">{title}</div>
|
<div class="card-row__title">{title}</div>
|
||||||
<div class="card-row__items">
|
<div class="card-row__items">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.card-row {
|
.card-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
flex: 1 1;
|
flex: 1 1;
|
||||||
content: " ";
|
content: ' ';
|
||||||
background-color: hsla(0,0%,100%,0.2);
|
background-color: hsla(0, 0%, 100%, 0.2);
|
||||||
height: 0.2rem;
|
height: 0.2rem;
|
||||||
border-radius: var(--rounded-max);
|
border-radius: var(--rounded-max);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__items {
|
|
||||||
display: flex;
|
|
||||||
grid-gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
|
|
||||||
/* Hide scrollbar */
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-of-type(even) {
|
|
||||||
background-color: hsla(0,0%,0%,0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
|
||||||
|
/* Hide scrollbar */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-of-type(even) {
|
||||||
|
background-color: hsla(0, 0%, 0%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,58 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FormField, Slider, TextInput, Button } from "omorphia"
|
import { Field, Slider, TextInput, Button } from 'omorphia';
|
||||||
import TitledSection from "$components/TitledSection.svelte"
|
import TitledSection from '$components/TitledSection.svelte';
|
||||||
import WindowSettings from "$components/WindowSettings.svelte"
|
import WindowSettings from '$components/WindowSettings.svelte';
|
||||||
|
|
||||||
export let maxConcurrentDownloads: number = 20;
|
export let maxConcurrentDownloads: number = 20;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<TitledSection title="Downloads">
|
<TitledSection title="Downloads">
|
||||||
<FormField label="Max concurrent downloads">
|
<Field label="Max concurrent downloads">
|
||||||
<Slider min=1 max=64 bind:value={maxConcurrentDownloads} />
|
<Slider min="1" max="64" bind:value={maxConcurrentDownloads} />
|
||||||
</FormField>
|
</Field>
|
||||||
</TitledSection>
|
</TitledSection>
|
||||||
<TitledSection title="Override game resolution" toggleable=true>
|
<TitledSection title="Override game resolution" toggleable="true">
|
||||||
<WindowSettings />
|
<WindowSettings />
|
||||||
</TitledSection>
|
</TitledSection>
|
||||||
<TitledSection title="Profile hooks">
|
<TitledSection title="Profile hooks">
|
||||||
<FormField label="Pre-launch">
|
<Field label="Pre-launch">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Wrapper">
|
<Field label="Wrapper">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Post-exit">
|
<Field label="Post-exit">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
</TitledSection>
|
</TitledSection>
|
||||||
<TitledSection title="Java">
|
<TitledSection title="Java">
|
||||||
<FormField label="Java 8 installation">
|
<Field label="Java 8 installation">
|
||||||
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home" />
|
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-8.jdk/Contents/Home" />
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<Button>Auto-detect</Button>
|
<Button>Auto-detect</Button>
|
||||||
<Button>Browse installations</Button>
|
<Button>Browse installations</Button>
|
||||||
<Button>Test</Button>
|
<Button>Test</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Java 17 installation">
|
<Field label="Java 17 installation">
|
||||||
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" />
|
<TextInput placeholder="/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home" />
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<Button>Auto-detect</Button>
|
<Button>Auto-detect</Button>
|
||||||
<Button>Browse installations</Button>
|
<Button>Browse installations</Button>
|
||||||
<Button>Test</Button>
|
<Button>Test</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Minimum memory allocatted (in MB)">
|
<Field label="Minimum memory allocatted (in MB)">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Maximum memory allocatted (in MB)">
|
<Field label="Maximum memory allocatted (in MB)">
|
||||||
<TextInput value="2048" />
|
<TextInput value="2048" />
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Arguments">
|
<Field label="Arguments">
|
||||||
<TextInput/>
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
</TitledSection>
|
</TitledSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|||||||
@ -1,85 +1,90 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import IconPlayFilled from "virtual:icons/carbon/play-filled-alt"
|
import IconPlayFilled from 'virtual:icons/carbon/play-filled-alt';
|
||||||
import IconBadgeCheck from "virtual:icons/heroicons-solid/badge-check"
|
import IconBadgeCheck from 'virtual:icons/heroicons-solid/badge-check';
|
||||||
|
|
||||||
export let title: string;
|
export let title: string;
|
||||||
export let id: string;
|
export let id: string;
|
||||||
export let version: string;
|
export let version: string;
|
||||||
export let modpack = false;
|
export let modpack = false;
|
||||||
export let image: string;
|
export let image: string;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="instance" href="/library/instance/{id}/settings"
|
<a
|
||||||
style:background-image="linear-gradient(5deg, hsla(0,0%,0%,0.8) 0%, hsla(0,0%,0%,0) 100%), url('{image}')">
|
class="instance"
|
||||||
<div class="instance__version">{version}{#if modpack}<IconBadgeCheck />{/if}</div>
|
href="/library/instance/{id}/settings"
|
||||||
<div class="instance__title">{title}</div>
|
style:background-image="linear-gradient(5deg, hsla(0,0%,0%,0.8) 0%, hsla(0,0%,0%,0) 100%), url('{image}')"
|
||||||
<button class="play-button">
|
>
|
||||||
<IconPlayFilled />
|
<div class="instance__version">
|
||||||
</button>
|
{version}{#if modpack}<IconBadgeCheck />{/if}
|
||||||
|
</div>
|
||||||
|
<div class="instance__title">{title}</div>
|
||||||
|
<button class="play-button">
|
||||||
|
<IconPlayFilled />
|
||||||
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.instance {
|
.instance {
|
||||||
--size: 8rem;
|
--size: 8rem;
|
||||||
min-width: var(--size);
|
min-width: var(--size);
|
||||||
min-height: var(--size);
|
min-height: var(--size);
|
||||||
border-radius: var(--rounded);
|
border-radius: var(--rounded);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
box-shadow: var(--shadow-raised) var(--shadow-inset);
|
box-shadow: var(--shadow-raised) var(--shadow-inset);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
&__version {
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
grid-gap: 0.15rem;
|
|
||||||
:global(svg) {
|
|
||||||
margin-bottom: 0.05rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .play-button {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
width: 2.5rem;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
border-radius: var(--rounded-max);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition-property: opacity, visibility, transform;
|
|
||||||
transition-duration: 0.2s;
|
|
||||||
transition-timing-function: ease-in-out;
|
|
||||||
transform: translateY(1rem);
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: auto;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
grid-gap: 0.15rem;
|
||||||
|
:global(svg) {
|
||||||
|
margin-bottom: 0.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .play-button {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
width: 2.5rem;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--rounded-max);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition-property: opacity, visibility, transform;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
transform: translateY(1rem);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: auto;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,49 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox } from "omorphia"
|
import { Checkbox } from 'omorphia';
|
||||||
|
|
||||||
export let title: string
|
export let title: string;
|
||||||
export let toggleable: boolean = false
|
export let toggleable: boolean = false;
|
||||||
|
|
||||||
export let enabled: boolean = false
|
export let enabled: boolean = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section__title">
|
<div class="section__title">
|
||||||
{#if toggleable}<Checkbox bind:checked={enabled}>{title}</Checkbox>
|
{#if toggleable}<Checkbox bind:checked={enabled}>{title}</Checkbox>
|
||||||
{:else}{title}
|
{:else}{title}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="section__items">
|
<div class="section__items">
|
||||||
{#if !toggleable || enabled}<slot />{/if}
|
{#if !toggleable || enabled}<slot />{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
flex: 1 1;
|
flex: 1 1;
|
||||||
content: " ";
|
content: ' ';
|
||||||
background-color: hsla(0, 0%, 100%, 0.2);
|
background-color: hsla(0, 0%, 100%, 0.2);
|
||||||
height: 0.2rem;
|
height: 0.2rem;
|
||||||
border-radius: var(--rounded-max);
|
border-radius: var(--rounded-max);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
grid-gap: 1rem;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,36 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import type { SvelteComponent } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
export let items: {
|
export let items: {
|
||||||
label: string,
|
label: string;
|
||||||
/** Page href, without slash prefix */
|
/** Page href, without slash prefix */
|
||||||
href: string,
|
href: string;
|
||||||
/** An icon, as a svelte component */
|
icon: SvelteComponent;
|
||||||
icon: any, // `SvelteComponentDev` has type errors
|
}[];
|
||||||
}[];
|
|
||||||
|
|
||||||
/** Path level in URL, zero-indexed */
|
/** Path level in URL, zero-indexed */
|
||||||
export let level = 0;
|
export let level = 0;
|
||||||
|
|
||||||
let path: string[];
|
let path: string[];
|
||||||
$: path = $page.url.pathname
|
$: path = $page.url.pathname.substring(1).split('/');
|
||||||
.substring(1)
|
|
||||||
.split('/')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="vertical-nav">
|
<div class="vertical-nav">
|
||||||
{#each items as item (item.href)}
|
{#each items as item (item.href)}
|
||||||
<a class="nav-item" href="/{item.href}" class:active={path[level] === item.href} sveltekit:prefetch>
|
<a
|
||||||
<svelte:component this={item.icon} />
|
class="nav-item"
|
||||||
{item.label}
|
href="/{item.href}"
|
||||||
</a>
|
class:active={path[level] === item.href}
|
||||||
{/each}
|
sveltekit:prefetch
|
||||||
|
>
|
||||||
|
<svelte:component this={item.icon} />
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.vertical-nav {
|
.vertical-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
grid-gap: 0.25rem;
|
grid-gap: 0.25rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FormField, TextInput } from "omorphia"
|
import { Field, TextInput } from 'omorphia';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<FormField label="Window width">
|
<Field label="Window width">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Window height">
|
<Field label="Window height">
|
||||||
<TextInput />
|
<TextInput />
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|||||||
10
theseus_gui/src/global.d.ts
vendored
10
theseus_gui/src/global.d.ts
vendored
@ -2,12 +2,12 @@
|
|||||||
/// <reference types="unplugin-icons/types/svelte" />
|
/// <reference types="unplugin-icons/types/svelte" />
|
||||||
|
|
||||||
declare module '$assets/images/*' {
|
declare module '$assets/images/*' {
|
||||||
export { SvelteComponentDev as default } from 'svelte/internal';
|
export { SvelteComponentDev as default } from 'svelte/internal';
|
||||||
}
|
}
|
||||||
declare module '$locales/*';
|
declare module '$locales/*';
|
||||||
|
|
||||||
declare module '*.svg' {
|
declare module '*.svg' {
|
||||||
import { SvelteComponent } from 'svelte';
|
import { SvelteComponent } from 'svelte';
|
||||||
const content: SvelteComponent;
|
const content: SvelteComponent;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/** @type {import('@sveltejs/kit').Handle} */
|
/** @type {import('@sveltejs/kit').Handle} */
|
||||||
export async function handle({ event, resolve }) {
|
export async function handle({ event, resolve }) {
|
||||||
return await resolve(event, {
|
return await resolve(event, {
|
||||||
ssr: false,
|
ssr: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Svrollbar } from 'svrollbar'
|
import { Svrollbar } from 'svrollbar';
|
||||||
|
|
||||||
let viewport: Element
|
let viewport: Element;
|
||||||
let contents: Element
|
let contents: Element;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div bind:this={viewport} class="viewport">
|
<div bind:this={viewport} class="viewport">
|
||||||
<div bind:this={contents} class="contents">
|
<div bind:this={contents} class="contents">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Svrollbar {viewport} {contents} />
|
</div>
|
||||||
|
<Svrollbar {viewport} {contents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.page {
|
.page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
--svrollbar-track-width: 20px;
|
--svrollbar-track-width: 20px;
|
||||||
--svrollbar-track-opacity: 0;
|
--svrollbar-track-opacity: 0;
|
||||||
|
|
||||||
--svrollbar-thumb-width: 8px;
|
--svrollbar-thumb-width: 8px;
|
||||||
--svrollbar-thumb-background: hsla(216,5%,60%);
|
--svrollbar-thumb-background: hsla(216, 5%, 60%);
|
||||||
--svrollbar-thumb-opacity: 0.9;
|
--svrollbar-thumb-opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 2.5rem);
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
/* hide scrollbar */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
/* hide scrollbar */
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport {
|
.contents {
|
||||||
position: relative;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
height: calc(100vh - 2.5rem);
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: hidden;
|
|
||||||
|
|
||||||
/* hide scrollbar */
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
/* hide scrollbar */
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contents {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:global(.v-thumb) {
|
:global(.v-thumb) {
|
||||||
margin: 4px auto 4px auto !important;
|
margin: 4px auto 4px auto !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,102 +1,132 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import VerticalNav from '../components/VerticalNav.svelte'
|
import VerticalNav from '../components/VerticalNav.svelte';
|
||||||
import IconHome from 'virtual:icons/lucide/home'
|
import IconHome from 'virtual:icons/lucide/home';
|
||||||
import IconSearch from 'virtual:icons/heroicons-outline/search'
|
import IconSearch from 'virtual:icons/heroicons-outline/search';
|
||||||
import IconPlus from 'virtual:icons/heroicons-outline/plus'
|
import IconPlus from 'virtual:icons/heroicons-outline/plus';
|
||||||
import IconLibrary from 'virtual:icons/lucide/library';
|
import IconLibrary from 'virtual:icons/lucide/library';
|
||||||
import IconSettings from 'virtual:icons/lucide/settings'
|
import IconSettings from 'virtual:icons/lucide/settings';
|
||||||
import { page } from "$app/stores";
|
import { page } from '$app/stores';
|
||||||
import { Button } from 'omorphia'
|
import { Button } from 'omorphia';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="account">
|
<div class="account">
|
||||||
<div class="account__heads">
|
<div class="account__heads">
|
||||||
<img src="https://mc-heads.net/avatar/venashial" alt="Minecraft head"/>
|
<img src="https://mc-heads.net/avatar/venashial" alt="Minecraft head" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<a class="account__info" href="/settings/accounts">
|
|
||||||
<div>venashial</div>
|
|
||||||
<div class="account__info__manage">Manage accounts</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VerticalNav items={[
|
<a class="account__info" href="/settings/accounts">
|
||||||
{
|
<div>venashial</div>
|
||||||
label: 'Home',
|
<div class="account__info__manage">Manage accounts</div>
|
||||||
href: '',
|
|
||||||
icon: IconHome,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Search',
|
|
||||||
href: 'search',
|
|
||||||
icon: IconSearch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Library',
|
|
||||||
href: 'library',
|
|
||||||
icon: IconLibrary,
|
|
||||||
}
|
|
||||||
]}/>
|
|
||||||
|
|
||||||
<div class="instance-list">
|
|
||||||
<div class="instance-list__title">Instances</div>
|
|
||||||
<div class="instance-list__container">
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
<a class="instance-list__container__item">Fabulously Optimized</a>
|
|
||||||
</div>
|
|
||||||
<div class="instance-list__create">
|
|
||||||
<Button color="primary"><IconPlus /></Button> Create instance
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a class="nav-item" href="/settings" class:active={$page.url.pathname.startsWith('/settings')}>
|
|
||||||
<IconSettings />
|
|
||||||
Settings
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VerticalNav
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Home',
|
||||||
|
href: '',
|
||||||
|
icon: IconHome
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search',
|
||||||
|
href: 'search',
|
||||||
|
icon: IconSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Library',
|
||||||
|
href: 'library',
|
||||||
|
icon: IconLibrary
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="instance-list">
|
||||||
|
<div class="instance-list__title">Instances</div>
|
||||||
|
<div class="instance-list__container">
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
<a class="instance-list__container__item">Fabulously Optimized</a>
|
||||||
|
</div>
|
||||||
|
<div class="instance-list__create">
|
||||||
|
<Button color="primary"><IconPlus /></Button> Create instance
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="nav-item" href="/settings" class:active={$page.url.pathname.startsWith('/settings')}>
|
||||||
|
<IconSettings />
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display: flex;
|
||||||
|
grid-gap: 0.75rem;
|
||||||
|
|
||||||
|
&__heads {
|
||||||
|
img {
|
||||||
|
width: 2.5rem;
|
||||||
|
border-radius: var(--rounded-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
&__manage {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-list {
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
|
||||||
|
&__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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem;
|
gap: 4px;
|
||||||
height: 100%;
|
|
||||||
background-color: var(--sidebar-bg);
|
|
||||||
|
|
||||||
.account {
|
&::-webkit-scrollbar {
|
||||||
display: flex;
|
display: none;
|
||||||
grid-gap: 0.75rem;
|
|
||||||
|
|
||||||
&__heads {
|
|
||||||
img {
|
|
||||||
width: 2.5rem;
|
|
||||||
border-radius: var(--rounded-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
&__manage {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.instance-list {
|
&__create {
|
||||||
padding: 0 8px;
|
margin-top: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
color: var(--color-text-light);
|
grid-gap: 8px;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -140,5 +170,20 @@
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(button) {
|
||||||
|
width: 34px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> :global(*) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:last-child {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -75,6 +75,7 @@
|
|||||||
color: var(--color-text-lightest);
|
color: var(--color-text-lightest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.statuses {
|
.statuses {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|||||||
@ -1,59 +1,58 @@
|
|||||||
<script context="module" lang="ts">
|
<script context="module" lang="ts">
|
||||||
import { init, waitLocale } from 'svelte-intl-precompile'
|
import { init, waitLocale, t, getLocaleFromAcceptLanguageHeader } from 'svelte-intl-precompile';
|
||||||
import { registerAll } from '$locales'
|
import { registerAll, availableLocales } from '$locales';
|
||||||
|
|
||||||
registerAll()
|
registerAll();
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Load} */
|
export const load: import('@sveltejs/kit').Load = async ({ session }) => {
|
||||||
export async function load({fetch, session, stuff}) {
|
|
||||||
init({
|
init({
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: 'en',
|
||||||
initialLocale: session.acceptedLanguage,
|
initialLocale: getLocaleFromAcceptLanguageHeader(session.acceptLanguage, availableLocales)
|
||||||
})
|
});
|
||||||
await waitLocale()
|
await waitLocale();
|
||||||
|
|
||||||
return {}
|
return {};
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '@fontsource/inter'
|
import '@fontsource/inter';
|
||||||
import 'omorphia/styles.postcss'
|
import 'omorphia/styles.postcss';
|
||||||
import '$styles/global.postcss'
|
import '$styles/global.postcss';
|
||||||
import Sidebar from '$layout/Sidebar.svelte'
|
import Sidebar from '$layout/Sidebar.svelte';
|
||||||
import StatusBar from "$layout/StatusBar.svelte";
|
import StatusBar from '$layout/StatusBar.svelte';
|
||||||
import Page from "$layout/Page.svelte";
|
import Page from '$layout/Page.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app base theme-dark">
|
<div class="app base theme-dark">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<Page>
|
<Page>
|
||||||
<slot/>
|
<slot />
|
||||||
</Page>
|
</Page>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.app {
|
.app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"sidebar status-bar"
|
'sidebar status-bar'
|
||||||
"sidebar page";
|
'sidebar page';
|
||||||
grid-template-rows: 2.5rem 1fr;
|
grid-template-rows: 2.5rem 1fr;
|
||||||
grid-template-columns: 14rem 1fr;
|
grid-template-columns: 14rem 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.page) {
|
:global(.page) {
|
||||||
grid-area: page;
|
grid-area: page;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.sidebar) {
|
:global(.sidebar) {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.status-bar) {
|
:global(.status-bar) {
|
||||||
grid-area: status-bar;
|
grid-area: status-bar;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,26 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Instance from "$components/Instance.svelte";
|
import Instance from '$components/Instance.svelte';
|
||||||
import CardRow from "$components/CardRow.svelte";
|
import CardRow from '$components/CardRow.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CardRow title="Jump back in">
|
<CardRow title="Jump back in">
|
||||||
{#each Array(5) as _, i}
|
{#each Array(5) as _, i}
|
||||||
<Instance title="New Caves" id="234" version="1.18" image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg" />
|
<Instance
|
||||||
{/each}
|
title="New Caves"
|
||||||
|
id="234"
|
||||||
|
version="1.18"
|
||||||
|
image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
</CardRow>
|
</CardRow>
|
||||||
|
|
||||||
<CardRow title="Popular packs">
|
<CardRow title="Popular packs">
|
||||||
{#each Array(5) as _, i}
|
{#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
|
||||||
{/each}
|
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>
|
||||||
|
|
||||||
<CardRow title="New releases">
|
<CardRow title="New releases">
|
||||||
{#each Array(5) as _, i}
|
{#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
|
||||||
{/each}
|
title="New Caves"
|
||||||
|
id="234"
|
||||||
|
version="1.18.2"
|
||||||
|
image="https://i.ibb.co/8KDxBwq/patchnotes-cavesandcliffs.jpg"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
</CardRow>
|
</CardRow>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
<script context="module" lang="ts">
|
<script context="module" lang="ts">
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,49 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import VerticalNav from "$components/VerticalNav.svelte";
|
import VerticalNav from '$components/VerticalNav.svelte';
|
||||||
import IconPackage from 'virtual:icons/lucide/package'
|
import IconPackage from 'virtual:icons/lucide/package';
|
||||||
import IconAdjustments from 'virtual:icons/heroicons-outline/adjustments'
|
import IconAdjustments from 'virtual:icons/heroicons-outline/adjustments';
|
||||||
import IconFileText from 'virtual:icons/lucide/file-text'
|
import IconFileText from 'virtual:icons/lucide/file-text';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="layout-instance">
|
<div class="layout-instance">
|
||||||
<div class="instance-sidebar">
|
<div class="instance-sidebar">
|
||||||
<VerticalNav level={3} items={[
|
<VerticalNav
|
||||||
|
level={3}
|
||||||
|
items={[
|
||||||
{
|
{
|
||||||
label: 'Mods',
|
label: 'Mods',
|
||||||
href: 'mods',
|
href: 'mods',
|
||||||
icon: IconPackage,
|
icon: IconPackage
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
href: 'settings',
|
href: 'settings',
|
||||||
icon: IconAdjustments,
|
icon: IconAdjustments
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Logs',
|
label: 'Logs',
|
||||||
href: 'logs',
|
href: 'logs',
|
||||||
icon: IconFileText,
|
icon: IconFileText
|
||||||
}
|
}
|
||||||
]}/>
|
]}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="layout-instance__page">
|
<div class="layout-instance__page">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.layout-instance {
|
.layout-instance {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 224px 1fr;
|
grid-template-columns: 224px 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&__page {
|
&__page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--sub-page-bg);
|
background-color: var(--sub-page-bg);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.instance-sidebar {
|
.instance-sidebar {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,43 +1,45 @@
|
|||||||
<script lang="ts">
|
<script context="module" lang="ts">
|
||||||
import { Checkbox, FormField, TextInput, Button } from "omorphia"
|
</script>
|
||||||
import GlobalSettings from "$components/GlobalSettings.svelte"
|
|
||||||
import TitledSection from "$components/TitledSection.svelte"
|
|
||||||
|
|
||||||
export let overrideGlobalSettings = false
|
<script lang="ts">
|
||||||
|
import GlobalSettings from '$components/GlobalSettings.svelte';
|
||||||
|
import TitledSection from '$components/TitledSection.svelte';
|
||||||
|
|
||||||
|
export let overrideGlobalSettings = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<TitledSection title="Override global settings" toggleable={true}>
|
<TitledSection title="Override global settings" toggleable={true}>
|
||||||
<GlobalSettings />
|
<GlobalSettings />
|
||||||
</TitledSection>
|
</TitledSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-gap: 1rem;
|
grid-gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
flex: 1 1;
|
flex: 1 1;
|
||||||
content: " ";
|
content: ' ';
|
||||||
background-color: hsla(0, 0%, 100%, 0.2);
|
background-color: hsla(0, 0%, 100%, 0.2);
|
||||||
height: 0.2rem;
|
height: 0.2rem;
|
||||||
border-radius: var(--rounded-max);
|
border-radius: var(--rounded-max);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
grid-gap: 1rem;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
<script context="module" lang="ts">
|
<script context="module" lang="ts">
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox, FormField, TextInput, Button } from "omorphia"
|
import GlobalSettings from '$components/GlobalSettings.svelte';
|
||||||
import GlobalSettings from "$components/GlobalSettings.svelte"
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<GlobalSettings />
|
<GlobalSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|||||||
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('');
|
||||||
@ -1,19 +1,19 @@
|
|||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: var(--rounded-sm);
|
border-radius: var(--rounded-sm);
|
||||||
box-shadow: var(--shadow-inset-sm) var(--shadow-raised);
|
box-shadow: var(--shadow-inset-sm) var(--shadow-raised);
|
||||||
transition: background-color 0.2s ease-in-out,
|
transition: background-color 0.2s ease-in-out, color 0.1s ease-in-out;
|
||||||
color 0.1s ease-in-out;
|
color: var(--color-text-light);
|
||||||
color: var(--color-text-light);
|
|
||||||
|
|
||||||
&:hover, &.active {
|
&:hover,
|
||||||
color: var(--color-text);
|
&.active {
|
||||||
}
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: var(--nav-active-bg);
|
background-color: var(--nav-active-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-dark {
|
.theme-dark {
|
||||||
--status-bg: hsl(216, 5%, 29%);
|
--status-bg: hsl(216, 5%, 29%);
|
||||||
--sidebar-bg: hsl(216, 10%, 3%);
|
--sidebar-bg: hsl(216, 10%, 3%);
|
||||||
--nav-active-bg: hsl(217, 9%, 25%);
|
--nav-active-bg: hsl(217, 9%, 25%);
|
||||||
--font-size-sm: 0.75rem;
|
--font-size-sm: 0.75rem;
|
||||||
--color-bg: hsl(217, 9%, 18%);
|
--color-bg: hsl(217, 9%, 18%);
|
||||||
--color-brand: hsl(145, 75%, 45%);
|
--color-brand: hsl(145, 75%, 45%);
|
||||||
--color-brand-contrast: black;
|
--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,39 +1,25 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
import path from "path";
|
import { preprocess } from 'omorphia/config/svelte';
|
||||||
import { preprocess, plugins } from 'omorphia/config/svelte.config'
|
import path from 'path';
|
||||||
import precompileIntl from "svelte-intl-precompile/sveltekit-plugin";
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
preprocess,
|
preprocess: [preprocess],
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
fallback: '200.html',
|
fallback: '200.html'
|
||||||
}),
|
}),
|
||||||
vite: {
|
|
||||||
plugins: [
|
alias: {
|
||||||
...plugins,
|
$generated: path.resolve('./generated'),
|
||||||
precompileIntl('locales'),
|
$stores: path.resolve('./src/stores'),
|
||||||
],
|
$assets: path.resolve('./src/assets'),
|
||||||
resolve: {
|
$components: path.resolve('./src/components'),
|
||||||
alias: {
|
$layout: path.resolve('./src/layout'),
|
||||||
$assets: path.resolve('./src/assets'),
|
$lib: path.resolve('./src/lib'),
|
||||||
$components: path.resolve('./src/components'),
|
$styles: path.resolve('./src/styles')
|
||||||
$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'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
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