Files
Unlucky-Launcher/crates/presenter/src/host/game/refresh/live.rs
T
AskaEth 2fb61d8006 fix(launch): M304 r1 —— 根不见了必须拒绝 + 运行时态落进快照 + 真 host 端到端同拍判据
三条 r1 复审 P2,全部按代码修(不是只改注释)。

**P2-1(唯一实质问题)**:`ext-` 实例「所属根消失 / 改名」被静默放行 ⇒ 可能起错副本。
`live.rs::discovered` 反查落空时原来一律 `Ok(())`;而 `roots::all` 对非配置根是
`if !extra.is_dir() { continue; }`,于是官方根被删 / 改名 / 盘没挂上时反查必然落空 ——
放行之后 `build_request` → `game_for` 会退回配置根,配置根里恰好有同名 `version_id`
就把**另一份**拉起来(gameDir / 存档都在另一边),相对改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝)
是回归。改法:把 M273 的「只哈希版本目录名」搬进 `discovered.rs`
(`legacy_external_instance_id`,与 `external_instance_id` 共用同一个多段 FNV),反查落空时
**老档案放行、其余一律拒绝**并给具名原因(+ `stale` 触发重扫)。
另加 `game::tests::instance_id_hashes_are_the_released_ones`:两个哈希公式的字面量钉死
——这次把哈希抽成公共函数,公式一漂就会误杀所有存量 `ext-` 档案。

**P2-2**:`send()` 只认 `InstancesChanged`,而 app 侧 `state.rs:274` 把
`InstanceRuntimeChanged` **就地**写进自己的列表 ⇒ `runtime` 这一位必定分叉,而
`accept::running_pid` 的兜底读的正是它。改:`send()` 同时把运行时态落到 `published`
对应条目,并加用例 `runtime_events_land_in_the_published_snapshot`。

**P2-3**:补真 `CoreHost` + 真 app 的端到端判据(`app/update/instances/scan_phase.rs`):
临时数据根 + 真 host,`advance` 到首份快照落地后点启动,**`advance` 之前**详情页必须已
`can_stop`。变异(把 Busy 挪进启动线程、只有回程才发)实测必红。

判据(新增 3 条 + 1 条钉公式):`revalidate_tests`(根不见了 ⇒ 拒绝)、
`target_tests`(运行时态落快照)、`scan_phase`(真 host 同一拍)、`game::tests`(哈希公式)。
用例夹具按 400 行硬线拆到 `launch/fixtures.rs`。

门禁:`cargo test -p unlucky-presenter -p unlucky-app-iced --all-targets` 全绿
(426 + 1076)· `clippy --workspace --all-targets -- -D warnings` 0 ·
`fmt --all --check` 0 · 行数全 ≤400(最大 366;未碰的 `app/update/instances.rs` 399)。
2026-11-13 20:46:00 +08:00

129 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.
//! 快照条目的**现在**:这一条还在不在盘上(M304)。
//!
//! ## 为什么需要
//!
//! M304 把「点启动」的实例解析从「同步扫一遍 [`super::super::instances`]」改成「查已发布快照」。
//! 快照是**过去**的一拍:用户可能在别处(文件管理器 / 另一个启动器 / 同一会话的另一条命令)
//! 把这个实例删了、或者把它绑的版本改了。照旧解析就会「按快照把它拉起来」——拉的是**已经不
//! 存在的东西**,最坏的一种是 `game_for` 反查不到那个根、退回配置根,于是**启动的是另一个根里
//! 同名版本**(用户看着 A,起来的却是 B)。
//!
//! ## 判据(单实例,不扫 `versions/`)
//!
//! 一次只判**这一条**,所以绝不能再用 `instances()`(那是全量扫盘:官方根 200 个版本 236 ms,
//! 叠加外部档案 1.35 s)——那等于把「点了就受理」换成「点了等一扫」。两条判据各用最便宜的
//! 那个事实:
//!
//! | 实例 | 判据 | 成本 |
//! |---|---|---|
//! | 档案实例(id 不是 `ext-`) | `profiles.json` 里还有这条 id,且 `version_id` 没被改过 | 一次小文件读 |
//! | 自动发现的(`ext-`) | 逐个根重算 id(哈希)定位到 (根, 版本),再用 core 的 `adopt::inspect` 判这一个版本 | #根 次哈希 + 一次单版本检查 |
//!
//! ## 反查落空的两种情形,必须分开(r1 复审 P2-1)
//!
//! `roots::all` 对非配置根是 `if !extra.is_dir() { continue; }`(只有配置根无条件入列)。于是
//! 「官方根被删 / 改名 / 盘没挂上 / `canonicalize` 变了」时,那个 `ext-` id 一个根都对不上——
//! 若照旧放行,`build_request` → `game_for`(`discovered::game_for` 的回落)会退回**配置根**,
//! 配置根里恰好有同名 `version_id` 就把**另一份**拉起来(gameDir / 存档都在另一边)。这是相对
//! 改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝「找不到实例」)的回归,所以必须分开判:
//!
//! - **M273 老档案**(id 只哈希版本目录名 ⇒ `legacy_external_instance_id` 命中):**放行**——
//! 那种 id 的语义本来就是「按配置根解析」,真没版本时 `build_request` 给具名阶段失败;
//! - **所属根不见了**(删 / 改名 / 盘没挂上 / `canonicalize` 变了):**拒绝** + 具名原因,并由
//! 调用方立刻重扫(`Rejection::stale`)。
//!
//! 放行老档案不是偷懒:改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在旧实现里
//! 照样能点、照样走到同一条具名失败(`VersionManifest` + 路径),进程一个都没起来。
//!
//! ## 为什么挂在 [`super::Scan`] 上
//!
//! [`super::super::discovered`] / [`super::super::roots`] 是 `game` 的私有子模块,`host` 其它地方
//! 只够得着 `game` 转出的那几个名字(`Scan` / `instances` / `game_for` …)。`impl` 块可以写在任何
//! 模块里,于是这一层用关联函数挂在 [`super::Scan`] 上——语义也对:被判的正是「这份扫描账里的
//! 那一条现在还成不成立」。
use unlucky_core::game::adopt;
use unlucky_core::game::install::instance::read_profiles;
use crate::dto::InstanceSummary;
use super::super::super::HostEnv;
use super::super::discovered::{external_instance_id, legacy_external_instance_id};
use super::super::roots;
impl super::Scan {
/// 这条快照条目**现在**还在不在盘上;不在就给一句给用户看的具名原因。
///
/// 只判这一条(成本见模块文档的表格),**不重扫 `versions/`**:调用方是「点启动」那条同步
/// 路径,它必须便宜到能在同一拍里给出 Busy。
///
/// # Errors
///
/// 返回的字符串是**给人读的原因**(presenter 没有 i18n 依赖,文案在这里拼,与 `toast` 同一种
/// 形状):实例清单里没了 / 版本被改过 / 版本目录已不可用。调用方负责拒绝受理并重扫一遍。
pub(in crate::host) fn revalidate(
env: &HostEnv,
instance: &InstanceSummary,
) -> Result<(), String> {
if crate::dto::is_discovered_instance(&instance.id) {
return discovered(env, instance);
}
profile(env, instance)
}
}
/// 档案实例:`profiles.json` 里还有这条 id,且绑的版本没被改过。
///
/// 「版本被改过」也算过期:用户改的是**同一个档案**,但屏幕上那条磁贴写着旧版本、启动链也会按
/// 旧版本装配——那不是他刚才看中的那一条。
fn profile(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> {
let profiles =
read_profiles(&env.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
let Some(profile) = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == instance.id)
else {
return Err(format!(
"实例 `{}` 已不在实例清单里(可能刚在别处被删掉),列表已过期",
instance.name
));
};
if profile.version_id != instance.version_id {
return Err(format!(
"实例 `{}` 绑的版本已被改成 `{}`(列表里还是 `{}`),列表已过期",
instance.name, profile.version_id, instance.version_id
));
}
Ok(())
}
/// 自动发现的实例:id 是 (根, 版本) 的哈希,逐个根重算一遍定位,再判这一个版本。
///
/// 反查不到根 ⇒ 放行(M273 老档案,理由见模块文档):不猜、也不在这里改成「拒绝」,
/// 否则所有老用户的 `ext-` 磁贴会集体点不动。
fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> {
for root in roots::all(env) {
if external_instance_id(root.key(), &instance.version_id) != instance.id {
continue;
}
return adopt::inspect(root.game(), &env.layout, &instance.version_id)
.map(|_| ())
.map_err(|error| {
format!(
"实例 `{}` 的版本 `{}` 已不可用({error}),列表已过期",
instance.name, instance.version_id
)
});
}
// 反查落空:老档案放行,**根不见了就拒绝**(理由见模块文档——放行会让 `game_for` 退回配置根,
// 把另一个根里同名的那个版本拉起来)。
if legacy_external_instance_id(&instance.version_id) == instance.id {
return Ok(());
}
Err(format!(
"实例 `{}` 所属的游戏目录已不在(被移走 / 改名 / 盘没挂上),列表已过期",
instance.name
))
}