From 5479f25068cdf50f8b61fd30ebba14df8711138a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 13:13:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(presenter):=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=94=B9=E5=B9=B6=E9=9B=86=EF=BC=88=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=20=E2=88=AA=20versions/*=EF=BC=89+=20=E7=A9=BA?= =?UTF-8?q?=E6=80=81=E6=8E=A5=E5=AF=BC=E5=85=A5=E5=90=91=E5=AF=BC=EF=BC=88?= =?UTF-8?q?M273=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) } } From c5009442c5f13ea6e2d79cffc97b5f17b7f8fa1f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 14:21:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(presenter):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E5=AE=9E=E4=BE=8B=E6=8C=89=E7=9B=98=E4=B8=8A?= =?UTF-8?q?=E7=97=95=E8=BF=B9=E9=80=89=E8=BF=90=E8=A1=8C=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=20+=20=E7=A9=BA=E6=80=81=E7=94=A8=E4=BE=8B=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E5=88=86=E6=94=AF/=E4=BC=A0=E5=8F=82=EF=BC=88M273=20r1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审的两条 P2。 ## P2-1:外部实例不再一律 isolated `versions//` 里出现 `mods` / `saves` / `config` / `options.txt` 任一 ⇒ `isolated` (gameDir = `versions/`);否则 ⇒ `shared`(gameDir = 游戏根 `.minecraft`)。 判据只 `exists()`,只读,不改盘;写盘仍然只在用户第一次改设置时(`ensure_profile`)。 - 判据收在 `host/game/discovered.rs::base_mode()`,签名 `(env, id, version_id)`: **有档案的实例返回全局 `instances.mode`**(D20 原口径,一字不变),自动发现的按痕迹判。 - 派生运行目录的五处(启动 / mods / saves / addon install / addon pack)从传 `global_mode` 改成传 `base_mode` ⇒ 「读到的存档/模组在哪」与磁贴上的 ISOLATION 同一份判据。 - `isolation_of(env, id, version_id)` 多收一个版本 id:实例文件优先,缺省 `base_mode`。 于是懒创建档案(只写 launch 段、isolation 仍为空)之后,磁贴不会从「共用目录」翻回「独立目录」。 - 磁贴/详情:自动发现的实例多一句「运行目录由盘上已有的文件决定」(i18n 中英); 按启动时再弹一条说清**这一次**用哪一边(`pages::instances::discovered_run_toast`, 同文案在屏不重复叠加)。 - `dto::DISCOVERED_ID_PREFIX` / `is_discovered_instance` 成为「自动发现」的公开判据 (不给 `InstanceSummary` 加字段:那要改 24 处结构体字面量,跨 agent / cli)。 ## P2-2:空态用例的覆盖面 r1 复审实测:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`(0 实例时反而没按钮), 当时三条用例全绿。现在: - `detail_empty` 拆成 `empty_not_selected` / `empty_no_instances` 两个函数,分支各自可断言; - `the_cta_lives_only_in_the_no_instances_branch`:CTA 只在「一个都没有」那一支、 「没选中」那一支必须是 `Vec::new()`,且分派必须写成 `if has_instances {`(取反即红); - `the_page_asks_the_empty_state_with_the_negated_row_count`:两条调用点(宽窗 + 窄窗) 都必须传 `!block.rows.is_empty()`,条数也钉住(多一条即红)。 - 两个变异已自证:传参取反 ⇒ 后一条红;CTA 挪支 ⇒ 前一条红(红原文见交付报告)。 ## P3 - `java::target_major()` 改读并集快照:只有自动发现实例的用户原来会拿到默认 JDK17 而不是 该版本要求的 JDK(`profiles.json` 是空的)。有档案时顺序与旧写法一致。 - `instances()` 全量扫仍在 UI 线程——本轮不动,刷新时机与代价写进报告(塔另开一条)。 ## 结构 `host/game.rs` 394 → 286 行:自动发现那一簇(痕迹判据 / 扫出来的版本 / 合成视图)搬到 `host/game/discovered.rs`(138 行),只为守 400 行硬线;`game::base_mode` 仍是同一条路径。 ## 门禁 app-iced lib 973 passed / presenter all-targets 386 passed / i18n 36 passed / fmt 0 / clippy -D warnings 0。 --- .../app-iced/src/app/update/page_messages.rs | 38 ++++- crates/app-iced/src/pages/instances.rs | 8 + crates/app-iced/src/pages/instances/detail.rs | 62 ++++++-- .../src/pages/instances/tests/empty_entry.rs | 92 +++++++++++- .../src/pages/instances/tests/i18n.rs | 3 + crates/app-iced/src/pages/instances/text.rs | 14 ++ crates/app-iced/src/pages/mod.rs | 4 +- crates/i18n/src/catalog/golden.rs | 3 + crates/i18n/src/catalog/instances.rs | 7 + crates/presenter/src/dto/instances.rs | 15 ++ crates/presenter/src/dto/mod.rs | 5 +- crates/presenter/src/host/addon/install.rs | 16 +- crates/presenter/src/host/addon/pack.rs | 16 +- crates/presenter/src/host/game.rs | 93 ++---------- crates/presenter/src/host/game/discovered.rs | 138 ++++++++++++++++++ crates/presenter/src/host/game/tests.rs | 5 +- crates/presenter/src/host/game/tests_union.rs | 82 ++++++++++- crates/presenter/src/host/java.rs | 13 +- crates/presenter/src/host/launch.rs | 9 +- crates/presenter/src/host/mods.rs | 14 +- crates/presenter/src/host/saves.rs | 14 +- 21 files changed, 494 insertions(+), 157 deletions(-) create mode 100644 crates/presenter/src/host/game/discovered.rs diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 5494647..dccd599 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -24,6 +24,7 @@ use unlucky_i18n::{Lang, LangChoice}; use crate::app::diag::diag; use crate::pages; use crate::ui::lang::LangState; +use unlucky_presenter::toast::ToastKind; use super::*; @@ -54,7 +55,13 @@ impl App { self.ensure_content_loaded(); } } - pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), + // M273 r1:自动发现的实例在**启动这一拍**说清「这一次用哪一边的运行目录」—— + // 判据(盘上有没有 mods/saves)决定的是「这次启动会读到哪份存档」,用户最需要知道的 + // 就是这一刻。文案走目录(`pages::instances::discovered_run_toast`),这里只搬。 + pages::Message::Launch(id) => { + self.hint_discovered_run_dir(&id); + self.dispatch(AppCommand::LaunchInstance(id)) + } // M159:自更新动作(检查 / 下载安装 / 忽略 / 立即重启 / 开关自动检查) pages::Message::Update(action) => return self.on_update_action(action), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), @@ -128,6 +135,35 @@ impl App { } impl App { + /// 自动发现实例的**运行目录提示**(M273 r1):按下启动时说清这一次用的是哪一边。 + /// + /// 判据全在 presenter(`isolation` 已经是「盘上有没有痕迹」的结论),这里不判盘、不猜。 + /// 同一条文案已经在屏幕上时不重复叠加——Toast 只有 4 秒 TTL,连点两次不该看到两行一样的字。 + fn hint_discovered_run_dir(&mut self, id: &str) { + let Some(instance) = self + .state + .instances + .iter() + .find(|instance| instance.id == id) + else { + return; + }; + if !unlucky_presenter::dto::is_discovered_instance(&instance.id) { + return; + } + let text = pages::discovered_run_toast(self.lang().catalog(), &instance.isolation); + if self + .state + .toasts + .items() + .iter() + .any(|toast| toast.text == text) + { + return; + } + self.state.toasts.push(ToastKind::Info, text); + } + /// 当前生效的界面语言(= 目录选择;页面文案一律由它派生)。 pub(in crate::app) fn lang(&self) -> Lang { self.lang.lang() diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index c32ff6d..762c65e 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -99,6 +99,14 @@ pub fn mods_failure_line( ) -> String { mods::failure_line(failure, Texts::of(catalog)) } + +/// 自动发现的实例按下「启动」时那句提示(**这一次**用的是哪一边的运行目录)。 +/// +/// 取值口径只有一处:[`Texts::discovered_run`],判据是快照里的 `isolation` +/// (`shared` = 共用游戏根;其余 = 它自己的版本目录)。界面层不自己判盘。 +pub fn discovered_run_toast(catalog: unlucky_i18n::Catalog, isolation: &str) -> String { + Texts::of(catalog).discovered_run(isolation).to_string() +} pub use state::{LaunchForm, Message as InstanceMessage}; pub(super) use text::Texts; diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 8554df9..45bc97e 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -47,6 +47,9 @@ pub(super) fn detail_column<'a>( .spacing(ui.space(Space::X4)) .width(Length::Fill); + if let Some(note) = discovered_note(ui, detail, texts) { + body = body.push(note); + } body = push_text(ui, metrics, body, detail); // M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高, // 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。 @@ -95,6 +98,9 @@ pub(super) fn narrow_detail<'a>( .spacing(ui.space(Space::X4)) .width(Length::Fill); + if let Some(note) = discovered_note(ui, detail, texts) { + body = body.push(note); + } body = push_text(ui, metrics, body, detail); // 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。 body = body.push(super::mods::section(ui, mods, forced, texts)); @@ -229,6 +235,23 @@ fn detail_head<'a>( .into() } +/// 自动发现的实例多一句话:**运行目录由盘上已有的文件决定**(M273 r1)。 +/// +/// 具体的档位就在上面的 ISOLATION 那一格(共用目录 / 独立目录),这里补的是「为什么」—— +/// 用户看到「共用目录」时必须能回答「我明明在 HMCL 里给它装过 mod」。 +/// 判据只有 id 前缀([`unlucky_presenter::dto::is_discovered_instance`]),界面层不自己判盘。 +fn discovered_note<'a>(ui: &'a Ui, detail: &Detail, texts: Texts) -> Option> { + unlucky_presenter::dto::is_discovered_instance(&detail.id).then(|| { + frame::text_role( + ui, + TypeRole::BodySmall, + false, + texts.discovered_note(), + ui.color(Role::OnSurfaceVariant), + ) + }) +} + /// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。 /// /// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**; @@ -294,28 +317,43 @@ fn action_row<'a>(ui: &'a Ui, forced: Option, texts: Texts) -> Elem /// 详情列的**空态**,两种含义分得很清楚: /// -/// | `has_instances` | 标题 / 说明 | 主行动 | -/// |---|---|---| -/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | 无 | -/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | +/// | `has_instances` | 标题 / 说明 | 主行动 | 画在哪 | +/// |---|---|---|---| +/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | **无** | [`empty_not_selected`] | +/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | [`empty_no_instances`] | /// /// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示 /// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路: -/// 接管盘上已经装好的版本(向导的另一半是「检测到了 N 个外部版本」的候选列表)。 +/// 接管盘上已经装好的版本。 +/// +/// 两个分支各自成一个函数,不是为了好看:CTA 落在哪一支、另一支有没有动作,只能这样分开断言。 +/// 「传参有没有传反」由调用点那一行的源码断言守(`tests/empty_entry.rs`)——那是本函数看不见的 +/// 事实,r1 复审的变异(把 `!block.rows.is_empty()` 写成 `block.rows.is_empty()`)就是从这里溜的。 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(), - ); + empty_not_selected(ui, texts) + } else { + empty_no_instances(ui, texts) } +} + +/// 有实例、但没选中:只说明「去左边点一个」,**不给动作**(动作在左列,点了就有)。 +fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> { + frame::empty_state( + ui, + IconKind::Instances, + texts.empty_title(), + texts.empty_body(), + Vec::new(), + ) +} + +/// 一个实例都没有:给一条主行动(导入已有版本 = 打开已有的接管向导)。 +fn empty_no_instances<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> { frame::empty_state( ui, IconKind::Instances, diff --git a/crates/app-iced/src/pages/instances/tests/empty_entry.rs b/crates/app-iced/src/pages/instances/tests/empty_entry.rs index 4286e23..cae4f83 100644 --- a/crates/app-iced/src/pages/instances/tests/empty_entry.rs +++ b/crates/app-iced/src/pages/instances/tests/empty_entry.rs @@ -42,7 +42,8 @@ fn the_empty_state_hands_out_the_adopt_intent() { /// 「未选择实例」敷衍——那句话在告诉用户「你已经有的东西只是没点」。 #[test] fn the_empty_state_wires_the_cta_to_that_intent() { - let body = source::function_body(include_str!("../detail.rs"), "fn detail_empty"); + let detail = include_str!("../detail.rs"); + let body = source::function_body(detail, "fn empty_no_instances"); let cta = source::code_line(body, "Button::new(texts.adopt_cta()") .unwrap_or_else(|| panic!("空态里没有一行**代码**建那枚「导入已有版本」按钮:\n{body}")); assert!( @@ -62,6 +63,95 @@ fn the_empty_state_wires_the_cta_to_that_intent() { ); } +/// **CTA 只属于「一个都没有」那一支**:`has_instances == true` 的空态不许有任何动作。 +/// +/// r1 复审实测的缺口:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,于是「0 个实例」 +/// 反而走「没选中」那一支、**没有按钮**,而当时的三条用例全绿。这条与下一条就是为它补的: +/// 这里钉「每个分支自己长什么样」,下一条钉「传参有没有传反」。 +/// +/// 抓得住:CTA 被挪到「没选中」那一支(用户有实例时反而看见「导入已有版本」); +/// 「没选中」那一支被塞进动作(`Vec::new()` 被换掉);两个分支被写成一模一样。 +/// 抓不住:`frame::empty_state` 真的把那枚按钮画出来(`Element` 无内省,见 `pages::layout::source`)。 +#[test] +fn the_cta_lives_only_in_the_no_instances_branch() { + let detail = include_str!("../detail.rs"); + let selected = source::code_text(source::function_body(detail, "fn empty_not_selected")); + let none = source::code_text(source::function_body(detail, "fn empty_no_instances")); + + assert!( + selected.contains("Vec::new()"), + "「有实例但没选中」那一支不该有任何动作(动作在左列):\n{selected}" + ); + for needle in ["adopt_cta", "adopt_action"] { + assert!( + !selected.contains(needle), + "「有实例但没选中」那一支混进了 `{needle}`(CTA 挪支了):\n{selected}" + ); + } + for needle in ["adopt_cta()", "adopt_action()"] { + assert!( + none.contains(needle), + "「一个实例都没有」那一支缺 `{needle}`(用户又找不到入口):\n{none}" + ); + } + + // 分派本身:`if has_instances` 走「没选中」,`else` 走带 CTA 的那一支。 + // 行首判据 ⇒ 写成 `if !has_instances` 立刻红(那种取反翻转下面两条顺序断言看不出来)。 + let dispatch = source::function_body(detail, "fn detail_empty"); + assert!( + source::has_code_line(dispatch, "if has_instances {"), + "`detail_empty` 必须以 `if has_instances {{` 分派(取反 = 两个空态互换):\n{dispatch}" + ); + let code = source::code_text(dispatch); + let if_at = code.find("if has_instances").expect("分派条件"); + let else_at = code.find("} else {").expect("else 分支"); + let selected_at = code + .find("empty_not_selected(ui, texts)") + .expect("没选中分支"); + let none_at = code + .find("empty_no_instances(ui, texts)") + .expect("一个都没有分支"); + assert!( + if_at < selected_at && selected_at < else_at, + "`has_instances` 为真必须走 `empty_not_selected`:\n{code}" + ); + assert!( + else_at < none_at, + "`has_instances` 为假必须走 `empty_no_instances`:\n{code}" + ); +} + +/// **调用点**:实例页量「有没有实例」的那一行不许传反。 +/// +/// 这是 r1 复审的变异原样:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,上面的 +/// 分支断言照样绿(两个分支各自都还对),但**用户看到的是错的那一支**。所以判据必须落在 +/// 调用点上——宽窗与窄窗各有一条,两条都要有 `!`。 +/// +/// 抓得住:传参取反(`!` 被删);只改掉一处(宽窗对了、窄窗忘了);新增第三条调用点却传了别的 +/// 值(条数断言只认 2 条,多出来就红)。 +/// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。 +#[test] +fn the_page_asks_the_empty_state_with_the_negated_row_count() { + let page = include_str!("../../instances.rs"); + let calls: Vec = source::code_lines(page) + .into_iter() + .filter(|line| line.contains("detail_empty(")) + .map(|line| line.trim().to_string()) + .collect(); + + assert_eq!( + calls.len(), + 2, + "实例页应当只有两条空态调用点(宽窗 + 窄窗),实际:{calls:?}" + ); + for line in &calls { + assert!( + line.contains("!block.rows.is_empty()"), + "调用点把「有没有实例」传反了(`!` 丢了 ⇒ 一个都没有时反而没有按钮):{line}" + ); + } +} + /// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。 /// /// 判据走 `code_text`(注释已剥):把那条 match 臂注释掉、或改成只打一行日志,这里就红—— diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 106aec0..0b68232 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -48,6 +48,9 @@ const ALLOWED: &[&str] = &[ // M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。 // 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。 "zip", + // M273 r1:`.minecraft` 是**目录名**(用户要照着去文件管理器里找的那一层)。共用目录那句 + // 必须原样写它,翻成「游戏根目录」会让用户对不上盘上那个文件夹。 + "minecraft", ]; /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index 05d965a..733768c 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -97,6 +97,20 @@ impl Texts { pub(super) fn adopt_cta(self) -> &'static str { self.text(Key::InstancesAdoptCta) } + /// 自动发现实例的运行目录规则(详情页常驻的一句话)。 + pub(super) fn discovered_note(self) -> &'static str { + self.text(Key::InstancesDiscoveredNote) + } + /// 自动发现实例**这一次**按哪一边跑(启动时弹的那一句)。 + /// + /// 判据只有 `isolation` 这一档:`shared` = 共用游戏根,其余(`isolated`)= 它自己的版本目录。 + pub(super) fn discovered_run(self, isolation: &str) -> &'static str { + if isolation == "shared" { + self.text(Key::InstancesDiscoveredRunShared) + } else { + self.text(Key::InstancesDiscoveredRunIsolated) + } + } pub(super) fn narrow_note(self) -> &'static str { self.text(Key::InstancesNarrowNote) } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index e840033..dbd6b66 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -124,8 +124,8 @@ mod tests; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; pub(crate) use instances::demo_form as demo_launch_form; pub use instances::{ - mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state, - InstanceMessage, LaunchForm, Mods, Saves, + discovered_run_toast, mods_failure_line, mods_from_state, saves_delete_prompt, + saves_failure_line, saves_from_state, InstanceMessage, LaunchForm, Mods, Saves, }; pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 30294e1..ca18ae3 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -157,6 +157,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("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", "导入已有版本"), + ("InstancesDiscoveredNote", "Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"), + ("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"), + ("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index d78d2f1..2b71551 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -224,6 +224,13 @@ macro_rules! instances_entries { 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", "导入已有版本"), + // ── 自动发现实例的**运行目录判据**(M273 r1 · P2-1)。 + // 判据是盘上的事实(`versions//` 里有没有 mods / saves / config / options.txt), + // 不是我们的偏好。必须让用户看得见,否则「为什么我的存档不在了」无从回答: + // 一句在详情页(常驻,说明规则),两句在启动那一拍(说明**这一次**用的是哪一边)。 + InstancesDiscoveredNote => ("Discovered in versions/: where it runs is decided by the files that are already there.", "在版本目录里自动发现:它从哪里启动,由盘上已有的文件决定。"), + InstancesDiscoveredRunShared => ("Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"), + InstancesDiscoveredRunIsolated => ("Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"), } } }; } diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 6026bb3..ca53b89 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -26,3 +26,18 @@ pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFa /// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 pub type InstanceId = String; + +/// **自动发现**实例的 id 前缀(M273):`versions/` 里扫出来、没有档案的版本用 +/// `ext-` 作实例 id。 +/// +/// 为什么让它是公开常量而不给 [`InstanceSummary`] 加字段:加字段要改 24 处结构体字面量 +/// (横跨 presenter / agent / cli / app-iced),而「这个实例是不是自动发现的」在界面上的 +/// 用途只有一处——详情页那句「为什么它按独立目录 / 共用目录跑」。前缀是**稳定契约**, +/// 由 `host::game` 的 `external_instance_id` 唯一生产(M274 多根时前缀不变,哈希里加根)。 +pub const DISCOVERED_ID_PREFIX: &str = "ext-"; + +/// 这个实例是不是「从盘上自动发现」的(没有用户 / 向导建过的档案)。 +#[must_use] +pub fn is_discovered_instance(id: &str) -> bool { + id.starts_with(DISCOVERED_ID_PREFIX) +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index db68d1d..277d745 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -61,8 +61,9 @@ pub use install::{ InstallStage, }; pub use instances::{ - CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, - LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, + is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, + LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, + Progress, DISCOVERED_ID_PREFIX, }; pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 991aadc..baeb4b8 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -288,20 +288,14 @@ fn find_instance(env: &HostEnv, id: &str) -> Result 全局 `instances.mode`)。 +/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。 fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { - let global = super::super::game::global_mode(env); + let base = super::super::game::base_mode(env, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| local(format!("读实例设置失败:{error}")))?; - derive_game_dir( - &env.game, - &instance.version_id, - global, - config.as_ref(), - None, - ) - .map(|derivation| derivation.game_dir) - .map_err(|error| local(format!("隔离目录派生失败:{error}"))) + derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + .map(|derivation| derivation.game_dir) + .map_err(|error| local(format!("隔离目录派生失败:{error}"))) } /// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉 diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 79c304b..738b8c7 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -280,24 +280,18 @@ pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) { }); } -/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。 +/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 基底档)。 pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result { let instance = super::super::game::instances(env) .into_iter() .find(|instance| &instance.id == id) .ok_or_else(|| format!("instance not found: {id}"))?; - let global = super::super::game::global_mode(env); + let base = super::super::game::base_mode(env, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("read instance config failed: {error}"))?; - derive_game_dir( - &env.game, - &instance.version_id, - global, - config.as_ref(), - None, - ) - .map(|derivation| derivation.game_dir) - .map_err(|error| format!("derive game dir failed: {error}")) + derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + .map(|derivation| derivation.game_dir) + .map_err(|error| format!("derive game dir failed: {error}")) } /// 实例快照(清单的 `game` + `dependencies` 从它来)。读不出来就返回 `None`, diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index 2c45a86..d41ccd8 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -22,6 +22,12 @@ use crate::dto::{ use super::env::HostEnv; +mod discovered; + +pub(super) use discovered::base_mode; + +use discovered::{external_summary, external_version_of, external_versions}; + /// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。 pub fn global_mode(env: &HostEnv) -> IsolationMode { env.store() @@ -108,12 +114,12 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind { }) } -/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。 -pub fn isolation_of(env: &HostEnv, id: &str) -> String { - let global = global_mode(env); +/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。 +pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String { + let base = base_mode(env, id, version_id); match read_instance_config(&env.layout, id) { - Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(), - _ => global.as_str().to_string(), + Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(), + _ => base.as_str().to_string(), } } @@ -173,78 +179,13 @@ fn profile_summary( last_played: played.get(id).cloned(), runtime: InstanceRuntime::Idle, icon: None, - isolation: isolation_of(env, id), + isolation: isolation_of(env, id, &version_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`] / 启动参数写回),于是 @@ -279,16 +220,6 @@ pub(super) fn ensure_profile(env: &HostEnv, id: &str) -> Result Option { - external_versions(env, profiles) - .into_iter() - .find(|version_id| external_instance_id(version_id) == id) -} - /// 改实例隔离档位(写实例设置文件;不动任何数据)。 /// /// 外部实例(盘上有版本、还没有档案)**第一次**改档位时才懒创建档案并落盘 diff --git a/crates/presenter/src/host/game/discovered.rs b/crates/presenter/src/host/game/discovered.rs new file mode 100644 index 0000000..730f3c1 --- /dev/null +++ b/crates/presenter/src/host/game/discovered.rs @@ -0,0 +1,138 @@ +//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。 +//! +//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事: +//! +//! 1. **哪些盘上的版本算实例**:`versions//.json` 能解析、没有被任何档案绑定 +//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步); +//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions//` 里有痕迹(`mods` / `saves` / +//! `config` / `options.txt`)就按独立目录,否则按共用目录(游戏根)。 +//! +//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节。 + +use std::collections::BTreeMap; + +use unlucky_core::game::isolation::IsolationMode; +use unlucky_core::game::ProfileList; + +use crate::dto::{InstanceRuntime, InstanceSummary}; + +use super::super::env::HostEnv; +use super::{describe_version, global_mode, isolation_of}; + +/// `versions//` 里这些名字出现任何一个,就说明这个版本本来就被当成 +/// **独立目录**在用(它自己的 mods / 存档 / 配置都在里面)。判据只 `exists()`,不读内容。 +/// +/// 为什么是这四个:HMCL / PCL / 官方启动器的「版本隔离」都把运行目录放在 `versions//`, +/// 于是跑过一次就会留下 `mods/` `saves/` `config/` `options.txt` 中的至少一个。 +const RUN_DIR_TRACES: [&str; 4] = ["mods", "saves", "config", "options.txt"]; + +/// 版本目录里有没有「它自己用过」的痕迹(只读)。 +fn has_own_run_dir(env: &HostEnv, version_id: &str) -> bool { + let dir = env.game.version_dir(version_id); + RUN_DIR_TRACES.iter().any(|name| dir.join(name).exists()) +} + +/// 实例的**基底隔离档**(实例文件之下那一层)。 +/// +/// | 实例 | 基底 | 依据 | +/// |---|---|---| +/// | 有档案(向导 / 用户 / AI 建的) | 全局 `instances.mode` | D20 原口径,一字不变 | +/// | **自动发现的**(id 带 [`crate::dto::DISCOVERED_ID_PREFIX`]) | `versions/` 里有痕迹 ⇒ `isolated`;否则 ⇒ `shared` | 「别人怎么存我们就怎么读」 | +/// +/// 为什么自动发现的实例不能一律 `isolated`:「版本隔离」下 `isolated` 的**运行目录就是** +/// `versions//`。一个从没跑过、也没有 `mods`/`saves` 的外部版本若按 `isolated` 处理, +/// 第一次启动就会在**别人的版本目录**里新长出 `mods/` `saves/` `config/`(D20 的 +/// `AdoptTarget` 缺省值正是为了躲开这件事而选 `InstanceDir`)。反过来,盘上已经有这些痕迹的 +/// 版本本来就是独立目录实例,按 `shared` 处理会让它突然读到 `.minecraft` 根下别人的存档 —— +/// 两种错都真实存在,所以判据只能是**盘上的事实**。 +/// +/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。 +pub(in crate::host) fn base_mode(env: &HostEnv, id: &str, version_id: &str) -> IsolationMode { + if !crate::dto::is_discovered_instance(id) { + return global_mode(env); + } + if has_own_run_dir(env, version_id) { + IsolationMode::Isolated + } else { + IsolationMode::Shared + } +} + +/// 盘上有版本、但没有档案绑定的那些版本 id(字典序,来自 core 的扫描)。 +/// +/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。 +/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。 +pub(super) 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`] 的其余部分。 +pub(super) 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!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX) +} + +/// 一条外部版本 → 磁贴(显示名 = 版本目录名;**只读**,不落任何文件)。 +pub(super) 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, version_id), + launch: super::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, + } +} + +/// 视图 id → 版本 id 的反查(只认「可登记、且还没有档案绑定」的版本)。 +/// +/// 与 [`external_versions`] 同一份判据:合成的视图 id 只在那个集合里有意义, +/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批。 +pub(super) 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) +} diff --git a/crates/presenter/src/host/game/tests.rs b/crates/presenter/src/host/game/tests.rs index 81e5e3e..e275619 100644 --- a/crates/presenter/src/host/game/tests.rs +++ b/crates/presenter/src/host/game/tests.rs @@ -189,9 +189,10 @@ fn isolation_write_then_read_round_trips() { unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单"); let id = profiles.profiles[0].id.as_str().to_string(); - assert_eq!(isolation_of(&env, &id), "isolated"); + // 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。 + assert_eq!(isolation_of(&env, &id, "1.20.1"), "isolated"); set_isolation(&env, &id, "shared").expect("切档位"); - assert_eq!(isolation_of(&env, &id), "shared"); + assert_eq!(isolation_of(&env, &id, "1.20.1"), "shared"); assert!(set_isolation(&env, &id, "nope").is_err()); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/presenter/src/host/game/tests_union.rs b/crates/presenter/src/host/game/tests_union.rs index f41fcdd..98f5f85 100644 --- a/crates/presenter/src/host/game/tests_union.rs +++ b/crates/presenter/src/host/game/tests_union.rs @@ -143,13 +143,17 @@ fn external_instance_writes_nothing_until_first_edit() { let id = snapshot[0].id.clone(); assert_eq!( - isolation_of(&env, &id), - "isolated", - "没有实例文件时按全局档位显示" + isolation_of(&env, &id, "A"), + "shared", + "`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)" ); - set_isolation(&env, &id, "shared").expect("第一次改档位 = 懒创建档案"); - assert_eq!(isolation_of(&env, &id), "shared"); + set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案"); + assert_eq!( + isolation_of(&env, &id, "A"), + "isolated", + "实例文件里写下的档位优先于基底档" + ); let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); assert_eq!(list.profiles.len(), 1, "懒创建只该写一条"); @@ -163,7 +167,7 @@ fn external_instance_writes_nothing_until_first_edit() { assert_eq!(created.name, "A"); // 幂等:再改一次不会长出第二条档案。 - set_isolation(&env, &id, "isolated").expect("第二次改档位"); + set_isolation(&env, &id, "shared").expect("第二次改档位"); let list = unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案"); assert_eq!(list.profiles.len(), 1, "重复改档位不该再写第二条档案"); @@ -232,3 +236,69 @@ fn the_chinese_version_folder_from_the_field_report_shows_up() { tile.id ); } + +// ------------------------------------------------------- 运行目录判据(M273 r1 · P2-1) +// +// **真值表**(自动发现的实例): +// +// | `versions//` 里有 | 隔离档 | gameDir | 为什么 | +// |---|---|---|---| +// | `mods` / `saves` / `config` / `options.txt` 任一 | `isolated` | `versions/` | 它本来就是「版本隔离」形态,别人的数据就放在里面 | +// | 一个都没有 | `shared` | 游戏根(`.minecraft`) | 从没用过隔离目录,写进 `versions/` 等于往别人的版本目录里拉屎 | +// +// 两条用例都顺带断言**派生前后盘上零变化**:判据是只读的,写盘只在用户第一次改设置时发生。 + +/// `versions/A/` 里只有清单 ⇒ 按**共用目录**跑(gameDir = 游戏根)。 +#[test] +fn external_instance_without_traces_runs_from_the_game_root() { + let root = temp::Root::new("ull-host-game-union-shared"); + let env = HostEnv::at_root(&root.0); + write_version(&env, "A"); + + let before = temp::entries_under(&root.0); + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 1); + assert_eq!( + snapshot[0].isolation, "shared", + "没有 mods / saves / config / options.txt ⇒ 共用目录" + ); + let dir = super::super::saves::run_dir(&env, &snapshot[0]).expect("派生运行目录"); + assert_eq!( + dir, + env.game.root(), + "共用目录的运行目录应当就是 .minecraft 根" + ); + assert_eq!( + before, + temp::entries_under(&root.0), + "判定 + 派生运行目录不许在盘上多出任何东西" + ); +} + +/// `versions/B/mods/` 存在 ⇒ 按**独立目录**跑(gameDir = `versions/B`)。 +#[test] +fn external_instance_with_mods_runs_in_its_version_folder() { + let root = temp::Root::new("ull-host-game-union-isolated"); + let env = HostEnv::at_root(&root.0); + write_version(&env, "B"); + std::fs::create_dir_all(env.game.version_dir("B").join("mods")).expect("mods 目录"); + + let before = temp::entries_under(&root.0); + let snapshot = instances(&env); + assert_eq!(snapshot.len(), 1); + assert_eq!( + snapshot[0].isolation, "isolated", + "`versions/B/mods/` 是「这个版本自带数据」的痕迹 ⇒ 独立目录" + ); + let dir = super::super::saves::run_dir(&env, &snapshot[0]).expect("派生运行目录"); + assert_eq!( + dir, + env.game.version_dir("B"), + "独立目录的运行目录应当是它自己的版本文件夹" + ); + assert_eq!( + before, + temp::entries_under(&root.0), + "判定 + 派生运行目录不许在盘上多出任何东西" + ); +} diff --git a/crates/presenter/src/host/java.rs b/crates/presenter/src/host/java.rs index 691bd81..51b48e3 100644 --- a/crates/presenter/src/host/java.rs +++ b/crates/presenter/src/host/java.rs @@ -19,13 +19,16 @@ use super::job::{spawn, HostTask, JobSender}; pub const DEFAULT_JDK_MAJOR: u32 = 17; /// 该装哪个大版本:取第一个实例版本的要求,没有实例就给 17。 +/// +/// 「第一个实例」读的是**并集快照**([`super::game::instances`]),不是 `profiles.json`: +/// M273 之后只有自动发现实例的用户(别的启动器装好了版本、ULL 一个档案都没有)完全合法, +/// 按旧写法他们会拿到默认 JDK17 而不是那个版本真正要求的 JDK,而 `profiles.json` 里 +/// 恰好什么都没有。有档案时顺序与旧写法一致(档案在前),所以既有行为不变。 pub fn target_major(env: &HostEnv) -> u32 { - let profiles = super::game::profiles(env); - profiles - .profiles + super::game::instances(env) .first() - .map(|profile| { - java::requirement_for_version(&env.game, &profile.version_id) + .map(|instance| { + java::requirement_for_version(&env.game, &instance.version_id) .minimum .max(8) }) diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 68bc05c..ab20b7e 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -193,13 +193,16 @@ pub fn build_request( Ok((request, warnings)) } -/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。 +/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。 +/// +/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`—— +/// 于是这里不需要知道实例是从哪来的,只有一处判据。 fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result { - let global = super::game::global_mode(env); + let base = super::game::base_mode(env, &target.id, &target.version_id); let config = read_instance_config(&env.layout, &target.id).map_err(|error| { LaunchFailureView::new(LaunchFailureStage::InstanceConfig, error.to_string()) })?; - let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None) + let derivation = derive_game_dir(&env.game, &target.version_id, base, config.as_ref(), None) .map_err(|error| { LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string()) })?; diff --git a/crates/presenter/src/host/mods.rs b/crates/presenter/src/host/mods.rs index 8513472..53a21df 100644 --- a/crates/presenter/src/host/mods.rs +++ b/crates/presenter/src/host/mods.rs @@ -184,19 +184,13 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) -> } } -/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 +/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。 fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { - let global = super::game::global_mode(env); + let base = super::game::base_mode(env, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; - let derivation = derive_game_dir( - &env.game, - &instance.version_id, - global, - config.as_ref(), - None, - ) - .map_err(|error| format!("派生运行目录失败:{error}"))?; + let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + .map_err(|error| format!("派生运行目录失败:{error}"))?; Ok(derivation.game_dir) } diff --git a/crates/presenter/src/host/saves.rs b/crates/presenter/src/host/saves.rs index 00b3ca5..a6dc5b5 100644 --- a/crates/presenter/src/host/saves.rs +++ b/crates/presenter/src/host/saves.rs @@ -240,19 +240,13 @@ pub(super) fn target( Ok((instance.clone(), dir)) } -/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。 +/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。 pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { - let global = super::game::global_mode(env); + let base = super::game::base_mode(env, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; - let derivation = derive_game_dir( - &env.game, - &instance.version_id, - global, - config.as_ref(), - None, - ) - .map_err(|error| format!("派生运行目录失败:{error}"))?; + let derivation = derive_game_dir(&env.game, &instance.version_id, base, config.as_ref(), None) + .map_err(|error| format!("派生运行目录失败:{error}"))?; Ok(derivation.game_dir) }