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

1190
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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" "$@"
'';
};
}; };
}; };

View File

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

View File

@@ -7,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
theseus/src/api/auth.rs Normal file
View File

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

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,

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

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

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

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?)
} }

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

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

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::*;

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}"
)) ))
})?; })?;

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

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?;

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,

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

View File

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

View File

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

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

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"

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

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

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

View File

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

View File

@@ -1,6 +1,7 @@
use dialoguer::{Confirm, Input, Select}; use 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!(