1 Commits
Author SHA1 Message Date
AskaEth 0522a41a52 release: v0.2.5
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
2026-11-12 12:06:59 +08:00
23 changed files with 107 additions and 972 deletions

No files matched your search

@@ -24,7 +24,6 @@ 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::*;
@@ -55,13 +54,7 @@ impl App {
self.ensure_content_loaded();
}
}
// M273 r1:自动发现的实例在**启动这一拍**说清「这一次用哪一边的运行目录」——
// 判据(盘上有没有 mods/saves)决定的是「这次启动会读到哪份存档」,用户最需要知道的
// 就是这一刻。文案走目录(`pages::instances::discovered_run_toast`),这里只搬。
pages::Message::Launch(id) => {
self.hint_discovered_run_dir(&id);
self.dispatch(AppCommand::LaunchInstance(id))
}
pages::Message::Launch(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)),
@@ -135,35 +128,6 @@ 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()
+6 -23
View File
@@ -99,14 +99,6 @@ 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;
@@ -169,7 +161,10 @@ pub fn view<'a>(
))
.action(buttons::text(
ui,
forced_state(Button::new(texts.import(), adopt_action()), forced),
forced_state(
Button::new(texts.import(), Message::Action(Action::Import)),
forced,
),
));
let body = match metrics.layout {
@@ -187,7 +182,7 @@ pub fn view<'a>(
forced,
texts,
),
None => detail::detail_empty(ui, texts, !block.rows.is_empty()),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
@@ -201,25 +196,13 @@ pub fn view<'a>(
forced,
texts,
),
None => frame::body(
metrics,
detail::detail_empty(ui, texts, !block.rows.is_empty()),
),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
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>(
+1 -65
View File
@@ -47,9 +47,6 @@ 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 完全看不见)。
@@ -98,9 +95,6 @@ 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));
@@ -235,23 +229,6 @@ 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<Element<'a, Message>> {
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,**数学上不可行**;
@@ -315,34 +292,7 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Elem
.into()
}
/// 详情列的**空态**,两种含义分得很清楚:
///
/// | `has_instances` | 标题 / 说明 | 主行动 | 画在哪 |
/// |---|---|---|---|
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | **无** | [`empty_not_selected`] |
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | [`empty_no_instances`] |
///
/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示
/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路:
/// 接管盘上已经装好的版本。
///
/// 两个分支各自成一个函数,不是为了好看: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 {
empty_not_selected(ui, texts)
} else {
empty_no_instances(ui, texts)
}
}
/// 有实例、但没选中:只说明「去左边点一个」,**不给动作**(动作在左列,点了就有)。
fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
@@ -351,17 +301,3 @@ fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
Vec::new(),
)
}
/// 一个实例都没有:给一条主行动(导入已有版本 = 打开已有的接管向导)。
fn empty_no_instances<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
texts.empty_none_title(),
texts.empty_none_body(),
vec![buttons::filled(
ui,
Button::new(texts.adopt_cta(), super::adopt_action()),
)],
)
}
@@ -13,9 +13,6 @@ 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};
@@ -1,173 +0,0 @@
//! 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 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!(
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(),
"空态主行动必须有可读标签(空标签 = 看不见的按钮)"
);
}
/// **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<String> = 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 臂注释掉、或改成只打一行日志,这里就红——
/// 那正是「功能做了但没接线」的原始形态(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`(向导从界面上又变得不可达)"
);
}
@@ -48,9 +48,6 @@ const ALLOWED: &[&str] = &[
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
"zip",
// M273 r1:`.minecraft` 是**目录名**(用户要照着去文件管理器里找的那一层)。共用目录那句
// 必须原样写它,翻成「游戏根目录」会让用户对不上盘上那个文件夹。
"minecraft",
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
@@ -85,32 +85,6 @@ 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 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)
}
+2 -2
View File
@@ -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::{
discovered_run_toast, mods_failure_line, mods_from_state, saves_delete_prompt,
saves_failure_line, saves_from_state, InstanceMessage, LaunchForm, Mods, Saves,
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};
-6
View File
@@ -154,12 +154,6 @@ 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", "导入已有版本"),
("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`)。
-15
View File
@@ -216,21 +216,6 @@ 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", "导入已有版本"),
// ── 自动发现实例的**运行目录判据**(M273 r1 · P2-1)。
// 判据是盘上的事实(`versions/<id>/` 里有没有 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/,这次启动按独立目录运行(它自己的版本文件夹)。"),
} }
};
}
-15
View File
@@ -26,18 +26,3 @@ pub use super::launch_failure::{LaunchFailureCause, LaunchFailureStage, LaunchFa
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// **自动发现**实例的 id 前缀(M273):`versions/<id>` 里扫出来、没有档案的版本用
/// `ext-<hash>` 作实例 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)
}
+2 -3
View File
@@ -61,9 +61,8 @@ pub use install::{
InstallStage,
};
pub use instances::{
is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary,
LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind,
Progress, DISCOVERED_ID_PREFIX,
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
+11 -5
View File
@@ -288,14 +288,20 @@ fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFail
.ok_or_else(|| local(format!("找不到实例 {id}")))
}
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 基底档,见 `game::base_mode`)。
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
let base = super::super::game::base_mode(env, &instance.id, &instance.version_id);
let global = super::super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.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}")))
derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
}
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
+11 -5
View File
@@ -280,18 +280,24 @@ 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<PathBuf, String> {
let instance = super::super::game::instances(env)
.into_iter()
.find(|instance| &instance.id == id)
.ok_or_else(|| format!("instance not found: {id}"))?;
let base = super::super::game::base_mode(env, &instance.id, &instance.version_id);
let global = super::super::game::global_mode(env);
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, base, 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,
global,
config.as_ref(),
None,
)
.map(|derivation| derivation.game_dir)
.map_err(|error| format!("derive game dir failed: {error}"))
}
/// 实例快照(清单的 `game` + `dependencies` 从它来)。读不出来就返回 `None`,
+35 -113
View File
@@ -1,20 +1,17 @@
//! 实例快照:`profiles.json` ∪ 盘上的 `versions/*` + 版本 JSON + 会话索引 + 实例设置
//! → [`InstanceSummary`]。
//! 实例快照:`profiles.json` + 版本 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, write_profiles,
read_instance_config, read_profiles, write_instance_config,
};
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList, VersionJson};
use unlucky_core::game::{ProfileList, VersionJson};
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
@@ -22,12 +19,6 @@ 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()
@@ -114,124 +105,57 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
})
}
/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。
pub fn isolation_of(env: &HostEnv, id: &str, version_id: &str) -> String {
let base = base_mode(env, id, version_id);
/// 实例的隔离档位(实例文件优先,缺省显示全局档位)。
pub fn isolation_of(env: &HostEnv, id: &str) -> String {
let global = global_mode(env);
match read_instance_config(&env.layout, id) {
Ok(Some(config)) => config.effective_isolation(base).as_str().to_string(),
_ => base.as_str().to_string(),
Ok(Some(config)) => config.effective_isolation(global).as_str().to_string(),
_ => global.as_str().to_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);
let mut snapshot: Vec<InstanceSummary> = profiles
profiles
.profiles
.iter()
.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_id),
version_present: env.game.is_version_installed(&version_id),
launch: super::launch_args::view(env, id, &version_id),
version_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)
.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,
}
})
.collect()
}
/// 改实例隔离档位(写实例设置文件;不动任何数据)。
///
/// 外部实例(盘上有版本、还没有档案)**第一次**改档位时才懒创建档案并落盘
/// ([`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 profile = ensure_profile(env, id)?;
let profiles = profiles(env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
let mut config = read_instance_config(&env.layout, id)
.map_err(|error| format!("读实例设置失败:{error}"))?
.unwrap_or_else(|| {
@@ -282,5 +206,3 @@ pub fn short_path(path: &Path) -> String {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_union;
@@ -1,138 +0,0 @@
//! 自动发现的实例(M273):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件的两件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析、没有被任何档案绑定
//! (判据来自 `core::game::adopt::scan`,本文件只做「有没有档案」这一步);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`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/<version_id>/` 里这些名字出现任何一个,就说明这个版本本来就被当成
/// **独立目录**在用(它自己的 mods / 存档 / 配置都在里面)。判据只 `exists()`,不读内容。
///
/// 为什么是这四个:HMCL / PCL / 官方启动器的「版本隔离」都把运行目录放在 `versions/<id>/`,
/// 于是跑过一次就会留下 `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/<id>` 里有痕迹 ⇒ `isolated`;否则 ⇒ `shared` | 「别人怎么存我们就怎么读」 |
///
/// 为什么自动发现的实例不能一律 `isolated`:「版本隔离」下 `isolated` 的**运行目录就是**
/// `versions/<id>/`。一个从没跑过、也没有 `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<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`] 的其余部分。
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<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, 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<String> {
external_versions(env, profiles)
.into_iter()
.find(|version_id| external_instance_id(version_id) == id)
}
+2 -11
View File
@@ -141,14 +141,6 @@ 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);
@@ -189,10 +181,9 @@ 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();
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
assert_eq!(isolation_of(&env, &id, "1.20.1"), "isolated");
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &id, "1.20.1"), "shared");
assert_eq!(isolation_of(&env, &id), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
@@ -1,304 +0,0 @@
//! 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(&current) 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, "A"),
"shared",
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 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, "懒创建只该写一条");
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, "shared").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
);
}
// ------------------------------------------------------- 运行目录判据(M273 r1 · P2-1)
//
// **真值表**(自动发现的实例):
//
// | `versions/<id>/` 里有 | 隔离档 | gameDir | 为什么 |
// |---|---|---|---|
// | `mods` / `saves` / `config` / `options.txt` 任一 | `isolated` | `versions/<id>` | 它本来就是「版本隔离」形态,别人的数据就放在里面 |
// | 一个都没有 | `shared` | 游戏根(`.minecraft`) | 从没用过隔离目录,写进 `versions/<id>` 等于往别人的版本目录里拉屎 |
//
// 两条用例都顺带断言**派生前后盘上零变化**:判据是只读的,写盘只在用户第一次改设置时发生。
/// `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),
"判定 + 派生运行目录不许在盘上多出任何东西"
);
}
+5 -8
View File
@@ -19,16 +19,13 @@ 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 {
super::game::instances(env)
let profiles = super::game::profiles(env);
profiles
.profiles
.first()
.map(|instance| {
java::requirement_for_version(&env.game, &instance.version_id)
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
+3 -6
View File
@@ -193,16 +193,13 @@ pub fn build_request(
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
let base = super::game::base_mode(env, &target.id, &target.version_id);
let global = super::game::global_mode(env);
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, base, config.as_ref(), None)
let derivation = derive_game_dir(&env.game, &target.version_id, global, config.as_ref(), None)
.map_err(|error| {
LaunchFailureView::new(LaunchFailureStage::Isolation, error.to_string())
})?;
+8 -3
View File
@@ -172,14 +172,19 @@ pub fn min_version_hint() -> &'static str {
impl super::CoreHost {
/// 写实例启动参数(先按 id 找档案拿 `versionId`,再落盘)。
///
/// 外部实例(盘上有版本、还没有档案)**第一次**写启动参数时才懒创建档案并落盘
/// (`game::ensure_profile`)——在那之前,浏览列表不会在用户的盘上多出任何文件。
/// 实例必须已登记:没有档案时写 `config/instances/<id>.json` 会造出一个孤立的设置文件,
/// 启动链根本不会读它(它按 `profiles.json` 找实例)。
pub(super) fn apply_launch_args(
&mut self,
id: &str,
patch: &LaunchArgsPatch,
) -> Result<(), String> {
let profile = super::game::ensure_profile(&self.env, id)?;
let profiles = super::game::profiles(&self.env);
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == id)
.ok_or_else(|| format!("找不到实例 `{id}`"))?;
apply(&self.env, id, &profile.version_id, patch)
}
}
+10 -4
View File
@@ -184,13 +184,19 @@ fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) ->
}
}
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
let base = super::game::base_mode(env, &instance.id, &instance.version_id);
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.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}"))?;
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}
+10 -4
View File
@@ -240,13 +240,19 @@ pub(super) fn target(
Ok((instance.clone(), dir))
}
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
let base = super::game::base_mode(env, &instance.id, &instance.version_id);
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.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}"))?;
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}