盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个 commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部 文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。 core(`game::adopt`,新模块): - `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 / 缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表; `versions/` 不存在才是空结果,读不了就是错误; - 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与 祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在 边界检查就停手,一个字节都不去读; - `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置): **缺省运行目录落 ULL 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局 `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出 mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术; - 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先 若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘; - 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增 `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立 设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register` 签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。 CLI: - `ull instances scan`(只读列出候选与原因)、`ull instances adopt <version-id> [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即 custom,相对路径在解析期就报用法错误; - 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。 测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed / `cargo test -p unlucky-cli --test adopt_test` 10 passed): - 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config + launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言; - 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接 放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated 的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘; - CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段; `data.game_dir` 全等); - 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与 junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。 界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改 (不扫第二个 `.minecraft`)。
242 lines
11 KiB
Rust
242 lines
11 KiB
Rust
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
|
||
|
||
pub mod accounts;
|
||
pub mod agent;
|
||
pub mod config;
|
||
pub mod identity;
|
||
pub mod install;
|
||
pub mod instance_launch;
|
||
pub mod instances;
|
||
pub mod java;
|
||
pub mod java_launch;
|
||
pub mod java_vendors;
|
||
pub mod launch;
|
||
pub mod meta;
|
||
pub mod pack;
|
||
pub mod sessions;
|
||
pub mod snapshot;
|
||
pub mod update;
|
||
|
||
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::InstancesLaunchArgs(args) => {
|
||
with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args))
|
||
}
|
||
Command::InstancesScan => with_ctx(&invocation.globals, instances::scan::run),
|
||
Command::InstancesAdopt(args) => {
|
||
with_ctx(&invocation.globals, |ctx| instances::adopt::run(ctx, args))
|
||
}
|
||
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
|
||
Command::PackImport(args) => {
|
||
with_ctx_outcome(&invocation.globals, |ctx| pack::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::UpdateCheck(args) => {
|
||
with_ctx_outcome(&invocation.globals, |ctx| update::check(ctx, args))
|
||
}
|
||
Command::UpdateApply(args) => {
|
||
with_ctx_outcome(&invocation.globals, |ctx| update::apply(ctx, args))
|
||
}
|
||
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("重新执行 `%prog% accounts add msa` 拿新的用户码".to_string())
|
||
}
|
||
AuthError::CredentialExpired => Some("重新登录:%prog% accounts add msa".to_string()),
|
||
// M117:id 缺失是「没配置」,不是「没实现」。hint 里给**能照抄的命令**(两条路:
|
||
// 运行期一次性的、构建期烘进二进制的),别只丢一个变量名让用户自己猜。
|
||
// M149:官方发布构建已把 id 烘进二进制(真值走仓库 Secret,不进 git 文件),所以能走到这个
|
||
// hint 的只可能是自建且没注入 id 的二进制——文案与 `cli/help.rs` 对齐:官方构建已内置。
|
||
AuthError::Unsupported(_) => Some(format!(
|
||
"官方构建已内置微软登录所需的客户端 id,装完即可用;自建构建得自己给:\
|
||
运行期 `{env}=<id> %prog% accounts add msa`(优先于内置值);\
|
||
或构建时注入 `{env}=<id> cargo build` 让二进制自带。",
|
||
env = unlucky_core::auth::microsoft::CLIENT_ID_ENV
|
||
)),
|
||
AuthError::Conflict { .. } => Some("用 `%prog% accounts list` 查看已有账户".to_string()),
|
||
AuthError::NotFound { .. } => Some("用 `%prog% 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,
|
||
}
|
||
}
|
||
|
||
/// GUI / CLI 共用的**启动日志一行**:`[auth] msa client_id=configured|missing source=env|build|none`。
|
||
///
|
||
/// M117:判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,这里只做出面——
|
||
/// `app-iced` 不直接依赖 `unlucky-core`,而 `unlucky-cli` 已经是它的依赖(同一个二进制既跑
|
||
/// GUI 也跑 `ull` 子命令)。所以「谁打印」不在 UI 侧重算一遍判据,免得两份口径漂。
|
||
///
|
||
/// 用途:日后看启动日志能一眼分辨「没配置」与「配了但登录失败」,不用再猜。
|
||
#[must_use]
|
||
pub fn msa_client_id_log_line() -> String {
|
||
unlucky_core::auth::microsoft::client_id_status().log_line()
|
||
}
|
||
|
||
/// 需要数据根的命令:解析上下文 → 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()))
|
||
}
|
||
|
||
/// 同 [`with_ctx`],但命令自己组装 [`Outcome`](需要指定退出码或多态结果时用)。
|
||
///
|
||
/// 只有 `pack import` 走这条路:它的三态要 0 / 3 / 1 三个退出码,`Report` 表达不了。
|
||
fn with_ctx_outcome<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
|
||
where
|
||
F: FnOnce(&Ctx) -> Result<Outcome, CliError>,
|
||
{
|
||
let ctx = Ctx::resolve(globals)?;
|
||
ctx.ensure_supported()?;
|
||
let mut outcome = action(&ctx)?;
|
||
outcome.warnings.extend(
|
||
ctx.notes()
|
||
.into_iter()
|
||
.map(|line| crate::cli::program::fill(&line)),
|
||
);
|
||
Ok(outcome)
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
|
||
#[test]
|
||
fn startup_log_line_is_one_machine_readable_line() {
|
||
// M117:这行是「没配置 vs 配了但登录失败」的唯一机读判据,形状不能漂。
|
||
let line = super::msa_client_id_log_line();
|
||
assert!(line.starts_with("[auth] msa client_id="), "{line}");
|
||
assert!(line.contains(" source="), "{line}");
|
||
assert!(!line.contains('\n'), "必须正好一行:{line}");
|
||
let source = line.rsplit("source=").next().unwrap_or_default();
|
||
assert!(matches!(source, "env" | "build" | "none"), "{line}");
|
||
}
|
||
|
||
#[test]
|
||
fn missing_client_id_hint_carries_a_runnable_command() {
|
||
// M117:用户实跑看到的「本期未支持」是错的表述,而且 hint 只丢了变量名。
|
||
// 判据:文案说「未配置」、不说「未支持/未实现」,并给出能照抄的命令(含变量名)。
|
||
let cli = map_auth_error(AuthError::Unsupported(
|
||
unlucky_core::auth::microsoft::MISSING_CLIENT_ID_MESSAGE.to_string(),
|
||
));
|
||
let message = cli.message();
|
||
assert_eq!(cli.code(), "unsupported", "机读码保持兼容");
|
||
assert!(message.contains("客户端 id"), "{message}");
|
||
assert!(!message.contains("未支持"), "{message}");
|
||
assert!(!message.contains("未实现"), "{message}");
|
||
|
||
let hint = cli.hint().expect("缺配置要有 hint").to_string();
|
||
assert!(hint.contains("ULL_MSA_CLIENT_ID=<id>"), "{hint}");
|
||
assert!(hint.contains("cargo build"), "{hint}");
|
||
assert!(hint.contains("accounts add msa"), "{hint}");
|
||
}
|
||
}
|