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), Err(err) => return Err(err),
}; };
if account.is_offline() && !self.has_microsoft_account().await {
return Err(AuthError::OfflineRequiresMicrosoft);
}
Ok(account) Ok(account)
} }

View file

@ -23,8 +23,13 @@ impl CredentialsStore {
return Ok(Self::default()); return Ok(Self::default());
} }
match polyio::read_json(&path).await { match polyio::read_json::<Self>(&path).await {
Ok(store) => Ok(store), 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) => { Err(err) => {
tracing::warn!("failed to read auth file: {err}"); tracing::warn!("failed to read auth file: {err}");
Ok(Self::default()) Ok(Self::default())
@ -91,22 +96,22 @@ impl CredentialsStore {
} }
fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> { fn insert_offline_account(&mut self, username: String) -> AuthResult<MinecraftAccount> {
if !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft);
}
validate_offline_username(&username)?; validate_offline_username(&username)?;
if self if let Some(existing) = self
.users .users
.values() .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); 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) Ok(account)
} }
@ -205,10 +210,6 @@ impl CredentialsStore {
return Ok(None); return Ok(None);
}; };
if account.is_offline() && !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft);
}
Ok(Some(account)) Ok(Some(account))
} }
} }
@ -222,3 +223,50 @@ pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool {
if source.is_connect() || source.is_timeout() 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] #[tokio::test]
async fn offline_account_blocked_without_microsoft() { async fn offline_account_allowed_without_microsoft() {
isolate_launcher_dir(); isolate_launcher_dir();
let mut store = CredentialsStore::default(); 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] #[tokio::test]
@ -51,7 +52,7 @@ async fn offline_account_allowed_when_microsoft_exists() {
} }
#[tokio::test] #[tokio::test]
async fn default_offline_fails_when_last_microsoft_removed() { async fn default_offline_works_when_last_microsoft_removed() {
isolate_launcher_dir(); isolate_launcher_dir();
let mut store = CredentialsStore::default(); let mut store = CredentialsStore::default();
@ -60,11 +61,13 @@ async fn default_offline_fails_when_last_microsoft_removed() {
store.users.insert(msa_id, msa); store.users.insert(msa_id, msa);
let offline = store.add_offline_account("Steve".into()).unwrap(); 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); store.users.remove(&msa_id);
let err = store.default_account().await.unwrap_err(); let account = store.default_account().await.unwrap().unwrap();
assert!(err.to_string().contains("Microsoft")); assert_eq!(account.id, offline_id);
assert_eq!(account.kind, AccountKind::Offline);
} }
#[test] #[test]
@ -147,3 +150,29 @@ fn offline_accounts_never_need_sign_in() {
assert!(!account.needs_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);
}