Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+339
View File
@@ -0,0 +1,339 @@
//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。
//!
//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训:
//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL——
//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名);
//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密);
//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」),
//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。
use serde::{Deserialize, Serialize};
use crate::auth::authlib::AuthlibSession;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::microsoft::MicrosoftSession;
use crate::auth::secret::random_hex32;
/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。
pub const ACCOUNT_ID_PREFIX: &str = "account:";
/// 账户条目 id:`account:<32 位十六进制>`。
///
/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AccountId(String);
impl AccountId {
/// 生成一个新条目 id。
#[must_use]
pub fn generate() -> Self {
Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32()))
}
/// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?;
if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
Some(Self(raw.to_ascii_lowercase()))
}
/// 字符串视图。
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for AccountId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
/// 三条轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AccountKind {
/// 离线(本地生成的 UUID,不联网)。
Offline,
/// 微软(device-code)。
Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
AuthlibInjector,
}
impl AccountKind {
/// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::AuthlibInjector => "authlibInjector",
}
}
/// 解析(未知值返回 `None`,让上层跳过该条记录)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"offline" => Some(Self::Offline),
"microsoft" => Some(Self::Microsoft),
// 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。
"authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector),
_ => None,
}
}
/// 人读标签(CLI/JSON 展示用)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::AuthlibInjector => "authlib-injector",
}
}
}
/// 账户元数据(**可公开**,不含任何凭据)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMeta {
/// 条目 id。
pub id: AccountId,
/// 轨道。
pub kind: AccountKind,
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
pub profile_id: String,
/// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。
#[serde(default)]
pub profile_name: String,
/// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login_name: Option<String>,
/// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_url: Option<String>,
/// 创建时刻(Unix 毫秒;仅用于排序/展示)。
pub created_at_ms: u64,
}
impl AccountMeta {
/// 构造一条元数据(`profile_name` 可后补)。
#[must_use]
pub fn new(
kind: AccountKind,
profile_id: impl Into<String>,
profile_name: impl Into<String>,
created_at_ms: u64,
) -> Self {
Self {
id: AccountId::generate(),
kind,
profile_id: profile_id.into(),
profile_name: profile_name.into(),
login_name: None,
server_url: None,
created_at_ms,
}
}
/// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。
#[must_use]
pub fn display_name(&self) -> &str {
if !self.profile_name.trim().is_empty() {
return &self.profile_name;
}
if let Some(login_name) = &self.login_name {
if !login_name.trim().is_empty() {
return login_name;
}
}
self.id.as_str()
}
/// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。
///
/// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。
#[must_use]
pub fn matches(&self, key: &str) -> bool {
let key = key.trim();
if key.is_empty() {
return false;
}
self.profile_name.eq_ignore_ascii_case(key)
|| self
.login_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(key))
|| self.id.as_str().eq_ignore_ascii_case(key)
}
/// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。
///
/// # Errors
///
/// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。
pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> {
if credentials.kind() == self.kind {
return Ok(());
}
Err(AuthError::Malformed(format!(
"账户 {} 的元数据轨道是 {},凭据却是 {}",
self.id,
self.kind.as_str(),
credentials.kind().as_str()
)))
}
/// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。
///
/// # Errors
///
/// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。
pub fn from_value(value: &serde_json::Value) -> AuthResult<Self> {
serde_json::from_value(value.clone())
.map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}")))
}
}
/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Credentials {
/// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。
Offline,
/// 微软会话(access + refresh + `notAfter`)。
Microsoft(MicrosoftSession),
/// authlib 会话(accessToken + clientToken)。
AuthlibInjector(AuthlibSession),
}
impl Credentials {
/// 所属轨道。
#[must_use]
pub const fn kind(&self) -> AccountKind {
match self {
Self::Offline => AccountKind::Offline,
Self::Microsoft(_) => AccountKind::Microsoft,
Self::AuthlibInjector(_) => AccountKind::AuthlibInjector,
}
}
/// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。
#[must_use]
pub fn secrets(&self) -> Vec<&str> {
match self {
Self::Offline => Vec::new(),
Self::Microsoft(session) => session
.secrets()
.into_iter()
.map(|secret| secret.expose())
.collect(),
Self::AuthlibInjector(session) => session
.secrets()
.into_iter()
.map(|secret| secret.expose())
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::{AccountId, AccountKind, AccountMeta, Credentials};
use crate::auth::microsoft::MicrosoftSession;
use crate::auth::secret::Secret;
#[test]
fn account_id_shape_and_parse() {
let id = AccountId::generate();
assert!(id.as_str().starts_with("account:"));
assert_eq!(id.as_str().len(), "account:".len() + 32);
assert_eq!(AccountId::parse(id.as_str()), Some(id.clone()));
assert_eq!(AccountId::parse("account:xyz"), None);
assert_eq!(AccountId::parse("0192-blah"), None);
}
#[test]
fn kind_roundtrip() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::AuthlibInjector,
] {
assert_eq!(AccountKind::parse(kind.as_str()), Some(kind));
assert!(!kind.label().is_empty());
}
assert_eq!(AccountKind::parse("nope"), None);
}
#[test]
fn meta_matches_name_login_and_id() {
let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1);
meta.login_name = Some("steve@example.com".to_string());
assert!(meta.matches("Steve"));
assert!(meta.matches("steve"));
assert!(meta.matches("steve@example.com"));
assert!(meta.matches(meta.id.as_str()));
assert!(!meta.matches("Alex"));
assert!(!meta.matches(" "));
}
#[test]
fn display_name_falls_back() {
let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1);
assert_eq!(meta.display_name(), meta.id.as_str());
meta.login_name = Some("me@example.com".to_string());
assert_eq!(meta.display_name(), "me@example.com");
meta.profile_name = "Steve".to_string();
assert_eq!(meta.display_name(), "Steve");
}
#[test]
fn credential_kind_must_match_meta() {
let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1);
let session = MicrosoftSession::new(
"uuid",
"Steve",
Secret::new("a"),
Secret::new("r"),
60,
0,
None,
);
assert!(meta
.check_credentials(&Credentials::Microsoft(session))
.is_ok());
assert!(meta.check_credentials(&Credentials::Offline).is_err());
}
#[test]
fn secrets_only_expose_real_credentials() {
assert!(Credentials::Offline.secrets().is_empty());
let session = MicrosoftSession::new(
"uuid",
"Steve",
Secret::new("mc-token"),
Secret::new("live-refresh"),
60,
0,
None,
);
assert_eq!(
Credentials::Microsoft(session).secrets(),
vec!["mc-token", "live-refresh"]
);
}
#[test]
fn malformed_record_is_an_error_not_a_panic() {
let value = serde_json::json!({ "id": "account:zz", "kind": "offline" });
assert!(AccountMeta::from_value(&value).is_err());
}
}