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

+198
View File
@@ -0,0 +1,198 @@
//! `ull accounts list|add|remove`:账户元数据操作,**凭据永不打印**。
//!
//! 设计约束(D9/Q21 + D4 凭据红线):
//! - 只开四条路径:`list` / `add offline <name>` / `add msa` / `remove <name>`;
//! authlib 的密码登录**故意不开 CLI 通道**(密码进 argv 会直接进 shell 历史与
//! `ps` 输出,破坏红线;留给 GUI 输入框);
//! - 输出只经 `AccountMeta`(可公开字段),`Report`/`--json` 里没有任何 token 字段;
//! - device-code 的 `user_code`/URL 打到 **stderr**(不管 `--quiet` 都要打:不打用户没法完成登录)。
use serde_json::json;
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth};
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, AuthJournal};
use crate::cli::{AccountsAddArgs, AccountsAddKind};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{progress_line, Report};
/// `accounts list`:列出账户(名称/轨道/UUID/创建时间)。
///
/// # Errors
///
/// 数据根解析失败(在 `Ctx`)或读盘 IO 失败。
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
let store = open_store(ctx);
let mut human = Vec::new();
if store.is_empty() {
human.push("还没有账户。".to_string());
human.push(" 离线:ull accounts add offline <玩家名>".to_string());
human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string());
} else {
human.push(format!("账户({} 个):", store.accounts().len()));
for meta in store.accounts() {
human.push(format!(
" {} · {} · {}",
meta.display_name(),
meta.kind.label(),
meta.profile_id
));
}
}
for warning in store.warnings() {
human.push(format!("提示: {warning}"));
}
let accounts: Vec<serde_json::Value> = store.accounts().iter().map(account_json).collect();
let data = json!({
"accounts": accounts,
"count": accounts.len(),
"auth_dir": store.paths().root().display().to_string(),
"warnings": store.warnings(),
});
Ok(Report::new(data, human))
}
/// `accounts add offline <name>`。
///
/// # Errors
///
/// 名字不合法、同名账户已存在、落盘失败。
pub fn add(ctx: &Ctx, args: &AccountsAddArgs) -> Result<Report, CliError> {
match args.kind {
AccountsAddKind::Offline => add_offline(ctx, args.name.as_deref().unwrap_or_default()),
AccountsAddKind::Microsoft => add_microsoft(ctx),
}
}
fn add_offline(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let meta = offline_meta(name, now_ms()).map_err(super::map_auth_error)?;
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Offline)
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加离线账户 {}(HMCL 兼容 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts add msa`:device-code 交互式登录。
fn add_microsoft(ctx: &Ctx) -> Result<Report, CliError> {
// 失败日志落**本次命令的实际数据根**(`ctx.layout().logs()`):`MicrosoftAuth::from_env()`
// 默认挂的是便携根(exe 同级 `.ULL/logs`),`--data-dir` / launcher.json 的 standard 模式下
// 那是别人的目录,排障时找不到 auth.jsonl(M38 遗留 finding)。
let auth = MicrosoftAuth::from_env()
.map_err(super::map_auth_error)?
.with_journal(Some(AuthJournal::in_logs_dir(ctx.layout().logs())));
// 设备码要先给用户看,再开始等:core 的 `present` 回调正好卡在这个时间点。
let session = unlucky_core::download::blocking::block_on(auth.device_code_login(|prompt| {
let (headline, code) = prompt.lines();
progress_line(&headline);
progress_line(&code);
}))
.map_err(super::map_auth_error)?;
let meta = AccountMeta::new(
AccountKind::Microsoft,
session.profile_id.clone(),
session.profile_name.clone(),
now_ms(),
);
let mut store = open_store(ctx);
store
.insert(meta.clone(), Credentials::Microsoft(session))
.map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![
format!(
"已添加微软账户 {}(档案 UUID {})",
meta.display_name(),
meta.profile_id
),
format!("账户文件:{}", store.paths().accounts_file().display()),
];
Ok(Report::new(added_json(&meta), human))
}
/// `accounts remove <name>`。
///
/// # Errors
///
/// 找不到该账户、落盘失败。
pub fn remove(ctx: &Ctx, name: &str) -> Result<Report, CliError> {
let mut store = open_store(ctx);
let meta = store.remove(name).map_err(super::map_auth_error)?;
store.save().map_err(super::map_auth_error)?;
let human = vec![format!(
"已删除账户 {}({})",
meta.display_name(),
meta.kind.label()
)];
let data = json!({
"removed": account_json(&meta),
"count": store.accounts().len(),
});
Ok(Report::new(data, human))
}
fn open_store(ctx: &Ctx) -> AccountStore {
AccountStore::open(ctx.auth_data())
}
fn added_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"account": account_json(meta),
"account_id": meta.id.as_str(),
})
}
/// 账户元数据 → JSON(**只含可公开字段**)。
fn account_json(meta: &AccountMeta) -> serde_json::Value {
json!({
"name": meta.display_name(),
"profile_name": meta.profile_name,
"id": meta.id.as_str(),
"kind": meta.kind.as_str(),
"profile_id": meta.profile_id,
"login_name": meta.login_name,
"server_url": meta.server_url,
"created_at_ms": meta.created_at_ms,
})
}
#[cfg(test)]
mod tests {
use super::account_json;
use unlucky_core::auth::account::{AccountKind, AccountMeta};
#[test]
fn account_json_has_no_credential_fields() {
let mut meta = AccountMeta::new(
AccountKind::Microsoft,
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
7,
);
meta.login_name = Some("me@example.com".to_string());
let value = account_json(&meta);
let keys: Vec<&str> = value
.as_object()
.expect("对象")
.keys()
.map(String::as_str)
.collect();
for forbidden in ["access_token", "refresh_token", "token", "password"] {
assert!(!keys.contains(&forbidden), "不该出现 {forbidden}");
}
assert_eq!(value["name"], "Steve");
assert_eq!(value["kind"], "microsoft");
}
}