r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。
改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。
同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。
回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。
占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
199 lines
7.0 KiB
Rust
199 lines
7.0 KiB
Rust
//! `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(" 离线:%prog% accounts add offline <玩家名>".to_string());
|
||
human.push(" 微软:%prog% 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");
|
||
}
|
||
}
|