feat(presenter): 导入已有版本的 DTO 与三条后台线程(M223)

core 的能力(M221)接进 presenter 的 IPC 面:

- dto/adopt.rs:AdoptCommand / AdoptEvent / 六种视图 / AdoptState + AdoptStage。
  全部 Serialize+Deserialize,**不 import 任何 core 类型**(契约 §5.4);
- host/adopt.rs:spawn_scan(adopt::scan)/ spawn_adopt(plan 或 adopt),结果经
  HostTask::Adopt 回流;core 的候选/计划/结果/失败逐字段搬进 DTO,problems[] 一条不少;
- CoreHost 加 adopt_epoch(每条命令 +1、关面板也 +1),连点与迟到的回程不会点亮面板;
- 登记成功后补一次 refresh_instances(新实例这一拍就出现在列表里)。

运行目录只从 plan()/adopt() 的返回值取(缺省档可能带 -2 后缀,只有 core 知道用了哪个);
target_notes() 的原文随 AdoptPlanView::notes 上屏,界面不另写一份后果话术。
This commit is contained in:
AskaEth committed 2026-11-10 19:37:23 +08:00
1 parent 9ab909d01c
commit 26e4fbb91d
6 files changed
+1012

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
//! 「导入已有版本」的 IPC 面(M223 GUIADOPT):扫描 → 预检 → 确认三段。
//!
//! 能力本身在 core(`unlucky_core::game::adopt`,M221 的契约见
//! `DevDocs/SubAgentsTalk/adoptcore-契约.md`);本文件只有**可序列化的视图**,
//! 一个 `unlucky_core::*` 类型都不出现(AGENTS §6 的跨边界纪律)。
//!
//! ## 三段:前两段零写盘,第三段只写 ULL 自己的配置
//!
//! | 段 | 指令 | 回程 | 写盘 |
//! |---|---|---|---|
//! | 扫描 | [`AdoptCommand::Scan`] | [`AdoptEvent::Scan`] | 零(不建目录、不改 mtime) |
//! | 预检 | [`AdoptCommand::Preview`] | [`AdoptEvent::Planned`] / [`AdoptEvent::Failed`] | 零 |
//! | 确认 | [`AdoptCommand::Confirm`] | [`AdoptEvent::Done`] / [`AdoptEvent::Failed`] | 实例设置 + `profiles.json`(+ 缺省运行目录) |
//!
//! ## 三条硬纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录只认回程**:[`AdoptPlanView::run_dir`] / [`AdoptDoneView::run_dir`] 是唯一来源。
//! 缺省档会带 `-2`/`-3` 后缀消歧(两个不同展示名可能落到同一个物理目录),**只有 core 知道
//! 最后用了哪个**;页面拿展示名自己派生一次就是错的(会与实例真正启动时用的目录分叉)。
//! 2. **后果话术只有一份**:`Shared` / `VersionDir` 的后果**不在本文件里另写**一句,
//! 由预检把 core `target_notes()` 的原文放进 [`AdoptPlanView::notes`]——于是界面上的
//! 「mods/saves/config 会落进别人的 `.minecraft`」与 CLI 打的是同一句。
//! 想显示后果就必须先预检(预检零写盘,所以这不是成本)。
//! 3. **问题逐条**:core 的结构化问题([`AdoptProblemView`],带稳定 `code`)与整次失败
//! ([`AdoptFailureView`])都原样搬过来,**不折叠成一句「导入失败」**。
//!
//! ## 缺省档为什么是 `InstanceDir` 而不是 `isolated`
//!
//! 全局缺省 `isolated` 的运行目录就是 `versions/<id>/`——那是**别人的版本目录**。沿用它会让第一次
//! 启动就在别人的 `.minecraft` 里长出 `mods/` `saves/` `logs/`。所以 [`AdoptTargetView`] 的
//! 缺省是 `InstanceDir`(ULL 数据区新建独立目录),另两档必须**明确点选**。
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// app → host 的导入意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptCommand {
/// 扫当前游戏根的 `versions/`(只读;顺带得到每个版本能不能登记、已登记给谁)
Scan,
/// **预检**:按请求算落点与后果(零写盘)。页面拿回 [`AdoptEvent::Planned`] 才画确认按钮。
Preview(Box<AdoptRequestView>),
/// 确认登记(唯一写盘入口)。
Confirm(Box<AdoptRequestView>),
/// 关掉面板(扫描结果 / 预检 / 结果卡都收起来)
Dismiss,
}
/// host → app 的回程事件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptEvent {
/// 扫描结果(`versions/` 里的全部条目,逐项带问题)
Scan(Box<AdoptScanView>),
/// 预检结果(落点 + 后果原文 + 将写的展示名)
Planned(Box<AdoptPlanView>),
/// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空)
Failed(Box<AdoptFailureView>),
/// 登记完成
Done(Box<AdoptDoneView>),
/// 面板已关(状态复位)
Cleared,
}
/// 登记后的**运行目录从哪来**(隔离档由它决定)。
///
/// GUI 只开三档:[`Self::InstanceDir`](缺省)/[`Self::Shared`] / [`Self::VersionDir`]。
/// `custom`(用户手输绝对路径)留在 CLI:GUI 要它就得配目录选择器,不在本 mission 的交付面里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AdoptTargetView {
/// 缺省:ULL 数据区新建独立运行目录(`custom` 档,与别人的 `.minecraft` 隔离)
#[default]
InstanceDir,
/// 共用游戏根(别人的 `.minecraft`)——必须明确点选
Shared,
/// 版本目录 `versions/<id>/`(`isolated` 档)——会在别人的版本目录里长玩家数据
VersionDir,
}
impl AdoptTargetView {
/// 三档的声明顺序(= 面板上的先后,缺省在最前)。
pub const ALL: [AdoptTargetView; 3] = [
AdoptTargetView::InstanceDir,
AdoptTargetView::Shared,
AdoptTargetView::VersionDir,
];
/// 稳定 slug(与 core 的 `AdoptTarget::as_str` 同词表;`instance_dir` 不进 CLI 词表)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::InstanceDir => "instance_dir",
Self::Shared => "shared",
Self::VersionDir => "version_dir",
}
}
/// 从 slug 反解。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
Self::ALL.into_iter().find(|target| target.slug() == slug)
}
/// 这一档会不会把玩家数据写进**别人的**目录(界面据此加警示标记)。
#[must_use]
pub const fn touches_foreign_dir(self) -> bool {
matches!(self, Self::Shared | Self::VersionDir)
}
}
/// 生效的隔离档(`core::game::install::instance::Isolation` 的视图形态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptIsolationView {
/// `versions/<id>/` 当运行目录
Isolated,
/// 游戏根当运行目录
Shared,
/// 指定/新建目录当运行目录(缺省档就是这一档)
Custom,
}
impl AdoptIsolationView {
/// 稳定 slug(与 core 的 `Isolation::as_str` 同一套字面量)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Isolated => "isolated",
Self::Shared => "shared",
Self::Custom => "custom",
}
}
/// 从 slug 反解(未知值返回 `None`,界面照原样上屏而不是猜)。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
match slug {
"isolated" => Some(Self::Isolated),
"shared" => Some(Self::Shared),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
/// 一次预检/登记的请求(页面表单 → host)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptRequestView {
/// 要接进来的版本 id(= `versions/<id>/` 的目录名)
pub version_id: String,
/// 展示名(`None` / 空串 = 用版本 id)
pub name: Option<String>,
/// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`])
pub target: AdoptTargetView,
}
impl AdoptRequestView {
/// 最小请求(缺省名 + 缺省运行目录)。
#[must_use]
pub fn new(version_id: impl Into<String>) -> Self {
Self {
version_id: version_id.into(),
name: None,
target: AdoptTargetView::default(),
}
}
}
/// 一条**结构化**的不合格原因(`code` 稳定,`message` 人读)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptProblemView {
/// 稳定机读码(`manifest_missing` / `missing_parent` / `outside_game_root` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文:带路径、行列号、缺哪个父版本)
pub message: String,
}
/// 盘上的一个候选版本。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptCandidateView {
/// 版本 id(= 目录名,登记后写进 `profiles.json`)
pub version_id: String,
/// 客户端 jar 在不在(只报事实,不校验内容)
pub jar_present: bool,
/// `inheritsFrom` 链(自身在前、最祖先在后)
pub inherits: Vec<String>,
/// 已经绑定这个版本的实例名(空 = 还没登记过)
pub registered: Vec<String>,
/// 问题清单(空 = 可登记)
pub problems: Vec<AdoptProblemView>,
}
impl AdoptCandidateView {
/// 能不能登记(没有任何问题)。
#[must_use]
pub fn adoptable(&self) -> bool {
self.problems.is_empty()
}
/// 已登记过(再登记会得到第二个实例,共用同一份版本文件)。
#[must_use]
pub fn is_registered(&self) -> bool {
!self.registered.is_empty()
}
}
/// 一次扫描的结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptScanView {
/// 被扫的目录(`<游戏根>/versions`)
pub versions_dir: PathBuf,
/// 候选(按版本 id 字典序)
pub candidates: Vec<AdoptCandidateView>,
}
impl AdoptScanView {
/// 候选条数。
#[must_use]
pub fn total(&self) -> usize {
self.candidates.len()
}
/// 可登记的条数。
#[must_use]
pub fn adoptable(&self) -> usize {
self.candidates
.iter()
.filter(|candidate| candidate.adoptable())
.count()
}
/// `versions/` 存在但一个候选都没有(新数据根,或只剩别的启动器的空目录)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// 预检结果:落点 + 后果原文 + 将写的展示名。**零写盘**。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptPlanView {
/// 版本 id
pub version_id: String,
/// 将写进档案的展示名(空名已回落到版本 id)
pub name: String,
/// 运行目录选择
pub target: AdoptTargetView,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// **生效运行目录**(可能是顺延后的 `-2`/`-3`;页面不许自己派生)
pub run_dir: PathBuf,
/// 人读提示(含 `target_notes()` 的原文:共享档/版本目录档会把玩家数据写进别人的目录)
pub notes: Vec<String>,
}
/// 登记完成。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptDoneView {
/// 实例 id(稳定 UUID)
pub instance_id: String,
/// 展示名
pub name: String,
/// 绑定的版本 id
pub version_id: String,
/// 生效隔离档
pub isolation: AdoptIsolationView,
/// 生效运行目录(**唯一来源**:启动时用的就是它)
pub run_dir: PathBuf,
/// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)
pub dir_created: bool,
/// 人读提示(与预检同一份)
pub notes: Vec<String>,
}
/// 一次失败(整次调用,不是单条候选问题)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptFailureView {
/// 稳定机读码(`adopt_not_adoptable` / `adopt_duplicate_name` / `adopt_target_not_empty` …)
pub code: String,
/// 人读原因(core 的 `Display` 原文)
pub message: String,
/// 候选问题清单(`adopt_not_adoptable` 时逐条给出;其它失败为空)
pub problems: Vec<AdoptProblemView>,
}
/// 向导的**事件态**(`AppState::adopt` 的全部内容)。
///
/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段
/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**——
/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AdoptState {
/// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉)
pub scan: Option<AdoptScanView>,
/// 最近一次预检(`None` = 还没预检 / 已过期)
pub plan: Option<AdoptPlanView>,
/// 最近一次失败
pub failure: Option<AdoptFailureView>,
/// 最近一次完成的登记
pub done: Option<AdoptDoneView>,
}
impl AdoptState {
/// 事件 → 状态(**唯一**写入点,与 `PackState::apply` 同一条纪律)。
pub fn apply(&mut self, event: AdoptEvent) {
match event {
AdoptEvent::Scan(scan) => {
// 新一轮扫描:上一轮的预检/失败/完成都作废(候选集合可能已经变了)
self.scan = Some(*scan);
self.plan = None;
self.failure = None;
self.done = None;
}
AdoptEvent::Planned(plan) => {
self.plan = Some(*plan);
self.failure = None;
self.done = None;
}
AdoptEvent::Failed(failure) => {
// 预检失败时上一份预检必须消失:留着会让确认按钮继续亮着
self.plan = None;
self.failure = Some(*failure);
}
AdoptEvent::Done(done) => {
// 登记成功:预检与失败都过期(这一轮已经有结论)
self.plan = None;
self.failure = None;
self.done = Some(*done);
}
AdoptEvent::Cleared => {
self.scan = None;
self.plan = None;
self.failure = None;
self.done = None;
}
}
}
/// 面板此刻该画的那一段(扫描 / 选版本 / 预检 / 结果)。
///
/// 优先级:完成 > 失败 > 预检 > 扫描列表。写成一个方法而不是让页面各判一遍,
/// 是为了「同一时刻只画一段」在单测里可断言。
#[must_use]
pub fn stage(&self) -> AdoptStage {
if self.done.is_some() {
return AdoptStage::Done;
}
if self.failure.is_some() {
return AdoptStage::Failed;
}
if self.plan.is_some() {
return AdoptStage::Preview;
}
AdoptStage::Pick
}
}
/// 面板当前画哪一段([`AdoptState::stage`] 的产物)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdoptStage {
/// 扫出来的候选列表(选版本)
Pick,
/// 预检结果(落点 + 后果原文 + 确认按钮)
Preview,
/// 失败(原因逐条)
Failed,
/// 完成
Done,
}
impl AdoptStage {
/// 稳定 slug(进日志/测试断言用)。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Pick => "pick",
Self::Preview => "preview",
Self::Failed => "failed",
Self::Done => "done",
}
}
}
#[cfg(test)]
mod tests;
+136
View File
@@ -0,0 +1,136 @@
//! `dto::adopt` 的 IPC 契约用例(M223 从 `dto/adopt.rs` **纯搬移**,断言一条未改)。
//!
//! 为什么搬:`dto/adopt.rs` 落笔 512 行,越过 400 行硬线(AGENTS §3.1);口径与
//! [`crate::dto::events::tests`] 完全一致——**纯搬移、一行不改**。`adopt.rs` 是非 `mod.rs`
//! 文件,`mod tests;` 的落点就是本文件。
use super::*;
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
AdoptCandidateView {
version_id: version_id.to_string(),
jar_present: true,
inherits: Vec::new(),
registered: Vec::new(),
problems,
}
}
fn scan_view() -> AdoptScanView {
AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: vec![
candidate("1.20.1", Vec::new()),
candidate(
"1.19.4",
vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "版本 1.19.4 没有清单文件".to_string(),
}],
),
],
}
}
#[test]
fn events_round_trip_through_json() {
let event = AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_not_adoptable".to_string(),
message: "版本 1.19.4 不能登记".to_string(),
problems: vec![AdoptProblemView {
code: "manifest_missing".to_string(),
message: "清单缺了".to_string(),
}],
}));
let text = serde_json::to_string(&event).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptEvent>(&text).expect("反序列化"),
event
);
let command = AdoptCommand::Preview(Box::new(AdoptRequestView {
version_id: "1.20.1".to_string(),
name: Some("生存".to_string()),
target: AdoptTargetView::Shared,
}));
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<AdoptCommand>(&text).expect("反序列化"),
command
);
}
#[test]
fn scan_summarises_adoptable_and_empty() {
let view = scan_view();
assert_eq!(view.total(), 2);
assert_eq!(view.adoptable(), 1);
assert!(!view.is_empty());
assert!(AdoptScanView {
versions_dir: PathBuf::from("/game/versions"),
candidates: Vec::new(),
}
.is_empty());
}
#[test]
fn target_slugs_round_trip_and_mark_foreign_dirs() {
for target in AdoptTargetView::ALL {
assert_eq!(AdoptTargetView::from_slug(target.slug()), Some(target));
}
assert_eq!(AdoptTargetView::default(), AdoptTargetView::InstanceDir);
assert!(!AdoptTargetView::InstanceDir.touches_foreign_dir());
assert!(AdoptTargetView::Shared.touches_foreign_dir());
assert!(AdoptTargetView::VersionDir.touches_foreign_dir());
}
#[test]
fn state_follows_the_four_stages() {
let mut state = AdoptState::default();
assert_eq!(state.stage(), AdoptStage::Pick);
state.apply(AdoptEvent::Scan(Box::new(scan_view())));
assert_eq!(state.stage(), AdoptStage::Pick);
assert_eq!(state.scan.as_ref().map(AdoptScanView::adoptable), Some(1));
let plan = AdoptPlanView {
version_id: "1.20.1".to_string(),
name: "1.20.1".to_string(),
target: AdoptTargetView::InstanceDir,
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
notes: vec!["运行目录 = /ull/data/instances/1.20.1".to_string()],
};
state.apply(AdoptEvent::Planned(Box::new(plan.clone())));
assert_eq!(state.stage(), AdoptStage::Preview);
assert_eq!(
state.plan.as_ref().map(|plan| &plan.run_dir),
Some(&plan.run_dir)
);
// 失败会把预检顶掉:留着预检,确认按钮就会在「不能登记」的结论上继续亮着
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
code: "adopt_duplicate_name".to_string(),
message: "已有同名实例".to_string(),
problems: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Failed);
assert!(state.plan.is_none());
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
instance_id: "id-1".to_string(),
name: "1.20.1".to_string(),
version_id: "1.20.1".to_string(),
isolation: AdoptIsolationView::Custom,
run_dir: PathBuf::from("/ull/data/instances/1.20.1"),
dir_created: true,
notes: Vec::new(),
})));
assert_eq!(state.stage(), AdoptStage::Done);
assert!(state.failure.is_none());
// 关面板 = 全清(再开要重新扫,否则画的是上一轮的候选)
state.apply(AdoptEvent::Cleared);
assert_eq!(state.stage(), AdoptStage::Pick);
assert!(state.scan.is_none() && state.done.is_none());
}
+7
View File
@@ -15,6 +15,8 @@
//! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。 //! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。
mod accounts; mod accounts;
// M223: 导入已有版本(从别的启动器只读登记 versions/<id>)
mod adopt;
mod agent; mod agent;
mod content; mod content;
mod events; mod events;
@@ -38,6 +40,11 @@ pub use accounts::{
AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase,
DeviceLoginView, LoginFailure, SkinImage, REDACTED, DeviceLoginView, LoginFailure, SkinImage, REDACTED,
}; };
pub use adopt::{
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
AdoptStage, AdoptState, AdoptTargetView,
};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{ pub use content::{
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
+261
View File
@@ -0,0 +1,261 @@
//! 「导入已有版本」的宿主侧(M223 GUIADOPT):扫描 / 预检 / 确认三条后台线程。
//!
//! 能力全在 core(`unlucky_core::game::adopt`,M221):本模块只做三件事——**起线程、翻 DTO、
//! 按代次回流**。这里不重新判定任何东西:能不能登记、落到哪个运行目录、有哪些问题,全部由 core
//! 说了算,本模块逐字段照搬([`scan_view`] / [`plan_view`] / [`done_view`] / [`failure_view`])。
//!
//! ## 三个命令与写盘面
//!
//! | 命令 | core 调用 | 写盘 |
//! |---|---|---|
//! | [`AdoptCommand::Scan`] | `adopt::scan` | 零 |
//! | [`AdoptCommand::Preview`] | `adopt::plan` | 零(预检就是 core 的 dry-run) |
//! | [`AdoptCommand::Confirm`] | `adopt::adopt` | 实例设置 + `profiles.json`(+ 缺省运行目录) |
//!
//! 预检与确认走**同一个** `AdoptRequestView`:页面在预检拿到落点与后果原文,用户点确认时把
//! **同一份请求**发回来(不是「预检 A、写 B」)。这也是「运行目录只认回程」的实现方式——
//! 页面从头到尾没有机会自己派生一个目录名。
//!
//! ## 代次:为什么要它
//!
//! 三条命令各起一条线程,用户可能连点(扫完再扫、预检后改目标再预检、失败后关面板)。
//! 每条线程带一个代次回来,`adopt_epoch` 只认**最新**那一条:关面板([`AdoptCommand::Dismiss`])
//! 也会把代次 +1,于是「点了关闭,两秒后扫描结果又把面板点亮」这种旧病不会发生
//! (它与 M105 安装取消、M113 导入取消是同一套做法)。
//!
//! ## 不做什么
//!
//! - **不自己派生运行目录**:`run_dir` 只从 `plan`/`adopt` 的返回里取(缺省档可能带 `-2` 后缀);
//! - **不自己拼后果话术**:`notes` 原样来自 core 的 `target_notes()`,与 CLI 打的是同一句;
//! - **不折叠问题**:`problems[]` 逐条搬进 [`AdoptFailureView`],一条都不省。
use unlucky_core::game::adopt::{
self, AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest,
AdoptTarget,
};
use unlucky_core::game::install::instance::Isolation;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use crate::dto::{
AdoptCandidateView, AdoptCommand, AdoptDoneView, AdoptEvent, AdoptFailureView,
AdoptIsolationView, AdoptPlanView, AdoptProblemView, AdoptRequestView, AdoptScanView,
AdoptTargetView, CoreEvent,
};
use super::env::HostEnv;
use super::job::{self, HostTask, JobSender};
use super::CoreHost;
/// 后台那一步是「只看不写」还是「真的写」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Mode {
/// `adopt::plan`(预检,零写盘)
Preview,
/// `adopt::adopt`(真写)
Confirm,
}
impl CoreHost {
/// 作废在飞的 adopt 结果,返回新代次(每条命令都调它,见模块文档「代次」一节)。
fn next_adopt_epoch(&mut self) -> u64 {
self.adopt_epoch = self.adopt_epoch.wrapping_add(1);
self.adopt_epoch
}
/// `AppCommand::Adopt` 的落点(M223)。
///
/// 每条命令都先把代次 +1:这一条之后回来的旧结果一律被 [`Self::handle_adopt`] 丢掉。
pub(super) fn adopt(&mut self, command: AdoptCommand) {
match command {
AdoptCommand::Scan => {
let epoch = self.next_adopt_epoch();
spawn_scan(self.env.clone(), self.tx.clone(), epoch);
}
AdoptCommand::Preview(request) => {
let epoch = self.next_adopt_epoch();
spawn_adopt(
self.env.clone(),
self.tx.clone(),
*request,
Mode::Preview,
epoch,
);
}
AdoptCommand::Confirm(request) => {
let epoch = self.next_adopt_epoch();
spawn_adopt(
self.env.clone(),
self.tx.clone(),
*request,
Mode::Confirm,
epoch,
);
}
AdoptCommand::Dismiss => {
// 关面板 = 作废在飞的一切(否则迟到的扫描结果会把面板重新点亮)
self.next_adopt_epoch();
self.send(CoreEvent::Adopt(Box::new(AdoptEvent::Cleared)));
}
}
}
/// 后台线程的回流落点:代次不符就丢(见模块文档「代次」一节)。
///
/// 登记成功之后补一次实例快照刷新:新实例必须**这一拍就出现在实例列表里**,
/// 而不是等用户手点刷新(否则「导入了但看不见」)。
pub(super) fn handle_adopt(&mut self, event: AdoptEvent, epoch: u64) {
if epoch != self.adopt_epoch {
return;
}
let registered = matches!(event, AdoptEvent::Done(_));
self.send(CoreEvent::Adopt(Box::new(event)));
if registered {
self.refresh_instances();
}
}
}
/// 扫描当前游戏根的 `versions/`(只读)。
pub(super) fn spawn_scan(env: HostEnv, tx: JobSender, epoch: u64) {
job::spawn("adopt-scan", move || {
let event = match adopt::scan(&env.game, &env.layout) {
Ok(scan) => AdoptEvent::Scan(Box::new(AdoptScanView {
versions_dir: scan.versions_dir.clone(),
candidates: scan.candidates.iter().map(candidate_view).collect(),
})),
// 读目录失败也是**具名**失败(断链的 versions 联接 / 权限),不降级成空列表
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
};
let _ = tx.send(HostTask::Adopt {
event: Box::new(event),
epoch,
});
});
}
/// 预检(零写盘)或确认登记。
pub(super) fn spawn_adopt(
env: HostEnv,
tx: JobSender,
request: AdoptRequestView,
mode: Mode,
epoch: u64,
) {
job::spawn("adopt", move || {
let request = core_request(request);
let event = match mode {
Mode::Preview => match adopt::plan(&env.game, &env.layout, &request) {
Ok(plan) => AdoptEvent::Planned(Box::new(plan_view(plan))),
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
},
Mode::Confirm => match adopt::adopt(&env.game, &env.layout, &request) {
Ok(outcome) => AdoptEvent::Done(Box::new(done_view(outcome))),
Err(error) => AdoptEvent::Failed(Box::new(failure_view(error))),
},
};
let _ = tx.send(HostTask::Adopt {
event: Box::new(event),
epoch,
});
});
}
/// 视图请求 → core 请求(`created_at` 由宿主给:core 不碰时钟)。
fn core_request(view: AdoptRequestView) -> AdoptRequest {
AdoptRequest {
version_id: view.version_id,
name: view.name.filter(|name| !name.trim().is_empty()),
target: target_of(view.target),
created_at: Some(format_rfc3339_millis(now())),
}
}
/// 三档视图 → core 的运行目录选择。
///
/// `custom` **不映射**:GUI 不开「用户手输绝对路径」这一档(那要目录选择器),
/// 需要它的调用方走 CLI 的 `ull instances adopt --isolation custom --game-dir DIR`。
fn target_of(view: AdoptTargetView) -> AdoptTarget {
match view {
AdoptTargetView::InstanceDir => AdoptTarget::InstanceDir,
AdoptTargetView::Shared => AdoptTarget::Shared,
AdoptTargetView::VersionDir => AdoptTarget::VersionDir,
}
}
/// core 隔离档 → 视图。
fn isolation_view(isolation: Isolation) -> AdoptIsolationView {
match isolation {
Isolation::Isolated => AdoptIsolationView::Isolated,
Isolation::Shared => AdoptIsolationView::Shared,
Isolation::Custom => AdoptIsolationView::Custom,
}
}
/// core 候选 → 视图(`registered` 只带实例名:界面要的是「给谁登记过了」)。
fn candidate_view(candidate: &AdoptCandidate) -> AdoptCandidateView {
AdoptCandidateView {
version_id: candidate.version_id.clone(),
jar_present: candidate.jar_present,
inherits: candidate.inherits.clone(),
registered: candidate
.registered
.iter()
.map(|instance| instance.name.clone())
.collect(),
problems: candidate.problems.iter().map(problem_view).collect(),
}
}
/// core 问题 → 视图(`code` 稳定 + `Display` 原文:路径、行列号、缺哪个父版本都在里面)。
fn problem_view(problem: &AdoptProblem) -> AdoptProblemView {
AdoptProblemView {
code: problem.code().to_string(),
message: problem.to_string(),
}
}
/// core 计划 → 视图。`run_dir` / `notes` **原样**搬运,界面不许再算一遍。
fn plan_view(plan: AdoptPlan) -> AdoptPlanView {
AdoptPlanView {
version_id: plan.candidate.version_id.clone(),
name: plan.name,
target: target_view(&plan.target),
isolation: isolation_view(plan.isolation),
run_dir: plan.run_dir,
notes: plan.notes,
}
}
/// core 结果 → 视图。
fn done_view(outcome: AdoptOutcome) -> AdoptDoneView {
AdoptDoneView {
instance_id: outcome.instance_id,
name: outcome.name,
version_id: outcome.version_id,
isolation: isolation_view(outcome.isolation),
run_dir: outcome.run_dir,
dir_created: outcome.dir_created,
notes: outcome.notes,
}
}
/// core 运行目录选择 → 视图(`Custom` 只在 core 侧出现,界面按「缺省档」显示)。
fn target_view(target: &AdoptTarget) -> AdoptTargetView {
match target {
AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => AdoptTargetView::InstanceDir,
AdoptTarget::Shared => AdoptTargetView::Shared,
AdoptTarget::VersionDir => AdoptTargetView::VersionDir,
}
}
/// core 失败 → 视图:**`problems[]` 一条不少地带出来**(契约 §5.3)。
fn failure_view(error: AdoptError) -> AdoptFailureView {
AdoptFailureView {
code: error.code().to_string(),
message: error.to_string(),
problems: error.problems().iter().map(problem_view).collect(),
}
}
#[cfg(test)]
mod tests;
+217
View File
@@ -0,0 +1,217 @@
//! `host::adopt` 的用例(M223):真盘夹具 + 三条命令的回程形状。
//!
//! 抓得住:扫描只读(`.ULL/config` 不出现)、预检与确认的落点一致且都来自 core、
//! 坏清单失败时 `problems[]` 逐条带出(带行列号)、三档映射。
//! 抓不住:真启动游戏用不用这个运行目录(那是 launch 链路的活);D30 视觉(另走截图通道)。
//!
//! 为什么单独一个文件:`host/adopt.rs` 带用例会到 440 行,越过 400 行硬线(AGENTS §3.1)。
use super::*;
use crate::dto::AdoptStage;
/// 真盘夹具:`<root>/.minecraft/versions/<id>/`(清单 + jar)+ `<root>/.ULL`。
struct Fixture {
root: std::path::PathBuf,
}
impl Fixture {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"ull-presenter-adopt-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(&root).expect("建临时根");
Self { root }
}
fn env(&self) -> HostEnv {
HostEnv::at_root(&self.root)
}
/// 铺一个「别的启动器装好的」版本(`manifest` 原样写,便于造坏清单)。
fn version(&self, id: &str, manifest: &str) {
let dir = self.root.join(".minecraft/versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(dir.join(format!("{id}.json")), manifest).expect("写清单");
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const MANIFEST: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.main.Main",
"libraries": []
}"#;
fn request(version_id: &str, target: AdoptTargetView) -> AdoptRequestView {
AdoptRequestView {
version_id: version_id.to_string(),
name: None,
target,
}
}
/// 收一条 `HostTask::Adopt`(同步等后台线程交卷)。
fn recv(rx: &super::super::job::JobReceiver) -> AdoptEvent {
match rx.recv().expect("后台线程应回一条") {
HostTask::Adopt { event, .. } => *event,
other => panic!("期望 HostTask::Adopt,收到 {other:?}"),
}
}
#[test]
fn scan_lists_the_foreign_versions_without_writing() {
let fixture = Fixture::new("scan");
fixture.version("1.20.1", MANIFEST);
let env = fixture.env();
let (tx, rx) = job::channel();
spawn_scan(env.clone(), tx, 1);
let AdoptEvent::Scan(scan) = recv(&rx) else {
panic!("扫描应回 AdoptEvent::Scan");
};
assert_eq!(scan.versions_dir, fixture.root.join(".minecraft/versions"));
assert_eq!(scan.total(), 1);
assert_eq!(scan.adoptable(), 1);
assert!(scan.candidates[0].jar_present);
// 扫描零写盘:`.ULL/config` 下不该出现任何东西
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
}
#[test]
fn confirm_registers_and_reports_the_core_run_dir() {
let fixture = Fixture::new("confirm");
fixture.version("1.20.1", MANIFEST);
let env = fixture.env();
// 先预检:零写盘,落点与后果原文都来自 core
let (tx, rx) = job::channel();
spawn_adopt(
env.clone(),
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Preview,
1,
);
let AdoptEvent::Planned(plan) = recv(&rx) else {
panic!("预检应回 AdoptEvent::Planned");
};
assert_eq!(plan.target, AdoptTargetView::InstanceDir);
assert_eq!(plan.isolation, AdoptIsolationView::Custom);
// 缺省档的运行目录落数据区(**不是** `versions/<id>`)
assert!(
plan.run_dir
.starts_with(fixture.root.join(".ULL/data/instances")),
"落点应在 ULL 数据区:{}",
plan.run_dir.display()
);
assert!(!plan.notes.is_empty(), "缺省档也要说清后果");
assert!(!fixture.root.join(".ULL/config/profiles.json").exists());
// 确认:同一份请求发回去,真的登记
let (tx, rx) = job::channel();
spawn_adopt(
env,
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Confirm,
2,
);
let AdoptEvent::Done(done) = recv(&rx) else {
panic!("确认应回 AdoptEvent::Done");
};
assert_eq!(done.version_id, "1.20.1");
assert_eq!(done.run_dir, plan.run_dir, "确认的落点必须与预检一致");
assert!(done.dir_created);
let profiles = std::fs::read_to_string(fixture.root.join(".ULL/config/profiles.json"))
.expect("profiles.json 应被写出来");
assert!(profiles.contains("1.20.1"));
// 游戏根的版本目录一个字节都没写(只有夹具自己那两个文件)
let dir = fixture.root.join(".minecraft/versions/1.20.1");
let mut names: Vec<String> = std::fs::read_dir(&dir)
.expect("读版本目录")
.map(|entry| {
entry
.expect("条目")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
assert_eq!(names, ["1.20.1.jar", "1.20.1.json"]);
}
#[test]
fn a_bad_manifest_comes_back_as_every_problem_not_one_sentence() {
let fixture = Fixture::new("problems");
fixture.version("1.20.1", "{ this is not json");
let (tx, rx) = job::channel();
spawn_adopt(
fixture.env(),
tx,
request("1.20.1", AdoptTargetView::InstanceDir),
Mode::Preview,
1,
);
let AdoptEvent::Failed(failure) = recv(&rx) else {
panic!("坏清单应回 AdoptEvent::Failed");
};
assert_eq!(failure.code, "adopt_not_adoptable");
assert_eq!(failure.problems.len(), 1);
assert_eq!(failure.problems[0].code, "manifest_invalid_json");
assert!(
failure.problems[0].message.contains("line"),
"清单问题要带行列号:{}",
failure.problems[0].message
);
}
#[test]
fn targets_map_to_core_and_custom_stays_out_of_the_gui() {
assert_eq!(
target_of(AdoptTargetView::InstanceDir),
AdoptTarget::InstanceDir
);
assert_eq!(target_of(AdoptTargetView::Shared), AdoptTarget::Shared);
assert_eq!(
target_of(AdoptTargetView::VersionDir),
AdoptTarget::VersionDir
);
// `Custom` 只在 core 侧出现(CLI 的 `--game-dir`):界面按缺省档显示
assert_eq!(
target_view(&AdoptTarget::Custom(std::path::PathBuf::from("/x"))),
AdoptTargetView::InstanceDir
);
assert_eq!(
isolation_view(Isolation::Custom),
AdoptIsolationView::Custom
);
assert_eq!(
isolation_view(Isolation::Shared),
AdoptIsolationView::Shared
);
assert_eq!(
isolation_view(Isolation::Isolated),
AdoptIsolationView::Isolated
);
// 空名回落到版本 id 由 core 负责;这里只钉「空白名不进请求」
let request = core_request(AdoptRequestView {
version_id: "1.20.1".to_string(),
name: Some(" ".to_string()),
target: AdoptTargetView::Shared,
});
assert_eq!(request.name, None);
assert!(request.created_at.is_some());
assert_eq!(AdoptStage::Pick.slug(), "pick");
}
+7
View File
@@ -14,6 +14,8 @@
mod accounts; mod accounts;
mod addon; mod addon;
// M223:导入已有版本(扫描 / 预检 / 确认三条后台线程)
mod adopt;
mod dispatch; mod dispatch;
mod engine; mod engine;
pub mod env; pub mod env;
@@ -106,6 +108,10 @@ pub struct CoreHost {
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装 /// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。 /// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
install_epoch: u64, install_epoch: u64,
/// 「导入已有版本」的代次(M223):每条 scan/preview/confirm 命令 +1,关面板也 +1。
///
/// 只认最新代次的回程事件——用户连点或点了关闭之后,旧线程带回来的结果不许再把面板点亮。
adopt_epoch: u64,
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
update: update_state::Runtime, update: update_state::Runtime,
} }
@@ -160,6 +166,7 @@ impl CoreHost {
chat: MockBackend::default(), chat: MockBackend::default(),
install_cancel: None, install_cancel: None,
install_epoch: 0, install_epoch: 0,
adopt_epoch: 0,
update, update,
}; };
if refresh { if refresh {