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

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
pub mod auth;
pub mod profile;
pub mod data {
pub use crate::{
launcher::Credentials,
state::{
DirectoryInfo, Hooks, JavaSettings, MemorySettings, ModLoader,
ProfileMetadata, Settings, WindowSize,
},
pub use crate::state::{
DirectoryInfo, Hooks, JavaSettings, MemorySettings, ModLoader,
ProfileMetadata, Settings, WindowSize,
};
}
pub mod prelude {
pub use crate::{
auth::{self, Credentials},
data::*,
profile::{self, Profile},
State,

View File

@@ -1,14 +1,17 @@
//! Theseus profile management interface
pub use crate::{
state::{JavaSettings, Profile},
State,
};
use daedalus as d;
use std::{future::Future, path::Path};
use std::{
future::Future,
path::{Path, PathBuf},
};
use tokio::process::{Child, Command};
/// Add a profile to the in-memory state
#[tracing::instrument]
pub async fn add(profile: Profile) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
@@ -18,6 +21,7 @@ pub async fn add(profile: Profile) -> crate::Result<()> {
}
/// Add a path as a profile in-memory
#[tracing::instrument]
pub async fn add_path(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
@@ -27,6 +31,7 @@ pub async fn add_path(path: &Path) -> crate::Result<()> {
}
/// Remove a profile
#[tracing::instrument]
pub async fn remove(path: &Path) -> crate::Result<()> {
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
@@ -36,19 +41,22 @@ pub async fn remove(path: &Path) -> crate::Result<()> {
}
/// Get a profile by path,
#[tracing::instrument]
pub async fn get(path: &Path) -> crate::Result<Option<Profile>> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
profiles.0.get(path).map_or(Ok(None), |prof| match prof {
Some(prof) => Ok(Some(prof.clone())),
None => Err(crate::Error::UnloadedProfileError(
None => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)),
)
.as_error()),
})
}
/// Check if a profile is already managed by Theseus
#[tracing::instrument]
pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
@@ -56,6 +64,7 @@ pub async fn is_managed(profile: &Path) -> crate::Result<bool> {
}
/// Check if a profile is loaded
#[tracing::instrument]
pub async fn is_loaded(profile: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
@@ -75,29 +84,41 @@ pub async fn edit<Fut>(
where
Fut: Future<Output = crate::Result<()>>,
{
let state = State::get().await.unwrap();
let state = State::get().await?;
let mut profiles = state.profiles.write().await;
match profiles.0.get_mut(path) {
Some(&mut Some(ref mut profile)) => action(profile).await,
Some(&mut None) => Err(crate::Error::UnloadedProfileError(
Some(&mut None) => Err(crate::ErrorKind::UnloadedProfileError(
path.display().to_string(),
)),
None => Err(crate::Error::UnmanagedProfileError(
)
.as_error()),
None => Err(crate::ErrorKind::UnmanagedProfileError(
path.display().to_string(),
)),
)
.as_error()),
}
}
/// Get a copy of the profile set
#[tracing::instrument]
pub async fn list(
) -> crate::Result<std::collections::HashMap<PathBuf, Option<Profile>>> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
Ok(profiles.0.clone())
}
/// Run Minecraft using a profile
#[tracing::instrument(skip_all)]
pub async fn run(
path: &Path,
credentials: &crate::launcher::Credentials,
credentials: &crate::auth::Credentials,
) -> crate::Result<Child> {
let state = State::get().await.unwrap();
let settings = state.settings.read().await;
let profile = get(path).await?.ok_or_else(|| {
crate::Error::OtherError(format!(
crate::ErrorKind::OtherError(format!(
"Tried to run a nonexistent or unloaded profile at path {}!",
path.display()
))
@@ -110,7 +131,7 @@ pub async fn run(
.iter()
.find(|it| it.id == profile.metadata.game_version.as_ref())
.ok_or_else(|| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Invalid or unknown Minecraft version: {}",
profile.metadata.game_version
))
@@ -130,10 +151,11 @@ pub async fn run(
.await?;
if !result.success() {
return Err(crate::Error::LauncherError(format!(
return Err(crate::ErrorKind::LauncherError(format!(
"Non-zero exit code for pre-launch hook: {}",
result.code().unwrap_or(-1)
)));
))
.as_error());
}
}
@@ -153,7 +175,7 @@ pub async fn run(
settings.java_8_path.as_ref()
}
.ok_or_else(|| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"No Java installed for version {}",
version_info.java_version.map_or(8, |it| it.major_version),
))
@@ -161,10 +183,11 @@ pub async fn run(
};
if !java_install.exists() {
return Err(crate::Error::LauncherError(format!(
return Err(crate::ErrorKind::LauncherError(format!(
"Could not find Java install: {}",
java_install.display()
)));
))
.as_error());
}
let ref java_args = profile
@@ -195,21 +218,26 @@ pub async fn run(
.await
}
#[tracing::instrument]
pub async fn kill(running: &mut Child) -> crate::Result<()> {
running.kill().await?;
wait_for(running).await
}
#[tracing::instrument]
pub async fn wait_for(running: &mut Child) -> crate::Result<()> {
let result = running.wait().await.map_err(|err| {
crate::Error::LauncherError(format!("Error running minecraft: {err}"))
crate::ErrorKind::LauncherError(format!(
"Error running minecraft: {err}"
))
})?;
match result.success() {
false => Err(crate::Error::LauncherError(format!(
false => Err(crate::ErrorKind::LauncherError(format!(
"Minecraft exited with non-zero code {}",
result.code().unwrap_or(-1)
))),
))
.as_error()),
true => Ok(()),
}
}

View File

@@ -1,17 +1,25 @@
//! Theseus error type
use tracing_error::InstrumentError;
#[derive(thiserror::Error, Debug)]
pub enum Error {
pub enum ErrorKind {
#[error("Filesystem error: {0}")]
FSError(String),
#[error("Serialization error (JSON): {0}")]
JSONError(#[from] serde_json::Error),
#[error("Error parsing UUID: {0}")]
UUIDError(#[from] uuid::Error),
#[error("Serialization error (Bincode): {0}")]
EncodeError(#[from] bincode::error::DecodeError),
EncodeError(#[from] bincode::error::EncodeError),
#[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}")]
DBError(#[from] sled::Error),
@@ -22,6 +30,9 @@ pub enum Error {
#[error("Metadata error: {0}")]
MetadataError(#[from] daedalus::Error),
#[error("Minecraft authentication error: {0}")]
HydraError(String),
#[error("I/O error: {0}")]
IOError(#[from] std::io::Error),
@@ -31,6 +42,12 @@ pub enum Error {
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Websocket error: {0}")]
WSError(#[from] async_tungstenite::tungstenite::Error),
#[error("Websocket closed before {0} could be received!")]
WSClosedError(String),
#[error("Incorrect Sha1 hash for download: {0} != {1}")]
HashError(String, String),
@@ -52,4 +69,35 @@ pub enum Error {
OtherError(String),
}
#[derive(Debug)]
pub struct Error {
source: tracing_error::TracedError<ErrorKind>,
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.source()
}
}
impl std::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(fmt, "{}", self.source)
}
}
impl<E: Into<ErrorKind>> From<E> for Error {
fn from(source: E) -> Self {
Self {
source: Into::<ErrorKind>::into(source).in_current_span(),
}
}
}
impl ErrorKind {
pub fn as_error(self) -> Error {
self.into()
}
}
pub type Result<T> = core::result::Result<T, Error>;

View File

@@ -40,10 +40,11 @@ pub fn get_class_paths(
client_path
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified class path {} does not exist",
client_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.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())?);
let path = &path.canonicalize().map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Library file at path {} does not exist",
path.to_string_lossy()
))
.as_error()
})?;
Ok(path.to_string_lossy().to_string())
@@ -104,10 +106,11 @@ pub fn get_jvm_arguments(
"-Djava.library.path={}",
&natives_path
.canonicalize()
.map_err(|_| crate::Error::LauncherError(format!(
.map_err(|_| crate::ErrorKind::LauncherError(format!(
"Specified natives path {} does not exist",
natives_path.to_string_lossy()
)))?
))
.as_error())?
.to_string_lossy()
.to_string()
));
@@ -142,10 +145,11 @@ fn parse_jvm_argument(
&natives_path
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified natives path {} does not exist",
natives_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy(),
)
@@ -154,10 +158,11 @@ fn parse_jvm_argument(
&libraries_path
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified libraries path {} does not exist",
libraries_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_string(),
@@ -239,7 +244,7 @@ fn parse_minecraft_argument(
.replace("${auth_access_token}", access_token)
.replace("${auth_session}", access_token)
.replace("${auth_player_name}", username)
.replace("${auth_uuid}", &uuid.to_hyphenated().to_string())
.replace("${auth_uuid}", &uuid.hyphenated().to_string())
.replace("${user_properties}", "{}")
.replace("${user_type}", "mojang")
.replace("${version_name}", version)
@@ -249,10 +254,11 @@ fn parse_minecraft_argument(
&game_directory
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified game directory {} does not exist",
game_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
@@ -262,10 +268,11 @@ fn parse_minecraft_argument(
&assets_directory
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified assets directory {} does not exist",
assets_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
@@ -275,10 +282,11 @@ fn parse_minecraft_argument(
&assets_directory
.canonicalize()
.map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Specified assets directory {} does not exist",
assets_directory.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
.to_owned(),
@@ -361,17 +369,19 @@ pub async fn get_processor_main_class(
Ok(tokio::task::spawn_blocking(move || {
let zipfile = std::fs::File::open(&path)?;
let mut archive = zip::ZipArchive::new(zipfile).map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Cannot read processor at {}",
path
))
.as_error()
})?;
let file = archive.by_name("META-INF/MANIFEST.MF").map_err(|_| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Cannot read processor manifest at {}",
path
))
.as_error()
})?;
let reader = BufReader::new(file);

View File

@@ -1,8 +1,168 @@
//! Authentication flow
// TODO: Implement authentication
#[derive(Debug)]
//! Authentication flow based on Hydra
use async_tungstenite as ws;
use bincode::{Decode, Encode};
use chrono::{prelude::*, Duration};
use futures::prelude::*;
use once_cell::sync::*;
use serde::Deserialize;
use url::Url;
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 {
#[bincode(with_serde)]
pub id: uuid::Uuid,
pub username: String,
pub access_token: String,
pub refresh_token: String,
#[bincode(with_serde)]
pub expires: DateTime<Utc>,
_ctor_scope: std::marker::PhantomData<()>,
}
// Implementation
pub struct HydraAuthFlow<S: AsyncRead + AsyncWrite + Unpin> {
socket: ws::WebSocketStream<S>,
}
impl HydraAuthFlow<ws::tokio::ConnectStream> {
pub async fn new() -> crate::Result<Self> {
let sock_url = wrap_ref_builder!(
it = HYDRA_URL =>
{ it.set_scheme("wss").ok() }
);
let (socket, _) = ws::tokio::connect_async(sock_url.clone()).await?;
Ok(Self { socket })
}
pub async fn prepare_login_url(&mut self) -> crate::Result<Url> {
let code_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let code = ErrorJSON::unwrap::<LoginCodeJSON>(&code_resp)?;
Ok(wrap_ref_builder!(
it = HYDRA_URL.join("login")? =>
{ it.query_pairs_mut().append_pair("id", &code.login_code); }
))
}
pub async fn extract_credentials(&mut self) -> crate::Result<Credentials> {
// Minecraft bearer token
let token_resp = self
.socket
.try_next()
.await?
.ok_or(
crate::ErrorKind::WSClosedError(String::from(
"login socket ID",
))
.as_error(),
)?
.into_data();
let token = ErrorJSON::unwrap::<TokenJSON>(&token_resp)?;
let expires =
Utc::now() + Duration::seconds(token.expires_after.into());
// Get account credentials
let info = fetch_info(&token.token).await?;
// Return structure from response
Ok(Credentials {
username: info.name,
id: info.id,
refresh_token: token.refresh_token,
access_token: token.token,
expires,
_ctor_scope: std::marker::PhantomData,
})
}
}
pub async fn refresh_credentials(
credentials: &mut Credentials,
) -> crate::Result<()> {
let resp = crate::config::REQWEST_CLIENT
.post(HYDRA_URL.join("/refresh")?)
.json(
&serde_json::json!({ "refresh_token": credentials.refresh_token }),
)
.send()
.await?
.error_for_status()?
.json::<TokenJSON>()
.await?;
credentials.access_token = resp.token;
credentials.refresh_token = resp.refresh_token;
credentials.expires =
Utc::now() + Duration::seconds(resp.expires_after.into());
Ok(())
}
pub async fn refresh_username(
credentials: &mut Credentials,
) -> crate::Result<()> {
let info = fetch_info(&credentials.access_token).await?;
credentials.username = info.name;
Ok(())
}
// Helpers
async fn fetch_info(token: &str) -> crate::Result<ProfileInfoJSON> {
let url =
Url::parse("https://api.minecraftservices.com/minecraft/profile")?;
Ok(crate::config::REQWEST_CLIENT
.get(url)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?
.error_for_status()?
.json::<ProfileInfoJSON>()
.await?)
}

View File

@@ -13,9 +13,9 @@ use daedalus::{
modded::LoaderVersion,
};
use futures::prelude::*;
use std::sync::Arc;
use tokio::{fs, sync::OnceCell};
#[tracing::instrument(skip_all)]
pub async fn download_minecraft(
st: &State,
version: &GameVersionInfo,
@@ -33,6 +33,7 @@ pub async fn download_minecraft(
Ok(())
}
#[tracing::instrument(skip_all, fields(version = version.id.as_str(), loader = ?loader))]
pub async fn download_version_info(
st: &State,
version: &GameVersion,
@@ -69,6 +70,7 @@ pub async fn download_version_info(
Ok(res)
}
#[tracing::instrument(skip_all)]
pub async fn download_client(
st: &State,
version_info: &GameVersionInfo,
@@ -78,9 +80,12 @@ pub async fn download_client(
let client_download = version_info
.downloads
.get(&d::minecraft::DownloadType::Client)
.ok_or(crate::Error::LauncherError(format!(
"No client downloads exist for version {version}"
)))?;
.ok_or(
crate::ErrorKind::LauncherError(format!(
"No client downloads exist for version {version}"
))
.as_error(),
)?;
let path = st
.directories
.version_dir(version)
@@ -99,6 +104,7 @@ pub async fn download_client(
Ok(())
}
#[tracing::instrument(skip_all)]
pub async fn download_assets_index(
st: &State,
version: &GameVersionInfo,
@@ -126,6 +132,7 @@ pub async fn download_assets_index(
Ok(res)
}
#[tracing::instrument(skip(st, index))]
pub async fn download_assets(
st: &State,
with_legacy: bool,
@@ -180,16 +187,13 @@ pub async fn download_assets(
Ok(())
}
#[tracing::instrument(skip(st, libraries))]
pub async fn download_libraries(
st: &State,
libraries: &[Library],
version: &str,
) -> crate::Result<()> {
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! {
fs::create_dir_all(st.directories.libraries_dir()),

View File

@@ -6,11 +6,11 @@ use tokio::process::{Child, Command};
mod args;
mod auth;
pub use auth::Credentials;
pub mod auth;
mod download;
#[tracing::instrument]
pub fn parse_rule(rule: &d::minecraft::Rule) -> bool {
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(
game_version: &str,
loader_version: &Option<d::modded::LoaderVersion>,
@@ -64,7 +65,7 @@ pub async fn launch_minecraft(
.versions
.iter()
.find(|it| it.id == game_version)
.ok_or(crate::Error::LauncherError(format!(
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {game_version}"
)))?;
@@ -115,8 +116,9 @@ pub async fn launch_minecraft(
}
}
let mut cp = processor.classpath.clone();
cp.push(processor.jar.clone());
let cp = wrap_ref_builder!(cp = processor.classpath.clone() => {
cp.push(processor.jar.clone())
});
let child = Command::new("java")
.arg("-cp")
@@ -131,7 +133,7 @@ pub async fn launch_minecraft(
)?)
.await?
.ok_or_else(|| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Could not find processor main class for {}",
processor.jar
))
@@ -145,16 +147,17 @@ pub async fn launch_minecraft(
.output()
.await
.map_err(|err| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Error running processor: {err}",
))
})?;
if !child.status.success() {
return Err(crate::Error::LauncherError(format!(
return Err(crate::ErrorKind::LauncherError(format!(
"Processor error: {}",
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 mut command = match wrapper {
Some(hook) => {
let mut cmd = Command::new(hook);
cmd.arg(java_install);
cmd
wrap_ref_builder!(it = Command::new(hook) => {it.arg(java_install)})
}
None => Command::new(String::from(java_install.to_string_lossy())),
};
@@ -203,10 +204,11 @@ pub async fn launch_minecraft(
.stderr(Stdio::inherit());
command.spawn().map_err(|err| {
crate::Error::LauncherError(format!(
crate::ErrorKind::LauncherError(format!(
"Error running Minecraft (minecraft-{} @ {}): {err}",
&version.id,
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,
and launching Modrinth mod packs
*/
#![warn(unused_import_braces, missing_debug_implementations)]
#![warn(unused_import_braces)]
#![deny(unused_must_use)]
#[macro_use]
mod util;
mod api;
mod config;
mod error;
mod launcher;
mod state;
mod util;
pub use api::*;
pub use error::*;

View File

@@ -10,10 +10,11 @@ pub struct DirectoryInfo {
impl DirectoryInfo {
/// Get all paths needed for Theseus to operate properly
#[tracing::instrument]
pub async fn init() -> crate::Result<Self> {
// Working directory
let working_dir = std::env::current_dir().map_err(|err| {
crate::Error::FSError(format!(
crate::ErrorKind::FSError(format!(
"Could not open working directory: {err}"
))
})?;
@@ -21,12 +22,12 @@ impl DirectoryInfo {
// Config directory
let config_dir = Self::env_path("THESEUS_CONFIG_DIR")
.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(),
))?;
fs::create_dir_all(&config_dir).await.map_err(|err| {
crate::Error::FSError(format!(
crate::ErrorKind::FSError(format!(
"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_DB_FIELD: &[u8] = b"metadata";
// TODO: store as subtree in database
#[derive(Encode, Decode, Debug)]
pub struct Metadata {
pub minecraft: MinecraftManifest,
@@ -48,6 +49,7 @@ impl Metadata {
})
}
#[tracing::instrument(skip_all)]
pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let mut metadata = None;
@@ -84,7 +86,10 @@ impl Metadata {
db.flush_async().await?;
Ok(meta)
} 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::*;
mod metadata;
pub use metadata::*;
mod settings;
pub use settings::*;
pub use self::metadata::*;
mod profiles;
pub use profiles::*;
pub use self::profiles::*;
mod settings;
pub use self::settings::*;
mod users;
pub use self::users::*;
// Global state
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
#[derive(Debug)]
pub struct State {
/// Database, used to store some information
pub(self) database: sled::Db,
@@ -28,52 +30,62 @@ pub struct State {
pub io_semaphore: Semaphore,
/// Launcher metadata
pub metadata: Metadata,
// TODO: settings API
/// Launcher configuration
pub settings: RwLock<Settings>,
/// Launcher profile metadata
pub profiles: RwLock<Profiles>,
pub(crate) profiles: RwLock<Profiles>,
/// Launcher user account info
pub(crate) users: RwLock<Users>,
}
impl State {
#[tracing::instrument]
/// Get the current launcher state, initializing it if needed
pub async fn get() -> crate::Result<Arc<Self>> {
LAUNCHER_STATE
.get_or_try_init(|| async {
// Directories
let directories = DirectoryInfo::init().await?;
.get_or_try_init(|| {
async {
// Directories
let directories = DirectoryInfo::init().await?;
// Database
// TODO: make database versioned
let database =
sled_config().path(directories.database_file()).open()?;
// Database
// TODO: make database versioned
let database = sled_config()
.path(directories.database_file())
.open()?;
// Settings
let settings =
Settings::init(&directories.settings_file()).await?;
// Settings
let settings =
Settings::init(&directories.settings_file()).await?;
// Metadata
let metadata = Metadata::init(&database).await?;
// Launcher data
let (metadata, profiles) = tokio::try_join! {
Metadata::init(&database),
Profiles::init(&database),
}?;
let users = Users::init(&database)?;
// Profiles
let profiles = Profiles::init(&database).await?;
// Loose initializations
let io_semaphore =
Semaphore::new(settings.max_concurrent_downloads);
// Loose initializations
let io_semaphore =
Semaphore::new(settings.max_concurrent_downloads);
Ok(Arc::new(Self {
database,
directories,
io_semaphore,
metadata,
settings: RwLock::new(settings),
profiles: RwLock::new(profiles),
}))
Ok(Arc::new(Self {
database,
directories,
io_semaphore,
metadata,
settings: RwLock::new(settings),
profiles: RwLock::new(profiles),
users: RwLock::new(users),
}))
}
})
.await
.map(Arc::clone)
}
#[tracing::instrument]
/// Synchronize in-memory state with persistent state
pub async fn sync() -> crate::Result<()> {
let state = Self::get().await?;

View File

@@ -12,8 +12,7 @@ use tokio::fs;
const PROFILE_JSON_PATH: &str = "profile.json";
const PROFILE_SUBTREE: &[u8] = b"profiles";
#[derive(Debug)]
pub struct Profiles(pub HashMap<PathBuf, Option<Profile>>);
pub(crate) struct Profiles(pub HashMap<PathBuf, Option<Profile>>);
// TODO: possibly add defaults to some of these values
pub const CURRENT_FORMAT_VERSION: u32 = 1;
@@ -84,15 +83,17 @@ pub struct JavaSettings {
}
impl Profile {
#[tracing::instrument]
pub async fn new(
name: String,
version: String,
path: PathBuf,
) -> crate::Result<Self> {
if name.trim().is_empty() {
return Err(crate::Error::InputError(String::from(
return Err(crate::ErrorKind::InputError(String::from(
"Empty name for instance!",
)));
))
.into());
}
Ok(Self {
@@ -114,11 +115,13 @@ impl Profile {
// TODO: deduplicate these builder methods
// They are flat like this in order to allow builder-style usage
#[tracing::instrument]
pub fn with_name(&mut self, name: String) -> &mut Self {
self.metadata.name = name;
self
}
#[tracing::instrument]
pub async fn with_icon<'a>(
&'a mut self,
icon: &'a Path,
@@ -136,17 +139,20 @@ impl Profile {
Ok(self)
} else {
Err(crate::Error::InputError(format!(
Err(crate::ErrorKind::InputError(format!(
"Unsupported image type: {ext}"
)))
))
.into())
}
}
#[tracing::instrument]
pub fn with_game_version(&mut self, version: String) -> &mut Self {
self.metadata.game_version = version;
self
}
#[tracing::instrument]
pub fn with_loader(
&mut self,
loader: ModLoader,
@@ -157,6 +163,7 @@ impl Profile {
self
}
#[tracing::instrument]
pub fn with_java_settings(
&mut self,
settings: Option<JavaSettings>,
@@ -165,6 +172,7 @@ impl Profile {
self
}
#[tracing::instrument]
pub fn with_memory(
&mut self,
settings: Option<MemorySettings>,
@@ -173,6 +181,7 @@ impl Profile {
self
}
#[tracing::instrument]
pub fn with_resolution(
&mut self,
resolution: Option<WindowSize>,
@@ -181,6 +190,7 @@ impl Profile {
self
}
#[tracing::instrument]
pub fn with_hooks(&mut self, hooks: Option<Hooks>) -> &mut Self {
self.hooks = hooks;
self
@@ -188,6 +198,7 @@ impl Profile {
}
impl Profiles {
#[tracing::instrument(skip(db))]
pub async fn init(db: &sled::Db) -> crate::Result<Self> {
let profile_db = db.get(PROFILE_SUBTREE)?.map_or(
Ok(Default::default()),
@@ -218,19 +229,23 @@ impl Profiles {
Ok(Self(profiles))
}
#[tracing::instrument(skip(self))]
pub fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
self.0.insert(
profile
.path
.canonicalize()?
.to_str()
.ok_or(crate::Error::UTFError(profile.path.clone()))?
.ok_or(
crate::ErrorKind::UTFError(profile.path.clone()).as_error(),
)?
.into(),
Some(profile),
);
Ok(self)
}
#[tracing::instrument(skip(self))]
pub async fn insert_from<'a>(
&'a mut self,
path: &'a Path,
@@ -238,12 +253,14 @@ impl Profiles {
self.insert(Self::read_profile_from_dir(&path.canonicalize()?).await?)
}
#[tracing::instrument(skip(self))]
pub fn remove(&mut self, path: &Path) -> crate::Result<&Self> {
let path = PathBuf::from(path.canonicalize()?.to_str().unwrap());
self.0.remove(&path);
Ok(self)
}
#[tracing::instrument(skip_all)]
pub async fn sync<'a>(
&'a self,
batch: &'a mut sled::Batch,

View File

@@ -19,6 +19,7 @@ pub struct Settings {
pub custom_java_args: Vec<String>,
pub java_8_path: Option<PathBuf>,
pub java_17_path: Option<PathBuf>,
pub default_user: Option<uuid::Uuid>,
pub hooks: Hooks,
pub max_concurrent_downloads: usize,
pub version: u32,
@@ -32,6 +33,7 @@ impl Default for Settings {
custom_java_args: Vec::new(),
java_8_path: None,
java_17_path: None,
default_user: None,
hooks: Hooks::default(),
max_concurrent_downloads: 64,
version: CURRENT_FORMAT_VERSION,
@@ -40,14 +42,16 @@ impl Default for Settings {
}
impl Settings {
#[tracing::instrument]
pub async fn init(file: &Path) -> crate::Result<Self> {
if file.exists() {
fs::read(&file)
.await
.map_err(|err| {
crate::Error::FSError(format!(
crate::ErrorKind::FSError(format!(
"Error reading settings file: {err}"
))
.as_error()
})
.and_then(|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<()> {
fs::write(to, serde_json::to_vec_pretty(self)?)
.await
.map_err(|err| {
crate::Error::FSError(format!(
crate::ErrorKind::FSError(format!(
"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;
#[tracing::instrument(skip(_permit))]
pub async fn fetch<'a>(
url: &str,
sha1: Option<&str>,
@@ -25,10 +26,11 @@ pub async fn fetch<'a>(
if let Some(hash) = sha1 {
let actual_hash = sha1_async(bytes.clone()).await;
if actual_hash != hash {
return Err(crate::Error::HashError(
return Err(crate::ErrorKind::HashError(
actual_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
// For now, allow it to be dead code
#[allow(dead_code)]
#[tracing::instrument(skip(sem))]
pub async fn fetch_mirrors(
urls: &[&str],
sha1: Option<&str>,
@@ -70,6 +73,7 @@ pub async fn fetch_mirrors(
.map(|it| it.0)
}
#[tracing::instrument(skip(bytes, _permit))]
pub async fn write<'a>(
path: &Path,
bytes: &[u8],

View File

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