feat(presenter): 实例清单改并集(档案 ∪ versions/*)+ 空态接导入向导(M273)
用户实测:HMCL 能看见 `.minecraft/versions/樱落怡然客户端`,ULL 的实例页是空的 (日志 `instances=0`)。根因不是路径找错,而是 `host::game::instances()` 只列 `profiles.json` 里的档案,第三方启动器装好的版本在界面上根本不存在。 ## 列表改并集(presenter/src/host/game.rs) - `versions/*` 里**能解析出清单**(`<id>/<id>.json` 的 id 与目录名一致、继承链走到底、 符号链接不越界)且没有被任何档案绑定的版本,就地合成一条实例视图: 显示名 = 目录名、`version_id` = 目录名。扫描复用 `core::game::adopt::scan`, 没有另写一套目录遍历。 - 只读:合成视图不建目录、不写 profiles.json、不碰 versions/ 一个字节。 - 档案优先:某版本已被档案绑定时只按档案出一条(名字/隔离档/启动参数都保留),不重复列。 - 坏目录(无清单 / JSON 坏 / id 不符 / 缺父 / 越界)不当实例;`versions/` 读不出来也只是 退回「只有档案」的旧行为,不让实例页报错。 - 外部实例的视图 id 用 `ext-<fnv64>`:`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。
This commit is contained in:
1 parent
751ae80f28
commit
5479f25068
11 files changed
+574
-46
No files matched your search
@@ -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>(
|
||||
|
||||
@@ -292,12 +292,38 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, 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()),
|
||||
)],
|
||||
)
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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`(向导从界面上又变得不可达)"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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", "导入已有版本"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
|
||||
/// 不存在,用户以为「读不到这个 .minecraft」。
|
||||
///
|
||||
/// 所以:档案照旧(名字 / 隔离档 / 启动参数都以档案为准),**盘上有清单但没有档案绑定的版本**
|
||||
/// 就地合成一条视图挂上去。判据不在这里另写一套:扫描走
|
||||
/// [`unlucky_core::game::adopt::scan`](`versions/<id>/<id>.json` 能解出 `id`、继承链走到底、
|
||||
/// 符号链接不越界、坏目录逐条报问题),本文件只做 DTO 翻译 + 「有没有档案绑定」这一步。
|
||||
///
|
||||
/// ## 三条不变量
|
||||
///
|
||||
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰 `versions/` 里一个字节
|
||||
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
|
||||
/// 2. **档案优先**:某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
|
||||
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
|
||||
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
|
||||
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);`versions/` 整个读不出来也只是退回
|
||||
/// 只有档案的旧行为,不让实例页因此报错。
|
||||
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
|
||||
let profiles = profiles(env);
|
||||
let played = last_played(env);
|
||||
profiles
|
||||
let mut snapshot: Vec<InstanceSummary> = 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<String, String>,
|
||||
) -> 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<String> {
|
||||
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/<id>.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<String, String>,
|
||||
) -> 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<InstanceProfile, String> {
|
||||
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<String> {
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! M273:实例快照 = **档案 ∪ 盘上的 `versions/*`**(外部实例发现)。
|
||||
//!
|
||||
//! 用户实测的症状是「HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页是空的」。
|
||||
//! 这组的三个用例正好按那次事故的形状造数据:临时数据根里只有 `versions/`,**没有**
|
||||
//! `profiles.json`。
|
||||
//!
|
||||
//! | 用例 | 抓得住什么 | 抓不住什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`versions_on_disk_become_instances`] | 列表漏读外部版本(用户那次事故的正面);坏目录被当成实例;档案绑定的版本被重复列一遍 | 外部实例的**加载器/版本号识别是否准确**(那是 `describe_version` 的事);真 `versions/<id>/<id>.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<String> {
|
||||
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/<id>/<id>.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/<id>.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
|
||||
);
|
||||
}
|
||||
@@ -172,19 +172,14 @@ pub fn min_version_hint() -> &'static str {
|
||||
impl super::CoreHost {
|
||||
/// 写实例启动参数(先按 id 找档案拿 `versionId`,再落盘)。
|
||||
///
|
||||
/// 实例必须已登记:没有档案时写 `config/instances/<id>.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user