feat(auth): enable offline mode accounts and launch without Microsoft account

This commit is contained in:
katR 2026-10-10 18:23:10 +05:30
parent b979feff0c
commit 1e66c9216c
3 changed files with 98 additions and 25 deletions

View file

@ -383,10 +383,6 @@ impl AuthService {
Err(err) => return Err(err),
};
if account.is_offline() && !self.has_microsoft_account().await {
return Err(AuthError::OfflineRequiresMicrosoft);
}
Ok(account)
}

View file

@ -23,8 +23,13 @@ impl CredentialsStore {
return Ok(Self::default());
}
match polyio::read_json(&path).await {
Ok(store) => Ok(store),
match polyio::read_json::<Self>(&path).await {
Ok(mut store) => {
if store.default_user.is_none() && !store.users.is_empty() {
store.default_user = store.users.keys().copied().next();
}
Ok(store)
}
Err(err) => {
tracing::warn!("failed to read auth file: {err}");
Ok(Self::default())
@ -91,22 +96,22 @@ impl CredentialsStore {
}
fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> {
if !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft);
}
validate_offline_username(&username)?;
if self
if let Some(existing) = self
.users
.values()
.any(|u| u.username.eq_ignore_ascii_case(&username))
.find(|u| u.username.eq_ignore_ascii_case(&username))
.cloned()
{
return Err(AuthError::DuplicateUsername { username });
self.default_user = Some(existing.id);
return Ok(existing);
}
let account = offline_account(username);
self.users.insert(account.id, account.clone());
let id = account.id;
self.users.insert(id, account.clone());
self.default_user = Some(id);
Ok(account)
}
@ -205,10 +210,6 @@ impl CredentialsStore {
return Ok(None);
};
if account.is_offline() && !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft);
}
Ok(Some(account))
}
}
@ -222,3 +223,50 @@ pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool {
if source.is_connect() || source.is_timeout()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offline_account_creation_sets_default() {
let mut store = CredentialsStore {
users: HashMap::new(),
default_user: None,
};
let account = store.insert_offline_account("Player".to_string()).unwrap();
assert_eq!(account.username, "Player");
assert_eq!(store.default_user, Some(account.id));
assert_eq!(store.users.len(), 1);
}
#[test]
fn test_custom_username_switches_default() {
let mut store = CredentialsStore {
users: HashMap::new(),
default_user: None,
};
let p1 = store.insert_offline_account("Player".to_string()).unwrap();
assert_eq!(store.default_user, Some(p1.id));
let p2 = store.insert_offline_account("Steve".to_string()).unwrap();
assert_eq!(store.default_user, Some(p2.id));
assert_eq!(store.users.len(), 2);
}
#[test]
fn test_case_insensitive_username_reuse() {
let mut store = CredentialsStore {
users: HashMap::new(),
default_user: None,
};
let p1 = store.insert_offline_account("Steve".to_string()).unwrap();
let p2 = store.insert_offline_account("steve".to_string()).unwrap();
assert_eq!(p1.id, p2.id);
assert_eq!(store.users.len(), 1);
assert_eq!(store.default_user, Some(p1.id));
}
}

View file

@ -28,13 +28,14 @@ fn offline_uuid_matches_vanilla_algorithm() {
}
#[tokio::test]
async fn offline_account_blocked_without_microsoft() {
async fn offline_account_allowed_without_microsoft() {
isolate_launcher_dir();
let mut store = CredentialsStore::default();
let err = store.add_offline_account("Steve".into()).unwrap_err();
let offline = store.add_offline_account("Steve".into()).unwrap();
assert!(err.to_string().contains("Microsoft"));
assert_eq!(offline.kind, AccountKind::Offline);
assert_eq!(offline.username, "Steve");
}
#[tokio::test]
@ -51,7 +52,7 @@ async fn offline_account_allowed_when_microsoft_exists() {
}
#[tokio::test]
async fn default_offline_fails_when_last_microsoft_removed() {
async fn default_offline_works_when_last_microsoft_removed() {
isolate_launcher_dir();
let mut store = CredentialsStore::default();
@ -60,11 +61,13 @@ async fn default_offline_fails_when_last_microsoft_removed() {
store.users.insert(msa_id, msa);
let offline = store.add_offline_account("Steve".into()).unwrap();
store.default_user = Some(offline.id);
let offline_id = offline.id;
store.default_user = Some(offline_id);
store.users.remove(&msa_id);
let err = store.default_account().await.unwrap_err();
assert!(err.to_string().contains("Microsoft"));
let account = store.default_account().await.unwrap().unwrap();
assert_eq!(account.id, offline_id);
assert_eq!(account.kind, AccountKind::Offline);
}
#[test]
@ -147,3 +150,29 @@ fn offline_accounts_never_need_sign_in() {
assert!(!account.needs_sign_in());
}
#[tokio::test]
async fn offline_account_for_launch_succeeds_without_microsoft() {
isolate_launcher_dir();
let (events, _rx) = oneclient_events::EventBus::channel();
let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default()).expect("net client");
let mut store = CredentialsStore::default();
let offline = store.add_offline_account("Steve".into()).unwrap();
let service = oneclient_auth::AuthService::with_store(store, net, events);
let launched = service
.account_for_launch(offline.id)
.await
.expect("offline account must be allowed for launch without Microsoft account");
assert_eq!(launched.id, offline.id);
assert_eq!(launched.username, "Steve");
assert_eq!(launched.kind, AccountKind::Offline);
let default_launched = service
.default_account_for_launch()
.await
.expect("default offline account must be allowed for launch")
.expect("default account should exist");
assert_eq!(default_launched.id, offline.id);
}