diff --git a/crates/presenter/src/dto/adopt.rs b/crates/presenter/src/dto/adopt.rs new file mode 100644 index 0000000..1ad8e30 --- /dev/null +++ b/crates/presenter/src/dto/adopt.rs @@ -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//`——那是**别人的版本目录**。沿用它会让第一次 +//! 启动就在别人的 `.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), + /// 确认登记(唯一写盘入口)。 + Confirm(Box), + /// 关掉面板(扫描结果 / 预检 / 结果卡都收起来) + Dismiss, +} + +/// host → app 的回程事件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AdoptEvent { + /// 扫描结果(`versions/` 里的全部条目,逐项带问题) + Scan(Box), + /// 预检结果(落点 + 后果原文 + 将写的展示名) + Planned(Box), + /// 失败:`code` 稳定、`message` 人读、`problems` 逐条(可能为空) + Failed(Box), + /// 登记完成 + Done(Box), + /// 面板已关(状态复位) + 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//`(`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::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//` 当运行目录 + 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 { + 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//` 的目录名) + pub version_id: String, + /// 展示名(`None` / 空串 = 用版本 id) + pub name: Option, + /// 运行目录选择(缺省 [`AdoptTargetView::InstanceDir`]) + pub target: AdoptTargetView, +} + +impl AdoptRequestView { + /// 最小请求(缺省名 + 缺省运行目录)。 + #[must_use] + pub fn new(version_id: impl Into) -> 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, + /// 已经绑定这个版本的实例名(空 = 还没登记过) + pub registered: Vec, + /// 问题清单(空 = 可登记) + pub problems: Vec, +} + +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, +} + +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, +} + +/// 登记完成。 +#[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, +} + +/// 一次失败(整次调用,不是单条候选问题)。 +#[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, +} + +/// 向导的**事件态**(`AppState::adopt` 的全部内容)。 +/// +/// 只有从 [`AdoptEvent`] 来的事实:扫描结果、预检结果、失败、完成。页面的表单字段 +/// (选中的版本、展示名输入、目标选择、面板开没开)属于 UI 本地态,**不在这里**—— +/// 状态只有一个写入点,才不会有「页面显示的落点和真正写盘的落点不是一回事」。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AdoptState { + /// 最近一次扫描(`None` = 还没扫过 / 已被新一轮顶掉) + pub scan: Option, + /// 最近一次预检(`None` = 还没预检 / 已过期) + pub plan: Option, + /// 最近一次失败 + pub failure: Option, + /// 最近一次完成的登记 + pub done: Option, +} + +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; diff --git a/crates/presenter/src/dto/adopt/tests.rs b/crates/presenter/src/dto/adopt/tests.rs new file mode 100644 index 0000000..3d4be0a --- /dev/null +++ b/crates/presenter/src/dto/adopt/tests.rs @@ -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) -> 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::(&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::(&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()); +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 7963d5e..5a5e8c9 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -15,6 +15,8 @@ //! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。 mod accounts; +// M223: 导入已有版本(从别的启动器只读登记 versions/) +mod adopt; mod agent; mod content; mod events; @@ -38,6 +40,11 @@ pub use accounts::{ AccountView, AuthlibLoginView, AuthlibRequest, BrowserLoginNote, DeviceLoginPhase, 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 content::{ loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, diff --git a/crates/presenter/src/host/adopt.rs b/crates/presenter/src/host/adopt.rs new file mode 100644 index 0000000..6658cd2 --- /dev/null +++ b/crates/presenter/src/host/adopt.rs @@ -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; diff --git a/crates/presenter/src/host/adopt/tests.rs b/crates/presenter/src/host/adopt/tests.rs new file mode 100644 index 0000000..ac1ad6a --- /dev/null +++ b/crates/presenter/src/host/adopt/tests.rs @@ -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; + +/// 真盘夹具:`/.minecraft/versions//`(清单 + jar)+ `/.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/`) + 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 = 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"); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 94f4aef..6c15375 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -14,6 +14,8 @@ mod accounts; mod addon; +// M223:导入已有版本(扫描 / 预检 / 确认三条后台线程) +mod adopt; mod dispatch; mod engine; pub mod env; @@ -106,6 +108,10 @@ pub struct CoreHost { /// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装 /// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。 install_epoch: u64, + /// 「导入已有版本」的代次(M223):每条 scan/preview/confirm 命令 +1,关面板也 +1。 + /// + /// 只认最新代次的回程事件——用户连点或点了关闭之后,旧线程带回来的结果不许再把面板点亮。 + adopt_epoch: u64, /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 update: update_state::Runtime, } @@ -160,6 +166,7 @@ impl CoreHost { chat: MockBackend::default(), install_cancel: None, install_epoch: 0, + adopt_epoch: 0, update, }; if refresh {