From 5479f25068cdf50f8b61fd30ebba14df8711138a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 13:13:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(presenter):=20=E5=AE=9E=E4=BE=8B=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E6=94=B9=E5=B9=B6=E9=9B=86=EF=BC=88=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=20=E2=88=AA=20versions/*=EF=BC=89+=20=E7=A9=BA=E6=80=81?= =?UTF-8?q?=E6=8E=A5=E5=AF=BC=E5=85=A5=E5=90=91=E5=AF=BC=EF=BC=88M273?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:HMCL 能看见 `.minecraft/versions/樱落怡然客户端`,ULL 的实例页是空的 (日志 `instances=0`)。根因不是路径找错,而是 `host::game::instances()` 只列 `profiles.json` 里的档案,第三方启动器装好的版本在界面上根本不存在。 ## 列表改并集(presenter/src/host/game.rs) - `versions/*` 里**能解析出清单**(`/.json` 的 id 与目录名一致、继承链走到底、 符号链接不越界)且没有被任何档案绑定的版本,就地合成一条实例视图: 显示名 = 目录名、`version_id` = 目录名。扫描复用 `core::game::adopt::scan`, 没有另写一套目录遍历。 - 只读:合成视图不建目录、不写 profiles.json、不碰 versions/ 一个字节。 - 档案优先:某版本已被档案绑定时只按档案出一条(名字/隔离档/启动参数都保留),不重复列。 - 坏目录(无清单 / JSON 坏 / id 不符 / 缺父 / 越界)不当实例;`versions/` 读不出来也只是 退回「只有档案」的旧行为,不让实例页报错。 - 外部实例的视图 id 用 `ext-`:`is_valid_instance_id` 只收 `[A-Za-z0-9_-]`, 而版本目录名可以是中文(现场就是),直接沿用版本 id 会让**启动**在读实例设置时就失败。 ## 懒创建档案(只有第一次改它才写盘) `set_isolation()` 与 `apply_launch_args()` 的「找不到实例」改成 `game::ensure_profile()`: 外部实例第一次改隔离档 / 启动参数时才写 profiles.json(幂等,id 与视图 id 相同)。 ## 空态可见入口 + 文案(app-iced / i18n) - 空态分两种:列表里有东西但没选中(原「未选择实例」)与**一个实例都没有** (新:「还没有实例」+ 实心主行动「导入已有版本」)。用户现场看不到任何入口正是前一种文案 在误导。 - `adopt_action()` 成为「导入已有版本」意图的唯一产地,页头按钮与空态主行动共用它。 ## 门禁 `cargo test -p unlucky-app-iced --lib` 971 passed / `cargo test -p unlucky-presenter --all-targets` 384 passed / `cargo test -p unlucky-i18n` 36 passed / `cargo fmt --all --check` 0 / `cargo clippy -p unlucky-presenter -p unlucky-app-iced --all-targets -- -D warnings` 0。 --- crates/app-iced/src/pages/instances.rs | 21 +- crates/app-iced/src/pages/instances/detail.rs | 34 ++- crates/app-iced/src/pages/instances/tests.rs | 3 + .../src/pages/instances/tests/empty_entry.rs | 83 +++++++ crates/app-iced/src/pages/instances/text.rs | 12 + crates/i18n/src/catalog/golden.rs | 3 + crates/i18n/src/catalog/instances.rs | 8 + crates/presenter/src/host/game.rs | 203 ++++++++++++--- crates/presenter/src/host/game/tests.rs | 8 + crates/presenter/src/host/game/tests_union.rs | 234 ++++++++++++++++++ crates/presenter/src/host/launch_args.rs | 11 +- 11 files changed, 574 insertions(+), 46 deletions(-) create mode 100644 crates/app-iced/src/pages/instances/tests/empty_entry.rs create mode 100644 crates/presenter/src/host/game/tests_union.rs diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 5dfb092..c32ff6d 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -161,10 +161,7 @@ pub fn view<'a>( )) .action(buttons::text( ui, - forced_state( - Button::new(texts.import(), Message::Action(Action::Import)), - forced, - ), + forced_state(Button::new(texts.import(), adopt_action()), forced), )); let body = match metrics.layout { @@ -182,7 +179,7 @@ pub fn view<'a>( forced, texts, ), - None => detail::detail_empty(ui, texts), + None => detail::detail_empty(ui, texts, !block.rows.is_empty()), }, ), _ => match &block.detail { @@ -196,13 +193,25 @@ pub fn view<'a>( forced, texts, ), - None => frame::body(metrics, detail::detail_empty(ui, texts)), + None => frame::body( + metrics, + detail::detail_empty(ui, texts, !block.rows.is_empty()), + ), }, }; frame::page(ui, metrics, header, body) } +/// 「导入已有版本」向导的入口意图——**页面里唯一产地**。 +/// +/// 两个入口共用它:页头的「导入」与空态的主行动(一个实例都没有时用户只看得到后一个)。 +/// 落点是 `app/update/page_messages.rs` 的 `Action::Import` 臂(→ `AdoptMessage::Open`), +/// 所以中间任何一段被拆掉,向导就再也打不开——`instances/tests/empty_entry.rs` 专门钉这条链。 +pub(crate) fn adopt_action() -> Message { + Message::Action(Action::Import) +} + // --------------------------------------------------------------- 列表列 fn list_column<'a>( diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 68ef1e4..8554df9 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -292,12 +292,38 @@ fn action_row<'a>(ui: &'a Ui, forced: Option, texts: Texts) -> Elem .into() } -pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> { +/// 详情列的**空态**,两种含义分得很清楚: +/// +/// | `has_instances` | 标题 / 说明 | 主行动 | +/// |---|---|---| +/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | 无 | +/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | +/// +/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示 +/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路: +/// 接管盘上已经装好的版本(向导的另一半是「检测到了 N 个外部版本」的候选列表)。 +pub(super) fn detail_empty<'a>( + ui: &'a Ui, + texts: Texts, + has_instances: bool, +) -> Element<'a, Message> { + if has_instances { + return frame::empty_state( + ui, + IconKind::Instances, + texts.empty_title(), + texts.empty_body(), + Vec::new(), + ); + } frame::empty_state( ui, IconKind::Instances, - texts.empty_title(), - texts.empty_body(), - Vec::new(), + texts.empty_none_title(), + texts.empty_none_body(), + vec![buttons::filled( + ui, + Button::new(texts.adopt_cta(), super::adopt_action()), + )], ) } diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 7a09588..2574d7d 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -13,6 +13,9 @@ mod saves; /// M252 / P18 的**回车**接线(行内重命名:回车 = 点「改名」)。 mod rename; +/// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。 +mod empty_entry; + use super::super::layout::source; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; diff --git a/crates/app-iced/src/pages/instances/tests/empty_entry.rs b/crates/app-iced/src/pages/instances/tests/empty_entry.rs new file mode 100644 index 0000000..4286e23 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/empty_entry.rs @@ -0,0 +1,83 @@ +//! M273 · 「导入已有版本」向导的**入口接线**(空态那一半)。 +//! +//! ## 为什么要单独一组 +//! +//! 这条功能的失败形态不是「算错了」,而是「界面上没有入口」(M223 之后用户实测: +//! `instances=0` 的空态只有一句「未选择实例」,他找不到任何能接管 `.minecraft` 的地方)。 +//! 逻辑测试抓不住这种洞——`on_adopt(Open)` 的用例一直是绿的,因为**没有人从界面派发它**。 +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`the_empty_state_hands_out_the_adopt_intent`] | 空态主行动发的是**哪一条**意图(被换成新建 / 删除 / 复制就红) | 那条意图真的从按钮里发出来(`Element` 没有内省接口,见 `pages::layout::source`) | +//! | [`the_empty_state_wires_the_cta_to_that_intent`] | 空态里那枚按钮**被删掉**、被注释掉、或接到别的消息上 | 按钮画出来好不好看 / 真的可点(出图 + 人工) | +//! | [`the_intent_still_reaches_the_adopt_wizard`] | `pages::Action::Import` → `AdoptMessage::Open` 这一跳被拆掉(那正是「UI 存在但没人派发」的复发形态) | `Open` 之后向导自己的四段(`app/update/adopt.rs` 的用例覆盖) | +//! +//! 三条合起来是一条链:空态按钮 ⇒ [`adopt_action`] ⇒ `Action::Import` ⇒ `AdoptMessage::Open`。 +//! 任何一段断开,这里至少红一条。 + +use crate::pages::layout::source; + +use super::super::adopt_action; +use super::super::Texts; +use crate::pages::{Action, Message}; +use unlucky_i18n::Lang; + +/// 空态主行动的发的是**导入**意图(不是新建、不是删除、不是占位动作)。 +#[test] +fn the_empty_state_hands_out_the_adopt_intent() { + assert_eq!( + format!("{:?}", adopt_action()), + format!("{:?}", Message::Action(Action::Import)), + "空态主行动必须发「导入已有版本」,否则向导依旧打不开" + ); + let text = format!("{:?}", adopt_action()); + for wrong in ["NewInstance", "Delete", "Duplicate", "OpenFolder"] { + assert!(!text.contains(wrong), "空态主行动接到了 {wrong} 上:{text}"); + } +} + +/// 空态的按钮**在代码里**接 `adopt_action()`(判据是代码行:注释掉等于没有)。 +/// +/// 两种空态还必须分开:一个实例都没有时用 [`Texts::empty_none_title`],不能再拿 +/// 「未选择实例」敷衍——那句话在告诉用户「你已经有的东西只是没点」。 +#[test] +fn the_empty_state_wires_the_cta_to_that_intent() { + let body = source::function_body(include_str!("../detail.rs"), "fn detail_empty"); + let cta = source::code_line(body, "Button::new(texts.adopt_cta()") + .unwrap_or_else(|| panic!("空态里没有一行**代码**建那枚「导入已有版本」按钮:\n{body}")); + assert!( + cta.contains("adopt_action()"), + "空态按钮没接 `adopt_action()`(接成别的消息 = 入口又断了):{cta}" + ); + + let en = Texts::of(Lang::En.catalog()); + assert_ne!( + en.empty_none_title(), + en.empty_title(), + "「一个实例都没有」与「没选中」必须是两句话,否则空态又变成误导" + ); + assert!( + !en.adopt_cta().is_empty(), + "空态主行动必须有可读标签(空标签 = 看不见的按钮)" + ); +} + +/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。 +/// +/// 判据走 `code_text`(注释已剥):把那条 match 臂注释掉、或改成只打一行日志,这里就红—— +/// 那正是「功能做了但没接线」的原始形态(M223 之前 `Action::Import` 就是一条占位动作)。 +#[test] +fn the_intent_still_reaches_the_adopt_wizard() { + let router = source::code_text(include_str!("../../../app/update/page_messages.rs")); + assert!( + source::has_code_line( + include_str!("../../../app/update/page_messages.rs"), + "pages::Message::Action(pages::Action::Import) =>" + ), + "`Action::Import` 在页面消息路由里没有分支了" + ); + assert!( + router.contains("self.on_adopt(crate::app::adopt::AdoptMessage::Open)"), + "`Action::Import` 不再派发 `AdoptMessage::Open`(向导从界面上又变得不可达)" + ); +} diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index 876e301..05d965a 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -85,6 +85,18 @@ impl Texts { pub(super) fn empty_body(self) -> &'static str { self.text(Key::InstancesEmptyBody) } + /// 一个实例都没有时的标题(与「有实例但没选」分开,见目录里 M273 那条注释)。 + pub(super) fn empty_none_title(self) -> &'static str { + self.text(Key::InstancesEmptyNoneTitle) + } + /// 一个实例都没有时的说明。 + pub(super) fn empty_none_body(self) -> &'static str { + self.text(Key::InstancesEmptyNoneBody) + } + /// 空态的主行动:「导入已有版本」。 + pub(super) fn adopt_cta(self) -> &'static str { + self.text(Key::InstancesAdoptCta) + } pub(super) fn narrow_note(self) -> &'static str { self.text(Key::InstancesNarrowNote) } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 439dd70..30294e1 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -154,6 +154,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesSavesPreviewTooLarge", "too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"), ("InstancesSavesPreviewUnreadable", "the file could not be read", "读不出这张图"), ("InstancesSavesPreviewDecode", "not a decodable image", "解不出这张图(可能已损坏或格式不支持)"), + ("InstancesEmptyNoneTitle", "No instances yet", "还没有实例"), + ("InstancesEmptyNoneBody", "Nothing has been registered yet. Import a version that is already on disk to get started.", "这里还没有实例。导入一个已经装在盘上的版本就能开始。"), + ("InstancesAdoptCta", "Import existing version", "导入已有版本"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index c36d55b..d78d2f1 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -216,6 +216,14 @@ macro_rules! instances_entries { InstancesSavesPreviewTooLarge => ("too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"), InstancesSavesPreviewUnreadable => ("the file could not be read", "读不出这张图"), InstancesSavesPreviewDecode => ("not a decodable image", "解不出这张图(可能已损坏或格式不支持)"), + // ── 空态(M273 · ADOPTWIRE)。 + // 原来空态只有一句「未选择实例」——那是对「有实例但没点」说的。**一个实例都没有**时 + // 同一条文案在骗人:用户看到的是「未选择」,以为自己漏点了什么。现在两种空态分开, + // 「一个都没有」那一态给一条主行动(导入已有版本),否则用户根本不知道还能接管 + // 别人 `.minecraft` 里已经装好的实例。 + InstancesEmptyNoneTitle => ("No instances yet", "还没有实例"), + InstancesEmptyNoneBody => ("Nothing has been registered yet. Import a version that is already on disk to get started.", "这里还没有实例。导入一个已经装在盘上的版本就能开始。"), + InstancesAdoptCta => ("Import existing version", "导入已有版本"), } } }; } diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index 1a90fa3..2c45a86 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -1,17 +1,20 @@ -//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。 +//! 实例快照:`profiles.json` ∪ 盘上的 `versions/*` + 版本 JSON + 会话索引 + 实例设置 +//! → [`InstanceSummary`]。 //! //! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id(解析在 //! [`unlucky_core::game::loader`],本文件只做 DTO 翻译)、最后游玩时间取自会话索引、 //! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。 +//! +//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。 use std::collections::BTreeMap; use std::path::Path; use unlucky_core::game::install::instance::{ - read_instance_config, read_profiles, write_instance_config, + read_instance_config, read_profiles, write_instance_config, write_profiles, }; use unlucky_core::game::isolation::IsolationMode; -use unlucky_core::game::{ProfileList, VersionJson}; +use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList, VersionJson}; use crate::dto::{ InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView, @@ -114,48 +117,190 @@ pub fn isolation_of(env: &HostEnv, id: &str) -> String { } } -/// 实例快照。 +/// 实例快照:**档案 ∪ 盘上的版本目录**。 +/// +/// ## 为什么是并集(M273) +/// +/// 用户实测:HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页却是空的—— +/// 因为这里以前只列 `profiles.json` 里的档案。第三方启动器(HMCL / PCL / 官方启动器)把实例 +/// 存在 `versions//.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上 +/// 不存在,用户以为「读不到这个 .minecraft」。 +/// +/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本** +/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走 +/// [`unlucky_core::game::adopt::scan`](`versions//.json` 能解出 `id`、继承链走到底、 +/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。 +/// +/// ## 三条不变量 +/// +/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰 `versions/` 里一个字节 +/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」); +/// 2. **档案优先**:某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**, +/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉); +/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接 +/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);`versions/` 整个读不出来也只是退回 +/// 只有档案的旧行为,不让实例页因此报错。 pub fn instances(env: &HostEnv) -> Vec { let profiles = profiles(env); let played = last_played(env); - profiles + let mut snapshot: Vec = profiles .profiles .iter() - .map(|profile| { - let version_id = profile.version_id.clone(); - let (mc_version, loader) = describe_version(env, &version_id); - let id = profile.id.as_str(); - InstanceSummary { - id: id.to_string(), - name: profile.name.clone(), - mc_version, - loader, - last_played: played.get(id).cloned(), - runtime: InstanceRuntime::Idle, - icon: None, - isolation: isolation_of(env, id), - version_present: env.game.is_version_installed(&version_id), - launch: super::launch_args::view(env, id, &version_id), - version_id, - } + .map(|profile| profile_summary(env, profile, &played)) + .collect(); + snapshot.extend( + external_versions(env, &profiles) + .iter() + .map(|version_id| external_summary(env, version_id, &played)), + ); + snapshot +} + +/// 一条档案 → 磁贴(档案路径的唯一翻译点)。 +fn profile_summary( + env: &HostEnv, + profile: &InstanceProfile, + played: &BTreeMap, +) -> InstanceSummary { + let version_id = profile.version_id.clone(); + let (mc_version, loader) = describe_version(env, &version_id); + let id = profile.id.as_str(); + InstanceSummary { + id: id.to_string(), + name: profile.name.clone(), + mc_version, + loader, + last_played: played.get(id).cloned(), + runtime: InstanceRuntime::Idle, + icon: None, + isolation: isolation_of(env, id), + version_present: env.game.is_version_installed(&version_id), + launch: super::launch_args::view(env, id, &version_id), + version_id, + } +} + +/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。 +/// +/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。 +/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。 +fn external_versions(env: &HostEnv, profiles: &ProfileList) -> Vec { + let Ok(scan) = unlucky_core::game::adopt::scan(&env.game, &env.layout) else { + return Vec::new(); + }; + scan.candidates + .into_iter() + .filter(|candidate| candidate.adoptable()) + .filter(|candidate| { + !profiles + .profiles + .iter() + .any(|profile| profile.version_id == candidate.version_id) }) + .map(|candidate| candidate.version_id) .collect() } +/// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。 +/// +/// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收 +/// `[A-Za-z0-9_-]{1,64}`(`config/instances/.json` 与启动链的 `read_instance_config` 都按 +/// 这个白名单校验),而版本目录名可以是中文——用户实测的那个就是 `樱落怡然客户端`。 +/// 直接沿用版本 id 会让**启动**在写盘前就死在 `LaunchFailureStage::InstanceConfig`。 +/// +/// 前缀 + 稳定哈希 ⇒ 同一个版本每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。 +/// +/// **M274(多根发现)**:两个根里都可能有 `1.20.4`,所以这里是**唯一**该把「根」并进身份的 +/// 地方——到时把 `root` 也喂进哈希(`external_instance_id(root, version_id)`), +/// 列表就自然出两条,不必动 [`instances`] 的其余部分。 +fn external_instance_id(version_id: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in version_id.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("ext-{hash:016x}") +} + +/// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。 +fn external_summary( + env: &HostEnv, + version_id: &str, + played: &BTreeMap, +) -> InstanceSummary { + let id = external_instance_id(version_id); + let (mc_version, loader) = describe_version(env, version_id); + InstanceSummary { + last_played: played.get(&id).cloned(), + isolation: isolation_of(env, &id), + launch: super::launch_args::view(env, &id, version_id), + version_present: env.game.is_version_installed(version_id), + name: version_id.to_string(), + version_id: version_id.to_string(), + mc_version, + loader, + runtime: InstanceRuntime::Idle, + icon: None, + id, + } +} + +/// 拿实例的档案;外部实例(盘上有版本、没有档案)**就地补一条并落盘**。 +/// +/// 只在「用户第一次改这个实例」时才走到这里([`set_isolation`] / 启动参数写回),于是 +/// 「读」永远不写盘:`instances()` 合成视图、启动、改运行目录都不碰 `profiles.json`。 +/// +/// 幂等:同样的 `id` 第二次调用直接返回已落盘的那条,不会长出第二条。 +/// +/// # Errors +/// +/// 档案既不存在、盘上也找不到对应版本时返回用户可读文案;读写 `profiles.json` 失败同样。 +pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result { + let mut list = + read_profiles(&env.layout).map_err(|error| format!("读实例清单失败:{error}"))?; + if let Some(existing) = list + .profiles + .iter() + .find(|profile| profile.id.as_str() == id) + { + return Ok(existing.clone()); + } + let Some(version_id) = external_version_of(env, &list, id) else { + return Err(format!("找不到实例 `{id}`")); + }; + let profile = InstanceProfile { + id: ProfileId::new(id), + name: version_id.clone(), + version_id, + ..InstanceProfile::default() + }; + list.profiles.push(profile.clone()); + write_profiles(&env.layout, &list).map_err(|error| format!("写实例清单失败:{error}"))?; + Ok(profile) +} + +/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。 +/// +/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义, +/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。 +fn external_version_of(env: &HostEnv, profiles: &ProfileList, id: &str) -> Option { + external_versions(env, profiles) + .into_iter() + .find(|version_id| external_instance_id(version_id) == id) +} + /// 改实例隔离档位(写实例设置文件;不动任何数据)。 /// +/// 外部实例(盘上有版本、还没有档案)**第一次**改档位时才懒创建档案并落盘 +/// ([`ensure_profile`])——在那之前,浏览列表不会在用户的盘上多出任何文件。 +/// /// # Errors /// /// 实例不存在、文件读不了/写不了时返回用户可读文案。 pub fn set_isolation(env: &HostEnv, id: &str, mode: &str) -> Result<(), String> { let parsed = IsolationMode::parse(mode) .ok_or_else(|| format!("未知隔离档位 `{mode}`(isolated / shared)"))?; - let profiles = profiles(env); - let profile = profiles - .profiles - .iter() - .find(|profile| profile.id.as_str() == id) - .ok_or_else(|| format!("找不到实例 `{id}`"))?; + let profile = ensure_profile(env, id)?; let mut config = read_instance_config(&env.layout, id) .map_err(|error| format!("读实例设置失败:{error}"))? .unwrap_or_else(|| { @@ -206,3 +351,5 @@ pub fn short_path(path: &Path) -> String { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_union; diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs index a451237..81e5e3e 100644 --- a/crates/presenter/src/host/game/tests.rs +++ b/crates/presenter/src/host/game/tests.rs @@ -141,6 +141,14 @@ fn own_installed_instances_snapshot_carries_their_loader() { unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单"); let snapshot = instances(&env); + // M273 之后 `versions/*` 也是实例来源:这个夹具为了造 `inheritsFrom` 还写了三个父版本目录 + // (`1.20.4` / `1.20.1` / `1.21.1`)而**没有**给它们建档案,于是它们会作为「外部实例」出现。 + // 这条用例守的是「这条链把加载器/版本号翻对」——所以只看它自己那四个版本,条数也在这一层断言; + // 「盘上有版本就出现在列表里」由 `tests_union.rs` 独占。 + let snapshot: Vec<_> = snapshot + .into_iter() + .filter(|tile| cases.iter().any(|case| case.0 == tile.version_id)) + .collect(); assert_eq!(snapshot.len(), cases.len()); for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) { assert_eq!(tile.version_id.as_str(), *version_id); diff --git a/crates/presenter/src/host/game/tests_union.rs b/crates/presenter/src/host/game/tests_union.rs new file mode 100644 index 0000000..f41fcdd --- /dev/null +++ b/crates/presenter/src/host/game/tests_union.rs @@ -0,0 +1,234 @@ +//! M273:实例快照 = **档案 ∪ 盘上的 `versions/*`**(外部实例发现)。 +//! +//! 用户实测的症状是「HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页是空的」。 +//! 这组的三个用例正好按那次事故的形状造数据:临时数据根里只有 `versions/`,**没有** +//! `profiles.json`。 +//! +//! | 用例 | 抓得住什么 | 抓不住什么 | +//! |---|---|---| +//! | [`versions_on_disk_become_instances`] | 列表漏读外部版本(用户那次事故的正面);坏目录被当成实例;档案绑定的版本被重复列一遍 | 外部实例的**加载器/版本号识别是否准确**(那是 `describe_version` 的事);真 `versions//.json` 的全部变体(扫描判据在 core 的 `adopt` 测试里) | +//! | [`external_instance_writes_nothing_until_first_edit`] | 「一打开启动器就改别人的文件夹」——`instances()` 前后目录里不许多个文件;第一次改档位之后档案必须落盘且**幂等** | 别的写路径(启动参数写回、安装 mod)是否也守「先不写」——那条由 [`external_instance_launch_args_are_usable`] 与 core 的 adopt 用例分担 | +//! | [`external_instance_launch_args_are_usable`] | 外部实例能拿到可用的启动参数视图、能写回(不再报「找不到实例」) | 启动真的能进游戏(要真 jvm/账号/网络,见报告「没验的部分」) | + +use super::super::CoreHost; +use super::*; + +/// 临时数据根(每个用例一个目录;守卫在断言 panic 时也会删)。 +mod temp { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Root(pub PathBuf); + + impl Root { + pub 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!("{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(Path::new(&self.0)); + } + } + + /// 目录下所有文件/目录的相对路径(排序后比较,用来做「写盘前后」快照)。 + pub fn entries_under(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(read) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in read.flatten() { + let path = entry.path(); + if let Ok(relative) = path.strip_prefix(dir) { + out.push(relative.to_string_lossy().replace('\\', "/")); + } + if path.is_dir() { + stack.push(path); + } + } + } + out.sort(); + out + } +} + +/// 造一个「别人的 `.minecraft`」:`versions//.json`,没有 `profiles.json`。 +fn write_version(env: &HostEnv, id: &str) { + let path = env.game.version_json(id); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON"); +} + +/// 盘上有版本、没有档案 ⇒ 列表里必须有它(`versions/A/A.json` 合法 + `versions/B` 无清单)。 +/// +/// 「B 没有清单」是用户现场的另一半形状(别的启动器的残留目录):它**不是**实例。 +#[test] +fn versions_on_disk_become_instances() { + let root = temp::Root::new("ull-host-game-union"); + let env = HostEnv::at_root(&root.0); + write_version(&env, "A"); + std::fs::create_dir_all(env.game.version_dir("B")).expect("B 目录"); + + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 1, + "只有 A 算实例(B 没有清单):{snapshot:?}" + ); + let tile = &snapshot[0]; + assert_eq!(tile.version_id, "A"); + assert_eq!(tile.name, "A", "显示名 = 版本目录名"); + assert_eq!(tile.mc_version, "A", "没有 inheritsFrom 时根就是自己"); + assert!(tile.version_present, "清单在盘上,磁贴不该亮黄条"); + + // 实例 id 必须是**合法实例 id**(`config/instances/.json` 与启动链都按它校验)。 + // 版本目录名可以是中文(用户现场就是),所以 id 不能直接沿用版本 id。 + assert!( + unlucky_core::platform::layout::is_valid_instance_id(&tile.id), + "合成的实例 id 不合法,启动链会在读实例设置时失败:{}", + tile.id + ); + + // 同一个版本再被档案绑定(不论档案 id)⇒ 只出一条,且以档案为准(名字是用户改过的那个)。 + let profile = InstanceProfile { + id: ProfileId::new("renamed-1"), + name: "改过名的 A".to_string(), + version_id: "A".to_string(), + ..InstanceProfile::default() + }; + write_profiles( + &env.layout, + &ProfileList { + profiles: vec![profile], + ..ProfileList::default() + }, + ) + .expect("写清单"); + + let snapshot = instances(&env); + assert_eq!( + snapshot.len(), + 1, + "档案与外部版本不许重复列两遍:{snapshot:?}" + ); + assert_eq!(snapshot[0].name, "改过名的 A"); + assert_eq!(snapshot[0].id, "renamed-1"); +} + +/// 浏览**不写盘**;第一次改隔离档才懒创建档案(并且幂等)。 +/// +/// 这是「不擅自动别人的文件夹」这条不变的唯一可验证形态:`instances()` 前后做一次目录快照, +/// 两边必须一模一样。 +#[test] +fn external_instance_writes_nothing_until_first_edit() { + let root = temp::Root::new("ull-host-game-union-readonly"); + let env = HostEnv::at_root(&root.0); + write_version(&env, "A"); + + let before = temp::entries_under(&root.0); + let snapshot = instances(&env); + let after = temp::entries_under(&root.0); + assert_eq!( + before, after, + "读一次列表就在盘上多了东西(用户明确不要自动导入)" + ); + + let id = snapshot[0].id.clone(); + assert_eq!( + isolation_of(&env, &id), + "isolated", + "没有实例文件时按全局档位显示" + ); + + set_isolation(&env, &id, "shared").expect("第一次改档位 = 懒创建档案"); + assert_eq!(isolation_of(&env, &id), "shared"); + + let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); + assert_eq!(list.profiles.len(), 1, "懒创建只该写一条"); + let created = &list.profiles[0]; + assert_eq!( + created.id.as_str(), + id, + "档案 id 必须与视图 id 一致(否则下次列表出两条)" + ); + assert_eq!(created.version_id, "A"); + assert_eq!(created.name, "A"); + + // 幂等:再改一次不会长出第二条档案。 + set_isolation(&env, &id, "isolated").expect("第二次改档位"); + let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); + assert_eq!(list.profiles.len(), 1, "重复改档位不该再写第二条档案"); + + // 外部实例仍然只出现一条(档案那条)。 + assert_eq!(instances(&env).len(), 1); +} + +/// 外部实例的启动参数:读得到视图、写回不报「找不到实例」。 +#[test] +fn external_instance_launch_args_are_usable() { + let root = temp::Root::new("ull-host-game-union-args"); + let env = HostEnv::at_root(&root.0); + write_version(&env, "A"); + + let snapshot = instances(&env); + let tile = &snapshot[0]; + assert_eq!( + tile.launch.instance_id, tile.id, + "启动参数视图绑的是同一个实例" + ); + assert_eq!(tile.launch.mc_version, "A"); + + let patch = crate::dto::LaunchArgsPatch { + jvm_args: Some("-XX:+UseG1GC".to_string()), + ..crate::dto::LaunchArgsPatch::default() + }; + let mut host = CoreHost::headless(env.clone()); + host.apply_launch_args(&tile.id, &patch) + .expect("外部实例写启动参数不该报「找不到实例」"); + + let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); + assert_eq!(list.profiles.len(), 1, "写参数同时懒创建了档案"); + let reread = instances(&env); + assert_eq!(reread.len(), 1); + assert_eq!(reread[0].launch.jvm_args, "-XX:+UseG1GC"); +} + +/// **用户现场逐字复现**:`versions/樱落怡然客户端/樱落怡然客户端.json` +/// (`id` 与文件夹同名、`type` 是 `release`、没有 `inheritsFrom`)⇒ 列表里 1 条,名字就是它。 +/// +/// 这一条与 [`versions_on_disk_become_instances`] 的区别只在**目录名是中文**:那正是让 +/// 「实例 id 直接沿用版本 id」这个看起来最自然的写法当场失败的地方 +/// (`is_valid_instance_id` 只收 ASCII),而且失败点离现场很远(启动时才报实例设置读不出来)。 +#[test] +fn the_chinese_version_folder_from_the_field_report_shows_up() { + let root = temp::Root::new("ull-host-game-union-cjk"); + let env = HostEnv::at_root(&root.0); + let name = "樱落怡然客户端"; + let path = env.game.version_json(name); + std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录"); + std::fs::write(&path, format!(r#"{{"id":"{name}","type":"release"}}"#)).expect("写版本 JSON"); + + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 1, "用户现场应当看到 1 个实例:{snapshot:?}"); + let tile = &snapshot[0]; + assert_eq!(tile.name, name, "显示名 = 文件夹名(不是哈希)"); + assert_eq!(tile.version_id, name); + assert!( + unlucky_core::platform::layout::is_valid_instance_id(&tile.id), + "中文版本目录合成了一个非法实例 id:{}", + tile.id + ); + assert!( + !tile.id.contains(name), + "实例 id 不该是版本目录名本身(那份白名单只收 ASCII):{}", + tile.id + ); +} diff --git a/crates/presenter/src/host/launch_args.rs b/crates/presenter/src/host/launch_args.rs index 2ab059c..c9f11f4 100644 --- a/crates/presenter/src/host/launch_args.rs +++ b/crates/presenter/src/host/launch_args.rs @@ -172,19 +172,14 @@ pub fn min_version_hint() -> &'static str { impl super::CoreHost { /// 写实例启动参数(先按 id 找档案拿 `versionId`,再落盘)。 /// - /// 实例必须已登记:没有档案时写 `config/instances/.json` 会造出一个孤立的设置文件, - /// 启动链根本不会读它(它按 `profiles.json` 找实例)。 + /// 外部实例(盘上有版本、还没有档案)**第一次**写启动参数时才懒创建档案并落盘 + /// (`game::ensure_profile`)——在那之前,浏览列表不会在用户的盘上多出任何文件。 pub(super) fn apply_launch_args( &mut self, id: &str, patch: &LaunchArgsPatch, ) -> Result<(), String> { - let profiles = super::game::profiles(&self.env); - let profile = profiles - .profiles - .iter() - .find(|profile| profile.id.as_str() == id) - .ok_or_else(|| format!("找不到实例 `{id}`"))?; + let profile = super::game::ensure_profile(&self.env, id)?; apply(&self.env, id, &profile.version_id, patch) } }