diff options
| -rw-r--r-- | ozone-cli/src/cli.rs | 24 | ||||
| -rw-r--r-- | ozone-cli/src/main.rs | 192 | ||||
| -rw-r--r-- | ozone/src/auth/types.rs | 81 | ||||
| -rw-r--r-- | ozone/src/launcher.rs | 3 | ||||
| -rw-r--r-- | ozone/src/launcher/constants.rs | 3 |
5 files changed, 284 insertions, 19 deletions
diff --git a/ozone-cli/src/cli.rs b/ozone-cli/src/cli.rs index 6ba23d3..8ed2f33 100644 --- a/ozone-cli/src/cli.rs +++ b/ozone-cli/src/cli.rs @@ -176,11 +176,33 @@ pub struct ProfileSelectArgs { pub gamertag: Option<String>
}
+#[derive(Args, Debug)]
+pub struct ProfileSignInArgs {
+ /// Elect to use an alternate client id to authenticate to Minecraft.
+ ///
+ /// Avoid using this if possible,
+ /// since you will not be able to easily revoke oauth access to the launcher.
+ ///
+ /// Situations when you might need to use this are if your Microsoft account's birthday
+ /// is set to a date fewer than 18 years in the past while not in a family.
+ #[arg(long)]
+ pub use_alt_client_id: bool,
+
+ #[arg(long)]
+ pub no_select: bool
+}
+
#[derive(Subcommand, Debug)]
pub enum AccountCommand {
Select(ProfileSelectArgs),
Forget,
- SignIn
+
+ #[command(name = "signin")]
+ SignIn(ProfileSignInArgs),
+
+ List,
+ Info,
+ Refresh
}
#[derive(Args, Debug)]
diff --git a/ozone-cli/src/main.rs b/ozone-cli/src/main.rs index 62f7155..f033a19 100644 --- a/ozone-cli/src/main.rs +++ b/ozone-cli/src/main.rs @@ -5,22 +5,23 @@ use std::path::Path; use std::process::ExitCode; use log::{error, info, trace, LevelFilter}; use clap::Parser; -use ozone::launcher::{Instance, JavaRuntimeSetting, Launcher, Settings}; +use ozone::launcher::{Instance, JavaRuntimeSetting, Launcher, Settings, ALT_CLIENT_ID, MAIN_CLIENT_ID}; use ozone::launcher::version::{VersionList, VersionResult}; use uuid::Uuid; -use ozone::auth::{Account, AuthenticationDatabase}; +use ozone::auth::{Account, AccountStorage, MsaAccount}; +use ozone::auth::error::AuthErrorKind; use crate::cli::{AccountCommand, Cli, InstanceCommand, ProfileSelectArgs, RootCommand}; -fn find_account<'a>(auth: &'a AuthenticationDatabase, input: &ProfileSelectArgs) -> Result<Vec<&'a Account>, ()> { +fn find_account<'a>(auth: &'a AccountStorage, input: &ProfileSelectArgs) -> Result<Vec<(&'a String, &'a Account)>, ()> { if let Some(uuid) = input.uuid { - Ok(auth.users.iter().filter(|a| match *a { + Ok(auth.iter_accounts().filter(|(_, a)| match *a { Account::Dummy(p) => p.id == uuid, Account::MSA(account) => account.player_profile.as_ref().is_some_and(|p| p.id == uuid) }).collect()) } else if let Some(ref name) = input.name { let name = name.to_ascii_lowercase(); - Ok(auth.users.iter().filter(|a| match *a { + Ok(auth.iter_accounts().filter(|(_, a)| match *a { Account::Dummy(profile) => profile.name.to_ascii_lowercase().starts_with(&name), Account::MSA(account) => account.player_profile.as_ref().is_some_and(|p| p.name.to_ascii_lowercase().starts_with(&name)) @@ -28,12 +29,12 @@ fn find_account<'a>(auth: &'a AuthenticationDatabase, input: &ProfileSelectArgs) } else if let Some(ref gt) = input.gamertag { let gt = gt.to_ascii_lowercase(); - Ok(auth.users.iter().filter(|a| match *a { + Ok(auth.iter_accounts().filter(|(_, a)| match *a { Account::MSA(account) => account.gamertag.as_ref().is_some_and(|g| g.to_ascii_lowercase().starts_with(>)), _ => false }).collect()) } else if let Some(ref xuid) = input.xuid { - Ok(auth.users.iter().filter(|a| match *a { + Ok(auth.iter_accounts().filter(|(_, a)| match *a { Account::MSA(account) => account.xuid.as_ref().is_some_and(|x| x == xuid), _ => false }).collect()) @@ -66,6 +67,45 @@ fn display_instance(instance: &Instance, id: Uuid, home: impl AsRef<Path>, selec } } +fn display_account(account: &Account, selected: bool, verbose: bool) { + let selected = if selected { " (selected)" } else { "" }; + + match *account { + Account::Dummy(ref profile) => { + println!("Dummy account:{selected}"); + println!(" Username: {}", profile.name); + println!(" UUID: {}", profile.id); + if verbose { + println!(" Properties: <{} propert{}>", profile.properties.len(), if profile.properties.len() == 1 { "y" } else { "ies" }); + } + }, + Account::MSA(ref msa_acct) => { + println!("Microsoft account:{selected}"); + + if let Some(ref profile) = msa_acct.player_profile { + println!(" Username: {}", profile.name); + println!(" UUID: {}", profile.id); + + if verbose { + println!(" Properties: <{} propert{}>", profile.properties.len(), if profile.properties.len() == 1 { "y" } else { "ies" }); + } + } else { + println!(" Username: <no profile>"); + println!(" UUID: <no profile>"); + + if verbose { + println!(" Properties: <no profile>"); + } + } + + println!(" Xbox Gamertag: {}", msa_acct.gamertag.as_deref().unwrap_or("<unknown>")); + if verbose { + println!(" XUID: {}", msa_acct.xuid.as_deref().unwrap_or("<unknown>")); + } + } + } +} + async fn main_inner(cli: Cli) -> Result<ExitCode, Box<dyn Error>> { let Some(home) = cli.home.or_else(Launcher::sensible_home) else { error!("Could not choose a launcher home directory. Please choose one with `--home'."); @@ -81,7 +121,7 @@ async fn main_inner(cli: Cli) -> Result<ExitCode, Box<dyn Error>> { let mut first = true; if settings.instances.is_empty() { - eprintln!("There are no instances. Create one with `profile create <name>'."); + eprintln!("There are no instances. Create one with `instance create <name>'."); return Ok(ExitCode::SUCCESS); } @@ -220,17 +260,149 @@ async fn main_inner(cli: Cli) -> Result<ExitCode, Box<dyn Error>> { } }, RootCommand::Account(account_args) => { - // TODO: load auth db and do stuff with it + let accounts_path = home.join("ozone_accounts.json"); + let mut accounts = AccountStorage::load(&accounts_path).await?; match &account_args.command { AccountCommand::Select(args) => { + let Ok(results) = find_account(&accounts, args) else { + return Ok(ExitCode::FAILURE); + }; + + if results.is_empty() { + eprintln!("No account was found."); + return Ok(ExitCode::FAILURE); + } + + if results.len() > 1 { + eprintln!("Ambiguous argument. Found {} accounts:", results.len()); + for (_, account) in results { + eprintln!("- {account}"); + } + return Ok(ExitCode::FAILURE); + } + + let (key, account) = results.into_iter().next().unwrap(); + println!("Selected account: {account}"); + let key = key.clone(); + accounts.set_selected_account(key); + accounts.save(&accounts_path).await?; }, AccountCommand::Forget => { + if accounts.pop_selected_account().is_none() { + eprintln!("No account selected."); + return Ok(ExitCode::FAILURE); + } + + accounts.save(&accounts_path).await?; + }, + AccountCommand::SignIn(args) => { + let (client_id, azure) = if args.use_alt_client_id { + (ALT_CLIENT_ID, false) + } else { + (MAIN_CLIENT_ID, true) + }; + + let client = MsaAccount::create_client(); + + let mut acct = MsaAccount::with_client_id(client_id, azure); + acct.xbl_login_device(&client, |d| async move { + if let Some(uri_complete) = d.verification_uri_complete() { + println!("In a browser, please navigate to the following URL:"); + println!("{}", uri_complete.secret()); + } else { + println!("In a browser, please navigate to the following URL: {}", d.verification_uri()); + println!("Use the following device code: {}", d.user_code().secret()) + } + }).await?; + + println!("Authentication success! Logging in..."); + + match acct.log_in_silent(&client).await { + Ok(_) => (), + Err(e) => match e.kind() { + AuthErrorKind::NotOnXbox => { + eprintln!("This Microsoft account is not on Xbox. Please make sure you are using the correct Microsoft account."); + return Ok(ExitCode::FAILURE); + }, + AuthErrorKind::TooYoung => { + eprintln!("Currently, Microsoft accounts held by minors (under 18 years old) cannot sign into third party applications (such as olauncher) unless they are in a family."); + eprintln!("If you do not wish to configure a family, try running this command with the `--use-alt-client-id' flag."); + return Ok(ExitCode::FAILURE); + }, + AuthErrorKind::NotEntitled => { + eprintln!("Warning: This Microsoft account does not seem to own the game."); + eprintln!("This account will only be able to play the demo version of the game."); + }, + AuthErrorKind::NoProfile => { + eprintln!("Warning: It appears that you own the game but have not yet created a profile."); + eprintln!("Visit https://minecraft.net to choose a name. Until then, you will only be able to play the demo version of the game."); + }, + _ => { + eprintln!("An unknown error occurred while signing into the account:"); + eprintln!("{e}"); + return Ok(ExitCode::FAILURE); + } + } + } + + let key = accounts.add_account(acct.into()).expect("authentication succeeded but xuid missing????"); + if !args.no_select { + accounts.set_selected_account(key); + } + + accounts.save(&accounts_path).await?; + + println!("Success! Account added."); + }, + AccountCommand::List => { + let iter = accounts.iter_accounts(); + if iter.len() == 0 { + eprintln!("There are no accounts."); + return Ok(ExitCode::FAILURE); + } + + let sel_id = accounts.get_selected_account().map(|(id, _)| id); + let mut first = true; + for (id, account) in iter { + if !first { + println!(); + } + + first = false; + display_account(account, sel_id.is_some_and(|s| s == id), false); + } }, - AccountCommand::SignIn => { + AccountCommand::Info => { + let Some((_, account)) = accounts.get_selected_account() else { + eprintln!("No account selected."); + return Ok(ExitCode::FAILURE); + }; + display_account(account, false, true); + }, + AccountCommand::Refresh => { + let Some(account) = accounts.get_selected_account_mut() else { + eprintln!("No account selected."); + return Ok(ExitCode::FAILURE); + }; + + let client = MsaAccount::create_client(); + + match account { + Account::MSA(msa_acct) => { + msa_acct.log_in_silent(&client).await?; + println!("Successfully refreshed account: {}", account); + }, + _ => { + eprintln!("Cannot refresh non-MSA account."); + return Ok(ExitCode::FAILURE); + } + } + + accounts.save(&accounts_path).await?; } } } diff --git a/ozone/src/auth/types.rs b/ozone/src/auth/types.rs index 134c45a..a2ac7da 100644 --- a/ozone/src/auth/types.rs +++ b/ozone/src/auth/types.rs @@ -1,11 +1,16 @@ pub mod property_map; + +use std::collections::HashMap; pub use property_map::PropertyMap; use std::fmt::{Debug, Display, Formatter}; +use std::io::ErrorKind; +use std::path::Path; use chrono::{DateTime, Utc}; use multimap::MultiMap; use oauth2::RefreshToken; use serde::{Deserialize, Serialize}; +use tokio::io; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize)] @@ -18,7 +23,7 @@ pub struct Property { } #[derive(Debug, Serialize, Deserialize)] -#[non_exhaustive] +#[non_exhaustive] // instantiating this struct requires some kind of validation pub struct PlayerProfile { #[serde(with = "uuid::serde::simple")] pub id: Uuid, @@ -157,9 +162,73 @@ pub enum Account { MSA(Box<MsaAccount>) } -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthenticationDatabase { - pub users: Vec<Account> +impl From<MsaAccount> for Account { + fn from(value: MsaAccount) -> Self { + Self::MSA(value.into()) + } +} + +impl Account { + fn get_key(&self) -> Option<String> { + match self { + Account::Dummy(profile) => Some(format!("dummy:{}", profile.id)), + Account::MSA(account) => account.xuid.as_ref().map(|x| format!("msa:xuid:{x}")) + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AccountStorage { + #[serde(default)] + accounts: HashMap<String, Account>, + + #[serde(skip_serializing_if = "Option::is_none")] + selected_account: Option<String> +} + +impl AccountStorage { + pub async fn load(path: impl AsRef<Path>) -> Result<AccountStorage, io::Error> { + let contents = match tokio::fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Default::default()), + Err(e) => return Err(e) + }; + + serde_json::from_str(&contents).map_err(|e| io::Error::new(ErrorKind::InvalidData, e)) + } + + pub async fn save(&self, path: impl AsRef<Path>) -> Result<(), io::Error> { + let contents = serde_json::to_string_pretty(self) // should be infallible but whatever + .map_err(|e| io::Error::new(ErrorKind::InvalidData, e))?; + tokio::fs::write(&path, &contents).await + } + + pub fn add_account(&mut self, account: Account) -> Result<String, ()> { + let key = account.get_key().ok_or(())?; + self.accounts.insert(key.clone(), account); + + Ok(key) + } + + pub fn get_selected_account(&self) -> Option<(&String, &Account)> { + self.selected_account.as_ref().and_then(|a| self.selected_account.as_ref().zip(self.accounts.get(a))) + } + + pub fn get_selected_account_mut(&mut self) -> Option<&mut Account> { + self.selected_account.as_ref().and_then(|a| self.accounts.get_mut(a)) + } + + pub fn pop_selected_account(&mut self) -> Option<Account> { + self.selected_account.as_ref().and_then(|a| self.accounts.remove(a)) + } + + pub fn set_selected_account(&mut self, key: impl Into<String>) { + self.selected_account = Some(key.into()); + } + + pub fn iter_accounts(&self) -> impl ExactSizeIterator<Item = (&String, &Account)> { + self.accounts.iter() + } } impl Display for Account { @@ -167,8 +236,8 @@ impl Display for Account { match self { Account::Dummy(profile) => write!(f, "[dummy] {}", profile.name), Account::MSA(account) => write!(f, "[msa] {} ({})", - account.gamertag.as_deref().unwrap_or("???"), - account.player_profile.as_ref().map(|p| p.name.as_str()).unwrap_or("???")) + account.player_profile.as_ref().map(|p| p.name.as_str()).unwrap_or("???"), + account.gamertag.as_deref().unwrap_or("???")) } } } diff --git a/ozone/src/launcher.rs b/ozone/src/launcher.rs index 8c5db3f..9add08c 100644 --- a/ozone/src/launcher.rs +++ b/ozone/src/launcher.rs @@ -24,7 +24,6 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use futures::{StreamExt, TryStreamExt}; use indexmap::IndexMap; use log::{debug, info, trace, warn}; -use reqwest::Client; use sysinfo::System; use tokio::{fs, io}; use tokio_stream::wrappers::ReadDirStream; @@ -38,12 +37,12 @@ use assets::{AssetError, AssetRepository}; use ozone_helpers::ozone_arch_bits; use crate::util::{self, AsJavaPath}; +pub use constants::{MAIN_CLIENT_ID, ALT_CLIENT_ID}; pub use settings::*; pub use runner::run_the_game; pub use crate::util::{EnsureFileError, FileVerifyError, IntegrityError}; use crate::assets::AssetIndex; use runner::ArgumentType; -use strsub::SubFunc; use crate::launcher::download::FileDownload; use crate::launcher::jre::{JavaRuntimeError, JavaRuntimeRepository}; use crate::launcher::version::VersionError; diff --git a/ozone/src/launcher/constants.rs b/ozone/src/launcher/constants.rs index 78e53f5..189ec82 100644 --- a/ozone/src/launcher/constants.rs +++ b/ozone/src/launcher/constants.rs @@ -9,6 +9,9 @@ pub const NATIVES_PREFIX: &str = "natives-"; pub const DEF_INSTANCE_NAME: &str = "Default"; +pub const MAIN_CLIENT_ID: &str = "60b6cc54-fc07-4bab-bca9-cbe9aa713c80"; // the standard olauncher client ID +pub const ALT_CLIENT_ID: &str = "00000000402b5328"; // regular Minecraft launcher client id + // https://github.com/unmojang/FjordLauncher/pull/14/files // https://login.live.com/oauth20_authorize.srf?client_id=00000000402b5328&redirect_uri=ms-xal-00000000402b5328://auth&response_type=token&display=touch&scope=service::user.auth.xboxlive.com::MBI_SSL%20offline_access&prompt=select_account |
