Files
Unlucky-Launcher/crates/cli/src/commands/mod.rs
T
AskaEth 9f5c001387 feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)
盘上已经铺好的版本(多半是 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`)。
2026-11-09 22:21:37 +08:00

242 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。
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}");
}
}