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
];
shell = [
toolchain
(with fenix; combine [toolchain default.clippy rust-analyzer])
(with fenix; combine [toolchain default.clippy complete.rust-src rust-analyzer])
git
jdk17 jdk8
];
@@ -58,19 +57,10 @@
cli = utils.mkApp {
drv = self.packages.${system}.theseus-cli;
};
cli-test = utils.mkApp {
drv = pkgs.writeShellApplication {
name = "theseus-test-cli";
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" "$@"
'';
};
cli-dev = utils.mkApp {
drv = self.packages.${system}.theseus-cli.overrideAttrs (old: old // {
release = false;
});
};
};

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
[dependencies]
thiserror = "1.0"
async-trait = "0.1.51"
daedalus = { version = "0.1.16", features = ["bincode"] }
bytes = "1"
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_json = "1.0"
json5 = "0.4.1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "0.8", features = ["serde", "v4"] }
bytes = "1"
zip = "0.5"
zip-extensions = "0.6"
sha1 = { version = "0.6.0", features = ["std"]}
path-clean = "0.1.0"
fs_extra = "1.2.0"
sled = { version = "0.34.7", features = ["compression"] }
url = "2.2"
uuid = { version = "1.1", features = ["serde", "v4"] }
zip = "0.5"
chrono = { version = "0.4.19", features = ["serde"] }
daedalus = { version = "0.1.16", features = ["bincode"] }
dirs = "4.0"
regex = "1.5"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
sys-info = "0.9.0"
# TODO: possibly replace with tracing to have structured logging
log = "0.4.14"
const_format = "0.2.22"
regex = "1.5"
sys-info = "0.9.0"
thiserror = "1.0"
tracing = "0.1"
tracing-error = "0.2"
async-tungstenite = { version = "0.17", features = ["tokio-runtime", "tokio-native-tls"] }
futures = "0.3"
once_cell = "1.9.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
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
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 {}
}
}

View File

@@ -15,10 +15,16 @@ futures = "0.3"
argh = "0.1"
paris = { version = "1.5", features = ["macros", "no_logger"] }
dialoguer = "0.10"
eyre = "0.6"
tabled = "0.5"
dirs = "4.0"
uuid = {version = "0.8", features = ["v4", "serde"]}
# TODO: merge logging with paris logging
pretty_env_logger = "0.4"
log = "0.4.14"
uuid = {version = "1.1", features = ["v4", "serde"]}
url = "2.2"
color-eyre = "0.6"
eyre = "0.6"
tracing = "0.1"
tracing-error = "0.2"
tracing-futures = "0.2"
tracing-subscriber = {version = "0.3", features = ["env-filter"]}
webbrowser = "0.7"

View File

@@ -1,26 +1,52 @@
use eyre::Result;
use futures::TryFutureExt;
use paris::*;
use tracing_error::ErrorLayer;
use tracing_futures::WithSubscriber;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
mod subcommands;
#[macro_use]
mod util;
#[derive(argh::FromArgs)]
mod subcommands;
#[derive(argh::FromArgs, Debug)]
/// The official Modrinth CLI
pub struct Args {
#[argh(subcommand)]
pub subcommand: subcommands::SubCommand,
pub subcommand: subcommands::Subcommand,
}
#[tokio::main]
async fn main() -> Result<()> {
#[tracing::instrument]
fn main() -> Result<()> {
let args = argh::from_env::<Args>();
pretty_env_logger::formatted_builder()
.filter_module("theseus", log::LevelFilter::Info)
.target(pretty_env_logger::env_logger::Target::Stderr)
color_eyre::install()?;
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))?;
let format = fmt::layer()
.without_time()
.with_writer(std::io::stderr)
.with_target(false)
.compact();
tracing_subscriber::registry()
.with(format)
.with(filter)
.with(ErrorLayer::default())
.init();
args.dispatch()
.inspect_err(|_| error!("An error has occurred!\n"))
.await
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(
async move {
args.dispatch()
.inspect_err(|_| error!("An error has occurred!\n"))
.await
}
.with_current_subscriber(),
)
}

View File

@@ -1,17 +1,20 @@
use eyre::Result;
mod profile;
mod user;
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum SubCommand {
pub enum Subcommand {
Profile(profile::ProfileCommand),
User(user::UserCommand),
}
impl crate::Args {
pub async fn dispatch(&self) -> Result<()> {
match self.subcommand {
SubCommand::Profile(ref cmd) => cmd.dispatch(self).await,
}
dispatch!(self.subcommand, (self) => {
Subcommand::Profile,
Subcommand::User
})
}
}

View File

@@ -1,27 +1,26 @@
//! Profile management subcommand
use crate::util::{
confirm_async, prompt_async, select_async, table_path_display,
confirm_async, prompt_async, select_async, table, table_path_display,
};
use daedalus::modded::LoaderVersion;
use eyre::{ensure, Result};
use futures::prelude::*;
use paris::*;
use std::path::{Path, PathBuf};
use tabled::{Table, Tabled};
use tabled::Tabled;
use theseus::prelude::*;
use tokio::fs;
use tokio_stream::wrappers::ReadDirStream;
use uuid::Uuid;
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "profile")]
/// profile management
/// manage Minecraft instances
pub struct ProfileCommand {
#[argh(subcommand)]
action: ProfileSubcommand,
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand)]
pub enum ProfileSubcommand {
Add(ProfileAdd),
@@ -31,7 +30,7 @@ pub enum ProfileSubcommand {
Run(ProfileRun),
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "add")]
/// add a new profile to Theseus
pub struct ProfileAdd {
@@ -71,7 +70,7 @@ impl ProfileAdd {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
#[argh(subcommand, name = "init")]
/// create a new profile and manage it with Theseus
pub struct ProfileInit {
@@ -260,7 +259,7 @@ impl ProfileInit {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// list all managed profiles
#[argh(subcommand, name = "list")]
pub struct ProfileList {}
@@ -311,16 +310,15 @@ impl ProfileList {
_args: &crate::Args,
_largs: &ProfileCommand,
) -> Result<()> {
let state = State::get().await?;
let profiles = state.profiles.read().await;
let profiles = profiles.0.iter().map(|(path, prof)| {
let profiles = profile::list().await?;
let rows = profiles.iter().map(|(path, prof)| {
prof.as_ref().map_or_else(
|| ProfileRow::from(path.as_path()),
ProfileRow::from,
)
});
let table = Table::new(profiles).with(tabled::Style::psql()).with(
let table = table(rows).with(
tabled::Modify::new(tabled::Column(1..=1))
.with(tabled::MaxWidth::wrapping(40)),
);
@@ -330,7 +328,7 @@ impl ProfileList {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// unmanage a profile
#[argh(subcommand, name = "remove")]
pub struct ProfileRemove {
@@ -364,7 +362,7 @@ impl ProfileRemove {
}
}
#[derive(argh::FromArgs)]
#[derive(argh::FromArgs, Debug)]
/// run a profile
#[argh(subcommand, name = "run")]
pub struct ProfileRun {
@@ -372,18 +370,9 @@ pub struct ProfileRun {
/// the profile to run
profile: PathBuf,
// TODO: auth
#[argh(option, short = 't')]
/// the Minecraft token to use for player login. Should be replaced by auth when that is a thing.
token: String,
#[argh(option, short = 'n')]
/// the uername to use for running the game
name: String,
#[argh(option, short = 'i')]
/// the account id to use for running the game
id: Uuid,
#[argh(option)]
/// the user to authenticate with
user: Option<uuid::Uuid>,
}
impl ProfileRun {
@@ -400,11 +389,18 @@ impl ProfileRun {
"Profile not managed by Theseus (if it exists, try using `profile add` first!)",
);
let credentials = Credentials {
id: self.id.clone(),
username: self.name.clone(),
access_token: self.token.clone(),
};
let id = future::ready(self.user.ok_or(()))
.or_else(|_| async move {
let state = State::get().await?;
let settings = state.settings.read().await;
settings.default_user
.ok_or(eyre::eyre!(
"Could not find any users, please add one using the `user add` command."
))
})
.await?;
let credentials = auth::refresh(id, false).await?;
let mut proc = profile::run(&path, &credentials).await?;
profile::wait_for(&mut proc).await?;
@@ -415,14 +411,14 @@ impl ProfileRun {
}
impl ProfileCommand {
pub async fn dispatch(&self, args: &crate::Args) -> Result<()> {
match &self.action {
ProfileSubcommand::Add(ref cmd) => cmd.run(args, self).await,
ProfileSubcommand::Init(ref cmd) => cmd.run(args, self).await,
ProfileSubcommand::List(ref cmd) => cmd.run(args, self).await,
ProfileSubcommand::Remove(ref cmd) => cmd.run(args, self).await,
ProfileSubcommand::Run(ref cmd) => cmd.run(args, self).await,
}
pub async fn run(&self, args: &crate::Args) -> Result<()> {
dispatch!(&self.action, (args, self) => {
ProfileSubcommand::Add,
ProfileSubcommand::Init,
ProfileSubcommand::List,
ProfileSubcommand::Remove,
ProfileSubcommand::Run
})
}
}

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 eyre::Result;
use std::{borrow::Cow, path::Path};
use tabled::{Table, Tabled};
// TODO: make primarily async to avoid copies
@@ -56,7 +57,11 @@ pub async fn confirm_async(prompt: String, default: bool) -> Result<bool> {
tokio::task::spawn_blocking(move || confirm(&prompt, default)).await?
}
// Table display helpers
// Table helpers
pub fn table<T: Tabled>(rows: impl IntoIterator<Item = T>) -> Table {
Table::new(rows).with(tabled::Style::psql())
}
pub fn table_path_display(path: &Path) -> String {
let mut res = path.display().to_string();
@@ -67,6 +72,20 @@ pub fn table_path_display(path: &Path) -> String {
res
}
// Dispatch macros
macro_rules! dispatch {
($on:expr, $args:tt => {$($option:path),+}) => {
match $on {
$($option (ref cmd) => dispatch!(@apply cmd => $args)),+
}
};
(@apply $cmd:expr => ($($args:expr),*)) => {{
use tracing_futures::WithSubscriber;
$cmd.run($($args),*).with_current_subscriber().await
}};
}
// Internal helpers
fn print_prompt(prompt: &str) {
println!(