Files
Unlucky-Launcher/crates/presenter/src/host/game/discovered.rs
T
AskaEth c5009442c5 fix(presenter): 自动发现实例按盘上痕迹选运行目录 + 空态用例补上分支/传参(M273 r1)
r1 复审的两条 P2。

## P2-1:外部实例不再一律 isolated

`versions/<id>/` 里出现 `mods` / `saves` / `config` / `options.txt` 任一 ⇒ `isolated`
(gameDir = `versions/<id>`);否则 ⇒ `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。
2026-11-12 14:21:30 +08:00

139 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 自动发现的实例(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)
}