mirror of
https://github.com/mat-1/azalea.git
synced 2025-08-02 06:16:04 +00:00
Add functions auth_with_link_code
, get_ms_link_code
, and get_ms_auth_token
. (#88)
* Add option for grabbing authentication code for Microsoft seperately. Created two new functions, one that outputs the DeviceCodeResponse and one that uses this response to authenticate an actual account. * Added documentation and cleaned up function names. Still wondering about code repeition * reduce code duplication, more docs, cleanup * clippy --------- Co-authored-by: mat <git@matdoes.dev>
This commit is contained in:
parent
fe687f9bdb
commit
5e46996882
9 changed files with 269 additions and 90 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -309,6 +309,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"parking_lot",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"uuid",
|
||||
|
|
32
azalea-auth/examples/auth_manual.rs
Executable file
32
azalea-auth/examples/auth_manual.rs
Executable file
|
@ -0,0 +1,32 @@
|
|||
//! Authenticate with Microsoft and get a Minecraft profile, but don't cache and
|
||||
//! use our own code to display the link code.
|
||||
//!
|
||||
//! If you still want it to cache, look at the code in [`azalea_auth::auth`] and
|
||||
//! see how that does it.
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use azalea_auth::ProfileResponse;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
env_logger::init();
|
||||
|
||||
let profile = auth().await?;
|
||||
println!("Logged in as {}", profile.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn auth() -> Result<ProfileResponse, Box<dyn Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let res = azalea_auth::get_ms_link_code(&client).await?;
|
||||
println!(
|
||||
"Go to {} and enter the code {}",
|
||||
res.verification_uri, res.user_code
|
||||
);
|
||||
let msa = azalea_auth::get_ms_auth_token(&client, res).await?;
|
||||
let auth_result = azalea_auth::get_minecraft_token(&client, &msa.data.access_token).await?;
|
||||
Ok(azalea_auth::get_profile(&client, &auth_result.minecraft_access_token).await?)
|
||||
}
|
|
@ -55,6 +55,10 @@ pub enum AuthError {
|
|||
/// The email is technically only used as a cache key, so it *could* be
|
||||
/// anything. You should just have it be the actual email so it's not confusing
|
||||
/// though, and in case the Microsoft API does start providing the real email.
|
||||
///
|
||||
/// If you want to use your own code to cache or show the auth code to the user
|
||||
/// in a different way, use [`get_ms_link_code`], [`get_ms_auth_token`],
|
||||
/// [`get_minecraft_token`] and [`get_profile`] instead instead.
|
||||
pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> {
|
||||
let cached_account = if let Some(cache_file) = &opts.cache_file {
|
||||
cache::get_account_in_cache(cache_file, email).await
|
||||
|
@ -62,16 +66,15 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError>
|
|||
None
|
||||
};
|
||||
|
||||
// these two MUST be set by the end, since we return them in AuthResult
|
||||
let profile: ProfileResponse;
|
||||
let minecraft_access_token: String;
|
||||
|
||||
if cached_account.is_some() && !cached_account.as_ref().unwrap().mca.is_expired() {
|
||||
let account = cached_account.as_ref().unwrap();
|
||||
// the minecraft auth data is cached and not expired, so we can just
|
||||
// use that instead of doing auth all over again :)
|
||||
profile = account.profile.clone();
|
||||
minecraft_access_token = account.mca.data.access_token.clone();
|
||||
|
||||
Ok(AuthResult {
|
||||
access_token: account.mca.data.access_token.clone(),
|
||||
profile: account.profile.clone(),
|
||||
})
|
||||
} else {
|
||||
let client = reqwest::Client::new();
|
||||
let mut msa = if let Some(account) = cached_account {
|
||||
|
@ -83,37 +86,20 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError>
|
|||
log::trace!("refreshing Microsoft auth token");
|
||||
msa = refresh_ms_auth_token(&client, &msa.data.refresh_token).await?;
|
||||
}
|
||||
let ms_access_token = &msa.data.access_token;
|
||||
log::trace!("Got access token: {}", ms_access_token);
|
||||
|
||||
let xbl_auth = auth_with_xbox_live(&client, ms_access_token).await?;
|
||||
let msa_token = &msa.data.access_token;
|
||||
log::trace!("Got access token: {msa_token}");
|
||||
|
||||
let xsts_token = obtain_xsts_for_minecraft(
|
||||
&client,
|
||||
&xbl_auth
|
||||
.get()
|
||||
.expect("Xbox Live auth token shouldn't have expired yet")
|
||||
.token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Minecraft auth
|
||||
let mca = auth_with_minecraft(&client, &xbl_auth.data.user_hash, &xsts_token).await?;
|
||||
|
||||
minecraft_access_token = mca
|
||||
.get()
|
||||
.expect("Minecraft auth shouldn't have expired yet")
|
||||
.access_token
|
||||
.to_string();
|
||||
let res = get_minecraft_token(&client, msa_token).await?;
|
||||
|
||||
if opts.check_ownership {
|
||||
let has_game = check_ownership(&client, &minecraft_access_token).await?;
|
||||
let has_game = check_ownership(&client, &res.minecraft_access_token).await?;
|
||||
if !has_game {
|
||||
return Err(AuthError::DoesNotOwnGame);
|
||||
}
|
||||
}
|
||||
|
||||
profile = get_profile(&client, &minecraft_access_token).await?;
|
||||
let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?;
|
||||
|
||||
if let Some(cache_file) = opts.cache_file {
|
||||
if let Err(e) = cache::set_account_in_cache(
|
||||
|
@ -121,9 +107,9 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError>
|
|||
email,
|
||||
CachedAccount {
|
||||
email: email.to_string(),
|
||||
mca,
|
||||
mca: res.mca,
|
||||
msa,
|
||||
xbl: xbl_auth,
|
||||
xbl: res.xbl,
|
||||
profile: profile.clone(),
|
||||
},
|
||||
)
|
||||
|
@ -132,14 +118,59 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError>
|
|||
log::error!("{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthResult {
|
||||
access_token: minecraft_access_token,
|
||||
profile,
|
||||
Ok(AuthResult {
|
||||
access_token: res.minecraft_access_token,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with Minecraft when we already have a Microsoft auth token.
|
||||
///
|
||||
/// Usually you don't need this since [`auth`] will call it for you, but it's
|
||||
/// useful if you want more control over what it does.
|
||||
///
|
||||
/// If you don't have a Microsoft auth token, you can get it from
|
||||
/// [`get_ms_link_code`] and then [`get_ms_auth_token`].
|
||||
pub async fn get_minecraft_token(
|
||||
client: &reqwest::Client,
|
||||
msa: &str,
|
||||
) -> Result<MinecraftTokenResponse, AuthError> {
|
||||
let xbl_auth = auth_with_xbox_live(client, msa).await?;
|
||||
|
||||
let xsts_token = obtain_xsts_for_minecraft(
|
||||
client,
|
||||
&xbl_auth
|
||||
.get()
|
||||
.expect("Xbox Live auth token shouldn't have expired yet")
|
||||
.token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Minecraft auth
|
||||
let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?;
|
||||
|
||||
let minecraft_access_token: String = mca
|
||||
.get()
|
||||
.expect("Minecraft auth shouldn't have expired yet")
|
||||
.access_token
|
||||
.to_string();
|
||||
|
||||
Ok(MinecraftTokenResponse {
|
||||
mca,
|
||||
xbl: xbl_auth,
|
||||
minecraft_access_token,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MinecraftTokenResponse {
|
||||
pub mca: ExpiringValue<MinecraftAuthResponse>,
|
||||
pub xbl: ExpiringValue<XboxLiveAuth>,
|
||||
pub minecraft_access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthResult {
|
||||
pub access_token: String,
|
||||
|
@ -148,64 +179,63 @@ pub struct AuthResult {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
user_code: String,
|
||||
device_code: String,
|
||||
verification_uri: String,
|
||||
expires_in: u64,
|
||||
interval: u64,
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct AccessTokenResponse {
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
scope: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
user_id: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
pub scope: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct XboxLiveAuthResponse {
|
||||
issue_instant: String,
|
||||
not_after: String,
|
||||
token: String,
|
||||
display_claims: HashMap<String, Vec<HashMap<String, String>>>,
|
||||
pub issue_instant: String,
|
||||
pub not_after: String,
|
||||
pub token: String,
|
||||
pub display_claims: HashMap<String, Vec<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
/// Just the important data
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct XboxLiveAuth {
|
||||
token: String,
|
||||
user_hash: String,
|
||||
pub token: String,
|
||||
pub user_hash: String,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MinecraftAuthResponse {
|
||||
username: String,
|
||||
roles: Vec<String>,
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
pub username: String,
|
||||
pub roles: Vec<String>,
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GameOwnershipResponse {
|
||||
items: Vec<GameOwnershipItem>,
|
||||
signature: String,
|
||||
key_id: String,
|
||||
pub items: Vec<GameOwnershipItem>,
|
||||
pub signature: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GameOwnershipItem {
|
||||
name: String,
|
||||
signature: String,
|
||||
pub name: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
@ -227,12 +257,33 @@ pub enum GetMicrosoftAuthTokenError {
|
|||
Timeout,
|
||||
}
|
||||
|
||||
/// Asks the user to go to a webpage and log in with Microsoft.
|
||||
async fn interactive_get_ms_auth_token(
|
||||
/// Get the Microsoft link code that's shown to the user for logging into
|
||||
/// Microsoft.
|
||||
///
|
||||
/// You should call [`get_ms_auth_token`] right after showing the user the
|
||||
/// [`verification_uri`](DeviceCodeResponse::verification_uri) and
|
||||
/// [`user_code`](DeviceCodeResponse::user_code).
|
||||
///
|
||||
/// If showing the link code in the terminal is acceptable, then you can just
|
||||
/// use [`interactive_get_ms_auth_token`] instead.
|
||||
///
|
||||
/// ```
|
||||
/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let res = azalea_auth::get_ms_link_code(&client).await?;
|
||||
/// println!(
|
||||
/// "Go to {} and enter the code {}",
|
||||
/// res.verification_uri, res.user_code
|
||||
/// );
|
||||
/// let msa = azalea_auth::get_ms_auth_token(client, res).await?;
|
||||
/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?;
|
||||
/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn get_ms_link_code(
|
||||
client: &reqwest::Client,
|
||||
email: &str,
|
||||
) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
|
||||
let res = client
|
||||
) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> {
|
||||
Ok(client
|
||||
.post("https://login.live.com/oauth20_connect.srf")
|
||||
.form(&vec![
|
||||
("scope", "service::user.auth.xboxlive.com::MBI_SSL"),
|
||||
|
@ -242,13 +293,17 @@ async fn interactive_get_ms_auth_token(
|
|||
.send()
|
||||
.await?
|
||||
.json::<DeviceCodeResponse>()
|
||||
.await?;
|
||||
log::trace!("Device code response: {:?}", res);
|
||||
println!(
|
||||
"Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m",
|
||||
res.verification_uri, res.user_code, email
|
||||
);
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Wait until the user logged into Microsoft with the given code. You get the
|
||||
/// device code response needed for this function from [`get_ms_link_code`].
|
||||
///
|
||||
/// You should pass the response from this to [`get_minecraft_token`].
|
||||
pub async fn get_ms_auth_token(
|
||||
client: &reqwest::Client,
|
||||
res: DeviceCodeResponse,
|
||||
) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
|
||||
let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in);
|
||||
|
||||
while Instant::now() < login_expires_at {
|
||||
|
@ -285,13 +340,30 @@ async fn interactive_get_ms_auth_token(
|
|||
Err(GetMicrosoftAuthTokenError::Timeout)
|
||||
}
|
||||
|
||||
/// Asks the user to go to a webpage and log in with Microsoft. If you need to
|
||||
/// access the code, then use [`get_ms_link_code`] and then
|
||||
/// [`get_ms_auth_token`] instead.
|
||||
pub async fn interactive_get_ms_auth_token(
|
||||
client: &reqwest::Client,
|
||||
email: &str,
|
||||
) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> {
|
||||
let res = get_ms_link_code(client).await?;
|
||||
log::trace!("Device code response: {:?}", res);
|
||||
println!(
|
||||
"Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m",
|
||||
res.verification_uri, res.user_code, email
|
||||
);
|
||||
|
||||
get_ms_auth_token(client, res).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RefreshMicrosoftAuthTokenError {
|
||||
#[error("Http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
async fn refresh_ms_auth_token(
|
||||
pub async fn refresh_ms_auth_token(
|
||||
client: &reqwest::Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> {
|
||||
|
|
|
@ -58,6 +58,15 @@ impl<T> ExpiringValue<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Clone for ExpiringValue<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
expires_at: self.expires_at,
|
||||
data: self.data.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_entire_cache(cache_file: &Path) -> Result<Vec<CachedAccount>, CacheError> {
|
||||
let mut cache: Vec<CachedAccount> = Vec::new();
|
||||
if cache_file.exists() {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#![doc = include_str!("../README.md")]
|
||||
|
||||
mod auth;
|
||||
mod cache;
|
||||
pub mod cache;
|
||||
pub mod certs;
|
||||
pub mod game_profile;
|
||||
pub mod sessionserver;
|
||||
|
|
|
@ -9,6 +9,7 @@ version = "0.7.0"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11.12", default-features = false }
|
||||
anyhow = "1.0.59"
|
||||
async-trait = "0.1.58"
|
||||
azalea-auth = { path = "../azalea-auth", version = "0.7.0" }
|
||||
|
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
|
||||
use crate::get_mc_dir;
|
||||
use azalea_auth::certs::{Certificates, FetchCertificatesError};
|
||||
use azalea_auth::AccessTokenResponse;
|
||||
use parking_lot::Mutex;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
@ -55,8 +56,15 @@ pub struct Account {
|
|||
/// The parameters that were passed for creating the associated [`Account`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AccountOpts {
|
||||
Offline { username: String },
|
||||
Microsoft { email: String },
|
||||
Offline {
|
||||
username: String,
|
||||
},
|
||||
Microsoft {
|
||||
email: String,
|
||||
},
|
||||
MicrosoftWithAccessToken {
|
||||
msa: Arc<Mutex<azalea_auth::cache::ExpiringValue<AccessTokenResponse>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Account {
|
||||
|
@ -106,27 +114,82 @@ impl Account {
|
|||
})
|
||||
}
|
||||
|
||||
/// This will create an online-mode account through
|
||||
/// [`azalea_auth::get_minecraft_token`] so you can have more control over
|
||||
/// the authentication process (like doing your own caching or
|
||||
/// displaying the Microsoft user code to the user in a different way).
|
||||
///
|
||||
/// Note that this will not refresh the token when it expires.
|
||||
///
|
||||
/// ```
|
||||
/// # use azalea_client::Account;
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = reqwest::Client::new();
|
||||
///
|
||||
/// let res = azalea_auth::get_ms_link_code(&client).await?;
|
||||
/// println!(
|
||||
/// "Go to {} and enter the code {}",
|
||||
/// res.verification_uri, res.user_code
|
||||
/// );
|
||||
/// let msa = azalea_auth::get_ms_auth_token(&client, res).await?;
|
||||
/// Account::with_microsoft_access_token(msa).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn with_microsoft_access_token(
|
||||
mut msa: azalea_auth::cache::ExpiringValue<AccessTokenResponse>,
|
||||
) -> Result<Self, azalea_auth::AuthError> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
if msa.is_expired() {
|
||||
log::trace!("refreshing Microsoft auth token");
|
||||
msa = azalea_auth::refresh_ms_auth_token(&client, &msa.data.refresh_token).await?;
|
||||
}
|
||||
|
||||
let msa_token = &msa.data.access_token;
|
||||
|
||||
let res = azalea_auth::get_minecraft_token(&client, msa_token).await?;
|
||||
|
||||
let profile = azalea_auth::get_profile(&client, &res.minecraft_access_token).await?;
|
||||
|
||||
Ok(Self {
|
||||
username: profile.name,
|
||||
access_token: Some(Arc::new(Mutex::new(res.minecraft_access_token))),
|
||||
uuid: Some(profile.id),
|
||||
account_opts: AccountOpts::MicrosoftWithAccessToken {
|
||||
msa: Arc::new(Mutex::new(msa)),
|
||||
},
|
||||
certs: None,
|
||||
})
|
||||
}
|
||||
/// Refresh the access_token for this account to be valid again.
|
||||
///
|
||||
/// This requires the `auth_opts` field to be set correctly (which is done
|
||||
/// by default if you used the constructor functions). Note that if the
|
||||
/// Account is offline-mode, this function won't do anything.
|
||||
/// Account is offline-mode then this function won't do anything.
|
||||
pub async fn refresh(&self) -> Result<(), azalea_auth::AuthError> {
|
||||
match &self.account_opts {
|
||||
// offline mode doesn't need to refresh so just don't do anything lol
|
||||
AccountOpts::Offline { .. } => Ok(()),
|
||||
AccountOpts::Microsoft { email } => {
|
||||
let new_account = Account::microsoft(email).await?;
|
||||
let access_token = self
|
||||
.access_token
|
||||
.as_ref()
|
||||
.expect("Access token should always be set for Microsoft accounts");
|
||||
let new_access_token = new_account
|
||||
.access_token
|
||||
.expect("Access token should always be set for Microsoft accounts")
|
||||
.lock()
|
||||
.clone();
|
||||
*access_token.lock() = new_access_token;
|
||||
let access_token_mutex = self.access_token.as_ref().unwrap();
|
||||
let new_access_token = new_account.access_token.unwrap().lock().clone();
|
||||
*access_token_mutex.lock() = new_access_token;
|
||||
Ok(())
|
||||
}
|
||||
AccountOpts::MicrosoftWithAccessToken { msa } => {
|
||||
let msa_value = msa.lock().clone();
|
||||
let new_account = Account::with_microsoft_access_token(msa_value).await?;
|
||||
|
||||
let access_token_mutex = self.access_token.as_ref().unwrap();
|
||||
let new_access_token = new_account.access_token.unwrap().lock().clone();
|
||||
|
||||
*access_token_mutex.lock() = new_access_token;
|
||||
let AccountOpts::MicrosoftWithAccessToken { msa: new_msa } =
|
||||
new_account.account_opts else { unreachable!() };
|
||||
*msa.lock() = new_msa.lock().clone();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ pub struct InventoryComponent {
|
|||
/// The item that is currently held by the cursor. `Slot::Empty` if nothing
|
||||
/// is currently being held.
|
||||
///
|
||||
/// This is different from [`Self::hotbar_selected_index`], which is the
|
||||
/// This is different from [`Self::selected_hotbar_slot`], which is the
|
||||
/// item that's selected in the hotbar.
|
||||
pub carried: ItemSlot,
|
||||
/// An identifier used by the server to track client inventory desyncs. This
|
||||
|
|
|
@ -179,6 +179,7 @@ pub fn generate(input: &DeclareMenus) -> TokenStream {
|
|||
/// Get the range of slot indexes that contain the player's hotbar. This may be different for each menu.
|
||||
///
|
||||
/// ```
|
||||
/// # let inventory = azalea_inventory::Menu::Player(azalea_inventory::Player::default());
|
||||
/// let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()];
|
||||
/// ```
|
||||
pub fn hotbar_slots_range(&self) -> RangeInclusive<usize> {
|
||||
|
|
Loading…
Add table
Reference in a new issue