Unlucky Launcher v0.2.1
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s

Single-commit source snapshot of the v0.2.1 release.
This commit is contained in:
AskaEth committed 2026-11-06 12:35:56 +08:00
commit d517f28140
1092 files changed
+211068

No files matched your search

+216
View File
@@ -0,0 +1,216 @@
//! `ull launch` 的账户选择:三轨账户 → [`AuthPlaceholders`](唯一的身份出口)。
//!
//! 选择规则(headless 没有交互,必须确定):
//! - `--account <name>`:按玩家名 / 登录名 / 条目 id 精确找(大小写不敏感);
//! - 不给 `--account`:**唯一账户自动选中**;零账户回落离线占位(`--username` 或 `Player`,
//! 与 M21 的行为一致,保证没有账户时 `ull launch` 照旧能跑);多账户报错并列出候选
//! (猜一个等于让用户拿错身份进服务器,宁可让他选)。
//!
//! 令牌策略:`--dry-run` **不刷新、不写盘**(M30 的 dry-run 纯读语义),只对过期状态给告警;
//! 真启动前对微软轨做一次 `ensure_fresh`(静默续期),刷新成功立刻回写凭据
//! (refresh_token 轮换后旧值就作废了,不能丢)。
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::MicrosoftAuth;
use unlucky_core::auth::offline::offline_login;
use unlucky_core::auth::AccountStore;
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::ProfileId;
use unlucky_core::launch::request::AuthPlaceholders;
use crate::context::Ctx;
use crate::error::CliError;
/// 解析结果。
pub struct Identity {
/// 喂给 launch 的占位符集合。
pub placeholders: AuthPlaceholders,
/// 选中的账户(`None` = 离线占位,没有账户)。
pub account: Option<AccountMeta>,
/// 需要展示给用户的说明(令牌过期、authlib 注入未落地等)。
pub notes: Vec<String>,
}
/// 解析本次启动的身份。
///
/// # Errors
///
/// 账户不存在/重名冲突/多账户未指定/凭据过期且刷新失败/凭据文件缺条目。
pub fn resolve(
ctx: &Ctx,
account: Option<&str>,
username: Option<&str>,
dry_run: bool,
) -> Result<Identity, CliError> {
let store = AccountStore::open(ctx.auth_data());
let chosen: Option<AccountMeta> = match account {
Some(key) => Some(
store
.find(key)
.ok_or_else(|| unknown_account(key, &store))?
.clone(),
),
None => match store.accounts() {
[] => None,
[only] => Some(only.clone()),
many => {
return Err(CliError::user(
"account_ambiguous",
format!("有 {} 个账户,需要指定用哪个", many.len()),
)
.with_hint(format!(
"%prog% launch <instance> --account <名字>;候选:{}",
names_of(&store)
)))
}
},
};
let Some(meta) = chosen else {
// 零账户:回落离线占位(保留 M21 行为)。
return Ok(offline_fallback(username));
};
if username.is_some() {
return Err(CliError::usage(format!(
"已选中账户 {},`--username` 只用于没有账户时的离线占位",
meta.display_name()
))
.with_hint("去掉 --username,或用 `%prog% accounts add offline <名字>` 建账户"));
}
let mut notes = Vec::new();
let placeholders = match meta.kind {
AccountKind::Offline => offline_login(&meta).map_err(super::map_auth_error)?,
AccountKind::Microsoft => microsoft_identity(ctx, &store, &meta, dry_run, &mut notes)?,
AccountKind::AuthlibInjector => authlib_identity(&store, &meta, &mut notes)?,
};
Ok(Identity {
placeholders,
account: Some(meta),
notes,
})
}
/// 零账户时的离线占位(uuid 随机,userType 用 HMCL 的 `legacy` 占位口径不变)。
fn offline_fallback(username: Option<&str>) -> Identity {
Identity {
placeholders: AuthPlaceholders {
player_name: username.unwrap_or("Player").to_string(),
uuid: ProfileId::generate().as_str().replace('-', ""),
user_type: "legacy".to_string(),
user_properties: "{}".to_string(),
..AuthPlaceholders::default()
},
account: None,
notes: vec![
"没有账户,按离线占位启动(%prog% accounts add offline <名字> 可建账户)".to_string(),
],
}
}
fn microsoft_identity(
ctx: &Ctx,
store: &AccountStore,
meta: &AccountMeta,
dry_run: bool,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::Microsoft(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()),
)
.with_hint("重新登录:%prog% accounts add msa"));
};
if dry_run {
// dry-run 不刷新:令牌过期只提示,命令行里的 token 反正会被脱敏。
if session.is_expired(unlucky_core::auth::microsoft::now_ms()) {
notes.push("微软令牌已过期(--dry-run 不刷新,真启动会自动续期)".to_string());
}
return session.to_placeholders().map_err(super::map_auth_error);
}
let auth = MicrosoftAuth::from_env().map_err(super::map_auth_error)?;
let (refreshed, reason) =
block_on(auth.ensure_fresh(session)).map_err(super::map_auth_error)?;
if let Some(reason) = reason {
notes.push(format!("{}(已静默续期)", reason.message()));
persist_refresh(ctx, meta, &refreshed)?;
}
refreshed.to_placeholders().map_err(super::map_auth_error)
}
/// 刷新成功后立刻回写凭据(refresh_token 已轮换,旧值作废)。
fn persist_refresh(
ctx: &Ctx,
meta: &AccountMeta,
session: &unlucky_core::auth::MicrosoftSession,
) -> Result<(), CliError> {
let mut store = AccountStore::open(ctx.auth_data());
store
.update_credentials(&meta.id, Credentials::Microsoft(session.clone()))
.map_err(super::map_auth_error)?;
let mut updated = meta.clone();
// profile_name 属于可变的私有缓存,刷新后顺手把元数据里的展示名补上。
if !session.profile_name.trim().is_empty() {
updated.profile_name = session.profile_name.clone();
store.update_meta(updated).map_err(super::map_auth_error)?;
}
store.save().map_err(super::map_auth_error)
}
fn authlib_identity(
store: &AccountStore,
meta: &AccountMeta,
notes: &mut Vec<String>,
) -> Result<AuthPlaceholders, CliError> {
let Some(Credentials::AuthlibInjector(session)) = store.credentials(&meta.id) else {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 的 authlib 凭据缺失或类型不符", meta.display_name()),
));
};
if !session.has_profile() {
return Err(CliError::user(
"account_credentials",
format!("账户 {} 还没有选中角色", meta.display_name()),
)
.with_hint("多角色服务器要在图形界面里选角色"));
}
notes.push("authlib-injector 的 -javaagent 参数属后续 mission,本次启动不会注入".to_string());
session.to_placeholders().map_err(super::map_auth_error)
}
fn names_of(store: &AccountStore) -> String {
store
.accounts()
.iter()
.map(|meta| meta.display_name().to_string())
.collect::<Vec<_>>()
.join("、")
}
fn unknown_account(key: &str, store: &AccountStore) -> CliError {
let hint = if store.is_empty() {
"还没有账户:%prog% accounts add offline <名字> 或 %prog% accounts add msa".to_string()
} else {
format!("可用账户:{}", names_of(store))
};
CliError::user("not_found", format!("找不到账户 `{key}`")).with_hint(hint)
}
#[cfg(test)]
mod tests {
use super::offline_fallback;
#[test]
fn fallback_uses_username_or_player() {
let identity = offline_fallback(Some("Alex"));
assert_eq!(identity.placeholders.player_name, "Alex");
assert!(identity.account.is_none());
assert_eq!(identity.placeholders.user_type, "legacy");
assert_eq!(identity.placeholders.uuid.len(), 32);
assert_eq!(offline_fallback(None).placeholders.player_name, "Player");
}
}