Authentication (#37)

* Initial authentication implementation

* Store user info in the database, improve encapsulation in profiles

* Add user list, remove unused dependencies, add spantraces

* Implement user remove, update UUID crate

* Add user set-default

* Revert submodule macro usage

* Make tracing significantly less verbose
This commit is contained in:
Danielle
2022-07-15 15:39:38 +00:00
committed by GitHub
parent 53948c7a5e
commit b223dc7cba
27 changed files with 1490 additions and 851 deletions
Generated
+564 -626
View File
File diff suppressed because it is too large Load Diff
+5 -15
View File
@@ -38,8 +38,7 @@
gtk4 gdk-pixbuf atk webkitgtk dbus gtk4 gdk-pixbuf atk webkitgtk dbus
]; ];
shell = [ shell = [
toolchain (with fenix; combine [toolchain default.clippy complete.rust-src rust-analyzer])
(with fenix; combine [toolchain default.clippy rust-analyzer])
git git
jdk17 jdk8 jdk17 jdk8
]; ];
@@ -58,19 +57,10 @@
cli = utils.mkApp { cli = utils.mkApp {
drv = self.packages.${system}.theseus-cli; drv = self.packages.${system}.theseus-cli;
}; };
cli-test = utils.mkApp { cli-dev = utils.mkApp {
drv = pkgs.writeShellApplication { drv = self.packages.${system}.theseus-cli.overrideAttrs (old: old // {
name = "theseus-test-cli"; release = false;
runtimeInputs = [ });
(self.packages.${system}.theseus-cli.overrideAttrs (old: old // {
release = false;
}))
];
text = ''
DUMMY_ID="$(printf '%0.sa' {1..32})"
theseus_cli profile run -t "" -n "Test" -i "$DUMMY_ID" "$@"
'';
};
}; };
}; };
-8
View File
@@ -1,8 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
rustc cargo clippy openssl pkg-config
gtk4 gdk-pixbuf atk webkitgtk
];
}
+19 -25
View File
@@ -7,38 +7,32 @@ 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"
daedalus = { version = "0.1.16", features = ["bincode"] }
bincode = { version = "2.0.0-rc.1", features = ["serde"] } bincode = { version = "2.0.0-rc.1", features = ["serde"] }
sled = { version = "0.34.7", features = ["compression"] }
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"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies] [dev-dependencies]
argh = "0.1.6" argh = "0.1.6"
+100
View File
@@ -0,0 +1,100 @@
//! Authentication flow interface
use crate::{launcher::auth as inner, State};
use futures::prelude::*;
use tokio::sync::oneshot;
pub use inner::Credentials;
/// Authenticate a user with Hydra
/// To run this, you need to first spawn this function as a task, then
/// open a browser to the given URL and finally wait on the spawned future
/// with the ability to cancel in case the browser is closed before finishing
#[tracing::instrument]
pub async fn authenticate(
browser_url: oneshot::Sender<url::Url>,
) -> crate::Result<Credentials> {
let mut flow = inner::HydraAuthFlow::new().await?;
let state = State::get().await?;
let mut users = state.users.write().await;
let url = flow.prepare_login_url().await?;
browser_url.send(url).map_err(|url| {
crate::ErrorKind::OtherError(format!(
"Error sending browser url to parent: {url}"
))
})?;
let credentials = flow.extract_credentials().await?;
users.insert(&credentials)?;
if state.settings.read().await.default_user.is_none() {
let mut settings = state.settings.write().await;
settings.default_user = Some(credentials.id);
}
Ok(credentials)
}
/// Refresh some credentials using Hydra, if needed
#[tracing::instrument]
pub async fn refresh(
user: uuid::Uuid,
update_name: bool,
) -> crate::Result<Credentials> {
let state = State::get().await?;
let mut users = state.users.write().await;
futures::future::ready(users.get(user)?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to refresh nonexistent user with ID {user}"
))
.as_error()
}))
.and_then(|mut credentials| async move {
if chrono::offset::Utc::now() > credentials.expires {
inner::refresh_credentials(&mut credentials).await?;
if update_name {
inner::refresh_username(&mut credentials).await?;
}
}
users.insert(&credentials)?;
Ok(credentials)
})
.await
}
/// Remove a user account from the database
#[tracing::instrument]
pub async fn remove_user(user: uuid::Uuid) -> crate::Result<()> {
let state = State::get().await?;
let mut users = state.users.write().await;
if state.settings.read().await.default_user == Some(user) {
let mut settings = state.settings.write().await;
settings.default_user = users
.0
.first()?
.map(|it| uuid::Uuid::from_slice(&it.0))
.transpose()?;
}
users.remove(user)?;
Ok(())
}
/// Check if a user exists in Theseus
#[tracing::instrument]
pub async fn has_user(user: uuid::Uuid) -> crate::Result<bool> {
let state = State::get().await?;
let users = state.users.read().await;
Ok(users.contains(user)?)
}
/// Get a copy of the list of all user credentials
#[tracing::instrument]
pub async fn users() -> crate::Result<Box<[Credentials]>> {
let state = State::get().await?;
let users = state.users.read().await;
users.iter().collect()
}
+5 -6
View File
@@ -1,18 +1,17 @@
//! API for interacting with Theseus //! API for interacting with Theseus
pub mod auth;
pub mod profile; pub mod profile;
pub mod data { pub mod data {
pub use crate::{ pub use crate::state::{
launcher::Credentials, DirectoryInfo, Hooks, JavaSettings, MemorySettings, ModLoader,
state::{ ProfileMetadata, Settings, WindowSize,
DirectoryInfo, Hooks, JavaSettings, MemorySettings, ModLoader,
ProfileMetadata, Settings, WindowSize,
},
}; };
} }
pub mod prelude { pub mod prelude {
pub use crate::{ pub use crate::{
auth::{self, Credentials},
data::*, data::*,
profile::{self, Profile}, profile::{self, Profile},
State, State,
+48 -20
View File
@@ -1,14 +1,17 @@
//! Theseus profile management interface //! Theseus profile management interface
pub use crate::{ pub use crate::{
state::{JavaSettings, Profile}, state::{JavaSettings, Profile},
State, State,
}; };
use daedalus as d; use daedalus as d;
use std::{future::Future, path::Path}; use std::{
future::Future,
path::{Path, PathBuf},
};
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
/// Add a profile to the in-memory state /// Add a profile to the in-memory state
#[tracing::instrument]
pub async fn add(profile: Profile) -> crate::Result<()> { pub async fn add(profile: Profile) -> crate::Result<()> {
let state = State::get().await?; let state = State::get().await?;
let mut profiles = state.profiles.write().await; let mut profiles = state.profiles.write().await;
@@ -18,6 +21,7 @@ pub async fn add(profile: Profile) -> crate::Result<()> {
} }
/// Add a path as a profile in-memory /// Add a path as a profile in-memory
#[tracing::instrument]
pub async fn add_path(path: &Path) -> crate::Result<()> { pub async fn add_path(path: &Path) -> crate::Result<()> {
let state = State::get().await?; let state = State::get().await?;
let mut profiles = state.profiles.write().await; let mut profiles = state.profiles.write().await;
@@ -27,6 +31,7 @@ pub async fn add_path(path: &Path) -> crate::Result<()> {
} }
/// Remove a profile /// Remove a profile
#[tracing::instrument]
pub async fn remove(path: &Path) -> crate::Result<()> { pub async fn remove(path: &Path) -> crate::Result<()> {
let state = State::get().await?; let state = State::get().await?;
let mut profiles = state.profiles.write().await; let mut profiles = state.profiles.write().await;
@@ -36,19 +41,22 @@ pub async fn remove(path: &Path) -> crate::Result<()> {
} }
/// Get a profile by path, /// Get a profile by path,
#[tracing::instrument]
pub async fn get(path: &Path) -> crate::Result<Option<Profile>> { pub async fn get(path: &Path) -> crate::Result<Option<Profile>> {
let state = State::get().await?; let state = State::get().await?;
let profiles = state.profiles.read().await; let profiles = state.profiles.read().await;
profiles.0.get(path).map_or(Ok(None), |prof| match prof { profiles.0.get(path).map_or(Ok(None), |prof| match prof {
Some(prof) => Ok(Some(prof.clone())), Some(prof) => Ok(Some(prof.clone())),
None => Err(crate::Error::UnloadedProfileError( None => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(), path.display().to_string(),
)), )
.as_error()),
}) })
} }
/// Check if a profile is already managed by Theseus /// Check if a profile is already managed by Theseus
#[tracing::instrument]
pub async fn is_managed(profile: &Path) -> crate::Result<bool> { pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?; let state = State::get().await?;
let profiles = state.profiles.read().await; let profiles = state.profiles.read().await;
@@ -56,6 +64,7 @@ pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
} }
/// Check if a profile is loaded /// Check if a profile is loaded
#[tracing::instrument]
pub async fn is_loaded(profile: &Path) -> crate::Result<bool> { pub async fn is_loaded(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?; let state = State::get().await?;
let profiles = state.profiles.read().await; let profiles = state.profiles.read().await;
@@ -75,29 +84,41 @@ pub async fn edit<Fut>(
where where
Fut: Future<Output = crate::Result<()>>, Fut: Future<Output = crate::Result<()>>,
{ {
let state = State::get().await.unwrap(); let state = State::get().await?;
let mut profiles = state.profiles.write().await; let mut profiles = state.profiles.write().await;
match profiles.0.get_mut(path) { match profiles.0.get_mut(path) {
Some(&mut Some(ref mut profile)) => action(profile).await, Some(&mut Some(ref mut profile)) => action(profile).await,
Some(&mut None) => Err(crate::Error::UnloadedProfileError( Some(&mut None) => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(), path.display().to_string(),
)), )
None => Err(crate::Error::UnmanagedProfileError( .as_error()),
None => Err(crate::ErrorKind::UnmanagedProfileError(
path.display().to_string(), 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 /// Run Minecraft using a profile
#[tracing::instrument(skip_all)]
pub async fn run( pub async fn run(
path: &Path, path: &Path,
credentials: &crate::launcher::Credentials, credentials: &crate::auth::Credentials,
) -> crate::Result<Child> { ) -> crate::Result<Child> {
let state = State::get().await.unwrap(); let state = State::get().await.unwrap();
let settings = state.settings.read().await; let settings = state.settings.read().await;
let profile = get(path).await?.ok_or_else(|| { let profile = get(path).await?.ok_or_else(|| {
crate::Error::OtherError(format!( crate::ErrorKind::OtherError(format!(
"Tried to run a nonexistent or unloaded profile at path {}!", "Tried to run a nonexistent or unloaded profile at path {}!",
path.display() path.display()
)) ))
@@ -110,7 +131,7 @@ pub async fn run(
.iter() .iter()
.find(|it| it.id == profile.metadata.game_version.as_ref()) .find(|it| it.id == profile.metadata.game_version.as_ref())
.ok_or_else(|| { .ok_or_else(|| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Invalid or unknown Minecraft version: {}", "Invalid or unknown Minecraft version: {}",
profile.metadata.game_version profile.metadata.game_version
)) ))
@@ -130,10 +151,11 @@ pub async fn run(
.await?; .await?;
if !result.success() { if !result.success() {
return Err(crate::Error::LauncherError(format!( return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}", "Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1) result.code().unwrap_or(-1)
))); ))
.as_error());
} }
} }
@@ -153,7 +175,7 @@ pub async fn run(
settings.java_8_path.as_ref() settings.java_8_path.as_ref()
} }
.ok_or_else(|| { .ok_or_else(|| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"No Java installed for version {}", "No Java installed for version {}",
version_info.java_version.map_or(8, |it| it.major_version), version_info.java_version.map_or(8, |it| it.major_version),
)) ))
@@ -161,10 +183,11 @@ pub async fn run(
}; };
if !java_install.exists() { if !java_install.exists() {
return Err(crate::Error::LauncherError(format!( return Err(crate::ErrorKind::LauncherError(format!(
"Could not find Java install: {}", "Could not find Java install: {}",
java_install.display() java_install.display()
))); ))
.as_error());
} }
let ref java_args = profile let ref java_args = profile
@@ -195,21 +218,26 @@ pub async fn run(
.await .await
} }
#[tracing::instrument]
pub async fn kill(running: &mut Child) -> crate::Result<()> { pub async fn kill(running: &mut Child) -> crate::Result<()> {
running.kill().await?; running.kill().await?;
wait_for(running).await wait_for(running).await
} }
#[tracing::instrument]
pub async fn wait_for(running: &mut Child) -> crate::Result<()> { pub async fn wait_for(running: &mut Child) -> crate::Result<()> {
let result = running.wait().await.map_err(|err| { let result = running.wait().await.map_err(|err| {
crate::Error::LauncherError(format!("Error running minecraft: {err}")) crate::ErrorKind::LauncherError(format!(
"Error running minecraft: {err}"
))
})?; })?;
match result.success() { match result.success() {
false => Err(crate::Error::LauncherError(format!( false => Err(crate::ErrorKind::LauncherError(format!(
"Minecraft exited with non-zero code {}", "Minecraft exited with non-zero code {}",
result.code().unwrap_or(-1) result.code().unwrap_or(-1)
))), ))
.as_error()),
true => Ok(()), true => Ok(()),
} }
} }
+51 -3
View File
@@ -1,17 +1,25 @@
//! Theseus error type //! Theseus error type
use tracing_error::InstrumentError;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum ErrorKind {
#[error("Filesystem error: {0}")] #[error("Filesystem error: {0}")]
FSError(String), FSError(String),
#[error("Serialization error (JSON): {0}")] #[error("Serialization error (JSON): {0}")]
JSONError(#[from] serde_json::Error), JSONError(#[from] serde_json::Error),
#[error("Error parsing UUID: {0}")]
UUIDError(#[from] uuid::Error),
#[error("Serialization error (Bincode): {0}")] #[error("Serialization error (Bincode): {0}")]
EncodeError(#[from] bincode::error::DecodeError), EncodeError(#[from] bincode::error::EncodeError),
#[error("Deserialization error (Bincode): {0}")] #[error("Deserialization error (Bincode): {0}")]
DecodeError(#[from] bincode::error::EncodeError), DecodeError(#[from] bincode::error::DecodeError),
#[error("Error parsing URL: {0}")]
URLError(#[from] url::ParseError),
#[error("Database error: {0}")] #[error("Database error: {0}")]
DBError(#[from] sled::Error), DBError(#[from] sled::Error),
@@ -22,6 +30,9 @@ pub enum Error {
#[error("Metadata error: {0}")] #[error("Metadata error: {0}")]
MetadataError(#[from] daedalus::Error), MetadataError(#[from] daedalus::Error),
#[error("Minecraft authentication error: {0}")]
HydraError(String),
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
IOError(#[from] std::io::Error), IOError(#[from] std::io::Error),
@@ -31,6 +42,12 @@ pub enum Error {
#[error("Error fetching URL: {0}")] #[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error), 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}")] #[error("Incorrect Sha1 hash for download: {0} != {1}")]
HashError(String, String), HashError(String, String),
@@ -52,4 +69,35 @@ pub enum Error {
OtherError(String), 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>; pub type Result<T> = core::result::Result<T, Error>;
+22 -12
View File
@@ -40,10 +40,11 @@ pub fn get_class_paths(
client_path client_path
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
@@ -70,10 +71,11 @@ pub fn get_lib_path(libraries_path: &Path, lib: &str) -> crate::Result<String> {
path.push(get_path_from_artifact(lib.as_ref())?); path.push(get_path_from_artifact(lib.as_ref())?);
let path = &path.canonicalize().map_err(|_| { let path = &path.canonicalize().map_err(|_| {
crate::Error::LauncherError(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()
})?; })?;
Ok(path.to_string_lossy().to_string()) Ok(path.to_string_lossy().to_string())
@@ -104,10 +106,11 @@ pub fn get_jvm_arguments(
"-Djava.library.path={}", "-Djava.library.path={}",
&natives_path &natives_path
.canonicalize() .canonicalize()
.map_err(|_| crate::Error::LauncherError(format!( .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()
)); ));
@@ -142,10 +145,11 @@ fn parse_jvm_argument(
&natives_path &natives_path
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
) )
@@ -154,10 +158,11 @@ fn parse_jvm_argument(
&libraries_path &libraries_path
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
@@ -239,7 +244,7 @@ fn parse_minecraft_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)
@@ -249,10 +254,11 @@ fn parse_minecraft_argument(
&game_directory &game_directory
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
@@ -262,10 +268,11 @@ fn parse_minecraft_argument(
&assets_directory &assets_directory
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
@@ -275,10 +282,11 @@ fn parse_minecraft_argument(
&assets_directory &assets_directory
.canonicalize() .canonicalize()
.map_err(|_| { .map_err(|_| {
crate::Error::LauncherError(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(),
@@ -361,17 +369,19 @@ pub async fn get_processor_main_class(
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(|_| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Cannot read processor at {}", "Cannot read processor at {}",
path 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(|_| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Cannot read processor manifest at {}", "Cannot read processor manifest at {}",
path path
)) ))
.as_error()
})?; })?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
+163 -3
View File
@@ -1,8 +1,168 @@
//! Authentication flow //! Authentication flow based on Hydra
// TODO: Implement authentication use async_tungstenite as ws;
#[derive(Debug)] use bincode::{Decode, Encode};
use chrono::{prelude::*, Duration};
use futures::prelude::*;
use once_cell::sync::*;
use serde::Deserialize;
use url::Url;
pub const HYDRA_URL: Lazy<Url> =
Lazy::new(|| Url::parse("https://hydra.modrinth.com").unwrap());
// Socket messages
#[derive(Deserialize)]
struct ErrorJSON {
error: String,
}
impl ErrorJSON {
pub fn unwrap<'a, T: Deserialize<'a>>(data: &'a [u8]) -> crate::Result<T> {
if let Ok(err) = serde_json::from_slice::<Self>(data) {
Err(crate::ErrorKind::HydraError(err.error).as_error())
} else {
Ok(serde_json::from_slice::<T>(data)?)
}
}
}
#[derive(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 { pub struct Credentials {
#[bincode(with_serde)]
pub id: uuid::Uuid, pub id: uuid::Uuid,
pub username: String, pub username: String,
pub access_token: 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?)
} }
+12 -8
View File
@@ -13,9 +13,9 @@ use daedalus::{
modded::LoaderVersion, modded::LoaderVersion,
}; };
use futures::prelude::*; use futures::prelude::*;
use std::sync::Arc;
use tokio::{fs, sync::OnceCell}; use tokio::{fs, sync::OnceCell};
#[tracing::instrument(skip_all)]
pub async fn download_minecraft( pub async fn download_minecraft(
st: &State, st: &State,
version: &GameVersionInfo, version: &GameVersionInfo,
@@ -33,6 +33,7 @@ pub async fn download_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(
st: &State, st: &State,
version: &GameVersion, version: &GameVersion,
@@ -69,6 +70,7 @@ pub async fn download_version_info(
Ok(res) Ok(res)
} }
#[tracing::instrument(skip_all)]
pub async fn download_client( pub async fn download_client(
st: &State, st: &State,
version_info: &GameVersionInfo, version_info: &GameVersionInfo,
@@ -78,9 +80,12 @@ pub async fn download_client(
let client_download = version_info let client_download = version_info
.downloads .downloads
.get(&d::minecraft::DownloadType::Client) .get(&d::minecraft::DownloadType::Client)
.ok_or(crate::Error::LauncherError(format!( .ok_or(
"No client downloads exist for version {version}" crate::ErrorKind::LauncherError(format!(
)))?; "No client downloads exist for version {version}"
))
.as_error(),
)?;
let path = st let path = st
.directories .directories
.version_dir(version) .version_dir(version)
@@ -99,6 +104,7 @@ pub async fn download_client(
Ok(()) Ok(())
} }
#[tracing::instrument(skip_all)]
pub async fn download_assets_index( pub async fn download_assets_index(
st: &State, st: &State,
version: &GameVersionInfo, version: &GameVersionInfo,
@@ -126,6 +132,7 @@ pub async fn download_assets_index(
Ok(res) Ok(res)
} }
#[tracing::instrument(skip(st, index))]
pub async fn download_assets( pub async fn download_assets(
st: &State, st: &State,
with_legacy: bool, with_legacy: bool,
@@ -180,16 +187,13 @@ pub async fn download_assets(
Ok(()) Ok(())
} }
#[tracing::instrument(skip(st, libraries))]
pub async fn download_libraries( pub async fn download_libraries(
st: &State, st: &State,
libraries: &[Library], libraries: &[Library],
version: &str, version: &str,
) -> crate::Result<()> { ) -> crate::Result<()> {
log::debug!("Loading libraries"); log::debug!("Loading libraries");
let (libraries_dir, natives_dir) = (
Arc::new(st.directories.libraries_dir()),
Arc::new(st.directories.version_natives_dir(version)),
);
tokio::try_join! { tokio::try_join! {
fs::create_dir_all(st.directories.libraries_dir()), fs::create_dir_all(st.directories.libraries_dir()),
+15 -13
View File
@@ -6,11 +6,11 @@ use tokio::process::{Child, Command};
mod args; mod args;
mod auth; pub mod auth;
pub use auth::Credentials;
mod download; mod download;
#[tracing::instrument]
pub fn parse_rule(rule: &d::minecraft::Rule) -> bool { pub fn parse_rule(rule: &d::minecraft::Rule) -> bool {
use d::minecraft::{Rule, RuleAction}; use d::minecraft::{Rule, RuleAction};
@@ -44,6 +44,7 @@ macro_rules! processor_rules {
} }
} }
#[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<d::modded::LoaderVersion>, loader_version: &Option<d::modded::LoaderVersion>,
@@ -64,7 +65,7 @@ pub async fn launch_minecraft(
.versions .versions
.iter() .iter()
.find(|it| it.id == game_version) .find(|it| it.id == game_version)
.ok_or(crate::Error::LauncherError(format!( .ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {game_version}" "Invalid game version: {game_version}"
)))?; )))?;
@@ -115,8 +116,9 @@ pub async fn launch_minecraft(
} }
} }
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")
@@ -131,7 +133,7 @@ pub async fn launch_minecraft(
)?) )?)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Could not find processor main class for {}", "Could not find processor main class for {}",
processor.jar processor.jar
)) ))
@@ -145,16 +147,17 @@ pub async fn launch_minecraft(
.output() .output()
.await .await
.map_err(|err| { .map_err(|err| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Error running processor: {err}", "Error running processor: {err}",
)) ))
})?; })?;
if !child.status.success() { if !child.status.success() {
return Err(crate::Error::LauncherError(format!( return Err(crate::ErrorKind::LauncherError(format!(
"Processor error: {}", "Processor error: {}",
String::from_utf8_lossy(&child.stderr) String::from_utf8_lossy(&child.stderr)
))); ))
.as_error());
} }
} }
} }
@@ -163,9 +166,7 @@ pub async fn launch_minecraft(
let args = version_info.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_install);
cmd
} }
None => Command::new(String::from(java_install.to_string_lossy())), None => Command::new(String::from(java_install.to_string_lossy())),
}; };
@@ -203,10 +204,11 @@ pub async fn launch_minecraft(
.stderr(Stdio::inherit()); .stderr(Stdio::inherit());
command.spawn().map_err(|err| { command.spawn().map_err(|err| {
crate::Error::LauncherError(format!( crate::ErrorKind::LauncherError(format!(
"Error running Minecraft (minecraft-{} @ {}): {err}", "Error running Minecraft (minecraft-{} @ {}): {err}",
&version.id, &version.id,
instance_path.display() instance_path.display()
)) ))
.as_error()
}) })
} }
+4 -2
View File
@@ -4,15 +4,17 @@
Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs, Theseus is a library which provides utilities for launching minecraft, creating Modrinth mod packs,
and launching Modrinth mod packs and launching Modrinth mod packs
*/ */
#![warn(unused_import_braces, missing_debug_implementations)] #![warn(unused_import_braces)]
#![deny(unused_must_use)] #![deny(unused_must_use)]
#[macro_use]
mod util;
mod api; mod api;
mod config; mod config;
mod error; mod error;
mod launcher; mod launcher;
mod state; mod state;
mod util;
pub use api::*; pub use api::*;
pub use error::*; pub use error::*;
+4 -3
View File
@@ -10,10 +10,11 @@ pub struct DirectoryInfo {
impl DirectoryInfo { impl DirectoryInfo {
/// Get all paths needed for Theseus to operate properly /// Get all paths needed for Theseus to operate properly
#[tracing::instrument]
pub async fn init() -> crate::Result<Self> { pub async fn init() -> crate::Result<Self> {
// Working directory // Working directory
let working_dir = std::env::current_dir().map_err(|err| { let working_dir = std::env::current_dir().map_err(|err| {
crate::Error::FSError(format!( crate::ErrorKind::FSError(format!(
"Could not open working directory: {err}" "Could not open working directory: {err}"
)) ))
})?; })?;
@@ -21,12 +22,12 @@ impl DirectoryInfo {
// Config directory // Config directory
let config_dir = Self::env_path("THESEUS_CONFIG_DIR") let config_dir = Self::env_path("THESEUS_CONFIG_DIR")
.or_else(|| Some(dirs::config_dir()?.join("theseus"))) .or_else(|| Some(dirs::config_dir()?.join("theseus")))
.ok_or(crate::Error::FSError( .ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(), "Could not find valid config dir".to_string(),
))?; ))?;
fs::create_dir_all(&config_dir).await.map_err(|err| { fs::create_dir_all(&config_dir).await.map_err(|err| {
crate::Error::FSError(format!( crate::ErrorKind::FSError(format!(
"Error creating Theseus config directory: {err}" "Error creating Theseus config directory: {err}"
)) ))
})?; })?;
+6 -1
View File
@@ -13,6 +13,7 @@ use std::collections::LinkedList;
const METADATA_URL: &str = "https://meta.modrinth.com/gamedata"; const METADATA_URL: &str = "https://meta.modrinth.com/gamedata";
const METADATA_DB_FIELD: &[u8] = b"metadata"; const METADATA_DB_FIELD: &[u8] = b"metadata";
// TODO: store as subtree in database
#[derive(Encode, Decode, Debug)] #[derive(Encode, Decode, Debug)]
pub struct Metadata { pub struct Metadata {
pub minecraft: MinecraftManifest, pub minecraft: MinecraftManifest,
@@ -48,6 +49,7 @@ impl Metadata {
}) })
} }
#[tracing::instrument(skip_all)]
pub async fn init(db: &sled::Db) -> crate::Result<Self> { pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let mut metadata = None; let mut metadata = None;
@@ -84,7 +86,10 @@ impl Metadata {
db.flush_async().await?; db.flush_async().await?;
Ok(meta) Ok(meta)
} else { } else {
Err(crate::Error::NoValueFor(String::from("launcher metadata"))) Err(
crate::ErrorKind::NoValueFor(String::from("launcher metadata"))
.as_error(),
)
} }
} }
} }
+45 -33
View File
@@ -8,17 +8,19 @@ mod dirs;
pub use self::dirs::*; pub use self::dirs::*;
mod metadata; mod metadata;
pub use metadata::*; pub use self::metadata::*;
mod settings;
pub use settings::*;
mod profiles; mod profiles;
pub use profiles::*; pub use self::profiles::*;
mod settings;
pub use self::settings::*;
mod users;
pub use self::users::*;
// Global state // Global state
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new(); static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
#[derive(Debug)]
pub struct State { pub struct State {
/// Database, used to store some information /// Database, used to store some information
pub(self) database: sled::Db, pub(self) database: sled::Db,
@@ -28,52 +30,62 @@ pub struct State {
pub io_semaphore: Semaphore, pub io_semaphore: Semaphore,
/// Launcher metadata /// Launcher metadata
pub metadata: Metadata, pub metadata: Metadata,
// TODO: settings API
/// Launcher configuration /// Launcher configuration
pub settings: RwLock<Settings>, pub settings: RwLock<Settings>,
/// Launcher profile metadata /// Launcher profile metadata
pub profiles: RwLock<Profiles>, pub(crate) profiles: RwLock<Profiles>,
/// Launcher user account info
pub(crate) users: RwLock<Users>,
} }
impl State { impl State {
#[tracing::instrument]
/// Get the current launcher state, initializing it if needed /// Get the current launcher state, initializing it if needed
pub async fn get() -> crate::Result<Arc<Self>> { pub async fn get() -> crate::Result<Arc<Self>> {
LAUNCHER_STATE LAUNCHER_STATE
.get_or_try_init(|| async { .get_or_try_init(|| {
// Directories async {
let directories = DirectoryInfo::init().await?; // Directories
let directories = DirectoryInfo::init().await?;
// Database // Database
// TODO: make database versioned // TODO: make database versioned
let database = let database = sled_config()
sled_config().path(directories.database_file()).open()?; .path(directories.database_file())
.open()?;
// Settings // Settings
let settings = let settings =
Settings::init(&directories.settings_file()).await?; Settings::init(&directories.settings_file()).await?;
// Metadata // Launcher data
let metadata = Metadata::init(&database).await?; let (metadata, profiles) = tokio::try_join! {
Metadata::init(&database),
Profiles::init(&database),
}?;
let users = Users::init(&database)?;
// Profiles // Loose initializations
let profiles = Profiles::init(&database).await?; let io_semaphore =
Semaphore::new(settings.max_concurrent_downloads);
// Loose initializations Ok(Arc::new(Self {
let io_semaphore = database,
Semaphore::new(settings.max_concurrent_downloads); directories,
io_semaphore,
Ok(Arc::new(Self { metadata,
database, settings: RwLock::new(settings),
directories, profiles: RwLock::new(profiles),
io_semaphore, users: RwLock::new(users),
metadata, }))
settings: RwLock::new(settings), }
profiles: RwLock::new(profiles),
}))
}) })
.await .await
.map(Arc::clone) .map(Arc::clone)
} }
#[tracing::instrument]
/// Synchronize in-memory state with persistent state /// Synchronize in-memory state with persistent state
pub async fn sync() -> crate::Result<()> { pub async fn sync() -> crate::Result<()> {
let state = Self::get().await?; let state = Self::get().await?;
+24 -7
View File
@@ -12,8 +12,7 @@ use tokio::fs;
const PROFILE_JSON_PATH: &str = "profile.json"; const PROFILE_JSON_PATH: &str = "profile.json";
const PROFILE_SUBTREE: &[u8] = b"profiles"; const PROFILE_SUBTREE: &[u8] = b"profiles";
#[derive(Debug)] pub(crate) struct Profiles(pub HashMap<PathBuf, Option<Profile>>);
pub struct Profiles(pub HashMap<PathBuf, Option<Profile>>);
// TODO: possibly add defaults to some of these values // TODO: possibly add defaults to some of these values
pub const CURRENT_FORMAT_VERSION: u32 = 1; pub const CURRENT_FORMAT_VERSION: u32 = 1;
@@ -84,15 +83,17 @@ pub struct JavaSettings {
} }
impl Profile { impl Profile {
#[tracing::instrument]
pub async fn new( pub async fn new(
name: String, name: String,
version: String, version: String,
path: PathBuf, path: PathBuf,
) -> crate::Result<Self> { ) -> crate::Result<Self> {
if name.trim().is_empty() { if name.trim().is_empty() {
return Err(crate::Error::InputError(String::from( return Err(crate::ErrorKind::InputError(String::from(
"Empty name for instance!", "Empty name for instance!",
))); ))
.into());
} }
Ok(Self { Ok(Self {
@@ -114,11 +115,13 @@ impl Profile {
// TODO: deduplicate these builder methods // TODO: deduplicate these builder methods
// They are flat like this in order to allow builder-style usage // They are flat like this in order to allow builder-style usage
#[tracing::instrument]
pub fn with_name(&mut self, name: String) -> &mut Self { pub fn with_name(&mut self, name: String) -> &mut Self {
self.metadata.name = name; self.metadata.name = name;
self self
} }
#[tracing::instrument]
pub async fn with_icon<'a>( pub async fn with_icon<'a>(
&'a mut self, &'a mut self,
icon: &'a Path, icon: &'a Path,
@@ -136,17 +139,20 @@ impl Profile {
Ok(self) Ok(self)
} else { } else {
Err(crate::Error::InputError(format!( Err(crate::ErrorKind::InputError(format!(
"Unsupported image type: {ext}" "Unsupported image type: {ext}"
))) ))
.into())
} }
} }
#[tracing::instrument]
pub fn with_game_version(&mut self, version: String) -> &mut Self { pub fn with_game_version(&mut self, version: String) -> &mut Self {
self.metadata.game_version = version; self.metadata.game_version = version;
self self
} }
#[tracing::instrument]
pub fn with_loader( pub fn with_loader(
&mut self, &mut self,
loader: ModLoader, loader: ModLoader,
@@ -157,6 +163,7 @@ impl Profile {
self self
} }
#[tracing::instrument]
pub fn with_java_settings( pub fn with_java_settings(
&mut self, &mut self,
settings: Option<JavaSettings>, settings: Option<JavaSettings>,
@@ -165,6 +172,7 @@ impl Profile {
self self
} }
#[tracing::instrument]
pub fn with_memory( pub fn with_memory(
&mut self, &mut self,
settings: Option<MemorySettings>, settings: Option<MemorySettings>,
@@ -173,6 +181,7 @@ impl Profile {
self self
} }
#[tracing::instrument]
pub fn with_resolution( pub fn with_resolution(
&mut self, &mut self,
resolution: Option<WindowSize>, resolution: Option<WindowSize>,
@@ -181,6 +190,7 @@ impl Profile {
self self
} }
#[tracing::instrument]
pub fn with_hooks(&mut self, hooks: Option<Hooks>) -> &mut Self { pub fn with_hooks(&mut self, hooks: Option<Hooks>) -> &mut Self {
self.hooks = hooks; self.hooks = hooks;
self self
@@ -188,6 +198,7 @@ impl Profile {
} }
impl Profiles { impl Profiles {
#[tracing::instrument(skip(db))]
pub async fn init(db: &sled::Db) -> crate::Result<Self> { pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let profile_db = db.get(PROFILE_SUBTREE)?.map_or( let profile_db = db.get(PROFILE_SUBTREE)?.map_or(
Ok(Default::default()), Ok(Default::default()),
@@ -218,19 +229,23 @@ impl Profiles {
Ok(Self(profiles)) Ok(Self(profiles))
} }
#[tracing::instrument(skip(self))]
pub fn insert(&mut self, profile: Profile) -> crate::Result<&Self> { pub fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
self.0.insert( self.0.insert(
profile profile
.path .path
.canonicalize()? .canonicalize()?
.to_str() .to_str()
.ok_or(crate::Error::UTFError(profile.path.clone()))? .ok_or(
crate::ErrorKind::UTFError(profile.path.clone()).as_error(),
)?
.into(), .into(),
Some(profile), Some(profile),
); );
Ok(self) Ok(self)
} }
#[tracing::instrument(skip(self))]
pub async fn insert_from<'a>( pub async fn insert_from<'a>(
&'a mut self, &'a mut self,
path: &'a Path, path: &'a Path,
@@ -238,12 +253,14 @@ impl Profiles {
self.insert(Self::read_profile_from_dir(&path.canonicalize()?).await?) self.insert(Self::read_profile_from_dir(&path.canonicalize()?).await?)
} }
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> { pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> {
let path = PathBuf::from(path.canonicalize()?.to_str().unwrap()); let path = PathBuf::from(path.canonicalize()?.to_str().unwrap());
self.0.remove(&path); self.0.remove(&path);
Ok(self) Ok(self)
} }
#[tracing::instrument(skip_all)]
pub async fn sync<'a>( pub async fn sync<'a>(
&'a self, &'a self,
batch: &'a mut sled::Batch, batch: &'a mut sled::Batch,
+8 -2
View File
@@ -19,6 +19,7 @@ pub struct Settings {
pub custom_java_args: Vec<String>, pub custom_java_args: Vec<String>,
pub java_8_path: Option<PathBuf>, pub java_8_path: Option<PathBuf>,
pub java_17_path: Option<PathBuf>, pub java_17_path: Option<PathBuf>,
pub default_user: Option<uuid::Uuid>,
pub hooks: Hooks, pub hooks: Hooks,
pub max_concurrent_downloads: usize, pub max_concurrent_downloads: usize,
pub version: u32, pub version: u32,
@@ -32,6 +33,7 @@ impl Default for Settings {
custom_java_args: Vec::new(), custom_java_args: Vec::new(),
java_8_path: None, java_8_path: None,
java_17_path: None, java_17_path: None,
default_user: None,
hooks: Hooks::default(), hooks: Hooks::default(),
max_concurrent_downloads: 64, max_concurrent_downloads: 64,
version: CURRENT_FORMAT_VERSION, version: CURRENT_FORMAT_VERSION,
@@ -40,14 +42,16 @@ impl Default for Settings {
} }
impl Settings { impl Settings {
#[tracing::instrument]
pub async fn init(file: &Path) -> crate::Result<Self> { pub async fn init(file: &Path) -> crate::Result<Self> {
if file.exists() { if file.exists() {
fs::read(&file) fs::read(&file)
.await .await
.map_err(|err| { .map_err(|err| {
crate::Error::FSError(format!( crate::ErrorKind::FSError(format!(
"Error reading settings file: {err}" "Error reading settings file: {err}"
)) ))
.as_error()
}) })
.and_then(|it| { .and_then(|it| {
serde_json::from_slice::<Settings>(&it) serde_json::from_slice::<Settings>(&it)
@@ -58,13 +62,15 @@ impl Settings {
} }
} }
#[tracing::instrument(skip(self))]
pub async fn sync(&self, to: &Path) -> crate::Result<()> { pub async fn sync(&self, to: &Path) -> crate::Result<()> {
fs::write(to, serde_json::to_vec_pretty(self)?) fs::write(to, serde_json::to_vec_pretty(self)?)
.await .await
.map_err(|err| { .map_err(|err| {
crate::Error::FSError(format!( crate::ErrorKind::FSError(format!(
"Error saving settings to file: {err}" "Error saving settings to file: {err}"
)) ))
.as_error()
}) })
} }
} }
+79
View File
@@ -0,0 +1,79 @@
//! User login info
use crate::{auth::Credentials, config::BINCODE_CONFIG};
const USER_DB_TREE: &[u8] = b"users";
/// The set of users stored in the launcher
#[derive(Clone)]
pub(crate) struct Users(pub(crate) sled::Tree);
impl Users {
#[tracing::instrument(skip(db))]
pub fn init(db: &sled::Db) -> crate::Result<Self> {
Ok(Self(db.open_tree(USER_DB_TREE)?))
}
#[tracing::instrument(skip_all)]
pub fn insert(
&mut self,
credentials: &Credentials,
) -> crate::Result<&Self> {
let id = credentials.id.as_bytes();
self.0.insert(
id,
bincode::encode_to_vec(credentials, *BINCODE_CONFIG)?,
)?;
Ok(self)
}
#[tracing::instrument(skip(self))]
pub fn contains(&self, id: uuid::Uuid) -> crate::Result<bool> {
Ok(self.0.contains_key(id.as_bytes())?)
}
#[tracing::instrument(skip(self))]
pub fn get(&self, id: uuid::Uuid) -> crate::Result<Option<Credentials>> {
self.0.get(id.as_bytes())?.map_or(Ok(None), |prof| {
bincode::decode_from_slice(&prof, *BINCODE_CONFIG)
.map_err(crate::Error::from)
.map(|it| Some(it.0))
})
}
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, id: uuid::Uuid) -> crate::Result<&Self> {
self.0.remove(id.as_bytes())?;
Ok(self)
}
pub fn iter(&self) -> UserIter<impl UserInnerIter> {
UserIter(self.0.iter().values(), false)
}
}
alias_trait! {
pub UserInnerIter: Iterator<Item = sled::Result<sled::IVec>>, Send, Sync
}
/// An iterator over the set of users
#[derive(Debug)]
pub struct UserIter<I: UserInnerIter>(I, bool);
impl<I: UserInnerIter> Iterator for UserIter<I> {
type Item = crate::Result<Credentials>;
#[tracing::instrument(skip(self))]
fn next(&mut self) -> Option<Self::Item> {
if self.1 {
return None;
}
let it = self.0.next()?;
let res = it.map_err(crate::Error::from).and_then(|it| {
Ok(bincode::decode_from_slice(&it, *BINCODE_CONFIG)?.0)
});
self.1 = res.is_err();
Some(res)
}
}
+6 -2
View File
@@ -10,6 +10,7 @@ use tokio::{
const FETCH_ATTEMPTS: usize = 3; const FETCH_ATTEMPTS: usize = 3;
#[tracing::instrument(skip(_permit))]
pub async fn fetch<'a>( pub async fn fetch<'a>(
url: &str, url: &str,
sha1: Option<&str>, sha1: Option<&str>,
@@ -25,10 +26,11 @@ pub async fn fetch<'a>(
if let Some(hash) = sha1 { if let Some(hash) = sha1 {
let actual_hash = sha1_async(bytes.clone()).await; let actual_hash = sha1_async(bytes.clone()).await;
if actual_hash != hash { if actual_hash != hash {
return Err(crate::Error::HashError( return Err(crate::ErrorKind::HashError(
actual_hash, actual_hash,
String::from(hash), String::from(hash),
)); )
.into());
} }
} }
@@ -45,6 +47,7 @@ pub async fn fetch<'a>(
// This is implemented, as it will be useful in porting modpacks // This is implemented, as it will be useful in porting modpacks
// For now, allow it to be dead code // For now, allow it to be dead code
#[allow(dead_code)] #[allow(dead_code)]
#[tracing::instrument(skip(sem))]
pub async fn fetch_mirrors( pub async fn fetch_mirrors(
urls: &[&str], urls: &[&str],
sha1: Option<&str>, sha1: Option<&str>,
@@ -70,6 +73,7 @@ pub async fn fetch_mirrors(
.map(|it| it.0) .map(|it| it.0)
} }
#[tracing::instrument(skip(bytes, _permit))]
pub async fn write<'a>( pub async fn write<'a>(
path: &Path, path: &Path,
bytes: &[u8], bytes: &[u8],
+20
View File
@@ -1,3 +1,23 @@
//! Theseus utility functions //! Theseus utility functions
pub mod fetch; pub mod fetch;
pub mod platform; 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 {}
}
}
+11 -5
View File
@@ -15,10 +15,16 @@ 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"]}
# TODO: merge logging with paris logging url = "2.2"
pretty_env_logger = "0.4"
log = "0.4.14" 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"
+37 -11
View File
@@ -1,26 +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>();
pretty_env_logger::formatted_builder()
.filter_module("theseus", log::LevelFilter::Info) color_eyre::install()?;
.target(pretty_env_logger::env_logger::Target::Stderr) let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))?;
let format = fmt::layer()
.without_time()
.with_writer(std::io::stderr)
.with_target(false)
.compact();
tracing_subscriber::registry()
.with(format)
.with(filter)
.with(ErrorLayer::default())
.init(); .init();
args.dispatch() tokio::runtime::Builder::new_multi_thread()
.inspect_err(|_| error!("An error has occurred!\n")) .enable_all()
.await .build()?
.block_on(
async move {
args.dispatch()
.inspect_err(|_| error!("An error has occurred!\n"))
.await
}
.with_current_subscriber(),
)
} }
+8 -5
View File
@@ -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
})
} }
} }
+36 -40
View File
@@ -1,27 +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 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::prelude::*; use theseus::prelude::*;
use tokio::fs; use tokio::fs;
use tokio_stream::wrappers::ReadDirStream; 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),
@@ -31,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 {
@@ -71,7 +70,7 @@ impl ProfileAdd {
} }
} }
#[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 {
@@ -260,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 {}
@@ -311,16 +310,15 @@ impl ProfileList {
_args: &crate::Args, _args: &crate::Args,
_largs: &ProfileCommand, _largs: &ProfileCommand,
) -> Result<()> { ) -> Result<()> {
let state = State::get().await?; let profiles = profile::list().await?;
let profiles = state.profiles.read().await; let rows = profiles.iter().map(|(path, prof)| {
let profiles = profiles.0.iter().map(|(path, prof)| {
prof.as_ref().map_or_else( prof.as_ref().map_or_else(
|| ProfileRow::from(path.as_path()), || ProfileRow::from(path.as_path()),
ProfileRow::from, 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)),
); );
@@ -330,7 +328,7 @@ impl ProfileList {
} }
} }
#[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 {
@@ -364,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 {
@@ -372,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 {
@@ -400,11 +389,18 @@ impl ProfileRun {
"Profile not managed by Theseus (if it exists, try using `profile add` first!)", "Profile not managed by Theseus (if it exists, try using `profile add` first!)",
); );
let credentials = Credentials { let id = future::ready(self.user.ok_or(()))
id: self.id.clone(), .or_else(|_| async move {
username: self.name.clone(), let state = State::get().await?;
access_token: self.token.clone(), 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?; let mut proc = profile::run(&path, &credentials).await?;
profile::wait_for(&mut proc).await?; profile::wait_for(&mut proc).await?;
@@ -415,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
View File
@@ -0,0 +1,178 @@
//! User management subcommand
use crate::util::{confirm_async, table};
use eyre::Result;
use paris::*;
use tabled::Tabled;
use theseus::prelude::*;
use tokio::sync::oneshot;
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "user")]
/// manage Minecraft accounts
pub struct UserCommand {
#[argh(subcommand)]
action: UserSubcommand,
}
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum UserSubcommand {
Add(UserAdd),
List(UserList),
Remove(UserRemove),
SetDefault(UserDefault),
}
#[derive(argh::FromArgs, Debug)]
/// add a new user to Theseus
#[argh(subcommand, name = "add")]
pub struct UserAdd {
#[argh(option)]
/// the browser to authenticate using
browser: Option<webbrowser::Browser>,
}
impl UserAdd {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Adding new user account to Theseus");
info!("A browser window will now open, follow the login flow there.");
let (tx, rx) = oneshot::channel::<url::Url>();
let flow = tokio::spawn(auth::authenticate(tx));
let url = rx.await?;
match self.browser {
Some(browser) => webbrowser::open_browser(browser, url.as_str()),
None => webbrowser::open(url.as_str()),
}?;
let credentials = flow.await??;
State::sync().await?;
success!("Logged in user {}.", credentials.username);
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// list all known users
#[argh(subcommand, name = "list")]
pub struct UserList {}
#[derive(Tabled)]
struct UserRow<'a> {
username: &'a str,
id: uuid::Uuid,
default: bool,
}
impl<'a> UserRow<'a> {
pub fn from(
credentials: &'a Credentials,
default: Option<uuid::Uuid>,
) -> Self {
Self {
username: &credentials.username,
id: credentials.id,
default: Some(credentials.id) == default,
}
}
}
impl UserList {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
let state = State::get().await?;
let default = state.settings.read().await.default_user;
let users = auth::users().await?;
let rows = users.iter().map(|user| UserRow::from(user, default));
let table = table(rows);
println!("{table}");
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// remove a user
#[argh(subcommand, name = "remove")]
pub struct UserRemove {
/// the user to remove
#[argh(positional)]
user: uuid::Uuid,
}
impl UserRemove {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Removing user {}", self.user.as_hyphenated());
if confirm_async(String::from("Do you wish to continue"), true).await? {
if !auth::has_user(self.user).await? {
warn!("Profile was not managed by Theseus!");
} else {
auth::remove_user(self.user).await?;
State::sync().await?;
success!("User removed!");
}
} else {
error!("Aborted!");
}
Ok(())
}
}
#[derive(argh::FromArgs, Debug)]
/// set the default user
#[argh(subcommand, name = "set-default")]
pub struct UserDefault {
/// the user to set as default
#[argh(positional)]
user: uuid::Uuid,
}
impl UserDefault {
pub async fn run(
&self,
_args: &crate::Args,
_largs: &UserCommand,
) -> Result<()> {
info!("Setting user {} as default", self.user.as_hyphenated());
// TODO: settings API
let state: std::sync::Arc<State> = State::get().await?;
let mut settings = state.settings.write().await;
if settings.default_user == Some(self.user) {
warn!("User is already the default!");
} else {
settings.default_user = Some(self.user);
success!("User set as default!");
}
Ok(())
}
}
impl UserCommand {
pub async fn run(&self, args: &crate::Args) -> Result<()> {
dispatch!(&self.action, (args, self) => {
UserSubcommand::Add,
UserSubcommand::List,
UserSubcommand::Remove,
UserSubcommand::SetDefault
})
}
}
+20 -1
View File
@@ -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!(