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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。
|
||||
//!
|
||||
//! 两个子命令:
|
||||
//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1);
|
||||
//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。
|
||||
//!
|
||||
//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下
|
||||
//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner
|
||||
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
|
||||
|
||||
pub mod ports;
|
||||
pub mod serve;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_agent::mcp::config_samples;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::AgentServeArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// `ull agent serve`:起服务(阻塞)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 见 [`serve::run`]。
|
||||
pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
|
||||
serve::run(ctx, args)
|
||||
}
|
||||
|
||||
/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。
|
||||
///
|
||||
/// # Errors
|
||||
/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。
|
||||
pub fn run_serve_with(
|
||||
globals: &crate::cli::Globals,
|
||||
args: &AgentServeArgs,
|
||||
err: &mut dyn std::io::Write,
|
||||
) -> Result<i32, CliError> {
|
||||
let ctx = Ctx::resolve(globals)?;
|
||||
ctx.ensure_supported()?;
|
||||
for note in ctx.notes() {
|
||||
let _ = writeln!(err, "提示: {note}");
|
||||
}
|
||||
run_serve(&ctx, args)
|
||||
}
|
||||
|
||||
/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。
|
||||
///
|
||||
/// # Errors
|
||||
/// 配置不可读时返回用户错误(schema 不认识的键不影响)。
|
||||
pub fn config_report(ctx: &Ctx, port: Option<u16>) -> Result<Report, CliError> {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let doc = store.doc();
|
||||
let enabled = doc
|
||||
.effective("agent.mcp.enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let allow_write = doc
|
||||
.effective("agent.mcp.allow_write")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let yolo = doc
|
||||
.effective("ai.yolo")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let token = doc
|
||||
.effective_str("agent.mcp.token")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string);
|
||||
let configured_port = doc
|
||||
.effective("agent.mcp.port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(47823);
|
||||
let port = port.unwrap_or(configured_port);
|
||||
let url = format!("http://127.0.0.1:{port}/mcp");
|
||||
let samples = config_samples(&url, token.as_deref());
|
||||
let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx))
|
||||
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?;
|
||||
let read_only_tools = registry.read_only_definitions().len();
|
||||
let all_tools = registry.len();
|
||||
|
||||
let data = json!({
|
||||
"url": url,
|
||||
"enabled": enabled,
|
||||
"allow_write": allow_write,
|
||||
"yolo": yolo,
|
||||
"token_configured": token.is_some(),
|
||||
"read_only_tools": read_only_tools,
|
||||
"all_tools": all_tools,
|
||||
"claude_code": samples.claude_code,
|
||||
"cursor": samples.cursor,
|
||||
});
|
||||
|
||||
let mut human = vec![
|
||||
format!("MCP 端点:{url}"),
|
||||
format!(
|
||||
"开关:{} · 写权限:{} · YOLO:{} · 令牌:{}",
|
||||
if enabled {
|
||||
"已启用"
|
||||
} else {
|
||||
"未启用(serve 仍可手动起)"
|
||||
},
|
||||
if allow_write {
|
||||
"开"
|
||||
} else {
|
||||
"关(只读)"
|
||||
},
|
||||
if yolo { "开" } else { "关" },
|
||||
if token.is_some() {
|
||||
"已设置"
|
||||
} else {
|
||||
"未设置"
|
||||
}
|
||||
),
|
||||
"Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(),
|
||||
];
|
||||
human.extend(pretty_lines(&samples.claude_code));
|
||||
human.push("Cursor(.cursor/mcp.json):".to_string());
|
||||
human.extend(pretty_lines(&samples.cursor));
|
||||
human.push(
|
||||
"启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(),
|
||||
);
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
fn pretty_lines(value: &Value) -> Vec<String> {
|
||||
match serde_json::to_string_pretty(value) {
|
||||
Ok(text) => text.lines().map(|line| format!(" {line}")).collect(),
|
||||
Err(_) => vec![" (序列化失败)".to_string()],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。
|
||||
//!
|
||||
//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接
|
||||
//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉:
|
||||
//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的
|
||||
//! 单测会先炸(序列化结果里不许出现 token/password/secret)。
|
||||
|
||||
use unlucky_agent::port::game::{AccountPort, AccountSummary};
|
||||
use unlucky_agent::port::PortResult;
|
||||
use unlucky_core::auth::AccountStore;
|
||||
|
||||
/// M31 账户库的只读端口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreAccounts {
|
||||
root: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl CoreAccounts {
|
||||
/// `root` = `<数据根>/.ULL/data/auth`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountPort for CoreAccounts {
|
||||
fn list_accounts(&self) -> PortResult<Vec<AccountSummary>> {
|
||||
let store = AccountStore::open(self.root.clone());
|
||||
if !store.warnings().is_empty() {
|
||||
// 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。
|
||||
eprintln!(
|
||||
"[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)",
|
||||
store.warnings().len()
|
||||
);
|
||||
}
|
||||
Ok(store
|
||||
.accounts()
|
||||
.iter()
|
||||
.map(|meta| AccountSummary {
|
||||
id: meta.id.as_str().to_string(),
|
||||
kind: meta.kind.as_str().to_string(),
|
||||
profile_id: meta.profile_id.clone(),
|
||||
profile_name: meta.profile_name.clone(),
|
||||
login_name: meta.login_name.clone(),
|
||||
created_at_ms: meta.created_at_ms,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
|
||||
//!
|
||||
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
|
||||
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_agent::port::{
|
||||
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
||||
PortResult,
|
||||
};
|
||||
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
|
||||
use unlucky_core::diagnose::loader::RuleLoader;
|
||||
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
|
||||
|
||||
use super::events::{tail_join, SessionSource};
|
||||
|
||||
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
|
||||
pub const DEFAULT_TAIL: usize = 2000;
|
||||
|
||||
/// M15 规则引擎的端口实现。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreDiagnose {
|
||||
source: SessionSource,
|
||||
tail: usize,
|
||||
}
|
||||
|
||||
impl CoreDiagnose {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
source: SessionSource::new(root),
|
||||
tail: DEFAULT_TAIL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_kind(kind: CoreFixKind) -> FixActionKind {
|
||||
match kind {
|
||||
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
|
||||
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
|
||||
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
|
||||
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
|
||||
CoreFixKind::Reinstall => FixActionKind::Reinstall,
|
||||
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
|
||||
let core_candidate = finding.to_candidate();
|
||||
DiagnosisCandidate {
|
||||
id: core_candidate.id,
|
||||
severity: core_candidate.severity.to_string(),
|
||||
category: core_candidate.category.to_string(),
|
||||
confidence: core_candidate.confidence,
|
||||
message_key: core_candidate.message_key,
|
||||
message: core_candidate.message,
|
||||
detail: core_candidate.detail.unwrap_or_default(),
|
||||
evidence: core_candidate.evidence,
|
||||
fix_actions: core_candidate
|
||||
.fix_actions
|
||||
.into_iter()
|
||||
.map(|action| FixAction {
|
||||
kind: map_kind(action.kind),
|
||||
tool: action.tool.to_string(),
|
||||
label: String::new(),
|
||||
args: action
|
||||
.args
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<String, String>>(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosePort for CoreDiagnose {
|
||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
||||
let entry = self.source.find(session_id)?;
|
||||
let events = self.source.events(&entry)?;
|
||||
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
|
||||
|
||||
let exit = ExitSignal::classify(
|
||||
entry.exit_code,
|
||||
Some(entry.duration_ms),
|
||||
error_lines,
|
||||
&stderr.join("\n"),
|
||||
);
|
||||
let mut input = DiagnosisInput::new().with_exit(exit);
|
||||
let stdout_text = tail_join(&stdout, self.tail);
|
||||
if !stdout_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stdout, stdout_text);
|
||||
}
|
||||
let stderr_text = tail_join(&stderr, self.tail);
|
||||
if !stderr_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stderr, stderr_text);
|
||||
}
|
||||
|
||||
let loader = RuleLoader::embedded();
|
||||
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
|
||||
let report = diagnoser.analyze(&input);
|
||||
|
||||
Ok(CrashDiagnosis {
|
||||
session_id: entry.session_id.clone(),
|
||||
source: "rules".to_string(),
|
||||
needs_more_info: report.needs_more_info(),
|
||||
candidates: report.findings.iter().map(candidate_of).collect(),
|
||||
secondary: report.secondary.iter().map(candidate_of).collect(),
|
||||
evidence: stderr.iter().rev().take(3).cloned().collect(),
|
||||
exit: ExitSummary {
|
||||
code: entry.exit_code,
|
||||
exit_type: entry.exit_type.clone(),
|
||||
silent_failure: entry.silent_failure,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn seed(root: &std::path::Path, stderr_line: &str) {
|
||||
let game = root.join("game");
|
||||
std::fs::create_dir_all(&game).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-29T12:45:01.123Z",
|
||||
"ended_at": "2026-10-29T12:45:05.123Z",
|
||||
"duration_ms": 4000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": false,
|
||||
"event_count": 1,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"crash_found": true,
|
||||
"diagnosis_top": null
|
||||
});
|
||||
std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
let event = json!({
|
||||
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
|
||||
"kind": "stderr",
|
||||
"payload": { "line": stderr_line, "level": "error" }
|
||||
});
|
||||
std::fs::write(
|
||||
game.join("20261029T124501-9f3c2a.jsonl"),
|
||||
format!("{event}\n"),
|
||||
)
|
||||
.expect("写事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_the_rule_engine_over_a_session() {
|
||||
let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
seed(
|
||||
&root,
|
||||
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher",
|
||||
);
|
||||
|
||||
let port = CoreDiagnose::new(root.clone());
|
||||
let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断");
|
||||
assert_eq!(report.session_id, "20261029T124501-9f3c2a");
|
||||
assert_eq!(report.source, "rules");
|
||||
assert_eq!(report.exit.code, Some(1));
|
||||
// 规则集里应有 JVM/完整性类结论命中这条 stderr
|
||||
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_session_is_a_readable_error() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id()));
|
||||
let port = CoreDiagnose::new(root);
|
||||
let err = port.diagnose("nope").unwrap_err();
|
||||
assert!(err.contains("找不到会话"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`<id>.jsonl`)。
|
||||
//!
|
||||
//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`]
|
||||
//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍,
|
||||
//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。
|
||||
//!
|
||||
//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
|
||||
use unlucky_agent::port::PortResult;
|
||||
|
||||
/// 一个数据根下的会话索引 + 事件流。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionSource {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// 索引文件路径。
|
||||
#[must_use]
|
||||
pub fn index(&self) -> PathBuf {
|
||||
session_index::index_path(&self.root)
|
||||
}
|
||||
|
||||
/// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。
|
||||
pub fn entries(&self) -> PortResult<Vec<SessionIndexEntry>> {
|
||||
let index = self.index();
|
||||
let mut entries = session_index::read_all(&index)
|
||||
.map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?;
|
||||
entries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 按完整 id 或唯一前缀找会话。
|
||||
///
|
||||
/// # Errors
|
||||
/// 找不到 / 前缀歧义时返回用户可读错误。
|
||||
pub fn find(&self, key: &str) -> PortResult<SessionIndexEntry> {
|
||||
let entries = self.entries()?;
|
||||
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
let matches: Vec<&SessionIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.session_id.starts_with(key))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[only] => Ok((*only).clone()),
|
||||
[] => Err(format!(
|
||||
"找不到会话 `{key}`(索引:{})",
|
||||
self.index().display()
|
||||
)),
|
||||
_ => Err(format!(
|
||||
"`{key}` 匹配到 {} 个会话,请给更长的前缀",
|
||||
matches.len()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 事件流文件读不了时返回用户可读错误。
|
||||
pub fn events(&self, entry: &SessionIndexEntry) -> PortResult<Vec<Value>> {
|
||||
let path = self.event_path(entry);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?;
|
||||
Ok(text
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 事件流文件路径。
|
||||
#[must_use]
|
||||
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
|
||||
self.root.join("game").join(&entry.log_path)
|
||||
}
|
||||
|
||||
/// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。
|
||||
///
|
||||
/// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。
|
||||
#[must_use]
|
||||
pub fn split_text(events: &[Value]) -> (Vec<String>, Vec<String>, u32) {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let mut error_lines = 0u32;
|
||||
for event in events {
|
||||
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
match event.get("kind").and_then(Value::as_str) {
|
||||
Some("stdout") => stdout.push(line.to_string()),
|
||||
Some("stderr") => {
|
||||
stderr.push(line.to_string());
|
||||
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
|
||||
error_lines += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(stdout, stderr, error_lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// 取末尾 `tail` 行并拼成文本(诊断输入用)。
|
||||
#[must_use]
|
||||
pub fn tail_join(lines: &[String], tail: usize) -> String {
|
||||
let start = lines.len().saturating_sub(tail.max(1));
|
||||
let mut text = lines[start..].join("\n");
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// 索引里有没有这个会话(存在性判断,不读事件流)。
|
||||
#[must_use]
|
||||
pub fn index_exists(root: &Path) -> bool {
|
||||
session_index::index_path(root).is_file()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 实例 / 版本 / Java 端口(M14 + M33)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_agent::port::game::{
|
||||
GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary,
|
||||
};
|
||||
use unlucky_agent::port::PortResult;
|
||||
use unlucky_core::game::java::{self, JavaOrigin};
|
||||
use unlucky_core::game::{install::instance::read_profiles, GameLayout};
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::{Platform, ProcessEnv};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
/// 实例 / 版本 / Java 的只读端口。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreGame {
|
||||
layout: LauncherLayout,
|
||||
game: GameLayout,
|
||||
launcher_config: PathBuf,
|
||||
mode: DataMode,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
impl CoreGame {
|
||||
/// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
layout: LauncherLayout,
|
||||
game: GameLayout,
|
||||
mode: DataMode,
|
||||
platform: Platform,
|
||||
) -> Self {
|
||||
let launcher_config = layout.launcher_config();
|
||||
Self {
|
||||
layout,
|
||||
game,
|
||||
launcher_config,
|
||||
mode,
|
||||
platform,
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> SettingStore {
|
||||
SettingStore::load(&self.launcher_config, launcher_schema())
|
||||
}
|
||||
|
||||
fn managed_roots(&self) -> Vec<PathBuf> {
|
||||
let store = self.store();
|
||||
let install_root = store
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from);
|
||||
java::location::scan_roots(
|
||||
self.mode,
|
||||
&self.layout,
|
||||
install_root.as_deref(),
|
||||
self.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl GamePort for CoreGame {
|
||||
fn list_instances(&self) -> PortResult<Vec<InstanceSummary>> {
|
||||
let profiles =
|
||||
read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
|
||||
let mut out: Vec<InstanceSummary> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| InstanceSummary {
|
||||
id: profile.id.as_str().to_string(),
|
||||
name: profile.name.clone(),
|
||||
version_id: profile.version_id.clone(),
|
||||
version_present: self.game.is_version_installed(&profile.version_id),
|
||||
game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()),
|
||||
java_path: profile.java_path.as_ref().map(|p| p.display().to_string()),
|
||||
last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms),
|
||||
})
|
||||
.collect();
|
||||
// `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。
|
||||
out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list_versions(&self) -> PortResult<Vec<VersionSummary>> {
|
||||
let versions = self
|
||||
.game
|
||||
.installed_versions()
|
||||
.map_err(|error| format!("读已安装版本失败:{error}"))?;
|
||||
let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?;
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let instance_count = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.version_id == id)
|
||||
.count();
|
||||
VersionSummary {
|
||||
id,
|
||||
kind: None,
|
||||
instance_count,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn java_status(&self) -> PortResult<JavaStatus> {
|
||||
let roots = self.managed_roots();
|
||||
let mut candidates = java::candidates_in_roots(&roots);
|
||||
if let Ok(profiles) = read_profiles(&self.layout) {
|
||||
for profile in &profiles.profiles {
|
||||
if let Some(path) = &profile.java_path {
|
||||
candidates.push(java::probe(path, JavaOrigin::Instance));
|
||||
}
|
||||
}
|
||||
}
|
||||
let candidates = java::discover::dedupe(candidates);
|
||||
let summary: Vec<JavaRuntimeSummary> = candidates
|
||||
.iter()
|
||||
.map(|runtime| JavaRuntimeSummary {
|
||||
binary: runtime.binary.display().to_string(),
|
||||
version: runtime.version.clone(),
|
||||
major: runtime.major,
|
||||
vendor: runtime.vendor.clone(),
|
||||
origin: runtime.origin.category().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default_memory = self
|
||||
.store()
|
||||
.effective("java.default_memory_mb")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
|
||||
Ok(JavaStatus {
|
||||
default_memory_mb: default_memory,
|
||||
managed_roots: roots
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect(),
|
||||
candidates: summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。
|
||||
///
|
||||
/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库:
|
||||
/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。
|
||||
fn parse_rfc3339_ms(text: &str) -> Option<u64> {
|
||||
let body = text.trim().trim_end_matches('Z');
|
||||
let (date, time) = body.split_once('T')?;
|
||||
let mut date_parts = date.split('-');
|
||||
let year: i64 = date_parts.next()?.parse().ok()?;
|
||||
let month: i64 = date_parts.next()?.parse().ok()?;
|
||||
let day: i64 = date_parts.next()?.parse().ok()?;
|
||||
let mut time_parts = time.split(':');
|
||||
let hour: i64 = time_parts.next()?.parse().ok()?;
|
||||
let minute: i64 = time_parts.next()?.parse().ok()?;
|
||||
let seconds_text = time_parts.next()?;
|
||||
let (secs, millis) = match seconds_text.split_once('.') {
|
||||
Some((secs, frac)) => (secs.parse::<i64>().ok()?, {
|
||||
let padded = format!("{frac:0<3}");
|
||||
padded.get(..3)?.parse::<i64>().ok()?
|
||||
}),
|
||||
None => (seconds_text.parse::<i64>().ok()?, 0),
|
||||
};
|
||||
|
||||
let days = days_from_civil(year, month, day);
|
||||
let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis;
|
||||
u64::try_from(total_ms).ok()
|
||||
}
|
||||
|
||||
/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。
|
||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (month + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_launch_timestamps() {
|
||||
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0));
|
||||
// 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z
|
||||
assert_eq!(
|
||||
parse_rfc3339_ms("2026-10-29T20:12:33.123Z"),
|
||||
Some(1_793_304_753_123)
|
||||
);
|
||||
assert_eq!(parse_rfc3339_ms("not-a-time"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。
|
||||
//!
|
||||
//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置),
|
||||
//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。
|
||||
//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。
|
||||
//!
|
||||
//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod diagnose;
|
||||
pub mod events;
|
||||
pub mod game;
|
||||
pub mod session;
|
||||
pub mod setting;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_agent::port::Ports;
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
|
||||
use crate::context::Ctx;
|
||||
|
||||
pub use accounts::CoreAccounts;
|
||||
pub use diagnose::CoreDiagnose;
|
||||
pub use game::CoreGame;
|
||||
pub use session::CoreSessions;
|
||||
pub use setting::CoreSetting;
|
||||
|
||||
/// 按一次 CLI 上下文装配五个端口。
|
||||
#[must_use]
|
||||
pub fn build(ctx: &Ctx) -> Ports {
|
||||
Ports {
|
||||
// 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set`
|
||||
// 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。
|
||||
setting: Arc::new(CoreSetting::split(
|
||||
ctx.launcher_config(),
|
||||
ctx.portable_launcher_config(),
|
||||
)),
|
||||
sessions: Arc::new(CoreSessions::new(ctx.sessions_root())),
|
||||
diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())),
|
||||
game: Arc::new(CoreGame::new(
|
||||
ctx.layout(),
|
||||
ctx.game(),
|
||||
data_mode(ctx),
|
||||
platform(ctx),
|
||||
)),
|
||||
accounts: Arc::new(CoreAccounts::new(ctx.auth_data())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn data_mode(ctx: &Ctx) -> DataMode {
|
||||
ctx.data.mode
|
||||
}
|
||||
|
||||
pub(crate) fn platform(ctx: &Ctx) -> Platform {
|
||||
ctx.platform
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use unlucky_agent::port::{
|
||||
GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult,
|
||||
};
|
||||
|
||||
use super::events::SessionSource;
|
||||
|
||||
/// M18 的会话索引 + 事件流。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreSessions {
|
||||
source: SessionSource,
|
||||
}
|
||||
|
||||
impl CoreSessions {
|
||||
/// `root` = `<数据根>/.ULL/data/agent/sessions`。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
source: SessionSource::new(root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_of(
|
||||
entry: &unlucky_core::launch::session_index::SessionIndexEntry,
|
||||
) -> GameSessionSummary {
|
||||
GameSessionSummary {
|
||||
session_id: entry.session_id.clone(),
|
||||
instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()),
|
||||
instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()),
|
||||
game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()),
|
||||
started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()),
|
||||
ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()),
|
||||
duration_ms: entry.duration_ms,
|
||||
exit_code: entry.exit_code,
|
||||
exit_type: entry.exit_type.clone(),
|
||||
silent_failure: entry.silent_failure,
|
||||
window_detected: entry.window_detected,
|
||||
event_count: entry.event_count,
|
||||
crash_found: entry.crash_found,
|
||||
diagnosis_top: entry.diagnosis_top.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent {
|
||||
LogEvent {
|
||||
seq: event
|
||||
.get("seq")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(fallback_seq),
|
||||
ts: event
|
||||
.get("ts")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0),
|
||||
kind: event
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
payload: event.get("payload").cloned().unwrap_or(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
impl GameSessionPort for CoreSessions {
|
||||
fn list_sessions(
|
||||
&self,
|
||||
limit: usize,
|
||||
instance_id: Option<&str>,
|
||||
) -> PortResult<Vec<GameSessionSummary>> {
|
||||
let mut entries = self.source.entries()?;
|
||||
if let Some(instance) = instance_id {
|
||||
entries.retain(|entry| entry.instance_id == instance);
|
||||
}
|
||||
entries.truncate(limit);
|
||||
Ok(entries.iter().map(summary_of).collect())
|
||||
}
|
||||
|
||||
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
|
||||
let entry = self.source.find(session_id)?;
|
||||
let raw = self.source.events(&entry)?;
|
||||
let events: Vec<LogEvent> = raw
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, event)| to_log_event(event, index as u64))
|
||||
.collect();
|
||||
|
||||
let mut kind_counts = std::collections::BTreeMap::new();
|
||||
for event in &events {
|
||||
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
|
||||
}
|
||||
let matched: Vec<&LogEvent> = events
|
||||
.iter()
|
||||
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
|
||||
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
|
||||
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
|
||||
.collect();
|
||||
let max = if query.max_events == 0 {
|
||||
matched.len()
|
||||
} else {
|
||||
query.max_events
|
||||
};
|
||||
let truncated = matched.len() > max;
|
||||
|
||||
Ok(LogPage {
|
||||
session_id: entry.session_id,
|
||||
total_events: events.len() as u64,
|
||||
matched_events: matched.len() as u64,
|
||||
truncated,
|
||||
kind_counts,
|
||||
first_ts: events.first().map(|e| e.ts.clone()),
|
||||
last_ts: events.last().map(|e| e.ts.clone()),
|
||||
events: matched.into_iter().take(max).cloned().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。
|
||||
#[must_use]
|
||||
pub fn log_event_json(event: &LogEvent) -> Value {
|
||||
json!({
|
||||
"seq": event.seq,
|
||||
"ts": event.ts,
|
||||
"t_ms": event.t_ms,
|
||||
"kind": event.kind,
|
||||
"payload": event.payload,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn seed_session(root: &Path) {
|
||||
let game_dir = root.join("game");
|
||||
std::fs::create_dir_all(&game_dir).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-29T12:45:01.123Z",
|
||||
"ended_at": "2026-10-29T12:46:31.123Z",
|
||||
"duration_ms": 90000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": true,
|
||||
"event_count": 2,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"crash_found": true,
|
||||
"diagnosis_top": "integrity.forge_missing_library"
|
||||
});
|
||||
std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
let events = [
|
||||
json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}),
|
||||
json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}),
|
||||
];
|
||||
let text: String = events
|
||||
.iter()
|
||||
.map(|event| format!("{event}\n"))
|
||||
.collect::<String>();
|
||||
std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_slices_a_session() {
|
||||
let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
seed_session(&root);
|
||||
|
||||
let port = CoreSessions::new(root.clone());
|
||||
let sessions = port.list_sessions(10, None).expect("应能列出");
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].exit_code, Some(1));
|
||||
assert!(sessions[0].crash_found);
|
||||
|
||||
let page = port
|
||||
.read_log(
|
||||
"20261029T124501",
|
||||
&LogQuery {
|
||||
max_events: 1,
|
||||
kinds: vec!["stderr".to_string()],
|
||||
..LogQuery::default()
|
||||
},
|
||||
)
|
||||
.expect("应能读");
|
||||
assert_eq!(page.total_events, 2);
|
||||
assert_eq!(page.events.len(), 1);
|
||||
assert_eq!(page.events[0].kind, "stderr");
|
||||
assert!(page.kind_counts.contains_key("stdout"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置,
|
||||
//! 缓存一份快照会让审计里的 `old` 骗人;
|
||||
//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道):
|
||||
//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」;
|
||||
//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键
|
||||
//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时
|
||||
//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」
|
||||
//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use unlucky_agent::confirm::Sensitivity;
|
||||
use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind};
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use crate::context::is_data_root_key;
|
||||
|
||||
/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。
|
||||
pub const MASK: &str = "<redacted>";
|
||||
|
||||
/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreSetting {
|
||||
/// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。
|
||||
path: PathBuf,
|
||||
/// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到
|
||||
/// exe 目录时为 `None`。
|
||||
core_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CoreSetting {
|
||||
/// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。
|
||||
#[must_use]
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self::split(path, None)
|
||||
}
|
||||
|
||||
/// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。
|
||||
///
|
||||
/// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。
|
||||
#[must_use]
|
||||
pub fn split(path: impl Into<PathBuf>, core_path: Option<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let core_path = core_path.filter(|core| core.as_path() != path.as_path());
|
||||
Self { path, core_path }
|
||||
}
|
||||
|
||||
/// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。
|
||||
fn path_for(&self, key: &str) -> &Path {
|
||||
match &self.core_path {
|
||||
Some(core) if is_data_root_key(key) => core.as_path(),
|
||||
_ => self.path.as_path(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。
|
||||
fn store_for(&self, key: &str) -> SettingStore {
|
||||
SettingStore::load(self.path_for(key), launcher_schema())
|
||||
}
|
||||
|
||||
/// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。
|
||||
fn store(&self) -> SettingStore {
|
||||
SettingStore::load(&self.path, launcher_schema())
|
||||
}
|
||||
}
|
||||
|
||||
/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录
|
||||
/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。
|
||||
fn raw_value<'a>(
|
||||
data: &'a SettingStore,
|
||||
core: Option<&'a SettingStore>,
|
||||
key: &str,
|
||||
) -> Option<&'a Value> {
|
||||
match core {
|
||||
Some(core) if is_data_root_key(key) => core.effective(key),
|
||||
_ => data.effective(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitivity_of(value: CoreSensitivity) -> Sensitivity {
|
||||
match value {
|
||||
CoreSensitivity::Low => Sensitivity::Low,
|
||||
CoreSensitivity::Medium => Sensitivity::Medium,
|
||||
CoreSensitivity::High => Sensitivity::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_label(kind: &ValueKind) -> &'static str {
|
||||
match kind {
|
||||
ValueKind::Bool => "boolean",
|
||||
ValueKind::Int { .. } => "integer",
|
||||
ValueKind::Float { .. } => "number",
|
||||
ValueKind::Text { .. } => "string",
|
||||
ValueKind::Path => "path",
|
||||
ValueKind::Enum(_) => "enum",
|
||||
ValueKind::TextList => "string[]",
|
||||
ValueKind::EnumList(_) => "enum[]",
|
||||
ValueKind::Any => "json",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。
|
||||
fn mask(value: Value, sensitive: bool) -> Value {
|
||||
if sensitive {
|
||||
json!(MASK)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingPort for CoreSetting {
|
||||
/// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。
|
||||
fn summary(&self) -> Value {
|
||||
let store = self.store();
|
||||
let core = self
|
||||
.core_path
|
||||
.as_ref()
|
||||
.map(|path| SettingStore::load(path, launcher_schema()));
|
||||
let schema = store.doc().schema().clone();
|
||||
let mut map = Map::new();
|
||||
for spec in schema.specs() {
|
||||
if spec.is_deprecated() {
|
||||
continue;
|
||||
}
|
||||
let sensitive = spec.ai.sensitivity == CoreSensitivity::High
|
||||
|| unlucky_agent::redact::is_sensitive_key(spec.key);
|
||||
let value = raw_value(&store, core.as_ref(), spec.key)
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
map.insert(spec.key.to_string(), mask(value, sensitive));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> PortResult<Value> {
|
||||
if unlucky_agent::redact::is_sensitive_key(path) {
|
||||
return Err(format!(
|
||||
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看"
|
||||
));
|
||||
}
|
||||
let store = self.store_for(path);
|
||||
let schema = store.doc().schema().clone();
|
||||
let Some(value) = store.effective(path).cloned() else {
|
||||
return Err(format!("配置键 `{path}` 不在 schema 里"));
|
||||
};
|
||||
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
|
||||
Ok(mask(value, sensitive))
|
||||
}
|
||||
|
||||
fn set(&self, path: &str, value: &Value) -> PortResult<SettingChange> {
|
||||
let file = self.path_for(path);
|
||||
let mut store = self.store_for(path);
|
||||
let schema = store.doc().schema().clone();
|
||||
let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High);
|
||||
let old = store
|
||||
.effective(path)
|
||||
.cloned()
|
||||
.map(|old| mask(old, sensitive));
|
||||
store
|
||||
.set(path, value.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {}
|
||||
// 只读/不可信:**不能**报成功——外部客户端会以为改完了。
|
||||
Ok(SaveOutcome::Skipped(reason)) => {
|
||||
return Err(format!(
|
||||
"{} 当前不可写({});请让用户检查文件权限或内容",
|
||||
file.display(),
|
||||
reason.as_str()
|
||||
))
|
||||
}
|
||||
Err(error) => return Err(format!("写 {} 失败:{error}", file.display())),
|
||||
}
|
||||
Ok(SettingChange {
|
||||
path: path.to_string(),
|
||||
old,
|
||||
new: mask(value.clone(), sensitive),
|
||||
})
|
||||
}
|
||||
|
||||
fn writable_keys(&self) -> Vec<SettingKey> {
|
||||
let store = self.store();
|
||||
let schema = store.doc().schema().clone();
|
||||
schema
|
||||
.specs()
|
||||
.iter()
|
||||
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
|
||||
.map(|spec| SettingKey {
|
||||
path: spec.key.to_string(),
|
||||
sensitivity: sensitivity_of(spec.ai.sensitivity),
|
||||
value_type: kind_label(&spec.kind).to_string(),
|
||||
description: spec.doc.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
// core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`,
|
||||
// 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。
|
||||
self.store().doc().schema().to_json_schema()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,226 @@
|
||||
//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。
|
||||
//!
|
||||
//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in`
|
||||
//! 把便携目录钉死在临时路径里,不动真实数据根。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::cli::Globals;
|
||||
use crate::commands::agent::ports::build;
|
||||
use crate::context::Ctx;
|
||||
|
||||
fn temp_config() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-core-setting-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir.join("launcher.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_omits_credential_keys_and_writable_keys_match_policy() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let schema = port.schema();
|
||||
let text = schema.to_string();
|
||||
assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema");
|
||||
assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}");
|
||||
|
||||
let paths: Vec<String> = port.writable_keys().into_iter().map(|k| k.path).collect();
|
||||
assert!(paths.contains(&"download.concurrency".to_string()));
|
||||
assert!(!paths.contains(&"ai.api_key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_masks_high_sensitivity_but_keeps_low() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let summary = port.summary();
|
||||
assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码");
|
||||
assert_ne!(
|
||||
summary["download.concurrency"],
|
||||
json!(MASK),
|
||||
"Low 键不必掩码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_key_get_is_refused() {
|
||||
let port = CoreSetting::new(temp_config());
|
||||
let err = port.get("ai.api_key").unwrap_err();
|
||||
assert!(err.contains("凭据类配置"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_get_round_trips_a_low_key() {
|
||||
let path = temp_config();
|
||||
let port = CoreSetting::new(path.clone());
|
||||
let change = port
|
||||
.set("download.concurrency", &json!(16))
|
||||
.expect("应写入");
|
||||
assert_eq!(change.new, json!(16));
|
||||
assert_eq!(port.get("download.concurrency").unwrap(), json!(16));
|
||||
assert!(path.is_file(), "应落盘 {}", path.display());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。
|
||||
fn schema_url() -> String {
|
||||
use unlucky_core::setting::keys::launcher_version;
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
|
||||
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) {
|
||||
std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
|
||||
std::fs::write(path, value.to_string()).expect("写配置");
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Value {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
|
||||
serde_json::from_slice(&bytes).expect("合法 JSON")
|
||||
}
|
||||
|
||||
/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。
|
||||
fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let exe = temp.path().join("exe");
|
||||
let launcher_home = temp.path().join("lhome");
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
write_json(
|
||||
&portable,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": {
|
||||
"data_mode": "standard",
|
||||
"launcher_home": launcher_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
(exe, portable, launcher_home)
|
||||
}
|
||||
|
||||
/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。
|
||||
///
|
||||
/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。
|
||||
#[test]
|
||||
fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() {
|
||||
let temp = TempDir::new("core-ai");
|
||||
let (exe, portable, launcher_home) = standard_fixture(&temp);
|
||||
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根");
|
||||
assert_eq!(ctx.data.mode.as_str(), "standard");
|
||||
let ports = build(&ctx);
|
||||
|
||||
ports
|
||||
.setting
|
||||
.set("core.data_mode", &json!("portable"))
|
||||
.expect("AI 侧写 core.data_mode");
|
||||
|
||||
assert_eq!(
|
||||
read_json(&portable)["core"]["data_mode"],
|
||||
json!("portable"),
|
||||
"数据根键要落在便携目录那份"
|
||||
);
|
||||
let data_file = launcher_home.join("config").join("launcher.json");
|
||||
assert!(
|
||||
!data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(),
|
||||
"数据根那份不该被写入 core.data_mode"
|
||||
);
|
||||
|
||||
let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析");
|
||||
assert_eq!(
|
||||
next.data.mode.as_str(),
|
||||
"portable",
|
||||
"改了没反应到此为止:下一次解析必须换模式"
|
||||
);
|
||||
|
||||
// 其余键仍走数据根那份,且不污染便携目录那份。
|
||||
ports
|
||||
.setting
|
||||
.set("download.concurrency", &json!(16))
|
||||
.expect("AI 侧写普通键");
|
||||
assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16));
|
||||
assert!(
|
||||
read_json(&portable)["download"]
|
||||
.get("concurrency")
|
||||
.is_none(),
|
||||
"普通键不该写进便携目录那份"
|
||||
);
|
||||
}
|
||||
|
||||
/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。
|
||||
#[test]
|
||||
fn summary_takes_data_root_keys_from_the_portable_file() {
|
||||
let temp = TempDir::new("core-summary");
|
||||
let (exe, portable, launcher_home) = standard_fixture(&temp);
|
||||
// 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。
|
||||
let data_file = launcher_home.join("config").join("launcher.json");
|
||||
write_json(
|
||||
&data_file,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": { "data_mode": "custom" },
|
||||
"download": { "concurrency": 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根");
|
||||
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
|
||||
let data = port.store();
|
||||
let core = SettingStore::load(&portable, launcher_schema());
|
||||
|
||||
assert_eq!(
|
||||
raw_value(&data, Some(&core), "core.data_mode"),
|
||||
Some(&json!("standard")),
|
||||
"数据根键取便携那份 = 解析读到的那份"
|
||||
);
|
||||
assert_eq!(
|
||||
raw_value(&data, Some(&core), "download.concurrency"),
|
||||
Some(&json!(4)),
|
||||
"其余键仍取数据根那份"
|
||||
);
|
||||
// `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。
|
||||
assert_eq!(port.summary()["core.data_mode"], json!(MASK));
|
||||
}
|
||||
|
||||
/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。
|
||||
#[test]
|
||||
fn portable_mode_keeps_a_single_landing_file() {
|
||||
let temp = TempDir::new("core-portable");
|
||||
let ctx =
|
||||
Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根");
|
||||
assert_eq!(ctx.data.mode.as_str(), "portable");
|
||||
let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config());
|
||||
assert_eq!(
|
||||
port.path_for("core.data_mode"),
|
||||
ctx.launcher_config().as_path()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。
|
||||
//!
|
||||
//! 装配顺序(每一步都对应 02 文档的一条约束):
|
||||
//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖);
|
||||
//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`);
|
||||
//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致);
|
||||
//! 4. 执行器:`JsonlAudit`(落盘 `<data>/agent/audit`)+ fail-closed 闸门 +
|
||||
//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点);
|
||||
//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_agent::audit::{audit_dir, JsonlAudit};
|
||||
use unlucky_agent::brain::exec::ToolExecutor;
|
||||
use unlucky_agent::clock::SystemClock;
|
||||
use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate};
|
||||
use unlucky_agent::event::NoopSink;
|
||||
use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer};
|
||||
use unlucky_agent::tools::build_v1_registry;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::AgentServeArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
|
||||
/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。
|
||||
pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result<i32, CliError> {
|
||||
let settings = Settings::load(ctx);
|
||||
let config = settings.mcp_config(args);
|
||||
let yolo = settings.yolo();
|
||||
let read_only = config.read_only;
|
||||
if !settings.enabled {
|
||||
eprintln!(
|
||||
"[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)"
|
||||
);
|
||||
}
|
||||
|
||||
let ports = super::ports::build(ctx);
|
||||
let registry = Arc::new(
|
||||
build_v1_registry(&ports)
|
||||
.map_err(|error| CliError::internal("agent_registry", error.to_string()))?,
|
||||
);
|
||||
// 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定
|
||||
// (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计,
|
||||
// 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。
|
||||
let exposed = if read_only {
|
||||
Arc::new(registry.read_only_subset())
|
||||
} else {
|
||||
Arc::clone(®istry)
|
||||
};
|
||||
|
||||
let audit = Arc::new(JsonlAudit::new(
|
||||
audit_dir(&ctx.layout().data()),
|
||||
Arc::new(SystemClock),
|
||||
));
|
||||
let executor = Arc::new(
|
||||
ToolExecutor::new(
|
||||
Arc::clone(&exposed),
|
||||
audit,
|
||||
Arc::new(NoUiGate),
|
||||
ConfirmPolicy { yolo },
|
||||
Arc::new(SystemClock),
|
||||
Arc::new(NoopSink),
|
||||
)
|
||||
.with_guidance(DenyGuidance::External),
|
||||
);
|
||||
|
||||
let server = RegistryServer::new(registry, executor, transport_label(args), read_only);
|
||||
if args.stdio {
|
||||
// stdio:stdout 是协议流,人类提示一律走 stderr。
|
||||
eprintln!(
|
||||
"[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})",
|
||||
server.tool_count(),
|
||||
if read_only { "关(只读)" } else { "开" },
|
||||
yolo
|
||||
);
|
||||
return block_on(async move { serve_stdio(server).await })
|
||||
.map(|()| crate::error::EXIT_OK)
|
||||
.map_err(|error| CliError::user("mcp_stdio", error.to_string()));
|
||||
}
|
||||
|
||||
let url = config.url();
|
||||
eprintln!("[unlucky-agent] MCP 端点:{url}");
|
||||
block_on(async move { serve_http(server, config).await })
|
||||
.map(|()| crate::error::EXIT_OK)
|
||||
.map_err(|error| CliError::user("mcp_http", error.to_string()))
|
||||
}
|
||||
|
||||
fn transport_label(args: &AgentServeArgs) -> &'static str {
|
||||
if args.stdio {
|
||||
"mcp-stdio"
|
||||
} else {
|
||||
"mcp-http"
|
||||
}
|
||||
}
|
||||
|
||||
/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。
|
||||
fn block_on<F, T>(future: F) -> Result<T, unlucky_agent::mcp::McpServeError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, unlucky_agent::mcp::McpServeError>> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?;
|
||||
runtime.block_on(future)
|
||||
}
|
||||
|
||||
/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。
|
||||
struct Settings {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
allow_write: bool,
|
||||
token: Option<String>,
|
||||
yolo: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
fn load(ctx: &Ctx) -> Self {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let doc = store.doc();
|
||||
let port = doc
|
||||
.effective("agent.mcp.port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(47823);
|
||||
Self {
|
||||
enabled: doc
|
||||
.effective("agent.mcp.enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
port,
|
||||
allow_write: doc
|
||||
.effective("agent.mcp.allow_write")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
token: doc
|
||||
.effective_str("agent.mcp.token")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string),
|
||||
yolo: doc
|
||||
.effective("ai.yolo")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig {
|
||||
McpConfig {
|
||||
host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port: args.port.unwrap_or(self.port),
|
||||
token: self.token.clone(),
|
||||
// 命令行 --allow-write 只能**收紧或放开到设置允许的程度**:
|
||||
// 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。
|
||||
read_only: !(self.allow_write || args.allow_write),
|
||||
}
|
||||
}
|
||||
|
||||
fn yolo(&self) -> bool {
|
||||
self.yolo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args() -> AgentServeArgs {
|
||||
AgentServeArgs::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_to_read_only_on_47823() {
|
||||
// Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」:
|
||||
// 用 Default 的字段逐项对照,避免为此造一个假 Ctx。
|
||||
let defaults = Settings {
|
||||
enabled: false,
|
||||
port: 47823,
|
||||
allow_write: false,
|
||||
token: None,
|
||||
yolo: false,
|
||||
};
|
||||
let config = defaults.mcp_config(&args());
|
||||
assert_eq!(config.port, 47823);
|
||||
assert!(config.read_only);
|
||||
assert_eq!(config.host, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_flags_override_port_and_write_switch() {
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
port: 47823,
|
||||
allow_write: true,
|
||||
token: Some("t".to_string()),
|
||||
yolo: true,
|
||||
};
|
||||
let overridden = settings.mcp_config(&AgentServeArgs {
|
||||
port: Some(50000),
|
||||
allow_write: false,
|
||||
..AgentServeArgs::default()
|
||||
});
|
||||
assert_eq!(overridden.port, 50000);
|
||||
assert!(!overridden.read_only, "设置里允许写时命令行不必再开");
|
||||
assert_eq!(overridden.token.as_deref(), Some("t"));
|
||||
assert!(settings.yolo());
|
||||
// `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它
|
||||
// (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。
|
||||
assert!(settings.enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。
|
||||
//!
|
||||
//! # 落点(M40:与数据根解析同源)
|
||||
//!
|
||||
//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` /
|
||||
//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json`
|
||||
//! (`<exe 目录>/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的
|
||||
//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode`
|
||||
//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析;
|
||||
//! - 其余键 → 解析后的数据根(`<data>/.ULL/config/launcher.json`)。
|
||||
//!
|
||||
//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、
|
||||
//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。
|
||||
//!
|
||||
//! 实例级配置(`config/instances/<id>.json`)留给实例管理 mission,这里不半途实现。
|
||||
//!
|
||||
//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键
|
||||
//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码——
|
||||
//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。
|
||||
//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use unlucky_core::setting::catalog::SettingSchema;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::spec::Sensitivity;
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS};
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 敏感值在输出里的占位。
|
||||
const MASK: &str = "<redacted>";
|
||||
|
||||
/// `ull config get [<key>] [--show-secret]`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 未知键、配置不可读时返回用户错误。
|
||||
pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, CliError> {
|
||||
match key {
|
||||
Some(key) => get_one(ctx, key, show_secret),
|
||||
None => dump(ctx, show_secret),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
|
||||
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
|
||||
let path = ctx.setting_path_for(key);
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let schema = store.doc().schema().clone();
|
||||
|
||||
let Some(value) = store.effective(key) else {
|
||||
return Err(
|
||||
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
|
||||
.with_hint("`ull config get`(不带键)可以看到全部生效键"),
|
||||
);
|
||||
};
|
||||
|
||||
let sensitive = is_sensitive(&schema, key);
|
||||
let hidden = sensitive && !show_secret;
|
||||
let shown = if hidden {
|
||||
Value::String(MASK.to_string())
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
let problems = render_problems(&store);
|
||||
let data = json!({
|
||||
"key": key,
|
||||
"value": shown,
|
||||
"redacted": hidden,
|
||||
"sensitive": sensitive,
|
||||
"path": path.display().to_string(),
|
||||
"problems": problems,
|
||||
});
|
||||
let mut human = vec![format!(
|
||||
"{key} = {}{}",
|
||||
render_value(&shown),
|
||||
if hidden {
|
||||
"(敏感键;加 --show-secret 显示明文)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)];
|
||||
human.push(format!("来自 {}", path.display()));
|
||||
for problem in &problems {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
human.extend(source_note(ctx, key));
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
|
||||
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
|
||||
let path = ctx.launcher_config();
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
let schema = store.doc().schema().clone();
|
||||
|
||||
let core_path = ctx
|
||||
.portable_launcher_config()
|
||||
.filter(|core| core.as_path() != path.as_path());
|
||||
let core_store = core_path
|
||||
.as_ref()
|
||||
.map(|core| SettingStore::load(core, launcher_schema()));
|
||||
|
||||
let mut document = store.doc().to_value();
|
||||
if let Some(core) = &core_store {
|
||||
// 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键);
|
||||
// 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。
|
||||
for key in DATA_ROOT_KEYS {
|
||||
if let Some(value) = core.doc().get(key) {
|
||||
overlay(&mut document, key, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut problems = render_problems(&store);
|
||||
if let Some(core) = &core_store {
|
||||
problems.extend(render_problems(core));
|
||||
}
|
||||
|
||||
let (document, masked) = if show_secret {
|
||||
(document, false)
|
||||
} else {
|
||||
mask_document(&schema, &document)
|
||||
};
|
||||
|
||||
let text = match serde_json::to_string_pretty(&document) {
|
||||
Ok(text) => text,
|
||||
Err(error) => return Err(CliError::internal("config_serialize", error.to_string())),
|
||||
};
|
||||
let mut human: Vec<String> = Vec::new();
|
||||
human.push(format!("配置 {}({:?})", path.display(), store.compat()));
|
||||
if let Some(core) = &core_path {
|
||||
human.push(format!("数据根键(core.*)读的是 {}", core.display()));
|
||||
}
|
||||
human.push(format!(
|
||||
"数据根:{}(launcher home {},游戏目录 {})",
|
||||
ctx.data.mode.as_str(),
|
||||
ctx.data.launcher_home.display(),
|
||||
ctx.data.game_home.display()
|
||||
));
|
||||
human.extend(text.lines().map(str::to_string));
|
||||
for problem in &problems {
|
||||
human.push(format!("问题:{problem}"));
|
||||
}
|
||||
if masked {
|
||||
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
|
||||
}
|
||||
Ok(Report::new(
|
||||
json!({
|
||||
"path": path.display().to_string(),
|
||||
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
|
||||
"config": document,
|
||||
"redacted": masked,
|
||||
"problems": problems,
|
||||
"data_root": {
|
||||
"mode": ctx.data.mode.as_str(),
|
||||
"launcher_home": ctx.data.launcher_home.display().to_string(),
|
||||
"game_home": ctx.data.game_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
human,
|
||||
))
|
||||
}
|
||||
|
||||
/// `ull config set <key> <value>`。
|
||||
///
|
||||
/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
|
||||
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
|
||||
let path = ctx.setting_path_for(key);
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
let sensitive = is_sensitive(store.doc().schema(), key);
|
||||
let value = parse_value(raw);
|
||||
let old = store.effective(key).cloned().map(|old| {
|
||||
if sensitive {
|
||||
Value::String(MASK.to_string())
|
||||
} else {
|
||||
old
|
||||
}
|
||||
});
|
||||
store.set(key, value.clone()).map_err(|error| {
|
||||
CliError::user("config_set", error.to_string())
|
||||
.with_hint(format!("键与取值范围见 {}", path.display()))
|
||||
})?;
|
||||
|
||||
let outcome = store.save().map_err(|error| {
|
||||
CliError::user(
|
||||
"config_save",
|
||||
format!("写 {} 失败:{error}", path.display()),
|
||||
)
|
||||
})?;
|
||||
match &outcome {
|
||||
SaveOutcome::Saved(saved) => {
|
||||
let data = json!({
|
||||
"key": key,
|
||||
"old": old,
|
||||
"new": value,
|
||||
"saved": saved.display().to_string(),
|
||||
});
|
||||
let mut human = vec![
|
||||
format!(
|
||||
"{key} = {}(原值 {})",
|
||||
render_value(&value),
|
||||
render_value(&old.unwrap_or(Value::Null))
|
||||
),
|
||||
format!("已保存:{}", saved.display()),
|
||||
];
|
||||
human.extend(source_note(ctx, key));
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
|
||||
json!({ "key": key, "new": value, "saved": null }),
|
||||
vec![format!("{key} 已经是该值,未写盘")],
|
||||
)),
|
||||
SaveOutcome::Skipped(reason) => {
|
||||
let error = CliError::user(
|
||||
"config_read_only",
|
||||
format!("{} 当前不可写({})", path.display(), reason.as_str()),
|
||||
);
|
||||
// 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。
|
||||
let problems = render_problems(&store);
|
||||
Err(if problems.is_empty() {
|
||||
error.with_hint("修好文件权限/内容后重试")
|
||||
} else {
|
||||
error.with_hint(problems.join(";"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。
|
||||
fn source_note(ctx: &Ctx, key: &str) -> Option<String> {
|
||||
if !is_data_root_key(key) {
|
||||
return None;
|
||||
}
|
||||
if ctx.data_dir_forced() {
|
||||
return Some(
|
||||
"本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(),
|
||||
);
|
||||
}
|
||||
if ctx.setting_path_for(key) != ctx.launcher_config() {
|
||||
return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。
|
||||
fn render_problems(store: &SettingStore) -> Vec<String> {
|
||||
store.problems().iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。
|
||||
fn overlay(root: &mut Value, key: &str, value: Value) {
|
||||
let mut segments = key.split('.').filter(|part| !part.is_empty());
|
||||
let Some(head) = segments.next() else {
|
||||
return;
|
||||
};
|
||||
let rest: Vec<&str> = segments.collect();
|
||||
let Some(map) = root.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
map.insert(head.to_string(), value);
|
||||
return;
|
||||
}
|
||||
overlay(
|
||||
map.entry(head.to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new())),
|
||||
&rest.join("."),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。
|
||||
fn parse_value(raw: &str) -> Value {
|
||||
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
|
||||
}
|
||||
|
||||
/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。
|
||||
fn is_sensitive(schema: &SettingSchema, key: &str) -> bool {
|
||||
matches!(schema.sensitivity(key), Some(Sensitivity::High))
|
||||
|| unlucky_agent::redact::is_sensitive_key(key)
|
||||
}
|
||||
|
||||
/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。
|
||||
///
|
||||
/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻;
|
||||
/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。
|
||||
fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) {
|
||||
mask_at(schema, "", document)
|
||||
}
|
||||
|
||||
fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) {
|
||||
if !path.is_empty() && is_sensitive(schema, path) {
|
||||
return (Value::String(MASK.to_string()), true);
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
let mut masked = false;
|
||||
for (key, child) in map {
|
||||
let child_path = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{path}.{key}")
|
||||
};
|
||||
let (child, child_masked) = mask_at(schema, &child_path, child);
|
||||
masked |= child_masked;
|
||||
out.insert(key.clone(), child);
|
||||
}
|
||||
(Value::Object(out), masked)
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut masked = false;
|
||||
let redacted: Vec<Value> = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let (item, item_masked) = mask_at(schema, path, item);
|
||||
masked |= item_masked;
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
(Value::Array(redacted), masked)
|
||||
}
|
||||
other => (other.clone(), false),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! [`super`] 的单测:数据根键与其余键的落点分流、dump 合并、`problems` 通道。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{dump, get, set};
|
||||
use crate::cli::Globals;
|
||||
use crate::context::Ctx;
|
||||
|
||||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ull-config-test-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 正确的 `$schema`(写成真前缀,加载才不会降级)。
|
||||
fn schema_url() -> String {
|
||||
use unlucky_core::setting::keys::launcher_version;
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaRef};
|
||||
SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url()
|
||||
}
|
||||
|
||||
fn write_json(path: &Path, value: &Value) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, value.to_string()).expect("写配置");
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Value {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display()));
|
||||
serde_json::from_slice(&bytes).expect("合法 JSON")
|
||||
}
|
||||
|
||||
fn ctx_with(globals: Globals, exe_dir: &Path) -> Ctx {
|
||||
Ctx::resolve_in(&globals, Some(exe_dir.to_path_buf())).expect("解析数据根")
|
||||
}
|
||||
|
||||
/// 便携模式:两个落点是同一份文件,`core.data_mode` 改了立刻是生效值。
|
||||
#[test]
|
||||
fn portable_mode_keeps_core_keys_in_one_file() {
|
||||
let temp = TempDir::new("portable");
|
||||
let ctx = ctx_with(Globals::default(), temp.path());
|
||||
assert_eq!(ctx.data.mode.as_str(), "portable");
|
||||
assert_eq!(
|
||||
ctx.setting_path_for("core.data_mode"),
|
||||
ctx.launcher_config(),
|
||||
"便携模式下两份路径本来就该重合"
|
||||
);
|
||||
|
||||
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
|
||||
let file = temp
|
||||
.path()
|
||||
.join(".ULL")
|
||||
.join("config")
|
||||
.join("launcher.json");
|
||||
assert!(file.is_file(), "应落在 {}", file.display());
|
||||
assert_eq!(read_json(&file)["core"]["data_mode"], json!("standard"));
|
||||
|
||||
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
|
||||
// `core.*` 在 schema 里标了 `AiPolicy::sensitive()`(High),读路径默认掩码成
|
||||
// `<redacted>`,所以要看生效值得像用户那样加 `--show-secret`。
|
||||
assert_eq!(report.data["value"], json!("standard"));
|
||||
assert_eq!(
|
||||
report.data["path"],
|
||||
json!(file.display().to_string()),
|
||||
"read 与 write 必须同一份"
|
||||
);
|
||||
}
|
||||
|
||||
/// standard 模式:数据根键走便携目录那份(与解析同源),其余键走数据根。
|
||||
#[test]
|
||||
fn standard_mode_routes_core_keys_to_the_portable_file() {
|
||||
let temp = TempDir::new("standard");
|
||||
let exe = temp.path().join("exe");
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
let launcher_home = temp.path().join("lhome");
|
||||
write_json(
|
||||
&portable,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": {
|
||||
"data_mode": "standard",
|
||||
"launcher_home": launcher_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let ctx = ctx_with(Globals::default(), &exe);
|
||||
assert_eq!(ctx.data.mode.as_str(), "standard");
|
||||
assert_eq!(ctx.data.launcher_home, launcher_home);
|
||||
assert_ne!(
|
||||
ctx.setting_path_for("core.data_mode"),
|
||||
ctx.launcher_config()
|
||||
);
|
||||
|
||||
let report = get(&ctx, Some("core.data_mode"), true).expect("读 core.data_mode");
|
||||
assert_eq!(
|
||||
report.data["value"],
|
||||
json!("standard"),
|
||||
"get 展示的必须是真正生效的值"
|
||||
);
|
||||
assert_eq!(report.data["path"], json!(portable.display().to_string()));
|
||||
|
||||
// 普通键仍写数据根。
|
||||
set(&ctx, "download.concurrency", "16").expect("写 download.concurrency");
|
||||
let core_file = ctx.launcher_config();
|
||||
assert_eq!(read_json(&core_file)["download"]["concurrency"], json!(16));
|
||||
|
||||
// 数据根键写便携目录那份,且不动数据根那份的 core.*。
|
||||
set(&ctx, "core.data_mode", "portable").expect("写 core.data_mode");
|
||||
assert_eq!(read_json(&portable)["core"]["data_mode"], json!("portable"));
|
||||
assert!(
|
||||
read_json(&core_file)["core"].get("data_mode").is_none(),
|
||||
"数据根那份不该被写入 core.data_mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// `--data-dir` 模式:数据根由命令行钉死,core.* 仍落便携目录那份并给出提示。
|
||||
#[test]
|
||||
fn data_dir_flag_routes_core_keys_to_portable_and_says_so() {
|
||||
let temp = TempDir::new("datadir");
|
||||
let exe = temp.path().join("exe");
|
||||
std::fs::create_dir_all(&exe).expect("建 exe 目录");
|
||||
let data = temp.path().join("data");
|
||||
let globals = Globals {
|
||||
data_dir: Some(data.clone()),
|
||||
..Globals::default()
|
||||
};
|
||||
let ctx = ctx_with(globals, &exe);
|
||||
assert_eq!(ctx.data.mode.as_str(), "custom");
|
||||
assert_eq!(ctx.data.launcher_home, data.join(".ULL"));
|
||||
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode");
|
||||
assert!(portable.is_file(), "应落在 {}", portable.display());
|
||||
assert!(!data
|
||||
.join(".ULL")
|
||||
.join("config")
|
||||
.join("launcher.json")
|
||||
.exists());
|
||||
|
||||
let report = get(&ctx, Some("core.data_mode"), true).expect("读回来");
|
||||
assert_eq!(report.data["value"], json!("standard"));
|
||||
assert!(
|
||||
report.human.iter().any(|line| line.contains("--data-dir")),
|
||||
"{:?}",
|
||||
report.human
|
||||
);
|
||||
}
|
||||
|
||||
/// 全量 dump:`core.*` 取便携目录那份的生效值,并标明两份文件与数据根。
|
||||
#[test]
|
||||
fn dump_merges_core_keys_from_the_portable_file() {
|
||||
let temp = TempDir::new("dump");
|
||||
let exe = temp.path().join("exe");
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
let launcher_home = temp.path().join("home2");
|
||||
write_json(
|
||||
&portable,
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"core": {
|
||||
"data_mode": "standard",
|
||||
"launcher_home": launcher_home.display().to_string(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// 数据根那份写一个普通键(默认值不落盘,dump 里只有文件里真有的键)。
|
||||
write_json(
|
||||
&launcher_home.join("config").join("launcher.json"),
|
||||
&json!({
|
||||
"$schema": schema_url(),
|
||||
"schemaVersion": 1,
|
||||
"download": { "concurrency": 4 },
|
||||
}),
|
||||
);
|
||||
let ctx = ctx_with(Globals::default(), &exe);
|
||||
|
||||
let report = dump(&ctx, true).expect("全量 dump");
|
||||
assert_eq!(
|
||||
report.data["config"]["core"]["data_mode"],
|
||||
json!("standard")
|
||||
);
|
||||
assert_eq!(
|
||||
report.data["core_path"],
|
||||
json!(portable.display().to_string())
|
||||
);
|
||||
assert_eq!(report.data["data_root"]["mode"], json!("standard"));
|
||||
assert_eq!(
|
||||
report.data["config"]["download"]["concurrency"],
|
||||
json!(4),
|
||||
"非 core.* 键仍取数据根那份"
|
||||
);
|
||||
}
|
||||
|
||||
/// 认不出的 `$schema` 前缀:值照读 + `problems` 里有 UnknownSchema,不再静默回落。
|
||||
#[test]
|
||||
fn unknown_schema_prefix_warns_and_still_shows_file_values() {
|
||||
let temp = TempDir::new("badschema");
|
||||
let exe = temp.path().join("exe");
|
||||
let portable = exe.join(".ULL").join("config").join("launcher.json");
|
||||
write_json(
|
||||
&portable,
|
||||
&json!({
|
||||
"$schema": "https://unlucky-launcher.local/schema/launcher/1.2.0.json",
|
||||
"schemaVersion": 1,
|
||||
"core": { "data_mode": "portable" },
|
||||
"download": { "concurrency": 32 },
|
||||
}),
|
||||
);
|
||||
let ctx = ctx_with(Globals::default(), &exe);
|
||||
|
||||
let report = get(&ctx, Some("download.concurrency"), false).expect("读普通键");
|
||||
assert_eq!(report.data["value"], json!(32), "值不该被回落成默认 8");
|
||||
let problems = report.data["problems"].as_array().expect("problems 数组");
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| problem.as_str().is_some_and(|p| p.contains("$schema"))),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(
|
||||
report.human.iter().any(|line| line.contains("$schema")),
|
||||
"{:?}",
|
||||
report.human
|
||||
);
|
||||
}
|
||||
@@ -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!(
|
||||
"ull 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,或用 `ull 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![
|
||||
"没有账户,按离线占位启动(ull 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("重新登录:ull 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() {
|
||||
"还没有账户:ull accounts add offline <名字> 或 ull 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
|
||||
//!
|
||||
//! ```text
|
||||
//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile)
|
||||
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
|
||||
//! → [--dry-run 到此为止,但多带一段 processors 计划]
|
||||
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
|
||||
//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35)
|
||||
//! → execute(loader 库并发下载 → 全量就绪才写 versions/<loader-id>/<loader-id>.json)
|
||||
//! → register(实例 versionId 指向 loader 版本)
|
||||
//! ```
|
||||
//!
|
||||
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
|
||||
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
|
||||
//!
|
||||
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
|
||||
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
|
||||
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::execute;
|
||||
|
||||
use super::report_loader::ProcessorSection;
|
||||
use super::{
|
||||
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error,
|
||||
register_instance, report, report_loader, vanilla, CONCURRENCY,
|
||||
};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 执行加载器安装。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、
|
||||
/// installer processors 失败、实例登记失败。
|
||||
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
|
||||
let version = args.version.trim().to_string();
|
||||
if version.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"install 需要版本 id:ull install <version> --loader fabric",
|
||||
));
|
||||
}
|
||||
|
||||
let engine = build_engine(ctx);
|
||||
let layout = ctx.layout();
|
||||
let game = ctx.game();
|
||||
let meta_cache = layout.meta_cache();
|
||||
let now = now_ms();
|
||||
|
||||
let release = block_on(async {
|
||||
let source = LoaderSource::new(&engine, &meta_cache);
|
||||
source
|
||||
.resolve(kind, &version, args.loader_version.as_deref())
|
||||
.await
|
||||
})
|
||||
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
|
||||
|
||||
let name = args
|
||||
.name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| release.version_id.clone());
|
||||
ensure_name_free(ctx, &name, &release.version_id)?;
|
||||
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &game,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
vanilla_installed: game.is_version_installed(&version),
|
||||
});
|
||||
let processor_plan = loader_processors::plan(ctx, &release)?;
|
||||
|
||||
if args.dry_run {
|
||||
let section = processor_plan.as_ref().map(|plan| ProcessorSection {
|
||||
plan,
|
||||
libraries: None,
|
||||
run: None,
|
||||
skipped: false,
|
||||
});
|
||||
return Ok(report_loader::build(
|
||||
ctx, &engine, now, true, &plan, None, None, &name, section,
|
||||
));
|
||||
}
|
||||
|
||||
if plan.vanilla_install_needed {
|
||||
let (_, vanilla_report) = vanilla::plan_and_install(
|
||||
ctx,
|
||||
&engine,
|
||||
&version,
|
||||
false,
|
||||
args.progress,
|
||||
args.json_progress,
|
||||
)?;
|
||||
if let Some(report) = &vanilla_report {
|
||||
if !report.is_complete() {
|
||||
return Err(failure_error(report, ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
|
||||
let mut processor_libs = None;
|
||||
let mut processor_run = None;
|
||||
let mut processor_skipped = false;
|
||||
if let Some(processor_plan) = &processor_plan {
|
||||
if game.is_version_installed(&release.version_id) {
|
||||
processor_skipped = true;
|
||||
} else {
|
||||
let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?;
|
||||
processor_libs = Some(libraries);
|
||||
processor_run = Some(run);
|
||||
}
|
||||
}
|
||||
|
||||
let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len());
|
||||
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
|
||||
.map_err(|error| map_install_error(&error, ctx))?;
|
||||
if !install_report.is_complete() {
|
||||
return Err(failure_error(&install_report, ctx));
|
||||
}
|
||||
|
||||
let registration = register_instance(ctx, &name, &release.version_id)?;
|
||||
|
||||
let section = processor_plan
|
||||
.as_ref()
|
||||
.map(|processor_plan| ProcessorSection {
|
||||
plan: processor_plan,
|
||||
libraries: processor_libs.as_ref(),
|
||||
run: processor_run.as_ref(),
|
||||
skipped: processor_skipped,
|
||||
});
|
||||
Ok(report_loader::build(
|
||||
ctx,
|
||||
&engine,
|
||||
now,
|
||||
false,
|
||||
&plan,
|
||||
Some(&install_report),
|
||||
Some(®istration),
|
||||
&name,
|
||||
section,
|
||||
))
|
||||
}
|
||||
|
||||
/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。
|
||||
fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError {
|
||||
let hint = match error.hint() {
|
||||
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
|
||||
None => format!("游戏根:{}", ctx.game().root().display()),
|
||||
};
|
||||
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。
|
||||
//!
|
||||
//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、
|
||||
//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。
|
||||
//!
|
||||
//! ```text
|
||||
//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单)
|
||||
//! → download_libraries(M27 引擎:镜像/健康度/退避/校验)
|
||||
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
|
||||
//! → run(串行 java -cp,产物存在性硬校验、摘要软校验)
|
||||
//! ```
|
||||
//!
|
||||
//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute`
|
||||
//! 写 `versions/<id>/<id>.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会
|
||||
//! 留下一个启动必崩却被报成"已安装"的实例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::processors::{
|
||||
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
|
||||
};
|
||||
use unlucky_core::addon::loader::LoaderRelease;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::InstallReport;
|
||||
|
||||
use super::{failure_error, report, CONCURRENCY};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::progress_line;
|
||||
|
||||
/// 组执行计划;不需要跑 installer 的加载器返回 `None`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。
|
||||
pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result<Option<ProcessorPlan>, CliError> {
|
||||
if !release.requires_installer() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(profile), Some(installer)) = (
|
||||
release.install_profile.as_ref(),
|
||||
release.installer.as_deref(),
|
||||
) else {
|
||||
return Err(CliError::user(
|
||||
"loader_metadata",
|
||||
format!(
|
||||
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径",
|
||||
release.kind.label(),
|
||||
release.loader_version
|
||||
),
|
||||
)
|
||||
.with_hint("重跑一次(元数据缓存可能不完整)"));
|
||||
};
|
||||
let game = ctx.game();
|
||||
let scratch = ctx
|
||||
.layout()
|
||||
.cache()
|
||||
.join("processors")
|
||||
.join(safe_name(&release.version_id));
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: release.kind,
|
||||
mc_version: &release.mc_version,
|
||||
version_id: &release.version_id,
|
||||
profile,
|
||||
layout: &game,
|
||||
installer,
|
||||
scratch: &scratch,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(map_processor_error)?;
|
||||
Ok(Some(plan))
|
||||
}
|
||||
|
||||
/// 下载依赖库 + 执行 processors。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、
|
||||
/// 产物没落盘。
|
||||
pub(super) fn execute(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
plan: &ProcessorPlan,
|
||||
args: &InstallArgs,
|
||||
) -> Result<(InstallReport, ProcessorRun), CliError> {
|
||||
let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len());
|
||||
let libraries = block_on(processors::download_libraries(
|
||||
engine,
|
||||
&plan.libraries,
|
||||
CONCURRENCY,
|
||||
sink,
|
||||
));
|
||||
if !libraries.is_complete() {
|
||||
return Err(failure_error(&libraries, ctx));
|
||||
}
|
||||
|
||||
let managed = ctx.layout().java_managed();
|
||||
let Some(java) = processors::select_java(&managed, &plan.java_requirement) else {
|
||||
return Err(CliError::user(
|
||||
"processor_java_missing",
|
||||
format!(
|
||||
"跑 installer processors 需要 {},但一个可用的 Java 都没找到",
|
||||
plan.java_requirement.describe()
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||
plan.java_requirement.minimum,
|
||||
managed.display()
|
||||
)));
|
||||
};
|
||||
let sink = step_sink(args.progress, args.json_progress, plan.step_count());
|
||||
let run = processors::run(plan, &java, sink).map_err(map_processor_error)?;
|
||||
Ok((libraries, run))
|
||||
}
|
||||
|
||||
/// 每步一行的实时进度(只写 stderr)。
|
||||
fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option<StepSink> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
Some(Arc::new(move |outcome: &StepOutcome| {
|
||||
let record = serde_json::json!({
|
||||
"event": "processor",
|
||||
"index": outcome.index,
|
||||
"total": total,
|
||||
"jar": outcome.label,
|
||||
"status": outcome.status.as_str(),
|
||||
"elapsed_ms": outcome.elapsed_ms,
|
||||
});
|
||||
let text = format!(
|
||||
"processor {}/{} {} → {}(输出 {} 个{})",
|
||||
outcome.index,
|
||||
total,
|
||||
outcome.label,
|
||||
if outcome.status.as_str() == "skipped" {
|
||||
"跳过(产物已就绪)"
|
||||
} else {
|
||||
"执行完成"
|
||||
},
|
||||
outcome.outputs,
|
||||
if outcome.elapsed_ms > 0 {
|
||||
format!(",{} ms", outcome.elapsed_ms)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
if json_mode {
|
||||
progress_line(&record.to_string());
|
||||
} else {
|
||||
progress_line(&text);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。
|
||||
fn map_processor_error(error: ProcessorError) -> CliError {
|
||||
let cli = CliError::user(error.kind().as_str(), error.message().to_string());
|
||||
match error.hint() {
|
||||
Some(hint) => cli.with_hint(hint.to_string()),
|
||||
None => cli,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。
|
||||
fn safe_name(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `ull install <version> [--loader <kind>] [--loader-version <v>]`:装版本本体 / 装加载器层。
|
||||
//!
|
||||
//! ```text
|
||||
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
|
||||
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
|
||||
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
|
||||
//! ```
|
||||
//!
|
||||
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
|
||||
//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。
|
||||
//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。
|
||||
|
||||
mod loader;
|
||||
mod loader_processors;
|
||||
mod report;
|
||||
mod report_loader;
|
||||
mod vanilla;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::LoaderKind;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::download::{
|
||||
ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable,
|
||||
};
|
||||
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
|
||||
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。
|
||||
pub(super) const CONCURRENCY: usize = 16;
|
||||
|
||||
/// 执行 `install`:按 `--loader` 分发。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
|
||||
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
||||
match args
|
||||
.loader
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
|
||||
Some(raw) => match LoaderKind::parse(raw) {
|
||||
Some(kind) => loader::run(ctx, args, kind),
|
||||
None => Err(
|
||||
CliError::user("loader_unknown", format!("未知加载器 `{raw}`"))
|
||||
.with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记实例(幂等):`profiles.json` + `config/instances/<id>.json`。
|
||||
///
|
||||
/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id
|
||||
/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。
|
||||
pub(super) fn register_instance(
|
||||
ctx: &Ctx,
|
||||
name: &str,
|
||||
version_id: &str,
|
||||
) -> Result<Registration, CliError> {
|
||||
let created_at =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||
unlucky_core::game::install::instance::register(
|
||||
&ctx.layout(),
|
||||
name,
|
||||
version_id,
|
||||
Some(&created_at),
|
||||
)
|
||||
.map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}")))
|
||||
}
|
||||
|
||||
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
|
||||
///
|
||||
/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线),
|
||||
/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。
|
||||
pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine {
|
||||
let layout = ctx.layout();
|
||||
let cache_root = layout.cache();
|
||||
let now = now_ms();
|
||||
let region_path = cache_root.join("region.json");
|
||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_path, &profile);
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(region),
|
||||
Arc::new(HealthRegistry::open_in(&cache_root, now)),
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。
|
||||
pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> {
|
||||
let profiles = super::instances::read_profiles(ctx)?;
|
||||
let conflict = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == name && profile.version_id != version);
|
||||
match conflict {
|
||||
Some(profile) => Err(CliError::user(
|
||||
"instance_exists",
|
||||
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
|
||||
)
|
||||
.with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。
|
||||
pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError {
|
||||
let hint = match error.hint() {
|
||||
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
|
||||
None => format!("游戏根:{}", ctx.game().root().display()),
|
||||
};
|
||||
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
||||
}
|
||||
|
||||
/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。
|
||||
pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
|
||||
let code = match report.failure_kind() {
|
||||
Some(DownloadErrorKind::Integrity) => "install_integrity",
|
||||
Some(DownloadErrorKind::Io) => "install_io",
|
||||
_ => "install_network",
|
||||
};
|
||||
let mut message = format!(
|
||||
"{} 个文件没下成(共 {} 个)",
|
||||
report.failed(),
|
||||
report.files.len()
|
||||
);
|
||||
for outcome in report.failures(3) {
|
||||
if let Some(failure) = &outcome.failure {
|
||||
message.push_str(&format!(";{}:{}", outcome.name, failure.message));
|
||||
}
|
||||
}
|
||||
CliError::user(code, message).with_hint(format!(
|
||||
"重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}",
|
||||
ctx.game().root().display()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。
|
||||
//!
|
||||
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
|
||||
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
|
||||
//! 差别只是多出逐文件清单与源选择。
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::install::{
|
||||
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
|
||||
};
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::output::{progress_line, Report};
|
||||
|
||||
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
|
||||
const PROGRESS_EVERY: usize = 32;
|
||||
|
||||
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
|
||||
const MAX_PROGRESS_FAILURES: usize = 5;
|
||||
|
||||
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
|
||||
///
|
||||
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
|
||||
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
|
||||
///
|
||||
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
|
||||
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let failures = Arc::new(AtomicUsize::new(0));
|
||||
Some(Arc::new(move |outcome: &FileOutcome| {
|
||||
let index = done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if let Some(failure) = &outcome.failure {
|
||||
let seen = failures.fetch_add(1, Ordering::Relaxed);
|
||||
if seen < MAX_PROGRESS_FAILURES {
|
||||
emit(
|
||||
json,
|
||||
json!({
|
||||
"event": "file_failed",
|
||||
"done": index,
|
||||
"total": total,
|
||||
"name": outcome.name,
|
||||
"role": outcome.role.as_str(),
|
||||
"kind": failure.kind.as_str(),
|
||||
"message": failure.message,
|
||||
}),
|
||||
&format!("失败 {index}/{total} {}:{}", outcome.name, failure.message),
|
||||
);
|
||||
} else if seen == MAX_PROGRESS_FAILURES {
|
||||
emit(
|
||||
json,
|
||||
json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}),
|
||||
"(更多失败不再逐条打印,见最终错误汇总)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if index.is_multiple_of(PROGRESS_EVERY) || index == total {
|
||||
emit(
|
||||
json,
|
||||
json!({"event": "progress", "done": index, "total": total}),
|
||||
&format!("下载 {index}/{total}"),
|
||||
);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
|
||||
fn emit(json_mode: bool, record: serde_json::Value, text: &str) {
|
||||
if json_mode {
|
||||
progress_line(&record.to_string());
|
||||
} else {
|
||||
progress_line(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// 排版一次安装结果。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
now: u64,
|
||||
dry_run: bool,
|
||||
plan: &InstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Report {
|
||||
let samples = if dry_run {
|
||||
plan.source_samples(engine, now)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut data = json!({
|
||||
"dry_run": dry_run,
|
||||
"version_id": plan.version_id,
|
||||
"jar_id": plan.jar_id,
|
||||
"game_root": plan.game_root.display().to_string(),
|
||||
"plan": plan.summary_json(),
|
||||
"result": report.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
|
||||
"warnings": report.warnings,
|
||||
"files": report.files.iter().map(outcome_json).collect::<Vec<_>>(),
|
||||
})),
|
||||
"instance": registration.map(|registration| json!({
|
||||
"id": registration.profile.id.as_str(),
|
||||
"name": registration.profile.name,
|
||||
"version_id": registration.profile.version_id,
|
||||
"created": registration.created,
|
||||
"config_path": registration.config_path.display().to_string(),
|
||||
"profiles_path": ctx.profiles_config().display().to_string(),
|
||||
})),
|
||||
});
|
||||
if dry_run {
|
||||
data["files"] = json!(plan.files_json());
|
||||
data["sources"] = json!(samples);
|
||||
}
|
||||
|
||||
Report::new(
|
||||
data,
|
||||
human_lines(dry_run, plan, report, registration, &samples, name, version),
|
||||
)
|
||||
}
|
||||
|
||||
/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。
|
||||
fn outcome_json(outcome: &FileOutcome) -> serde_json::Value {
|
||||
json!({
|
||||
"role": outcome.role.as_str(),
|
||||
"name": outcome.name,
|
||||
"dest": outcome.dest.display().to_string(),
|
||||
"status": outcome.status.as_str(),
|
||||
"bytes": outcome.bytes,
|
||||
"source": outcome.source,
|
||||
"error": outcome.failure.as_ref().map(|failure| json!({
|
||||
"kind": failure.kind.as_str(),
|
||||
"message": failure.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn human_lines(
|
||||
dry_run: bool,
|
||||
plan: &InstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
samples: &[serde_json::Value],
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Vec<String> {
|
||||
let mut human = Vec::new();
|
||||
let (total_bytes, unknown) = plan.total_bytes();
|
||||
human.push(if dry_run {
|
||||
format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id)
|
||||
} else {
|
||||
format!("已安装 {}(jar {})", plan.version_id, plan.jar_id)
|
||||
});
|
||||
for (role, (count, bytes)) in plan.by_role() {
|
||||
human.push(format!(
|
||||
" {:<8} {} 个 / {}",
|
||||
role.label(),
|
||||
count,
|
||||
human_bytes(bytes)
|
||||
));
|
||||
}
|
||||
human.push(format!(
|
||||
" 合计 {} 个文件 / {}{}(游戏根 {})",
|
||||
plan.file_count(),
|
||||
human_bytes(total_bytes),
|
||||
if unknown > 0 {
|
||||
format!(" + {unknown} 个大小未知")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
plan.game_root.display()
|
||||
));
|
||||
for sample in samples {
|
||||
let sources: Vec<&str> = sample["candidates"]
|
||||
.as_array()
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|candidate| candidate["source"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
human.push(format!(
|
||||
" 源选择 {} → {}",
|
||||
sample["label"].as_str().unwrap_or_default(),
|
||||
sources.join(" → ")
|
||||
));
|
||||
}
|
||||
for warning in &plan.warnings {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
if let Some(report) = report {
|
||||
human.push(format!(
|
||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||
report.downloaded(),
|
||||
report.skipped(),
|
||||
report.failed(),
|
||||
human_bytes(report.bytes())
|
||||
));
|
||||
if let Some(path) = &report.version_json {
|
||||
human.push(format!("版本清单: {}", path.display()));
|
||||
}
|
||||
}
|
||||
match registration {
|
||||
Some(registration) => human.push(format!(
|
||||
"已登记实例 {}({})→ 版本 {}({})",
|
||||
name,
|
||||
registration.profile.id.as_str(),
|
||||
version,
|
||||
if registration.created {
|
||||
"新建"
|
||||
} else {
|
||||
"已存在,复用"
|
||||
}
|
||||
)),
|
||||
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
|
||||
}
|
||||
human
|
||||
}
|
||||
|
||||
/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。
|
||||
pub fn human_bytes(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{} KB", bytes / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::human_bytes;
|
||||
|
||||
#[test]
|
||||
fn formats_sizes_without_fake_precision() {
|
||||
assert_eq!(human_bytes(0), "0 B");
|
||||
assert_eq!(human_bytes(1023), "1023 B");
|
||||
assert_eq!(human_bytes(1024), "1 KB");
|
||||
assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
|
||||
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。
|
||||
//!
|
||||
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
|
||||
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
|
||||
use unlucky_core::addon::loader::LoaderInstallPlan;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::install::{InstallReport, Registration};
|
||||
|
||||
use super::report::human_bytes;
|
||||
use crate::context::Ctx;
|
||||
use crate::output::Report;
|
||||
|
||||
/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。
|
||||
///
|
||||
/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。
|
||||
pub struct ProcessorSection<'a> {
|
||||
/// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。
|
||||
pub plan: &'a ProcessorPlan,
|
||||
/// processor 依赖库的下载结果。
|
||||
pub libraries: Option<&'a InstallReport>,
|
||||
/// 执行结果。
|
||||
pub run: Option<&'a ProcessorRun>,
|
||||
/// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
/// 排版一次加载器安装结果。
|
||||
///
|
||||
/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、
|
||||
/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
now: u64,
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
name: &str,
|
||||
processors: Option<ProcessorSection<'_>>,
|
||||
) -> Report {
|
||||
let samples = if dry_run {
|
||||
plan.install.source_samples(engine, now)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut data = json!({
|
||||
"dry_run": dry_run,
|
||||
"loader": plan.release.to_json(),
|
||||
"vanilla_install_needed": plan.vanilla_install_needed,
|
||||
"game_root": plan.install.game_root.display().to_string(),
|
||||
"plan": plan.summary_json(),
|
||||
"result": report.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
|
||||
"warnings": report.warnings,
|
||||
})),
|
||||
"instance": registration.map(|registration| json!({
|
||||
"id": registration.profile.id.as_str(),
|
||||
"name": registration.profile.name,
|
||||
"version_id": registration.profile.version_id,
|
||||
"created": registration.created,
|
||||
"config_path": registration.config_path.display().to_string(),
|
||||
"profiles_path": ctx.profiles_config().display().to_string(),
|
||||
})),
|
||||
});
|
||||
if dry_run {
|
||||
data["files"] = json!(plan.install.files_json());
|
||||
data["sources"] = json!(samples);
|
||||
}
|
||||
if let Some(section) = &processors {
|
||||
data["processors"] = processor_json(section);
|
||||
}
|
||||
|
||||
Report::new(
|
||||
data,
|
||||
human_lines(
|
||||
dry_run,
|
||||
plan,
|
||||
report,
|
||||
registration,
|
||||
&samples,
|
||||
name,
|
||||
processors.as_ref(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。
|
||||
fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
|
||||
let mut node = section.plan.to_json();
|
||||
if let Some(object) = node.as_object_mut() {
|
||||
object.insert("skipped".to_string(), json!(section.skipped));
|
||||
object.insert(
|
||||
"libraries_result".to_string(),
|
||||
json!(section.libraries.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
}))),
|
||||
);
|
||||
object.insert(
|
||||
"run".to_string(),
|
||||
json!(section.run.map(ProcessorRun::to_json)),
|
||||
);
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn human_lines(
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
samples: &[serde_json::Value],
|
||||
name: &str,
|
||||
processors: Option<&ProcessorSection<'_>>,
|
||||
) -> Vec<String> {
|
||||
let release = &plan.release;
|
||||
let mut human = Vec::new();
|
||||
human.push(format!(
|
||||
"{} {}《{} {}》(继承原版 {},主类 {})",
|
||||
if dry_run {
|
||||
"加载器安装计划"
|
||||
} else {
|
||||
"已安装加载器"
|
||||
},
|
||||
release.kind.label(),
|
||||
release.mc_version,
|
||||
release.loader_version,
|
||||
release.mc_version,
|
||||
release.version_json.main_class.as_deref().unwrap_or("?"),
|
||||
));
|
||||
human.push(format!(
|
||||
" 版本 id {} → {}",
|
||||
release.version_id,
|
||||
plan.install.version_json_path.display()
|
||||
));
|
||||
human.push(format!(
|
||||
" 原版 {}",
|
||||
if plan.vanilla_install_needed {
|
||||
"未安装:执行时先装原版"
|
||||
} else {
|
||||
"已安装:跳过"
|
||||
}
|
||||
));
|
||||
let (total_bytes, unknown) = plan.install.total_bytes();
|
||||
human.push(format!(
|
||||
" loader 库 {} 个 / {}{}",
|
||||
plan.install.file_count(),
|
||||
human_bytes(total_bytes),
|
||||
if unknown > 0 {
|
||||
format!(" + {unknown} 个大小未知")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
for sample in samples {
|
||||
let sources: Vec<&str> = sample["candidates"]
|
||||
.as_array()
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|candidate| candidate["source"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
human.push(format!(
|
||||
" 源选择 {} → {}",
|
||||
sample["label"].as_str().unwrap_or_default(),
|
||||
sources.join(" → ")
|
||||
));
|
||||
}
|
||||
for warning in plan.warnings() {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
if let Some(section) = processors {
|
||||
human.extend(processor_lines(section));
|
||||
}
|
||||
if let Some(report) = report {
|
||||
human.push(format!(
|
||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||
report.downloaded(),
|
||||
report.skipped(),
|
||||
report.failed(),
|
||||
human_bytes(report.bytes())
|
||||
));
|
||||
if let Some(path) = &report.version_json {
|
||||
human.push(format!("loader 版本清单: {}", path.display()));
|
||||
}
|
||||
}
|
||||
match registration {
|
||||
Some(registration) => human.push(format!(
|
||||
"已登记实例 {}({})→ 版本 {}({})",
|
||||
name,
|
||||
registration.profile.id.as_str(),
|
||||
registration.profile.version_id,
|
||||
if registration.created {
|
||||
"新建"
|
||||
} else {
|
||||
"已存在,复用"
|
||||
}
|
||||
)),
|
||||
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
|
||||
}
|
||||
human
|
||||
}
|
||||
|
||||
/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。
|
||||
fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
|
||||
let plan = section.plan;
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
" installer processors {} 步,需要 {}(installer {})",
|
||||
plan.step_count(),
|
||||
plan.java_requirement.describe(),
|
||||
plan.installer.display()
|
||||
));
|
||||
if section.skipped {
|
||||
lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string());
|
||||
}
|
||||
if let Some(libraries) = section.libraries {
|
||||
lines.push(format!(
|
||||
" processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}",
|
||||
plan.libraries.len(),
|
||||
libraries.downloaded(),
|
||||
libraries.skipped(),
|
||||
libraries.failed()
|
||||
));
|
||||
}
|
||||
if let Some(run) = section.run {
|
||||
lines.push(format!(
|
||||
" Java {}({}){}",
|
||||
run.java
|
||||
.major
|
||||
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
|
||||
run.java.origin.as_str(),
|
||||
run.java.binary.display()
|
||||
));
|
||||
let total = run.steps.len();
|
||||
for step in &run.steps {
|
||||
lines.push(format!(
|
||||
" [{}/{}] {} → {}{}",
|
||||
step.index,
|
||||
total,
|
||||
step.label,
|
||||
if step.status.as_str() == "skipped" {
|
||||
"跳过(产物已就绪)".to_string()
|
||||
} else {
|
||||
format!("执行 {} ms", step.elapsed_ms)
|
||||
},
|
||||
if step.outputs > 0 {
|
||||
format!("(产物 {} 个)", step.outputs)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
}
|
||||
for warning in &run.warnings {
|
||||
lines.push(format!(" 告警: {warning}"));
|
||||
}
|
||||
}
|
||||
for warning in &plan.warnings {
|
||||
lines.push(format!(" 告警: {warning}"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `ull install <version>`(无加载器):M27 原版安装流程的宿主。
|
||||
//!
|
||||
//! ```text
|
||||
//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链)
|
||||
//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取)
|
||||
//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色)
|
||||
//! → [--dry-run 到此为止]
|
||||
//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON)
|
||||
//! → register(profiles.json + config/instances/<id>.json)
|
||||
//! ```
|
||||
//!
|
||||
//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律),
|
||||
//! reqwest 的连接池也不会跨运行时而失效。
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{
|
||||
execute, InstallError, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY,
|
||||
};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 执行 `install`(原版)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
|
||||
pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
||||
let version = args.version.trim().to_string();
|
||||
if version.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"install 需要版本 id:ull install <version>",
|
||||
));
|
||||
}
|
||||
let name = args
|
||||
.name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| version.clone());
|
||||
ensure_name_free(ctx, &name, &version)?;
|
||||
|
||||
let engine = build_engine(ctx);
|
||||
let now = now_ms();
|
||||
let (plan, report) = plan_and_install(
|
||||
ctx,
|
||||
&engine,
|
||||
&version,
|
||||
args.dry_run,
|
||||
args.progress,
|
||||
args.json_progress,
|
||||
)?;
|
||||
|
||||
if let Some(report) = &report {
|
||||
if !report.is_complete() {
|
||||
return Err(failure_error(report, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
let registration = if args.dry_run {
|
||||
None
|
||||
} else {
|
||||
Some(super::register_instance(ctx, &name, &version)?)
|
||||
};
|
||||
|
||||
Ok(report::build(
|
||||
ctx,
|
||||
&engine,
|
||||
now,
|
||||
args.dry_run,
|
||||
&plan,
|
||||
report.as_ref(),
|
||||
registration.as_ref(),
|
||||
&name,
|
||||
&version,
|
||||
))
|
||||
}
|
||||
|
||||
/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本不存在、网络失败、清单装不全、写盘失败。
|
||||
pub(super) fn plan_and_install(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
version: &str,
|
||||
dry_run: bool,
|
||||
progress: bool,
|
||||
json_progress: bool,
|
||||
) -> Result<(InstallPlan, Option<InstallReport>), CliError> {
|
||||
let game = ctx.game();
|
||||
let meta_cache = ctx.layout().meta_cache();
|
||||
let now = now_ms();
|
||||
|
||||
block_on(async {
|
||||
let source = RemoteSource::new(engine, &game, &meta_cache, now);
|
||||
let resolved = source.resolve(version).await?;
|
||||
let fetched = match resolved
|
||||
.asset_index
|
||||
.as_ref()
|
||||
.filter(|index| index.url.is_some())
|
||||
{
|
||||
Some(reference) => {
|
||||
let (index, bytes) = source.asset_index(reference).await?;
|
||||
Some((index, game.asset_index(&reference.id), bytes))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &game,
|
||||
version_id: version,
|
||||
resolved: &resolved,
|
||||
asset_index: fetched.as_ref().map(|(index, _, _)| index),
|
||||
environment: &Environment::host(),
|
||||
})?;
|
||||
if let Some((_, dest, bytes)) = fetched {
|
||||
plan.prefetch(dest, bytes);
|
||||
}
|
||||
if dry_run {
|
||||
return Ok::<Attempt, InstallError>(Attempt { plan, report: None });
|
||||
}
|
||||
// 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。
|
||||
if !plan.is_installable() {
|
||||
let reasons = if plan.warnings.is_empty() {
|
||||
"清单缺少客户端 jar 的下载信息".to_string()
|
||||
} else {
|
||||
plan.warnings.join(";")
|
||||
};
|
||||
return Err(InstallError::new(
|
||||
InstallErrorKind::Metadata,
|
||||
format!("版本 {version} 装不全:{reasons}"),
|
||||
)
|
||||
.with_hint(
|
||||
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`",
|
||||
));
|
||||
}
|
||||
let sink = report::progress_sink(progress, json_progress, plan.file_count());
|
||||
let report = execute(engine, &plan, CONCURRENCY, sink).await?;
|
||||
Ok(Attempt {
|
||||
plan,
|
||||
report: Some(report),
|
||||
})
|
||||
})
|
||||
.map(|attempt| (attempt.plan, attempt.report))
|
||||
.map_err(|error: InstallError| map_install_error(&error, ctx))
|
||||
}
|
||||
|
||||
/// 一次安装尝试的产物(`--dry-run` 只有计划)。
|
||||
struct Attempt {
|
||||
plan: InstallPlan,
|
||||
report: Option<InstallReport>,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! `instances list`:实例与已安装版本的总览。
|
||||
//!
|
||||
//! 安装/登记在 [`super::install`];这里只读(`profiles.json` + `versions/` 的存在性)。
|
||||
//! 读 `profiles.json` 的实现委托给 `core::game::install::instance`,避免 CLI 自带一份
|
||||
//! 解析逻辑(同一份文件两处解析迟早会漂移)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout;
|
||||
use unlucky_core::game::ProfileList;
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// `ull instances list`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `profiles.json` 读不了或解析失败时返回用户错误。
|
||||
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let profiles = read_profiles(ctx)?;
|
||||
let game = ctx.game();
|
||||
let installed = game.installed_versions().unwrap_or_default();
|
||||
|
||||
let mut rows: Vec<Value> = Vec::new();
|
||||
let mut body: Vec<String> = Vec::new();
|
||||
for profile in &profiles.profiles {
|
||||
let present = game.is_version_installed(&profile.version_id);
|
||||
body.push(format!(
|
||||
" {} [{}] {} {}",
|
||||
profile.name,
|
||||
profile.version_id,
|
||||
if present { "已安装" } else { "载荷缺失" },
|
||||
profile.id.as_str()
|
||||
));
|
||||
rows.push(json!({
|
||||
"id": profile.id.as_str(),
|
||||
"name": profile.name,
|
||||
"version_id": profile.version_id,
|
||||
"version_present": present,
|
||||
"game_dir": option_path(profile.game_dir.as_ref()),
|
||||
"java_path": option_path(profile.java_path.as_ref()),
|
||||
"last_played_at": profile.last_played_at,
|
||||
}));
|
||||
}
|
||||
|
||||
let data = json!({
|
||||
"data_root": ctx.data.launcher_home.display().to_string(),
|
||||
"game_root": ctx.data.game_home.display().to_string(),
|
||||
"instances": rows,
|
||||
"installed_versions": installed,
|
||||
});
|
||||
|
||||
let mut human = vec![format!(
|
||||
"实例 {} 个(数据根 {})",
|
||||
profiles.profiles.len(),
|
||||
ctx.data.launcher_home.display()
|
||||
)];
|
||||
if body.is_empty() {
|
||||
human.push(" (空)用 `ull install <version>` 登记第一个实例".to_string());
|
||||
}
|
||||
human.extend(body);
|
||||
if installed.is_empty() {
|
||||
human.push("已安装版本: (无)".to_string());
|
||||
} else {
|
||||
human.push(format!("已安装版本: {}", installed.join(", ")));
|
||||
}
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 读实例清单(文件不存在 = 空列表,不算错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读盘失败或 JSON 非法时返回用户错误。
|
||||
pub fn read_profiles(ctx: &Ctx) -> Result<ProfileList, CliError> {
|
||||
let path = ctx.profiles_config();
|
||||
read_profiles_from_layout(&ctx.layout()).map_err(|error| {
|
||||
CliError::user(
|
||||
"profiles_parse",
|
||||
format!("读实例清单 {} 失败:{error}", path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn option_path(path: Option<&PathBuf>) -> Option<String> {
|
||||
path.map(|path| path.display().to_string())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Java 运行时:`ull java list` / `install` / `vendors`(D13 v1 + D23 v2)。
|
||||
//!
|
||||
//! 四件事:建下载引擎、把 core 的结果排成人读与 `--json` 两种形态、把错误翻成稳定码、
|
||||
//! 从设置里取 JDK 的落点与发行版优先级。探测/下载/解压/映射/回退链全在 `core::game::java`。
|
||||
//!
|
||||
//! D23 之后的三个用户可见变化:
|
||||
//!
|
||||
//! 1. **落点可选**:`java.installRoot` 设了就装到那里;否则 Standard 数据模式装到
|
||||
//! `%APPDATA%\Java\<发行方>\<版本>\`(mac/Linux 见 `game::java::location`),
|
||||
//! Portable 仍在数据根 `java-managed/`(v1 布局不变);
|
||||
//! 2. **发行版可选**:`--vendor temurin|oracle|microsoft|zulu` 钉死一家;不给就按
|
||||
//! `java.vendorPriority` → 地区默认序跑回退链,降级原因进 `--json` 的 `chain` 块;
|
||||
//! 3. **`java vendors`** 一眼看清四家的可用性、镜像与当前顺序。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::download::{
|
||||
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, RegionProfile,
|
||||
};
|
||||
use unlucky_core::game::java::{
|
||||
self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode,
|
||||
DEFAULT_MAJOR,
|
||||
};
|
||||
use unlucky_core::launch::request::JavaSelection;
|
||||
use unlucky_core::platform::paths::ProcessEnv;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
pub use super::java_launch::{resolve_for_launch, LaunchJava};
|
||||
pub use super::java_vendors::vendors;
|
||||
|
||||
/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。
|
||||
const MIN_INSTALLABLE_MAJOR: u32 = 8;
|
||||
|
||||
/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。
|
||||
///
|
||||
/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在
|
||||
/// [`resolve_for_launch`] 里求值(那一步还会看托管目录与版本要求)。
|
||||
#[must_use]
|
||||
pub fn selection(explicit: Option<&Path>) -> JavaSelection {
|
||||
let (binary, origin) = match explicit {
|
||||
Some(path) => (path.to_path_buf(), JavaOrigin::Instance),
|
||||
None => match env_java() {
|
||||
Some(binary) => (binary, JavaOrigin::JavaHome),
|
||||
None => (PathBuf::from(java::java_binary_name()), JavaOrigin::Path),
|
||||
},
|
||||
};
|
||||
let runtime = java::probe(&binary, origin);
|
||||
JavaSelection::new(binary, runtime.major.unwrap_or(DEFAULT_MAJOR), runtime.bits)
|
||||
}
|
||||
|
||||
/// 托管 JDK 可能存在的全部安装根(Portable 数据根 + Standard/Custom 落点,D23 后不止一个)。
|
||||
#[must_use]
|
||||
pub fn managed_roots(ctx: &Ctx) -> Vec<PathBuf> {
|
||||
let settings = JavaSettings::load(ctx);
|
||||
location::scan_roots(
|
||||
ctx.data.mode,
|
||||
&ctx.layout(),
|
||||
settings.install_root.as_deref(),
|
||||
ctx.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
|
||||
/// `ull java list`:探测托管目录 / `JAVA_HOME` / `PATH` / 实例覆盖,并显示各 JDK 的落点。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例清单读不了时返回用户错误(其余探测失败只体现为缺项)。
|
||||
pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let settings = JavaSettings::load(ctx);
|
||||
let roots = managed_roots(ctx);
|
||||
// 托管 → JAVA_HOME → PATH → 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表),
|
||||
// 同一路径只列一次且保留优先级高者(`discover::dedupe` 负责)。
|
||||
let mut candidates = java::candidates_in_roots(&roots);
|
||||
let profiles = super::instances::read_profiles(ctx)?;
|
||||
for profile in &profiles.profiles {
|
||||
if let Some(java_path) = &profile.java_path {
|
||||
candidates.push(java::probe(java_path, JavaOrigin::Instance));
|
||||
}
|
||||
}
|
||||
let candidates = java::discover::dedupe(candidates);
|
||||
let candidates: Vec<JavaRuntime> = candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.major.is_some())
|
||||
.collect();
|
||||
|
||||
let mode = settings.location_mode(ctx);
|
||||
let installed_root = settings.location(ctx).map(|root| root.root).ok();
|
||||
let data = json!({
|
||||
// 兼容 v1 的 `managed_root`(首个扫描根),同时给出多根清单(D23 之后不止一个根)。
|
||||
"managed_root": roots.first().map(|root| root.display().to_string()),
|
||||
"managed_roots": roots.iter().map(|root| root.display().to_string()).collect::<Vec<_>>(),
|
||||
"install": {
|
||||
"mode": mode.as_str(),
|
||||
"root": installed_root.as_ref().map(|root| root.display().to_string()),
|
||||
"override": settings.install_root.as_ref().map(|root| root.display().to_string()),
|
||||
},
|
||||
"candidates": candidates.iter().map(candidate_json).collect::<Vec<_>>(),
|
||||
});
|
||||
let mut human = vec![format!("Java {} 个", candidates.len())];
|
||||
if candidates.is_empty() {
|
||||
human.push(
|
||||
"(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java <path>`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
human.push(format!(
|
||||
"落点模式:{}",
|
||||
describe_mode(mode, settings.install_root.as_deref())
|
||||
));
|
||||
for root in &roots {
|
||||
human.push(format!(" 扫描根:{}", root.display()));
|
||||
}
|
||||
for candidate in &candidates {
|
||||
human.push(format!(
|
||||
" {} {} [{}]",
|
||||
candidate
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "版本未知".to_string()),
|
||||
candidate.binary.display(),
|
||||
source_label(candidate)
|
||||
));
|
||||
}
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// `ull java install <major> [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。
|
||||
///
|
||||
/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎,
|
||||
/// 国内环境按 D7 的地区策略优先院校/国内镜像。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。
|
||||
pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Report, CliError> {
|
||||
if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) {
|
||||
return Err(CliError::usage(format!(
|
||||
"大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)"
|
||||
)));
|
||||
}
|
||||
let settings = JavaSettings::load(ctx);
|
||||
let engine = build_engine(ctx);
|
||||
let install_root = settings.location(ctx)?;
|
||||
let request = match vendor {
|
||||
Some(vendor) => {
|
||||
InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone())
|
||||
}
|
||||
None => InstallRequest::new(major).with_priority(settings.priority.clone()),
|
||||
};
|
||||
let installed =
|
||||
block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?;
|
||||
|
||||
let data = json!({
|
||||
"major": installed.major,
|
||||
"vendor": installed.vendor.id(),
|
||||
"release": installed.release.release_name,
|
||||
"version": installed.release.version,
|
||||
"semver": installed.release.semver,
|
||||
"archive": installed.release.archive.name,
|
||||
"sha256": installed.release.archive.sha256,
|
||||
"size": installed.release.archive.size,
|
||||
"root": installed.root.display().to_string(),
|
||||
"install_mode": installed.mode.as_str(),
|
||||
"binary": installed.binary().display().to_string(),
|
||||
"reused": installed.reused(),
|
||||
"source": installed.downloaded,
|
||||
"bytes": installed.bytes,
|
||||
"files": installed.files,
|
||||
"extracted": installed.extracted,
|
||||
"chain": installed.chain.to_json(),
|
||||
"runtime": candidate_json(&installed.runtime),
|
||||
});
|
||||
|
||||
let mut human = Vec::new();
|
||||
if installed.reused() {
|
||||
human.push(format!(
|
||||
"Java {}({})已就绪({}),未走网络",
|
||||
installed.major,
|
||||
installed.vendor.id(),
|
||||
installed.root.display()
|
||||
));
|
||||
} else {
|
||||
human.push(format!(
|
||||
"已安装 Java {}({},来源 {},{} 字节 / {} 个文件)",
|
||||
installed.major,
|
||||
installed.release.summary(),
|
||||
installed.downloaded.as_deref().unwrap_or("?"),
|
||||
installed.bytes,
|
||||
installed.files
|
||||
));
|
||||
}
|
||||
human.push(format!("落点:{}", installed.location_line()));
|
||||
human.push(format!("可执行文件:{}", installed.binary().display()));
|
||||
if let Some(version) = &installed.runtime.version {
|
||||
human.push(format!("探测:{version}"));
|
||||
}
|
||||
for note in installed.chain.degradations() {
|
||||
human.push(format!("降级:{note}"));
|
||||
}
|
||||
human.push("`ull launch` 会自动优先用它".to_string());
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct JavaSettings {
|
||||
pub(crate) install_root: Option<PathBuf>,
|
||||
pub(crate) priority: Vec<JdkVendor>,
|
||||
}
|
||||
|
||||
impl JavaSettings {
|
||||
/// 读 `<数据根>/.ULL/config/launcher.json`(坏文件退化成默认值,不阻断命令)。
|
||||
pub(crate) fn load(ctx: &Ctx) -> Self {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
let install_root = store
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let priority = store
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
let texts: Vec<String> = items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
vendor::parse_priority(&texts)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
install_root,
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
/// 落点模式(只描述,不建目录)。
|
||||
fn location_mode(&self, ctx: &Ctx) -> LocationMode {
|
||||
if self.install_root.is_some() {
|
||||
LocationMode::Custom
|
||||
} else if ctx.data.mode == unlucky_core::platform::data_dir::DataMode::Standard {
|
||||
LocationMode::Standard
|
||||
} else {
|
||||
LocationMode::Portable
|
||||
}
|
||||
}
|
||||
|
||||
/// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。
|
||||
fn location(&self, ctx: &Ctx) -> Result<java::JavaInstallRoot, CliError> {
|
||||
java::JavaInstallRoot::resolve(
|
||||
ctx.data.mode,
|
||||
&ctx.layout(),
|
||||
self.install_root.as_deref(),
|
||||
ctx.platform,
|
||||
&ProcessEnv,
|
||||
)
|
||||
.map_err(map_java_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。
|
||||
fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String {
|
||||
match mode {
|
||||
LocationMode::Custom => format!(
|
||||
"custom(java.installRoot = {})",
|
||||
override_root.unwrap_or(Path::new("?")).display()
|
||||
),
|
||||
LocationMode::Standard => {
|
||||
"standard(%APPDATA%\\Java\\<发行方>\\<版本> 等系统标准目录)".to_string()
|
||||
}
|
||||
LocationMode::Portable => "portable(<数据根>/.ULL/data/java-managed)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个运行时的 `--json` 形状。
|
||||
fn candidate_json(runtime: &JavaRuntime) -> Value {
|
||||
json!({
|
||||
"binary": runtime.binary.display().to_string(),
|
||||
"source": runtime.origin.as_str(),
|
||||
"source_category": runtime.origin.category(),
|
||||
"version": runtime.version,
|
||||
"major": runtime.major,
|
||||
"bits": runtime.bits,
|
||||
"vendor": runtime.vendor,
|
||||
"install_root": install_root_of(runtime),
|
||||
})
|
||||
}
|
||||
|
||||
/// 托管 JDK 的实际目录(`bin/java` 的上两级);非托管来源为 `None`。
|
||||
fn install_root_of(runtime: &JavaRuntime) -> Option<String> {
|
||||
if runtime.origin != JavaOrigin::Managed {
|
||||
return None;
|
||||
}
|
||||
runtime
|
||||
.binary
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(|dir| dir.display().to_string())
|
||||
}
|
||||
|
||||
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
|
||||
fn build_engine(ctx: &Ctx) -> DownloadEngine {
|
||||
let layout = ctx.layout();
|
||||
let cache_root = layout.cache();
|
||||
let now = now_ms();
|
||||
let region_path = cache_root.join("region.json");
|
||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_path, &profile);
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(region),
|
||||
Arc::new(HealthRegistry::open_in(&cache_root, now)),
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。
|
||||
fn map_java_error(error: java::JavaError) -> CliError {
|
||||
let cli = CliError::user(error.code(), error.message().to_string());
|
||||
match error.hint() {
|
||||
Some(hint) => cli.with_hint(hint.to_string()),
|
||||
None => cli,
|
||||
}
|
||||
}
|
||||
|
||||
/// `JAVA_HOME/bin/java`(存在才返回)。
|
||||
fn env_java() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("JAVA_HOME")?;
|
||||
let binary = PathBuf::from(home)
|
||||
.join("bin")
|
||||
.join(java::java_binary_name());
|
||||
binary.is_file().then_some(binary)
|
||||
}
|
||||
|
||||
/// 运行时 → 展示标签(托管项带上 vendor)。
|
||||
fn source_label(runtime: &JavaRuntime) -> String {
|
||||
match &runtime.vendor {
|
||||
Some(vendor) => format!("{} · {vendor}", runtime.origin.as_str()),
|
||||
None => runtime.origin.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前地区档案(`java vendors` 展示「国内/国外」口径;也给 GUI 复用)。
|
||||
#[must_use]
|
||||
pub fn region_profile(ctx: &Ctx) -> RegionProfile {
|
||||
build_engine(ctx).region().clone()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。
|
||||
//!
|
||||
//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。
|
||||
//! 这里的职责只有两条:①按 D13 的优先链挑一个 Java;②把结果整理成 `--json` 块与告警行。
|
||||
//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::game::java::{self, JavaMismatch, JavaOrigin, JavaRequirement, JavaRuntime};
|
||||
|
||||
use crate::context::Ctx;
|
||||
|
||||
/// 启动期的 Java 解析结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchJava {
|
||||
/// 喂给 `PlanInput.java` 的显式覆盖;`None` = 让 `snapshot::prepare` 按系统兜底。
|
||||
pub override_path: Option<PathBuf>,
|
||||
/// 实际选中的运行时。
|
||||
pub runtime: Option<JavaRuntime>,
|
||||
/// 目标版本对 Java 的要求。
|
||||
pub requirement: JavaRequirement,
|
||||
/// 不符说明(只提示,不阻断)。
|
||||
pub mismatch: Option<JavaMismatch>,
|
||||
}
|
||||
|
||||
impl LaunchJava {
|
||||
/// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
let runtime = self.runtime.as_ref();
|
||||
json!({
|
||||
"binary": runtime.map(|runtime| runtime.binary.display().to_string()),
|
||||
"major": runtime.and_then(|runtime| runtime.major),
|
||||
"bits": runtime.map(|runtime| runtime.bits),
|
||||
"source": runtime.map(|runtime| runtime.origin.as_str()),
|
||||
"vendor": runtime.and_then(|runtime| runtime.vendor.clone()),
|
||||
"required": self.requirement.describe(),
|
||||
"required_major": self.requirement.minimum,
|
||||
"requirement_source": self.requirement.source.as_str(),
|
||||
"mismatch": self.mismatch.map(|mismatch| json!({
|
||||
"code": mismatch.code(),
|
||||
"message": mismatch.message(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/// 告警行(dry-run 与真启动都输出)。
|
||||
///
|
||||
/// 两类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查);
|
||||
/// ②Java 与版本要求不符(D13:提示,不阻断)。
|
||||
#[must_use]
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if let Some(runtime) = &self.runtime {
|
||||
if runtime.major.is_none() {
|
||||
warnings.push(format!(
|
||||
"探测不到 {} 的 `java -version` 输出(路径可能不对)",
|
||||
runtime.binary.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(mismatch) = self.mismatch {
|
||||
warnings.push(mismatch.message());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// 人读一行(Java 来源 + 要求)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let source = self
|
||||
.runtime
|
||||
.as_ref()
|
||||
.map_or("未探测到", |runtime| runtime.origin.as_str());
|
||||
let version = self
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| runtime.major)
|
||||
.map_or_else(|| "未知".to_string(), |major| major.to_string());
|
||||
format!(
|
||||
"Java {version}({source})· 该版本要求 {}",
|
||||
self.requirement.describe()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。
|
||||
///
|
||||
/// 覆盖优先级的求值顺序写在 `core::game::java::probe`(纯函数,可注入候选);
|
||||
/// 这里只负责「实例覆盖单独成路」与「把结果钉成路径」。
|
||||
#[must_use]
|
||||
pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava {
|
||||
let requirement = java::requirement_for_version(&ctx.game(), version_id);
|
||||
let (runtime, mismatch) = match explicit {
|
||||
Some(path) => {
|
||||
let runtime = java::probe(path, JavaOrigin::Instance);
|
||||
let mismatch = runtime
|
||||
.major
|
||||
.and_then(|major| java::check(major, &requirement));
|
||||
(Some(runtime), mismatch)
|
||||
}
|
||||
None => {
|
||||
// 先看托管 + 环境变量;都不满足要求才去扫盘(别为了一次启动白跑 N 次 java -version)。
|
||||
// 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。
|
||||
let roots = super::java::managed_roots(ctx);
|
||||
let mut candidates = java::discover_managed_roots(&roots);
|
||||
candidates.extend(java::system_candidates());
|
||||
let mut resolution = java::resolve(&candidates, &requirement);
|
||||
if resolution.mismatch.is_some() {
|
||||
candidates.extend(java::standard_candidates());
|
||||
resolution = java::resolve(&java::discover::dedupe(candidates), &requirement);
|
||||
}
|
||||
(resolution.runtime, resolution.mismatch)
|
||||
}
|
||||
};
|
||||
// 托管 JDK 要钉死路径(用户 PATH 上的 java 可能正是我们要避开的那一个);
|
||||
// 系统来源交回 `snapshot::prepare` 的 `selection()` 兜底,避免重复实现一遍环境判断。
|
||||
let override_path = match &runtime {
|
||||
Some(runtime) if runtime.origin == JavaOrigin::Managed => Some(runtime.binary.clone()),
|
||||
_ => explicit.map(Path::to_path_buf),
|
||||
};
|
||||
LaunchJava {
|
||||
override_path,
|
||||
runtime,
|
||||
requirement,
|
||||
mismatch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! `ull java vendors`:四家发行版的可用性 / 镜像 / 当前优先级顺序(D23)。
|
||||
//!
|
||||
//! 从 `commands/java.rs` 拆出来(那边 list/install 已经占满一屏)。这里只读不写:
|
||||
//! 不联网、不改配置,把 `mirrors.json` 的表与设置里的优先级排成一张人读 + `--json` 的对照表。
|
||||
//!
|
||||
//! 存在的意义是**可观测**:用户问「国内为什么默认走 Oracle 而不是微软」时,命令要能直接答
|
||||
//! (顺序 + 每家的镜像与实测吞吐),而不是让人翻文档。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::download::MirrorTable;
|
||||
use unlucky_core::game::java::{vendor, JdkVendor};
|
||||
|
||||
use super::java::{region_profile, JavaSettings};
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// `ull java vendors`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 设置文件坏掉时退化为默认值;命令本身不产生用户错误。
|
||||
pub fn vendors(ctx: &Ctx) -> Result<Report, CliError> {
|
||||
let settings = JavaSettings::load(ctx);
|
||||
let domestic = region_profile(ctx).is_domestic();
|
||||
let order = vendor::order(&settings.priority, domestic);
|
||||
let table = MirrorTable::builtin();
|
||||
let rows: Vec<Value> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| vendor_json(&table, *item, index))
|
||||
.collect();
|
||||
let priority_source = if settings.priority.is_empty() {
|
||||
"region_default"
|
||||
} else {
|
||||
"java.vendorPriority"
|
||||
};
|
||||
let data = json!({
|
||||
"region": if domestic { "domestic" } else { "global" },
|
||||
"priority_source": priority_source,
|
||||
"order": vendor::ids(&order),
|
||||
"vendors": rows,
|
||||
});
|
||||
let mut human = vec![format!(
|
||||
"发行版顺序({},来源 {}):{}",
|
||||
if domestic { "国内" } else { "国外" },
|
||||
if settings.priority.is_empty() {
|
||||
"地区默认序"
|
||||
} else {
|
||||
"java.vendorPriority"
|
||||
},
|
||||
vendor::ids(&order).join(" → ")
|
||||
)];
|
||||
for row in &rows {
|
||||
human.push(format!(
|
||||
" {} {} {} 支持 Java {}",
|
||||
row["rank"].as_u64().unwrap_or(0),
|
||||
row["vendor"].as_str().unwrap_or("?"),
|
||||
row["display"].as_str().unwrap_or(""),
|
||||
join_numbers(&row["majors"], "/")
|
||||
));
|
||||
for source in row["sources"].as_array().into_iter().flatten() {
|
||||
if let Some(text) = source.as_str() {
|
||||
human.push(format!(" 源:{text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 一家的 `--json` 形状(源清单按家取,缺表时为空数组而不是报错)。
|
||||
fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value {
|
||||
let sources: Vec<String> = match item {
|
||||
JdkVendor::Temurin => table
|
||||
.adoptium
|
||||
.iter()
|
||||
.filter(|mirror| mirror.enabled)
|
||||
.map(|mirror| {
|
||||
format!(
|
||||
"{}{}",
|
||||
mirror.name,
|
||||
if mirror.latest_only {
|
||||
"(只留最新补丁)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
})
|
||||
.chain(std::iter::once(
|
||||
"GitHub 官方(国内套 gh-proxy)".to_string(),
|
||||
))
|
||||
.collect(),
|
||||
JdkVendor::Oracle => table
|
||||
.oracle_mirrors()
|
||||
.map(|mirror| match mirror.speed.trim() {
|
||||
"" => format!("{} {}", mirror.name, mirror.base()),
|
||||
speed => format!("{} {} {}", mirror.name, mirror.base(), speed),
|
||||
})
|
||||
.collect(),
|
||||
JdkVendor::Microsoft => table
|
||||
.microsoft_openjdk()
|
||||
.map(|microsoft| {
|
||||
let speed = if microsoft.measured_speed.trim().is_empty() {
|
||||
"实测约 400 KB/s".to_string()
|
||||
} else {
|
||||
microsoft.measured_speed.clone()
|
||||
};
|
||||
vec![format!(
|
||||
"aka.ms → VS CDN({speed});无国内镜像、无 GitHub 资产"
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
JdkVendor::Zulu => table
|
||||
.zulu()
|
||||
.map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)])
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
json!({
|
||||
"rank": index + 1,
|
||||
"vendor": item.id(),
|
||||
"display": item.display(),
|
||||
"majors": item.hint_majors(),
|
||||
"supported": (8..=99u32).filter(|major| item.supports(*major)).collect::<Vec<_>>(),
|
||||
"sources": sources,
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON 数字数组 → `21/17/11` 这种分隔串。
|
||||
fn join_numbers(value: &Value, separator: &str) -> String {
|
||||
value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_u64)
|
||||
.map(|number| number.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! `ull launch <instance> [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。
|
||||
//!
|
||||
//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链
|
||||
//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录,
|
||||
//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}`
|
||||
//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::game::install::instance::{
|
||||
read_instance_config, write_instance_config, InstanceConfig, Isolation,
|
||||
};
|
||||
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
|
||||
use unlucky_core::game::ProfileList;
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::cli::LaunchArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
use super::identity::{self, Identity};
|
||||
use super::snapshot::{self, PlanInput};
|
||||
|
||||
/// 执行 `launch`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。
|
||||
pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
||||
let profiles = super::instances::read_profiles(ctx)?;
|
||||
let game = ctx.game();
|
||||
let profile = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance);
|
||||
|
||||
let (input, instance_found) = match profile {
|
||||
Some(profile) => (
|
||||
PlanInput {
|
||||
version_id: profile.version_id.clone(),
|
||||
instance_id: profile.id.as_str().to_string(),
|
||||
instance_name: profile.name.clone(),
|
||||
game_dir: profile.game_dir.clone(),
|
||||
java: args.java.clone().or_else(|| profile.java_path.clone()),
|
||||
username: args.username.clone(),
|
||||
max_memory: args.max_memory,
|
||||
..PlanInput::default()
|
||||
},
|
||||
true,
|
||||
),
|
||||
None => {
|
||||
// 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。
|
||||
if !game.is_version_installed(&args.instance) {
|
||||
return Err(CliError::user(
|
||||
"not_found",
|
||||
format!(
|
||||
"既没有实例 `{}`,也没有版本 `{}`",
|
||||
args.instance, args.instance
|
||||
),
|
||||
)
|
||||
.with_hint(instance_hint(&profiles)));
|
||||
}
|
||||
(
|
||||
PlanInput {
|
||||
version_id: args.instance.clone(),
|
||||
instance_name: args.instance.clone(),
|
||||
java: args.java.clone(),
|
||||
username: args.username.clone(),
|
||||
max_memory: args.max_memory,
|
||||
..PlanInput::default()
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
|
||||
// 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。
|
||||
let identity = identity::resolve(
|
||||
ctx,
|
||||
args.account.as_deref(),
|
||||
args.username.as_deref(),
|
||||
args.dry_run,
|
||||
)?;
|
||||
let input = PlanInput {
|
||||
auth: Some(identity.placeholders.clone()),
|
||||
..input
|
||||
};
|
||||
let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?;
|
||||
let input = PlanInput {
|
||||
// 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。
|
||||
game_dir: Some(isolation.game_dir.clone()),
|
||||
..input
|
||||
};
|
||||
|
||||
// D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。
|
||||
let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref());
|
||||
let input = PlanInput {
|
||||
java: java.override_path.clone(),
|
||||
..input
|
||||
};
|
||||
|
||||
let plan = snapshot::prepare(ctx, &input)?;
|
||||
let config = LauncherConfig::new(ctx.sessions_root())
|
||||
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||
let launcher = Launcher::new(config);
|
||||
let mut human = Vec::new();
|
||||
human.push(format!(
|
||||
"隔离档位 {}(来源 {})· 运行目录 {}",
|
||||
isolation.isolation.as_str(),
|
||||
isolation.source.as_str(),
|
||||
isolation.game_dir.display()
|
||||
));
|
||||
if let Some(warning) = &isolation.warning {
|
||||
human.push(format!("提示: {warning}"));
|
||||
}
|
||||
human.push(identity_line(&identity));
|
||||
for note in &identity.notes {
|
||||
human.push(format!("提示: {note}"));
|
||||
}
|
||||
human.push(java.summary());
|
||||
for warning in java.warnings() {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
|
||||
if args.dry_run {
|
||||
let dry = launcher
|
||||
.dry_run(&plan.request)
|
||||
.map_err(|error| map_launch_error(&error, ctx))?;
|
||||
let mut lines = vec![dry.line().to_string()];
|
||||
lines.push(format!(
|
||||
"(--dry-run:未启动进程 · classpath {} 项 · 编码 {})",
|
||||
dry.classpath_entries, dry.encoding
|
||||
));
|
||||
if let Some(natives) = &dry.natives {
|
||||
lines.push(format!("natives:{}", natives.summary()));
|
||||
}
|
||||
let mut warnings = dry.warnings.clone();
|
||||
warnings.extend(java.warnings());
|
||||
// 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。
|
||||
for warning in &dry.warnings {
|
||||
lines.push(format!("告警: {warning}"));
|
||||
}
|
||||
human.extend(lines);
|
||||
let natives = dry.natives.as_ref().map(|natives| {
|
||||
json!({
|
||||
"dir": natives.dir.display().to_string(),
|
||||
"files": natives.files,
|
||||
"skipped": natives.skipped,
|
||||
"ignored": natives.ignored,
|
||||
"jars": natives.jars,
|
||||
"custom": natives.custom,
|
||||
"up_to_date": natives.up_to_date,
|
||||
"era": natives.era.as_str(),
|
||||
})
|
||||
});
|
||||
let data = json!({
|
||||
"dry_run": true,
|
||||
"instance_found": instance_found,
|
||||
"version_id": plan.version_id,
|
||||
"isolation": isolation.isolation.as_str(),
|
||||
"isolation_source": isolation.source.as_str(),
|
||||
"game_dir": isolation.game_dir.display().to_string(),
|
||||
"mode_change_warning": isolation.warning,
|
||||
"account": account_json(&identity),
|
||||
"java": java.to_json(),
|
||||
"program": dry.program.display().to_string(),
|
||||
"argv": dry.argv,
|
||||
"display": dry.display,
|
||||
"classpath_entries": dry.classpath_entries,
|
||||
"encoding": dry.encoding,
|
||||
"warnings": warnings,
|
||||
"natives": natives,
|
||||
"adapter_warnings": plan.warnings,
|
||||
});
|
||||
return Ok(Report::new(data, human));
|
||||
}
|
||||
|
||||
let handle = launcher
|
||||
.launch(&plan.request)
|
||||
.map_err(|error| map_launch_error(&error, ctx))?;
|
||||
let data = json!({
|
||||
"dry_run": false,
|
||||
"instance_found": instance_found,
|
||||
"isolation": isolation.isolation.as_str(),
|
||||
"isolation_source": isolation.source.as_str(),
|
||||
"game_dir": isolation.game_dir.display().to_string(),
|
||||
"mode_change_warning": isolation.warning,
|
||||
"account": account_json(&identity),
|
||||
"java": java.to_json(),
|
||||
"warnings": java.warnings(),
|
||||
"session_id": handle.session_id,
|
||||
"log_path": handle.log_path.display().to_string(),
|
||||
"exit_code": handle.exit.code,
|
||||
"exit_type": handle.exit.exit_type.as_str(),
|
||||
"uptime_ms": handle.uptime_ms,
|
||||
"summary": handle.exit.summary(),
|
||||
"artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::<Vec<_>>(),
|
||||
"adapter_warnings": plan.warnings,
|
||||
});
|
||||
human.push(format!(
|
||||
"会话 {} 结束:{}",
|
||||
handle.session_id,
|
||||
handle.exit.summary()
|
||||
));
|
||||
human.push(format!("事件流: {}", handle.log_path.display()));
|
||||
Ok(Report::new(data, human))
|
||||
}
|
||||
|
||||
/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。
|
||||
struct IsolationPlan {
|
||||
/// 生效运行目录(喂 `--gameDir`)。
|
||||
game_dir: PathBuf,
|
||||
/// 生效档位。
|
||||
isolation: Isolation,
|
||||
/// 档位来自哪一层(`temporary` / `instance` / `global`)。
|
||||
source: unlucky_core::game::IsolationSource,
|
||||
/// 切换检测提示(两侧都有内容时;`None` = 不必提示)。
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 按覆盖链派生运行目录,并在启动时做一次切换检测。
|
||||
///
|
||||
/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。
|
||||
/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`,
|
||||
/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。
|
||||
///
|
||||
/// 遗留实例(`profiles.json` 有档案、`config/instances/<id>.json` 还没有——M27 之前的形态)
|
||||
/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。
|
||||
/// `--dry-run` 仍然一个字节都不写。
|
||||
fn resolve_isolation(
|
||||
ctx: &Ctx,
|
||||
profile: Option<&unlucky_core::game::InstanceProfile>,
|
||||
input: &PlanInput,
|
||||
dry_run: bool,
|
||||
) -> Result<IsolationPlan, CliError> {
|
||||
let global_mode = global_mode(ctx);
|
||||
let mut config = match profile {
|
||||
Some(profile) => {
|
||||
read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| {
|
||||
CliError::user(
|
||||
"instance_config",
|
||||
format!("读实例设置 {} 失败:{error}", profile.id.as_str()),
|
||||
)
|
||||
})?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。
|
||||
let bootstrap = config.is_none() && profile.is_some();
|
||||
if bootstrap {
|
||||
config = Some(InstanceConfig::new(
|
||||
input.instance_id.clone(),
|
||||
input.version_id.clone(),
|
||||
));
|
||||
}
|
||||
// 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义
|
||||
// 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**,
|
||||
// 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。
|
||||
if let Some(config) = config.as_mut() {
|
||||
if config.isolation.is_none() {
|
||||
if let Some(dir) = profile
|
||||
.and_then(|profile| profile.game_dir.clone())
|
||||
.filter(|dir| !dir.as_os_str().is_empty())
|
||||
{
|
||||
config.isolation = Some(Isolation::Custom);
|
||||
config.game_dir = Some(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let derivation = derive_game_dir(
|
||||
&ctx.game(),
|
||||
&input.version_id,
|
||||
global_mode,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map_err(|error| CliError::user("isolation", error.to_string()))?;
|
||||
|
||||
// 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。
|
||||
let at = format_rfc3339_millis(now());
|
||||
let mut dirty = false;
|
||||
let warning = match config.as_mut() {
|
||||
Some(config) => {
|
||||
let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation)
|
||||
.map_err(|error| CliError::user("isolation_probe", error.to_string()))?;
|
||||
let warning = config.take_mode_change_warning();
|
||||
dirty = bootstrap || changed || warning.is_some();
|
||||
warning.map(|warning| warning.message())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。
|
||||
if !dry_run && dirty {
|
||||
if let Some(config) = config.as_ref() {
|
||||
write_instance_config(&ctx.layout(), config).map_err(|error| {
|
||||
CliError::user("instance_config", format!("写实例设置失败:{error}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(IsolationPlan {
|
||||
game_dir: derivation.game_dir,
|
||||
isolation: derivation.isolation,
|
||||
source: derivation.source,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。
|
||||
fn global_mode(ctx: &Ctx) -> IsolationMode {
|
||||
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
|
||||
store
|
||||
.doc()
|
||||
.effective_str("instances.mode")
|
||||
.and_then(IsolationMode::parse)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
|
||||
let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display());
|
||||
CliError::user("launch", error.to_string()).with_hint(hint)
|
||||
}
|
||||
|
||||
fn instance_hint(profiles: &ProfileList) -> String {
|
||||
if profiles.profiles.is_empty() {
|
||||
"用 `ull instances list` 查看实例,或 `ull install <version>` 登记一个".to_string()
|
||||
} else {
|
||||
let names: Vec<&str> = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| profile.name.as_str())
|
||||
.collect();
|
||||
format!("可用实例:{}", names.join("、"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读一行:本次用谁的身份启动。
|
||||
fn identity_line(identity: &Identity) -> String {
|
||||
match &identity.account {
|
||||
Some(meta) => format!(
|
||||
"账户 {}({})· UUID {}",
|
||||
meta.display_name(),
|
||||
meta.kind.label(),
|
||||
identity.placeholders.uuid
|
||||
),
|
||||
None => format!(
|
||||
"账户 离线占位 {}(未登记账户)",
|
||||
identity.placeholders.player_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。
|
||||
fn account_json(identity: &Identity) -> serde_json::Value {
|
||||
match &identity.account {
|
||||
Some(meta) => json!({
|
||||
"name": meta.display_name(),
|
||||
"kind": meta.kind.as_str(),
|
||||
"profile_id": identity.placeholders.uuid,
|
||||
"account_id": meta.id.as_str(),
|
||||
"registered": true,
|
||||
}),
|
||||
None => json!({
|
||||
"name": identity.placeholders.player_name,
|
||||
"kind": "offline-placeholder",
|
||||
"profile_id": identity.placeholders.uuid,
|
||||
"registered": false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 元命令:`--version` / 无子命令 / `help`。
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cli::help;
|
||||
use crate::os;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 启动器名(`${launcher_name}` 与 `--version` 输出共用)。
|
||||
pub const LAUNCHER_NAME: &str = "UnluckyLauncher";
|
||||
|
||||
/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。
|
||||
///
|
||||
/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。
|
||||
#[must_use]
|
||||
pub fn version() -> Report {
|
||||
let info = os::detect();
|
||||
let data = json!({
|
||||
"name": "ull",
|
||||
"package": env!("CARGO_PKG_NAME"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"os": os::to_json(&info),
|
||||
});
|
||||
let mut human = vec![
|
||||
format!(
|
||||
"ull {}({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
env!("CARGO_PKG_NAME")
|
||||
),
|
||||
format!("os: {}", os::summary_line(&info)),
|
||||
];
|
||||
human.extend(os::notes(&info));
|
||||
Report::new(data, human)
|
||||
}
|
||||
|
||||
/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。
|
||||
#[must_use]
|
||||
pub fn welcome() -> Report {
|
||||
let version_report = version();
|
||||
let mut human = version_report.human;
|
||||
human.push(String::new());
|
||||
human.extend(help::general().lines().map(str::to_string));
|
||||
let mut data = version_report.data;
|
||||
data["help"] = json!(help::general());
|
||||
Report::new(data, human)
|
||||
}
|
||||
|
||||
/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。
|
||||
#[must_use]
|
||||
pub fn help(topic: Option<&str>) -> Report {
|
||||
let text = topic.and_then(help::topic).unwrap_or_else(help::general);
|
||||
let data = json!({
|
||||
"topic": topic,
|
||||
"text": text,
|
||||
});
|
||||
Report::new(data, text.lines().map(str::to_string).collect())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod agent;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod install;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
pub mod java_vendors;
|
||||
pub mod launch;
|
||||
pub mod meta;
|
||||
pub mod sessions;
|
||||
pub mod snapshot;
|
||||
|
||||
use crate::cli::{Command, Globals, Invocation};
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::{Outcome, Report};
|
||||
|
||||
/// 执行一条已解析的命令。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法/数据根/目标不存在等用户错误,或序列化失配等内部错误。
|
||||
pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
|
||||
match &invocation.command {
|
||||
// 这三条不需要数据根(`--version` 在不达基线的机器上也要能跑,用于诊断)。
|
||||
Command::Welcome => Ok(Outcome::new(meta::welcome())),
|
||||
Command::Version => Ok(Outcome::new(meta::version())),
|
||||
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
|
||||
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
|
||||
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
|
||||
Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)),
|
||||
Command::SessionsList { limit } => {
|
||||
with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit))
|
||||
}
|
||||
Command::Logs(args) => with_ctx(&invocation.globals, |ctx| sessions::logs(ctx, args)),
|
||||
Command::Diag(args) => with_ctx(&invocation.globals, |ctx| sessions::diag(ctx, args)),
|
||||
Command::ConfigGet(args) => with_ctx(&invocation.globals, |ctx| {
|
||||
config::get(ctx, args.key.as_deref(), args.show_secret)
|
||||
}),
|
||||
Command::ConfigSet(args) => with_ctx(&invocation.globals, |ctx| {
|
||||
config::set(ctx, &args.key, &args.value)
|
||||
}),
|
||||
Command::JavaList => with_ctx(&invocation.globals, java::list),
|
||||
Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| {
|
||||
java::install(ctx, *major, *vendor)
|
||||
}),
|
||||
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
|
||||
Command::AccountsList => with_ctx(&invocation.globals, accounts::list),
|
||||
Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)),
|
||||
Command::AccountsRemove { name } => {
|
||||
with_ctx(&invocation.globals, |ctx| accounts::remove(ctx, name))
|
||||
}
|
||||
// `AgentServe` 由 `runner::run` 提前分流(stdio 形态下 stdout 就是协议流,
|
||||
// 不能走「先返回 Report 再打印」的路径);这里只兜住「有人直接调 commands::run」。
|
||||
Command::AgentServe(_) => Err(CliError::usage(
|
||||
"agent serve 必须在进程入口直接运行(runner 会提前分流)",
|
||||
)),
|
||||
Command::AgentConfig { port } => {
|
||||
with_ctx(&invocation.globals, |ctx| agent::config_report(ctx, *port))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `core::auth` 的错误 → CLI 错误(保留稳定机读码;文案已是人话,这里只补「下一步」)。
|
||||
///
|
||||
/// 集中一处的原因:账户相关命令(`accounts *`)与 `launch` 的身份解析要为**同一批**
|
||||
/// 错误给出一致的码与提示——两处各写一份必然会漂。
|
||||
pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError {
|
||||
use unlucky_core::auth::body;
|
||||
use unlucky_core::auth::AuthError;
|
||||
let hint = match &error {
|
||||
AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()),
|
||||
AuthError::Timeout { .. } => {
|
||||
Some("重新执行 `ull accounts add msa` 拿新的用户码".to_string())
|
||||
}
|
||||
AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()),
|
||||
AuthError::Unsupported(_) => {
|
||||
Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string())
|
||||
}
|
||||
AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()),
|
||||
AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()),
|
||||
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => {
|
||||
Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string())
|
||||
}
|
||||
AuthError::Xbox { .. } => {
|
||||
Some("去 xbox.com / 家庭组设置里解决该账号的 Xbox 限制".to_string())
|
||||
}
|
||||
// M38 的两类分流落到 hint 行:拦截页(HTML/纯文本/空体)先查网络,
|
||||
// JSON 拒绝按步骤查许可与区域。`code()` 仍是 "denied"(M31 兼容),
|
||||
// 细分结论由 `detail_code()` 带在 hint 里,排障脚本照样能机读分流。
|
||||
AuthError::Response(detail) if detail.is_interception() => Some(format!(
|
||||
"检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案);诊断码 {}",
|
||||
detail.detail_code()
|
||||
)),
|
||||
AuthError::Response(detail) => Some(format!(
|
||||
"{};诊断码 {}",
|
||||
body::advice(&detail.step, detail.kind),
|
||||
detail.detail_code()
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
let cli = CliError::user(error.code(), error.message());
|
||||
match hint {
|
||||
Some(hint) => cli.with_hint(hint),
|
||||
None => cli,
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。
|
||||
fn with_ctx<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
|
||||
where
|
||||
F: FnOnce(&Ctx) -> Result<Report, CliError>,
|
||||
{
|
||||
let ctx = Ctx::resolve(globals)?;
|
||||
ctx.ensure_supported()?;
|
||||
let report = action(&ctx)?;
|
||||
Ok(Outcome::with_warnings(report, ctx.notes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::map_auth_error;
|
||||
use unlucky_core::auth::{AuthError, BodyKind, ResponseDetail};
|
||||
|
||||
fn response(step: &str, kind: BodyKind) -> AuthError {
|
||||
AuthError::Response(Box::new(ResponseDetail {
|
||||
step: step.to_string(),
|
||||
what: "Minecraft 登录被拒".to_string(),
|
||||
status: 403,
|
||||
content_type: None,
|
||||
kind,
|
||||
server_message: None,
|
||||
digest: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_hints_split_network_from_license_and_carry_detail_code() {
|
||||
let blocked = map_auth_error(response("login_with_xbox", BodyKind::Html));
|
||||
assert_eq!(blocked.code(), "denied", "code() 仍是 M31 的 denied");
|
||||
let hint = blocked.hint().expect("拦截页要有 hint").to_string();
|
||||
assert!(hint.contains("代理"), "{hint}");
|
||||
assert!(hint.contains("login_with_xbox_non_json"), "{hint}");
|
||||
|
||||
let denied = map_auth_error(response("login_with_xbox", BodyKind::Json));
|
||||
let hint = denied.hint().expect("JSON 拒绝要有 hint").to_string();
|
||||
assert!(hint.contains("许可"), "{hint}");
|
||||
assert!(!hint.contains("代理"), "{hint}");
|
||||
assert!(hint.contains("login_with_xbox_403"), "{hint}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。
|
||||
//!
|
||||
//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5:
|
||||
//! - `<sessions_root>/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序);
|
||||
//! - `<sessions_root>/game/<id>.jsonl`:事件流(`kind` + `payload`)。
|
||||
//!
|
||||
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
||||
use unlucky_core::diagnose::loader::RuleLoader;
|
||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||
|
||||
use crate::cli::{DiagArgs, LogsArgs};
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// `ull sessions list [--limit N]`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 索引文件读不了时返回用户错误(不存在 = 空列表)。
|
||||
pub fn list(ctx: &Ctx, limit: usize) -> Result<Report, CliError> {
|
||||
let index = ctx.session_index();
|
||||
let entries = session_index::read_all(&index)
|
||||
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
|
||||
let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect();
|
||||
|
||||
let rows: Vec<Value> = shown
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"session_id": entry.session_id,
|
||||
"instance_id": entry.instance_id,
|
||||
"instance_name": entry.instance_name,
|
||||
"game_version": entry.game_version,
|
||||
"started_at": entry.started_at,
|
||||
"ended_at": entry.ended_at,
|
||||
"duration_ms": entry.duration_ms,
|
||||
"exit_code": entry.exit_code,
|
||||
"exit_type": entry.exit_type,
|
||||
"silent_failure": entry.silent_failure,
|
||||
"window_detected": entry.window_detected,
|
||||
"crash_found": entry.crash_found,
|
||||
"diagnosis_top": entry.diagnosis_top,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut human = vec![format!(
|
||||
"会话 {} 条(索引 {})",
|
||||
shown.len(),
|
||||
index.display()
|
||||
)];
|
||||
if shown.is_empty() {
|
||||
human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string());
|
||||
}
|
||||
for entry in &shown {
|
||||
human.push(format!(
|
||||
" {} {} {} {} {}",
|
||||
entry.session_id,
|
||||
entry.instance_name,
|
||||
entry.game_version,
|
||||
entry.started_at,
|
||||
exit_label(entry),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Report::new(json!({ "sessions": rows }), human))
|
||||
}
|
||||
|
||||
/// `ull logs <sid> [--tail N]`:打印事件流末尾 N 条。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。
|
||||
pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result<Report, CliError> {
|
||||
let entry = find_entry(ctx, &args.session_id)?;
|
||||
let path = event_path(ctx, &entry);
|
||||
let events = read_events(&path)?;
|
||||
let start = events.len().saturating_sub(args.tail.max(1));
|
||||
let shown = &events[start..];
|
||||
|
||||
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
|
||||
let shown: Vec<Value> = shown.iter().map(redact_value).collect();
|
||||
let mut human = vec![format!(
|
||||
"会话 {} · {} · 事件 {} 条(显示末尾 {})",
|
||||
entry.session_id,
|
||||
entry.instance_name,
|
||||
events.len(),
|
||||
shown.len()
|
||||
)];
|
||||
human.extend(shown.iter().map(render_event));
|
||||
|
||||
Ok(Report::new(
|
||||
json!({
|
||||
"session_id": entry.session_id,
|
||||
"path": path.display().to_string(),
|
||||
"total": events.len(),
|
||||
"events": shown,
|
||||
}),
|
||||
human,
|
||||
))
|
||||
}
|
||||
|
||||
/// `ull diag <sid> [--tail N]`:规则引擎诊断。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话不存在、事件流读不了、结论序列化失败时返回错误。
|
||||
pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
||||
let entry = find_entry(ctx, &args.session_id)?;
|
||||
let path = event_path(ctx, &entry);
|
||||
let events = read_events(&path)?;
|
||||
|
||||
let mut stdout_lines: Vec<&str> = Vec::new();
|
||||
let mut stderr_lines: Vec<&str> = Vec::new();
|
||||
let mut error_lines: u32 = 0;
|
||||
for event in &events {
|
||||
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
match event.get("kind").and_then(Value::as_str) {
|
||||
Some("stdout") => stdout_lines.push(line),
|
||||
Some("stderr") => {
|
||||
stderr_lines.push(line);
|
||||
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
|
||||
error_lines += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let stdout_text = tail_join(&stdout_lines, args.tail);
|
||||
let stderr_text = tail_join(&stderr_lines, args.tail);
|
||||
|
||||
let exit = ExitSignal::classify(
|
||||
entry.exit_code,
|
||||
Some(entry.duration_ms),
|
||||
error_lines,
|
||||
&stderr_text,
|
||||
);
|
||||
let mut input = DiagnosisInput::new().with_exit(exit);
|
||||
if !stdout_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stdout, stdout_text);
|
||||
}
|
||||
if !stderr_text.is_empty() {
|
||||
input.push_text(SignalOrigin::Stderr, stderr_text);
|
||||
}
|
||||
|
||||
let loader = RuleLoader::embedded();
|
||||
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
|
||||
let report = diagnoser.analyze(&input);
|
||||
let record = serde_json::to_value(report.to_record())
|
||||
.map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?;
|
||||
|
||||
let mut human = vec![format!(
|
||||
"会话 {} · {} · {} · {}",
|
||||
entry.session_id,
|
||||
entry.instance_name,
|
||||
entry.game_version,
|
||||
exit_label(&entry)
|
||||
)];
|
||||
match report.primary() {
|
||||
Some(finding) => {
|
||||
human.push(format!("主因: {}", finding.message));
|
||||
if let Some(detail) = &finding.detail {
|
||||
human.push(format!("说明: {detail}"));
|
||||
}
|
||||
for evidence in finding.evidence.iter().take(3) {
|
||||
human.push(format!("证据: {}", evidence.render()));
|
||||
}
|
||||
}
|
||||
None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()),
|
||||
}
|
||||
human.push(format!(
|
||||
"规则评估 {} 条 · 结论 {} 条 · 次生 {} 条",
|
||||
report.rules_evaluated,
|
||||
report.findings.len(),
|
||||
report.secondary.len()
|
||||
));
|
||||
let human: Vec<String> = human
|
||||
.iter()
|
||||
.map(|line| unlucky_agent::redact::redact_text(line))
|
||||
.collect();
|
||||
|
||||
let payload = redact_value(&json!({
|
||||
"session_id": entry.session_id,
|
||||
"instance_id": entry.instance_id,
|
||||
"game_version": entry.game_version,
|
||||
"exit": {
|
||||
"code": entry.exit_code,
|
||||
"exit_type": entry.exit_type,
|
||||
"silent_failure": entry.silent_failure,
|
||||
"duration_ms": entry.duration_ms,
|
||||
},
|
||||
"diagnosis": record,
|
||||
}));
|
||||
Ok(Report::new(payload, human))
|
||||
}
|
||||
|
||||
fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
|
||||
let index = ctx.session_index();
|
||||
let entries = session_index::read_all(&index)
|
||||
.map_err(|error| CliError::io("读会话索引", &index, &error))?;
|
||||
if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
let matches: Vec<&SessionIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.session_id.starts_with(key))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[only] => Ok((*only).clone()),
|
||||
[] => Err(
|
||||
CliError::user("session_not_found", format!("找不到会话 `{key}`"))
|
||||
.with_hint(format!("索引文件:{}", index.display())),
|
||||
),
|
||||
_ => Err(CliError::user(
|
||||
"session_ambiguous",
|
||||
format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
|
||||
ctx.sessions_root().join("game").join(&entry.log_path)
|
||||
}
|
||||
|
||||
fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|error| CliError::io("读会话事件流", path, &error))?;
|
||||
Ok(text
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn tail_join(lines: &[&str], tail: usize) -> String {
|
||||
let start = lines.len().saturating_sub(tail.max(1));
|
||||
let mut text = lines[start..].join("\n");
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn render_event(event: &Value) -> String {
|
||||
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
|
||||
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
|
||||
let detail = event
|
||||
.pointer("/payload/line")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| event.pointer("/payload/summary").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
format!("[{ts}] {kind} {detail}")
|
||||
}
|
||||
|
||||
/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。
|
||||
fn redact_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)),
|
||||
Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
|
||||
Value::Object(map) => Value::Object(
|
||||
map.iter()
|
||||
.map(|(key, child)| (key.clone(), redact_value(child)))
|
||||
.collect(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_label(entry: &SessionIndexEntry) -> String {
|
||||
let code = entry
|
||||
.exit_code
|
||||
.map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}"));
|
||||
let exit_type = entry
|
||||
.exit_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let silent = if entry.silent_failure {
|
||||
" · 静默失败"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{code}/{exit_type}{silent}")
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。
|
||||
//!
|
||||
//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` /
|
||||
//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::{self, ProfileId, VersionJson};
|
||||
use unlucky_core::launch::adapter::{
|
||||
self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions};
|
||||
|
||||
/// 装配输入(来自命令行 + 实例档案)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PlanInput {
|
||||
/// 版本 id(实例绑定的 `versions/<id>/`)。
|
||||
pub version_id: String,
|
||||
/// 实例 id(写进会话索引;无档案时为空)。
|
||||
pub instance_id: String,
|
||||
/// 实例显示名。
|
||||
pub instance_name: String,
|
||||
/// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。
|
||||
pub game_dir: Option<PathBuf>,
|
||||
/// Java 可执行文件覆盖。
|
||||
pub java: Option<PathBuf>,
|
||||
/// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。
|
||||
pub username: Option<String>,
|
||||
/// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。
|
||||
pub auth: Option<AuthPlaceholders>,
|
||||
/// 最大堆 MB。
|
||||
pub max_memory: Option<u32>,
|
||||
}
|
||||
|
||||
/// 装配结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Plan {
|
||||
/// 实际使用的版本 id。
|
||||
pub version_id: String,
|
||||
/// 装配好的启动请求。
|
||||
pub request: LaunchRequest,
|
||||
/// 非致命告警(缺主类/缺资源索引等)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 装配一个 [`LaunchRequest`]。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。
|
||||
pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result<Plan, CliError> {
|
||||
let game = ctx.game();
|
||||
let version_id = input.version_id.clone();
|
||||
let manifest_path = game.version_json(&version_id);
|
||||
let manifest = read_version_json(&manifest_path)?;
|
||||
|
||||
let source = |id: &str| -> Option<VersionJson> {
|
||||
let path = game.version_json(id);
|
||||
read_version_json(&path).ok()
|
||||
};
|
||||
let resolved = game::resolve(&source, &manifest).map_err(|error| {
|
||||
CliError::user("inherit", format!("解析继承链失败:{error}"))
|
||||
.with_hint(format!("检查 {}", manifest_path.display()))
|
||||
})?;
|
||||
|
||||
let environment = Environment::host();
|
||||
let expanded = resolved
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|arguments| arguments.expand(&environment))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut libraries: Vec<LibraryInput> = Vec::new();
|
||||
for library in resolved.libraries.as_deref().unwrap_or_default() {
|
||||
if !library.applies(&environment) || library.native_classifier(&environment).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Ok(coordinate) = library.coordinate() else {
|
||||
continue;
|
||||
};
|
||||
let path = if library.is_local() {
|
||||
game.version_library(&version_id, &coordinate)
|
||||
} else {
|
||||
game.library(&coordinate)
|
||||
};
|
||||
libraries.push(LibraryInput::new(path, library.name.clone()));
|
||||
}
|
||||
|
||||
// natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。
|
||||
let natives = unlucky_core::launch::natives::collect(
|
||||
&game,
|
||||
&version_id,
|
||||
resolved.libraries.as_deref().unwrap_or_default(),
|
||||
&environment,
|
||||
);
|
||||
|
||||
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
|
||||
let primary_jar = game.version_jar(&jar_id);
|
||||
// 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根
|
||||
// (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。
|
||||
let game_dir = input
|
||||
.game_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.data.game_home.clone());
|
||||
let assets_root = game.assets_dir();
|
||||
let assets_index = resolved
|
||||
.assets
|
||||
.clone()
|
||||
.or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone()));
|
||||
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: &version_id,
|
||||
version_name: Some(&input.instance_name),
|
||||
version_type: None,
|
||||
game_version: None,
|
||||
main_class: resolved.main_class.as_deref(),
|
||||
jvm_arguments: &expanded.jvm,
|
||||
game_arguments: &expanded.game,
|
||||
legacy_arguments: resolved.minecraft_arguments.as_deref(),
|
||||
libraries: &libraries,
|
||||
natives: &natives,
|
||||
primary_jar,
|
||||
game_dir,
|
||||
assets_index: assets_index.as_deref(),
|
||||
natives_dir: game.natives_dir(&version_id),
|
||||
library_dir: game.libraries_dir(),
|
||||
assets_root: assets_root.clone(),
|
||||
game_assets: assets_root,
|
||||
};
|
||||
|
||||
let options = AdapterOptions {
|
||||
java: super::java::selection(input.java.as_deref()),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: input.max_memory,
|
||||
},
|
||||
auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders {
|
||||
player_name: input
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Player".to_string()),
|
||||
uuid: ProfileId::generate().as_str().replace('-', ""),
|
||||
user_type: "legacy".to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
..AuthPlaceholders::default()
|
||||
}),
|
||||
launcher_name: super::meta::LAUNCHER_NAME.to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
..AdapterOptions::strict()
|
||||
};
|
||||
|
||||
let output = adapter::build_request(&snapshot, options);
|
||||
let warnings = output.warnings.iter().map(describe_warning).collect();
|
||||
let mut request = output.request;
|
||||
// 会话索引要用实例身份(adapter 默认拿版本 id/名)。
|
||||
if !input.instance_id.is_empty() {
|
||||
request.instance.id = input.instance_id.clone();
|
||||
}
|
||||
request.instance.name = input.instance_name.clone();
|
||||
|
||||
Ok(Plan {
|
||||
version_id,
|
||||
request,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_version_json(path: &std::path::Path) -> Result<VersionJson, CliError> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(CliError::user(
|
||||
"version_missing",
|
||||
format!("版本 JSON 不存在:{}", path.display()),
|
||||
)
|
||||
.with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)"));
|
||||
}
|
||||
Err(error) => return Err(CliError::io("读版本 JSON", path, &error)),
|
||||
};
|
||||
VersionJson::from_json_str(&text)
|
||||
.map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display())))
|
||||
}
|
||||
|
||||
/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。
|
||||
fn describe_warning(warning: &AdapterWarning) -> String {
|
||||
match warning {
|
||||
AdapterWarning::MissingLibrary { name, path } => {
|
||||
format!("缺少库文件 {name}({})", path.display())
|
||||
}
|
||||
AdapterWarning::MissingGameArguments => {
|
||||
"版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string()
|
||||
}
|
||||
AdapterWarning::MissingAssetsIndex => {
|
||||
"版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string()
|
||||
}
|
||||
AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(),
|
||||
AdapterWarning::MissingNativeJar { name, path } => {
|
||||
format!("缺少 natives 构件 {name}({})", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user