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
62 changed files with 657 additions and 5329 deletions

No files matched your search

+29 -30
View File
@@ -109,7 +109,8 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.os }}
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 exclude)⇒ 不拉起 slint / usvg / i-slint-*。
# `--workspace` 现在是 8 个 crate,不含 `unlucky-app`(M118 的 workspace exclude),
# 所以这条命令不会拉起 slint / usvg / i-slint-*。
- run: cargo clippy --workspace --all-targets -- -D warnings
test:
@@ -186,15 +187,16 @@ jobs:
# 只会打一条 `excluded package(s) … not found in workspace` 的警告然后空转(实测 exit 0,不报错)。
#
# ---- 取证(M147):下面是同一件事的两个平台版本,不是两条不同的测试 ----
# M144 的 `test (windows)` 只留 `Process completed with exit code 1`(cargo 自己的失败码是 **101**)
# ⇒ 那个 1 来源不明;job log 要登录才能读(匿名 403),但**注解匿名可读**
# (`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事各对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认首个失败即停(M144 实测 Linux 只跑 7 个 test binary 就退出),
# 后面还有没有第二条失败看不到;
# 2) `error` / `failures:` / panicked 行升成 `::error::`:拿不到 job log 时注解是匿名侧唯一正文
# (M275 把同一手法补到末尾的 `update-e2e` 步骤);
# 3) 显式打印 cargo 退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、
# 零诊断」分开——这正是 M144 没定的那一半。
# M144 的第三条红 `test (windows)` 只留下 `Process completed with exit code 1`,而 cargo 自己的失败码
# 是 **101** ⇒ 那个 1 的来源不明(包装层洗码 / 进程被杀,两说都未排除);job log 要登录才能读
# (匿名 403),但**注解匿名可读**(`GET /check-runs/<id>/annotations` → 200,M144 实证)。三件事
# 各对准一种「看不见」:
# 1) `--no-fail-fast`:cargo 默认在第一个失败的 target 就停(M144 实测 Linux 只跑 7 个 test
# binary 就退出),后面还有没有第二条失败完全看不到;
# 2) `error` / `failures:` / panicked 行升成 `::error::` 注解:拿不到 job log 时,注解是匿名侧
# 唯一能取到的失败正文;
# 3) 显式打印 cargo 的退出码:把「包装层把码洗成 1」「cargo 真失败(101)」「进程被杀只给码、零诊断」
# 分开——这正是 M144 没定的那一半。
- name: cargo test --workspace --all-targets(ubuntu,失败升注解)
if: runner.os == 'Linux'
run: |
@@ -362,14 +364,20 @@ jobs:
path: ${{ runner.temp }}/smoke/
if-no-files-found: warn
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / 篡改包体 /
# 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。只在 Windows:真实产物是裸 `.exe` +
# `portable-exe`,Linux/macOS 是 `.tar.gz` + `portable-zip`(`ensure_self_replaceable` 直接拒),
# 在 ubuntu 上跑绿只证明「世上不存在的 linux portable-exe 能装上」(r1 复审 P2-4)。
# 步骤名 `update-e2e` 与 run 里的 `--test update_e2e_test` 被 `packaging/check_test_source_off.py` 的
# `ALLOWED_STEP` 钉死(`update-test-source` 只许在这一项里出现)——改这里的 name / run 之前先读那段注释。
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions` / 优化 / panic 行为
# 都不同,用 debug 跑这条 E2E 就失去它存在的理由)。能跑起来比跑得快重要。
# M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 /
# 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。
#
# 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` +
# `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable`
# 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿
# (r1 复审 P2-4)。
#
# 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在
# `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test
# update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。
#
# 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与
# panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。
update-e2e:
name: update-e2e (windows x64)
runs-on: windows-latest
@@ -383,16 +391,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: windows-latest
# M275:裸 `cargo test` 失败只留一句 `exit code 1`、check-run 上零注解(匿名侧等于信息为零),所以照
# test 作业同一手法升注解:`$LASTEXITCODE` 是 cargo 的真码(Tee-Object 是 cmdlet,不改它)、`%` → `%25`。
- name: update-e2e
shell: pwsh
run: |
$log = Join-Path $env:RUNNER_TEMP 'update-e2e.log'
cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test 2>&1 | Tee-Object -FilePath $log
$code = $LASTEXITCODE; Write-Host "cargo exit=$code"
if ($code -ne 0) {
Select-String -Path $log -Pattern '(error(\[E\d+\])?:|^failures:|test result: FAILED|thread .* panicked at|^\s+--> )' | Select-Object -First 40 |
ForEach-Object { Write-Host "::error::$($_.Line.Replace('%', '%25'))" }
}
exit $code
run: >
cargo test --release -p unlucky-cli --features update-test-source
--test update_e2e_test --test update_e2e_tamper_test
+236 -12
View File
@@ -57,17 +57,7 @@ impl App {
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
//
// M277:**这一档本来就没有旧页时也必须丢**。判据是 `Kind::has_old_page()`(M256 写好的
// 设计口径,此前全仓无人调用 ⇒ 首屏照样画幽灵)。为什么它必须在这里:`pages::render` 把
// `previous_view` 整棵画在当前页**下面**,只在空隙里露出来——而 boot 那份快照是
// `construct.rs` 的零实例初始模型(被 `refresh.rs` 在第一次 refresh 时换进去),
// 于是「每次启动闪一张『还没有实例…』的空态页」约 250~300ms。
// 为什么不在 `refresh.rs` 存之前拦:本 mission 的 scope 不含 `app/state/**`;
// 而这里比"存之前拦"更靠下游一格,任何来源(含将来新增的)都被一并兜住。
// 丢在这里的时机是 `update` 的尾巴、`view` 之前 ⇒ 一帧都画不到。
let keeps_old_page = motion::kind().is_none_or(motion::Kind::has_old_page);
if !motion::needs_frames() || !keeps_old_page {
if !motion::needs_frames() {
self.previous_view = None;
}
}
@@ -171,4 +161,238 @@ fn nav_rank(route: &Route) -> Option<u8> {
}
#[cfg(test)]
mod tests;
mod tests {
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
}
@@ -1,331 +0,0 @@
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
/// **首屏(`Kind::Boot`)没有旧页**(M277 / 复审 M257-r1 P1-1 的修):boot 期间 `previous_view`
/// 不许留在那儿等 `pages::render` 去画。
///
/// 幽灵是怎么来的(不必重跑也能读出来):`pages::render` 把 `previous_view` 整棵画在当前页**下面**
/// (`stack![opaque(ghost), current]`),当前页不透明处盖住它,只在空隙里露出文字与图标磁贴;
/// 而 boot 那份快照是 `app/state/construct.rs` 的**零实例初始模型**,被 `refresh.rs` 在第一次
/// refresh 时换进 `previous_view` ⇒ 每次启动闪一张「这里还没有实例…」的空态页直到退场腿走完
/// (`Kind::Boot` 的退场腿 = `PEER_EXIT` 250ms,`transition.rs` 的表)。
///
/// 抓得住:把 `sync_motion` 的判据退回 `if !motion::needs_frames()`(即删掉 `keeps_old_page`
/// 那半句)⇒ boot 那一拍结束时的第一条 `is_none()` 立刻红(实测:`前提/首屏` 那条断言位置报
/// 「首屏(Kind::Boot)不许留旧页 …」),后面「每拍都跑」那条也红。`frame.exiting` 那条断言是
/// 配套的:它证明**那一帧本来会画**,所以这不是「没什么可画」的假绿。
/// 抓不住:`pages::render` 的调用点本身(单测没有 iced 布局树);那一处只认
/// 「`previous_view.is_some()` 且 `frame.exiting`」,本用例把前一半钉成 `None`。
#[test]
fn the_first_screen_never_keeps_a_previous_page_to_draw() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"前提:首屏起的是 Boot 档(这是「没有旧页」的那一档)"
);
assert!(
motion::frame().exiting,
"前提:Boot 的退场腿还在跑 ⇒ 没有守卫时这一帧就会把旧页画出来"
);
assert!(motion::needs_frames(), "前提:Boot 轨道还在跑");
assert!(
app.previous_view.is_none(),
"首屏(Kind::Boot)不许留旧页:boot 那一拍的 refresh 已经把零实例初始模型换进 \
previous_view,删掉 `keeps_old_page` 那半句即红"
);
// 再钉一次「不是只清了第一拍」:boot 期间又一份快照落地(换页判据会在屏幕上冒出一次
// 「屏或详情变了」)⇒ 守卫必须每一拍都生效,而不是只在 Boot 头一帧清一次。
app.previous_view = Some(pages::Model::new(
pages::Screen::Instances,
app.width,
app.height,
Some(pages::Screen::Instances.nav()),
));
let _ = app.update(Message::Frame);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"一帧之后仍在 Boot 档(`exiting` 为真的窗口里)"
);
assert!(
app.previous_view.is_none(),
"首屏每一拍都不许留旧页(守卫要每拍都跑,不是只在 Boot 头一帧清一次)"
);
}
/// **正常换页仍然看得见旧页退场**(M277 的反向守卫:不许为首屏把这条一起关掉)。
///
/// Peer 换页(Instances → Settings 这一档)的旧页退场是产品要的;误关掉比幽灵更显眼。
/// 抓得住:把 `keeps_old_page` 写死成假、或把 `sync_motion` 改成无条件清 `previous_view` ⇒ 红。
#[test]
fn a_peer_page_change_still_keeps_the_old_page_for_the_exit_leg() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
motion::advance_by(FRAME_MS);
assert!(
matches!(motion::kind(), Some(motion::Kind::Peer { .. })),
"前提:同级换页走 Peer 档(有旧页)"
);
assert!(motion::frame().exiting, "前提:退场腿还没走完");
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"Instances → Settings 的旧页必须还留着(删掉旧页退场即红)"
);
}
/// `MotionScale::NONE`:既不起页面轨道,也不留旧页(两条一起钉住,M277 回归项之一)。
///
/// 抓得住:把 `sync_motion` 的 `!needs_frames()` 那半句删掉后若又给 NONE 档插了轨道;
/// 或把守卫写成「只有 Boot 才丢」却让 NONE 档留下快照(`kind()` 为 `None` 时 `is_none_or` 为真,
/// 靠 `!needs_frames()` 兜住——这一条就是那道兜底的守卫)。
#[test]
fn motion_off_leaves_neither_a_page_track_nor_a_previous_page() {
motion::reset();
motion::configure_for_test(MotionScale::NONE, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(motion::kind(), None, "关掉动效连轨道都不插");
assert!(!motion::needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
assert!(app.previous_view.is_none(), "没有过渡就没有旧页可留");
motion::reset();
}
@@ -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()
@@ -59,7 +59,6 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
let (fill, content, _border) = variant.roles();
let forced = spec.forced();
let enabled = spec.on_press.is_some();
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
debug_assert!(
@@ -126,17 +125,9 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
.padding(geometry.padding)
.style(style);
let element: Element<'a, M> = match spec.on_press {
match spec.on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
};
// A8:**运行期态**才套状态取样(gallery 的 `state(..)` 强制态是取证,不许有过渡);
// 禁用的按钮不跟踪(它没有状态层可动,白占帧)。
if spec.state.is_some() {
element
} else {
interaction::tracked(element, enabled)
}
}
@@ -210,13 +201,13 @@ pub(super) fn style_closure(
(spec.fill.map(|_| container), content)
}
None => {
// 有填充:状态层叠在**容器角色**上;透明容器:叠在脚下那一层(`base`)上
// (否则 hover 看不见)。两条都经 `layer_color`:A8 起它带时间轴,
// 没有取样控件时**逐位**退回 `overlay_color`。
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
let background = match spec.fill {
// 有填充:静息就是容器色(不是「没有背景」),过渡中才换成插值色。
Some(role) => interaction::layer_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
Some(role) => match state.layer() {
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
.or(Some(ui.color(role))),
None => Some(ui.color(role)),
},
None => transparent_state_color(ui, spec.base, state, spec.content),
};
(background, ui.color(spec.content))
@@ -254,16 +245,19 @@ pub(super) fn style_closure(
}
}
/// 透明容器的状态层(`Outlined` / `Text` / `StandardIcon`):可见反馈只能来自叠在
/// **脚下那一层**([`Base`])上的状态层。
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
/// 叠在**内容角色**上的状态层——把它们当作「叠在 `base`(脚下那一层)上」求值,得到不透明实色。
///
/// M257 起它只是 [`interaction::layer_color`] 的一次调用(两者的区别只在「用哪个角色当底」),
/// 保留这个具名入口是为了让「透明容器的状态层叠在 base 上」这条口径在源码里仍然只有一处。
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
/// 三个变体共用一条口径,免得各自发挥。
///
/// M234 复审 P2-3:`base` 之前写死 `Role::Surface`,卡片里的 Text/Outlined 按钮抬起时
/// 状态层叠在画布色上(与禁用支的口径不一致)。
pub(super) fn transparent_state_color(
ui: &Ui,
base: Base,
state: Interaction,
content: Role,
) -> Option<iced::Color> {
interaction::layer_color(ui, state, content, base.role())
interaction::overlay_color(ui, state, content, base.role())
}
+2 -10
View File
@@ -54,7 +54,7 @@ use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::interaction::{self, Base, Interaction};
use super::interaction::{Base, Interaction};
use super::layout;
mod assemble;
@@ -359,7 +359,6 @@ pub fn icon_on<'a, M: Clone + 'a>(
let base = base.into();
let geometry = IconGeometry::of(ui);
let (fill, content, _) = variant.roles();
let enabled = on_press.is_some();
let forced = state.or(if on_press.is_some() {
None
} else {
@@ -384,15 +383,8 @@ pub fn icon_on<'a, M: Clone + 'a>(
.padding(Padding::default())
.style(style);
let element: Element<'a, M> = match on_press {
match on_press {
Some(message) => element.on_press(message).into(),
None => element.into(),
};
// A8:与文字按钮同一条口径——强制态不套取样控件,禁用不跟踪。
if state.is_some() {
element
} else {
interaction::tracked(element, enabled)
}
}
@@ -1,226 +0,0 @@
//! **hover / pressed 的取样控件**(M257 / A8):把「状态变了」这件事搬到 `update` 侧。
//!
//! ## 为什么需要它(一句话)
//!
//! `button` 的 hover 状态只在 `style` 闭包里可见,而闭包在 **`draw`** 里跑——那已经在订阅表
//! 求值之后了。若在 draw 侧起跑过渡,「鼠标停住不动」的整段时间里**一帧都不会来**,状态层会永远
//! 停在 `progress = 0`(看不见)。本控件用 `Widget::update`(早于订阅表求值)把目标态写进
//! [`super::layer`],于是同一拍 `needs_frames()` 就变真。
//!
//! ## 它做什么 / 不做什么
//!
//! | 做 | 不做 |
//! |---|---|
//! | 用 [`Tracker`] 把鼠标事件折成五态 | **不**消费事件(`shell` 的捕获位不动,按钮照常收到点击) |
//! | 改自己 `Tree` 状态里的 [`Layer`] 目标 | **不**改颜色、不改布局(颜色仍由 `style` 闭包求值) |
//! | `draw` 时把自己的层压到栈顶([`layer::with_current`]) | **不**包装禁用控件(没有状态层可动) |
//!
//! ## 命中测试用的是**布局矩形**
//!
//! `cursor.is_over(layout.bounds())`:与 `button` 自己的判定同源(它也读布局矩形)。触摸板的
//! 「按下后拖出再拖回」在 `Tracker` 里按 `pressed` 跟随 `over` 折,与 iced 的 `button` 语义一致。
//!
//! ## 抓不住什么
//!
//! 覆盖在按钮**之上**的另一层(模态遮罩 / tooltip)不算:本控件只看自己的矩形,而 `button`
//! 的 hover 会被上层遮住的行为由 iced 的 overlay 决定。两者在这类遮挡下可能短暂不一致
//! (按钮自己认为被 hover、本控件也认为被 hover),但既然按钮被遮住就点不到,观感无差。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Element, Event, Length, Rectangle, Size, Vector};
use super::layer::{self, Layer};
use super::Tracker;
use crate::app::components::motion;
/// 包一层「状态取样」:`enabled == false` 时**完全不跟踪**(禁用控件没有状态层)。
pub fn tracked<'a, Message, Theme, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
enabled: bool,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
Element::new(Tracked {
content: content.into(),
enabled,
})
}
/// 本控件自己的 `Tree` 状态:状态机 + 它那一条过渡。
#[derive(Debug)]
struct State {
tracker: Tracker,
layer: Layer,
}
impl Default for State {
fn default() -> Self {
Self {
tracker: Tracker::new(true),
layer: Layer::new(),
}
}
}
struct Tracked<'a, Message, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
enabled: bool,
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Tracked<'_, Message, Theme, Renderer>
where
Renderer: renderer::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
// 先让内容吃这条事件(按钮的点击 / 键盘都靠它),再做本控件自己的取样。
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if !self.enabled {
return;
}
let over = cursor.is_over(layout.bounds());
let state = tree.state.downcast_mut::<State>();
if !state.tracker.event(event, over) {
return;
}
// 起跑**在这里**(update 侧):同一拍 `needs_frames()` 就能看见它。
if state.layer.retarget(state.tracker.state(), motion::scale()) {
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let content = self.content.as_widget();
// 状态颜色由 `style` 闭包求值,而闭包只拿得到 `&Theme` —— 所以「这一帧处于哪条过渡」
// 只能经开画栈传给它们(见 `layer::with_current`)。
layer::with_current(state.layer, || {
content.draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -1,242 +0,0 @@
//! **状态层过渡**(M257 / A8):hover / pressed 的颜色不是硬切,而是按令牌时长插值。
//!
//! ## 这个模块解决什么
//!
//! M3 的状态层不是「另一种灰」,而是**内容色 + 固定 α 叠在容器上**(见 [`super`] 的文档表)。
//! 到 M256 为止,那张表的求值是**瞬时**的:`button::Status` 一变,背景色当帧跳到 8% / 10%。
//! 本模块给这件事加一条**时间轴**,判据是「泵被驱动了几帧 + 端点精确 + 中间态单调」。
//!
//! ## 两条时间轴,一个订阅点
//!
//! 控件状态(hover / pressed)活在各自的 iced `Tree` 里,所以过渡的 `from/to` 也放在那里
//! (见 [`super::hover`] 的包装控件)。本模块只持一条**进程内时钟**与一张**到期表**:
//!
//! | 谁 | 存什么 | 何时推进 |
//! |---|---|---|
//! | 包装控件(`Tree` 状态) | `from` / `to` / 起跑时刻 | `Widget::update` 里改目标 |
//! | 本模块(thread-local) | `NOW` + 到期时刻表 | `motion::advance` 每帧调 [`advance`] |
//!
//! [`is_animating`] 被 `motion::needs_frames()` 问(**唯一的订阅判断点**),所以状态层过渡与
//! 页面 / 模态 / 折叠共用同一条 `window::frames()` 订阅,不另开订阅。
//!
//! ## 起跑必须在 update 侧(M256 的 P1-1 教训)
//!
//! `button` 的 hover 状态只在 `draw` 侧(`style` 闭包)可见,而 `draw` 发生在订阅表求值**之后**
//! ⇒ 若在 draw 侧起跑,「鼠标停住不动」的那段会卡在 `progress = 0`(状态层永远看不见)。
//! 所以目标态的**改变**全部发生在 [`super::hover`] 包装控件的 `Widget::update` 里——那里早于
//! 订阅表求值,帧因此当拍就订阅上。
//!
//! ## `MotionScale::NONE` = 严格等于终值
//!
//! `ms = 0` ⇒ [`Layer::progress`] 恒为 `1.0`、不登记到期 ⇒ [`is_animating`] 不会被它点亮
//! ⇒ **一帧都不订阅**。这不是「很快走完」,是「根本不存在中间态」。
use std::cell::{Cell, RefCell};
use iced::Color;
use unlucky_design::motion::{DurationToken, Easing, MotionScale};
use super::Interaction;
use crate::app::components::motion::{Leg, MAX_STEP_MS};
/// 进(状态层变强:静息→悬停、悬停→按下):`short1` 50ms + 标准减速。
///
/// 出处:`14-motion-and-animation.md` §3 的 A8 行点名 `Short1`(50ms)。50ms 在 62.5Hz 的采样下
/// 是 3 帧——数量少但**不是硬切**(3 个可判别的中间值),而且 hover 本来就该「快而轻」。
pub const ENTER: Leg = Leg {
duration: DurationToken::Short1,
easing: Easing::StandardDecelerate,
};
/// 出(状态层变弱:按下→悬停、悬停→静息):同档时长 + 标准加速。
///
/// 为什么进出同档而不是「出更短」:50ms 已经是最短档,再短就没有中间态可采样了(回到硬切)。
/// 两条曲线的差别(减速 vs 加速)承担了「进略长于出」的观感差。
pub const EXIT: Leg = Leg {
duration: DurationToken::Short1,
easing: Easing::StandardAccelerate,
};
thread_local! {
/// 进程内时钟(毫秒)。只由 [`advance`] 推。
#[allow(clippy::missing_const_for_thread_local)]
static NOW: Cell<f32> = const { Cell::new(0.0) };
/// 还没到期的过渡(时刻表)。到期的**当场剔除**,否则空闲也一直要帧。
#[allow(clippy::missing_const_for_thread_local)]
static DEADLINES: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
/// 正在画的控件栈([`with_current`] 压、包装控件的 `draw` 用)。
#[allow(clippy::missing_const_for_thread_local)]
static STACK: RefCell<Vec<Layer>> = const { RefCell::new(Vec::new()) };
}
/// 当前时刻(毫秒,进程内单调)。
pub fn now_ms() -> f32 {
NOW.with(Cell::get)
}
/// 推进时钟(`motion::advance` 每帧调一次)。
///
/// 单步上限取 [`MAX_STEP_MS`](与帧泵同一条):空闲醒来 / 掉帧时 stale 的帧基准不许把一段
/// 50ms 的过渡一次性抹完(那会让「中间态」在真机上消失,而在单测里照样绿)。
pub fn advance(dt_ms: f64) {
if !dt_ms.is_finite() || dt_ms <= 0.0 {
return;
}
let step = dt_ms.min(MAX_STEP_MS) as f32;
NOW.with(|now| now.set(now.get() + step));
let now = now_ms();
DEADLINES.with(|list| list.borrow_mut().retain(|deadline| *deadline > now));
}
/// 现在有没有状态层过渡在跑(`motion::needs_frames` 的一个分支)。
pub fn is_animating() -> bool {
let now = now_ms();
DEADLINES.with(|list| {
let mut list = list.borrow_mut();
list.retain(|deadline| *deadline > now);
!list.is_empty()
})
}
/// 一次状态层过渡(值放在控件的 `Tree` 状态里,**不**在这里)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Layer {
/// 起点态(颜色从这里出发)。
from: Interaction,
/// 目标态(颜色走到这里)。
to: Interaction,
/// 起跑时刻。
at: f32,
/// 这一腿的时长(已按 `MotionScale` 缩放;`0` = 关着动效)。
ms: f32,
/// 这一腿的曲线。
easing: Easing,
}
impl Layer {
/// 静息层(还没发生过任何过渡):进度严格 `1.0`,等价于今天的行为。
pub const REST: Self = Self {
from: Interaction::Enabled,
to: Interaction::Enabled,
at: 0.0,
ms: 0.0,
easing: EXIT.easing,
};
/// 出厂的层(与 [`Layer::REST`] 同值;`Default` 便于 `Tree` 状态构造)。
pub fn new() -> Self {
Self::REST
}
/// 换目标;返回 `true` = 起跑了新的一条腿(调用方该请求重绘)。
///
/// `scale` 是**唯一**的降级口:`NONE` 时 `ms = 0`(进度恒为终值,且不登记到期 ⇒ 零帧)。
/// 中途反向时 `from` 取**上一个目标**(不是当前插值色):50ms 的腿上最多差一个 α 档,
/// 肉眼不可见;换来的是「不用每帧写回状态」(写回会让过渡自我拖动)。
pub fn retarget(&mut self, state: Interaction, scale: MotionScale) -> bool {
if state == self.to {
return false;
}
let stronger = rank(state) >= rank(self.to);
let leg = if stronger { ENTER } else { EXIT };
self.from = self.to;
self.to = state;
self.at = now_ms();
self.ms = scale.ms(leg.duration);
self.easing = leg.easing;
if self.ms > 0.0 {
// 多留一帧:到期后还至少要再画一次,屏幕上的**最后一帧才是终值**(否则停在 0.97)。
DEADLINES.with(|list| list.borrow_mut().push(self.at + self.ms + FRAME_GRACE_MS));
}
true
}
/// 这一帧的进度:`0` = 完全 `from`,`1` = 完全 `to`(**端点逐位精确**)。
pub fn progress(&self) -> f32 {
if self.ms <= 0.0 {
return 1.0;
}
let t = (now_ms() - self.at) / self.ms;
if t >= 1.0 {
return 1.0;
}
if t <= 0.0 {
return 0.0;
}
self.easing.sample(t).clamp(0.0, 1.0)
}
}
impl Default for Layer {
fn default() -> Self {
Self::new()
}
}
/// 态的「强 → 弱」次序(决定用 [`ENTER`] 还是 [`EXIT`] 那条腿)。
const fn rank(state: Interaction) -> u8 {
match state {
Interaction::Disabled => 0,
Interaction::Enabled => 1,
Interaction::Hovered => 2,
Interaction::Focused | Interaction::Pressed => 3,
}
}
/// 到期之后多等一帧(≈16ms):保证「最后一帧画的是终值」。
const FRAME_GRACE_MS: f32 = 16.0;
/// 画一棵子树时把它的层压到栈顶(包装控件的 `draw` 里调;`draw` 结束自动弹栈)。
pub fn with_current<R>(layer: Layer, draw: impl FnOnce() -> R) -> R {
STACK.with(|stack| stack.borrow_mut().push(layer));
let out = draw();
STACK.with(|stack| {
stack.borrow_mut().pop();
});
out
}
/// 当前栈顶的过渡;没有包装控件 = `None`(= 调用方按今天的行为求值)。
pub fn current() -> Option<(Interaction, Interaction, f32)> {
STACK.with(|stack| {
stack
.borrow()
.last()
.map(|layer| (layer.from, layer.to, layer.progress()))
})
}
/// 两个不透明色的插值:`p <= 0` 逐位等于 `from`,`p >= 1` 逐位等于 `to`。
///
/// **不产生 α**:两端都是不透明色(D27:GPU/CPU 两档逐像素一致),中间值也是不透明色。
pub fn blend(from: Color, to: Color, p: f32) -> Color {
if p <= 0.0 {
return from;
}
if p >= 1.0 {
return to;
}
Color {
r: from.r + (to.r - from.r) * p,
g: from.g + (to.g - from.g) * p,
b: from.b + (to.b - from.b) * p,
a: from.a + (to.a - from.a) * p,
}
}
#[cfg(test)]
pub fn reset() {
NOW.with(|now| now.set(0.0));
DEADLINES.with(|list| list.borrow_mut().clear());
STACK.with(|stack| stack.borrow_mut().clear());
}
/// 测试专用:按给定毫秒推进(`advance` 读 dt,单测不能等真实时间)。
#[cfg(test)]
pub fn advance_by(ms: f64) {
advance(ms);
}
#[cfg(test)]
mod tests;
@@ -1,145 +0,0 @@
//! 状态层过渡的判据(M257 / A8):**帧数 + 端点精确 + 中间态严格单调**,外加两条硬契约。
//!
//! 两条硬契约(塔在 M257 briefing 里点名的):
//!
//! 1. `MotionScale::NONE` 下进度**严格**等于终值(独立用例,不靠别的用例顺带覆盖);
//! 2. 「鼠标停住不动」的那段时间里过渡必须**自己走完并停在终值**——本文件用「只推时钟、不喂事件」
//! 的形态抓「停在 progress 0」这个具体故障。
use super::*;
use crate::app::components::motion::FRAME_MS;
use unlucky_design::motion::MotionScale;
const STANDARD: MotionScale = MotionScale::STANDARD;
const NONE: MotionScale = MotionScale::NONE;
/// 一条全新的过渡(每个用例开头都从干净时钟起跑)。
fn fresh() -> Layer {
reset();
Layer::new()
}
/// **悬停:50ms 内走到 1.0,端点精确,中间严格单调——且**不靠任何事件**。**
///
/// 这条用例直接对着塔的硬判据写:*「鼠标停住不动的整段时间里,过渡必须走完并停在终值」*。
/// 实现里唯一驱动它的是 [`advance`](`motion::advance` 每帧调),所以「停在 progress 0」
/// (= 起跑写在 draw 侧、没有帧订阅)在这里会立刻红。
///
/// 抓得住:①把 `short1`(50) 改成 0(进度当帧就是 1.0 ⇒ 「第一帧必须是 0」的断言红);
/// ②把 `advance` 的时钟加去掉(进度永远 0 ⇒ 终值断言红);③把终止条件删掉(`is_animating` 恒真)。
/// 抓不住:真实帧率(那由 `window::frames()` 决定)、hover 的观感。
#[test]
fn hovering_fades_in_over_the_token_duration_without_any_further_events() {
let mut layer = fresh();
assert_eq!(layer.progress(), 1.0, "静息层:进度是终值(= 今天的行为)");
assert!(
layer.retarget(Interaction::Hovered, STANDARD),
"目标变了必须起跑"
);
assert_eq!(layer.progress(), 0.0, "第一帧必须还在静息端(端点精确)");
assert!(is_animating(), "起跑之后同一拍就该要帧");
let mut values = vec![layer.progress()];
let mut frames = 0;
while is_animating() {
advance_by(FRAME_MS);
values.push(layer.progress());
frames += 1;
assert!(frames < 60, "过渡没停,疑似终止条件失效");
}
// 50ms / 16ms ≈ 3~4 帧(含到期后多要的那一帧)。
assert!(
(3..=5).contains(&frames),
"50ms 应落在 3~5 帧,实测 {frames} 帧"
);
assert_eq!(values.last().copied(), Some(1.0), "终值必须**严格**是 1.0");
for pair in values.windows(2) {
assert!(
pair[1] >= pair[0],
"淡入必须单调不减:{:?} → {:?}",
pair[0],
pair[1]
);
}
let distinct: Vec<_> = values.iter().filter(|v| **v > 0.0 && **v < 1.0).collect();
assert!(
!distinct.is_empty(),
"必须有可判别的中间态(否则就是硬切):{values:?}"
);
assert!(!is_animating(), "走完必须退订帧");
assert_eq!(layer.progress(), 1.0, "没有轨道时进度恒为终值");
}
/// **`MotionScale::NONE` = 严格等于终值**(独立用例;不靠上一条顺带覆盖)。
///
/// 抓得住:把 `retarget` 里的 `NONE ⇒ ms = 0` 删掉(那会跑一遍 50ms ⇒ 首帧进度是 0.0,本用例红);
/// 或把 NONE 实现成「很快走完」(同样红:本用例断言的是**当帧就到位**,不是「几帧后到位」)。
/// 抓不住:`NONE` 之外的档位(那是别的用例的事)。
#[test]
fn motion_scale_none_has_no_intermediate_state_at_all() {
let mut layer = fresh();
assert!(layer.retarget(Interaction::Hovered, NONE));
assert_eq!(layer.progress(), 1.0, "NONE 下当帧就是终值(严格等于 1)");
assert!(!is_animating(), "NONE 下不许要帧(不是「很快走完」)");
assert!(layer.retarget(Interaction::Enabled, NONE));
assert_eq!(layer.progress(), 1.0, "移出同样是当帧终值");
assert!(!is_animating());
}
/// 同态重复不改目标(否则每次 draw 都会重启一条腿 ⇒ 过渡被无限拉长)。
///
/// 抓得住:把 `retarget` 的 `state == self.to` 早退删掉。
#[test]
fn retargeting_the_same_state_is_a_no_op() {
let mut layer = fresh();
assert!(layer.retarget(Interaction::Hovered, STANDARD));
advance_by(FRAME_MS);
let mid = layer.progress();
assert!(!layer.retarget(Interaction::Hovered, STANDARD));
assert_eq!(layer.progress(), mid, "同一目标的重复报告不许重置进度");
}
/// 按下比悬停更「强」:从悬停转按下走 `ENTER` 那条腿(起跑端是**上一个目标**,不是静息)。
///
/// 抓得住:把 `retarget` 里的 `self.from = self.to` 写成 `self.from = Interaction::Enabled`
/// (按下瞬间会先掉回静息色一帧——状态层闪一下),本用例断言 `from` 是 `Hovered`。
#[test]
fn pressing_starts_from_the_previous_target_not_from_rest() {
let mut layer = fresh();
layer.retarget(Interaction::Hovered, STANDARD);
// 不推进:模拟「悬停刚开始就按下」(鼠标点得比淡入还快)。
assert!(layer.retarget(Interaction::Pressed, STANDARD));
let (from, to, p) = current_from(&layer);
assert_eq!(from, Interaction::Hovered, "起跑端必须是上一个目标");
assert_eq!(to, Interaction::Pressed);
assert_eq!(p, 0.0, "新的一条腿从 0 开始");
}
/// 把层的两端读出来(`Layer` 的字段是私有的,这里经 [`with_current`] 走公开路径)。
fn current_from(layer: &Layer) -> (Interaction, Interaction, f32) {
with_current(*layer, || current().expect("刚压进去的层必须在栈顶"))
}
/// 两个不透明色的插值:**端点逐位相等**,中点严格落在两者之间。
///
/// 抓得住:①把端点写成「差不多」(`p == 0` 时返回插值结果 0.999…)——逐位断言红;
/// ②引入 α(`a` 不再是 1.0)——D27 的「两个后端逐像素一致」就靠这条守着。
#[test]
fn blend_hits_both_endpoints_bit_for_bit() {
let from = iced::Color::from_rgb(0.1, 0.2, 0.3);
let to = iced::Color::from_rgb(0.7, 0.8, 0.9);
assert_eq!(blend(from, to, 0.0), from);
assert_eq!(blend(from, to, 1.0), to);
for p in [0.25, 0.5, 0.75] {
let mid = blend(from, to, p);
assert!((0.1..=0.7).contains(&mid.r), "r 必须落在两端之间");
assert!((0.2..=0.8).contains(&mid.g));
assert!((0.3..=0.9).contains(&mid.b));
assert_eq!(mid.a, 1.0, "中间态也不许引入半透明(D27)");
}
}
@@ -64,14 +64,11 @@ use unlucky_design::color::{Role, StateLayer};
use crate::theme::Ui;
mod hover;
mod tracker;
/// 状态层**过渡的时间轴**(M257 / A8):hover / pressed 按令牌时长插值,不再是硬切。
pub mod layer;
// 事件 → 态的**状态机**在 [`tracker`]:M257 起它有真消费者——[`hover`] 的取样控件。
pub use hover::tracked;
// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用;
// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。
#[allow(unused_imports)]
pub use tracker::Tracker;
// 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
@@ -154,40 +151,6 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role
.map(|layer| ui.state_over(layer, content, container))
}
/// 这一帧的状态层颜色(**组件唯一入口**,A8 起带时间轴)。
///
/// 三种情形,前两种都**逐位等于** M257 之前的输出:
///
/// | 情形 | 返回 | 为什么 |
/// |---|---|---|
/// | 没有取样控件(gallery 强制态 / 未包装路径) | [`overlay_color`] | 取证图与旧基线一字不差 |
/// | 有取样控件且过渡已到位(`p >= 1`) | 目标态的颜色 | 静息与终态都是**精确**的令牌求值 |
/// | 过渡中(`0 < p < 1`) | 两态不透明色的插值 | 中间帧既不是起点也不是终点(可判别的中间态) |
///
/// 中间态为什么能插值而不产生半透明:两端都是 `state_over` 合成的**不透明**色,线性插值的结果
/// 也不透明(D27:GPU/CPU 两档逐像素一致)。`back` 是状态层合成的底(填充按钮 = 容器角色,
/// 透明容器 = [`Base`]),当前态不叠层时以它为端点色(那正是「没画状态层」时看到的颜色)。
pub fn layer_color(ui: &Ui, state: Interaction, content: Role, back: Role) -> Option<Color> {
let Some((from, to, p)) = layer::current() else {
return overlay_color(ui, state, content, back);
};
let source = overlay_color(ui, from, content, back);
let target = overlay_color(ui, to, content, back);
if p >= 1.0 {
return target;
}
if p <= 0.0 {
return source;
}
// 端点缺失(静息态不叠层)时用**底**补一个不透明色:那正是用户眼里的「没有状态层」。
let backdrop = ui.color(back);
Some(layer::blend(
source.unwrap_or(backdrop),
target.unwrap_or(backdrop),
p,
))
}
mod base;
mod disabled;
@@ -272,68 +235,6 @@ mod tests {
}
}
/// **没有取样控件时,[`layer_color`] 逐位等于 M257 之前的 [`overlay_color`]**。
///
/// 抓得住:有人把时间轴接进求值路径时顺手改了静息/终态的颜色(D30 的旧基线会漂)。
/// 抓不住:过渡中间帧(那是下一条用例与 `layer::tests` 的事)。
#[test]
fn layer_color_without_a_sampler_is_bit_identical_to_overlay_color() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
for state in Interaction::ALL {
for (content, back) in [
(Role::OnPrimary, Role::Primary),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
] {
assert_eq!(
layer_color(&ui, state, content, back),
overlay_color(&ui, state, content, back),
"{flavor:?}/{state:?}/{content:?} 必须逐位退回旧口径"
);
}
}
}
}
/// **过渡中间帧:颜色落在两端之间,且不透明**(D27:不许把 α 交给渲染器)。
///
/// 抓得住:①中间帧被写成硬切(`p` 被忽略 ⇒ 起点色或终点色,`assert_ne` 红);
/// ②有人为了「淡出」改用 `Color { a: 0.5 }`(末尾的「必须不透明」红)。
/// 抓不住:观感(8%/10% 的差在图上只有几级色阶,好不好看得看图)。
#[test]
fn a_running_transition_yields_an_opaque_colour_between_the_two_ends() {
use super::layer;
let ui = ui(SchemeFlavor::Light);
layer::reset();
let resting = overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface);
let hovered = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
.expect("hover 有颜色");
let mut state = layer::Layer::new();
state.retarget(
Interaction::Hovered,
unlucky_design::motion::MotionScale::STANDARD,
);
layer::advance_by(16.0);
let mid = layer::with_current(state, || {
layer_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
})
.expect("过渡中必须有颜色");
assert_eq!(mid.a, 1.0, "中间帧也必须不透明(D27)");
assert!(
resting.is_none(),
"前提:静息态本来不叠层(透明容器的情形)"
);
assert_ne!(mid, hovered, "中间帧不许等于终点色(那就是硬切)");
let backdrop = ui.color(Role::Surface);
assert_ne!(mid, backdrop, "中间帧也不许还停在起点色的底上");
layer::reset();
}
#[test]
fn hover_is_weaker_than_pressed_on_the_same_container() {
let ui = ui(SchemeFlavor::Light);
@@ -1,202 +0,0 @@
//! **裁切容器**(M257):可见宽可以逐帧变,**内容排版宽不变**。
//!
//! ## 为什么不能直接 `container(..).clip(true)`
//!
//! iced 的 `container::layout` 把父级 `Limits` 的 **max** 一路传给子级,而 `Limits::resolve` 会把
//! `Length::Fixed(w)` 夹到那个 max 上。于是「外层宽逐帧变 + 内层固定 320」的实际排版宽是
//! `min(320, 这一帧的外层宽)`:正文每帧重新折行 ⇒ 折叠那 200ms 里文字会「沸腾」(M257 的
//! 第 02 张取证图就是这么暴露的:空态文案在过渡中折成两行)。
//!
//! 本控件把两件事拆开:
//!
//! | 谁 | 用什么宽 |
//! |---|---|
//! | 子内容的**排版** | `content_width`(目标宽,整段过渡里不变 ⇒ 不重排) |
//! | 自己占的**格子**(= 裁剪框) | `visible`(逐帧变,由折叠进度给) |
//!
//! 绘制走 `Renderer::with_layer(bounds)`(两个后端都实现:tiny-skia 是 `push_clip`,
//! wgpu 是 `push_transformation` 同族的层栈),所以内容超出 `visible` 的部分**画不出来**——
//! 这就是「不溢出」在像素上的落点,不需要半透明。
//!
//! ## 内容锚在**右边**
//!
//! 子节点被放在 `visible − content_width`(负数)处:右栗贴在窗口右缘,折叠时**右边不动、左边
//! 被裁**。于是图标条 / 面板头部的右端元素不位移(「折叠后图标位置不跳」的另一半也在这一句里)。
//!
//! ## 已知局限(与 `zoom.rs` 同类,登记在报告里)
//!
//! **命中测试用排版矩形**:过渡期间内容的可点区域比可见区域宽(多出来的那一段在左侧、看不见)。
//! 只在 `visible != content_width` 的 200ms 里成立,收尾(或展开到位)时两者相等、逐像素与静息
//! 相同。
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{tree, Tree};
use iced::advanced::{mouse, Clipboard, Shell, Widget};
use iced::{Element, Event, Length, Point, Rectangle, Size, Vector};
/// 把 `content` 按 `content_width` 排版、按 `visible` 裁切显示(内容**右端对齐**)。
pub fn clipped<'a, Message, Theme, Renderer>(
content: impl Into<Element<'a, Message, Theme, Renderer>>,
visible: f32,
content_width: f32,
) -> Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + 'a,
{
Element::new(Clipped {
content: content.into(),
visible,
content_width,
})
}
struct Clipped<'a, Message, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
/// 这一帧占的格子宽(= 裁剪框宽)。
visible: f32,
/// 内容排版宽(整段过渡里不变)。
content_width: f32,
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for Clipped<'_, Message, Theme, Renderer>
where
Renderer: iced::advanced::Renderer,
{
fn tag(&self) -> tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut Tree) {
self.content.as_widget().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
// 排版宽**只**认 `content_width`:父级 max 不参与(那正是 container 会夹住它的地方)。
let child_limits = layout::Limits::new(
Size::ZERO,
Size::new(self.content_width, limits.max().height),
);
let node = self
.content
.as_widget_mut()
.layout(tree, renderer, &child_limits);
let size = node.size();
// 右对齐:内容右端贴在裁剪框右缘。
let dx = self.visible - size.width;
layout::Node::with_children(
Size::new(self.visible, size.height),
vec![node.move_to(Point::new(dx, 0.0))],
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let Some(child) = layout.children().next() else {
return;
};
let bounds = layout.bounds();
let content = self.content.as_widget();
// `with_layer` 在两个后端都裁到 `bounds`(见模块文档的表)。
renderer.with_layer(bounds, |renderer| {
content.draw(tree, renderer, theme, style, child, cursor, viewport);
});
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn iced::advanced::widget::Operation,
) {
let Some(child) = layout.children().next() else {
return;
};
self.content
.as_widget_mut()
.operate(tree, child, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let Some(child) = layout.children().next() else {
return;
};
self.content.as_widget_mut().update(
tree, event, child, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let Some(child) = layout.children().next() else {
return mouse::Interaction::None;
};
self.content
.as_widget()
.mouse_interaction(tree, child, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<iced::overlay::Element<'a, Message, Theme, Renderer>> {
let child = layout.children().next()?;
self.content
.as_widget_mut()
.overlay(tree, child, renderer, viewport, translation)
}
}
+3 -50
View File
@@ -48,7 +48,6 @@
//! 不做逐像素 α(D27:CPU 档禁面板半透明)也不做模糊(同上),所以两个渲染后端出图一致。
//! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。
mod clip;
mod pump;
mod transition;
mod zoom;
@@ -59,11 +58,10 @@ mod zoom;
pub use pump::{Pump, FRAME_MS, MAX_STEP_MS};
// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。
// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。)
pub use clip::clipped;
#[allow(unused_imports)]
pub use transition::{
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, rail_spec, Kind, Leg,
ModalFrame, ModalSpec, PageFrame, PageSpec, RailSpec, MODAL_SCALE_FROM,
enter_offset, exit_offset, modal_scale, modal_spec, page_spec, Kind, Leg, ModalFrame,
ModalSpec, PageFrame, PageSpec, MODAL_SCALE_FROM,
};
pub use zoom::scaled;
@@ -140,12 +138,8 @@ pub fn samples() -> u64 {
}
/// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。
///
/// **两条时间轴都问**:本模块的轨道(页面 / 模态 / 折叠)与 `interaction::layer` 的状态层过渡。
/// 后者有自己的时钟(状态层要按控件逐个推进,而控件状态在各自的 `Tree` 里),所以它只借这里的
/// 帧订阅:`App::subscription` 仍然只认这一个函数(**没有第二个判断点**)。
pub fn needs_frames() -> bool {
PUMP.with(|pump| pump.borrow().is_animating()) || super::interaction::layer::is_animating()
PUMP.with(|pump| pump.borrow().is_animating())
}
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
@@ -225,45 +219,6 @@ pub fn advance() {
PUMP.with(|pump| {
pump.borrow_mut().advance(elapsed_ms);
});
// A8:状态层过渡的时钟(单步上限与本模块同一条 [`MAX_STEP_MS`],掉帧不许把过渡一帧抹掉)。
super::interaction::layer::advance(elapsed_ms);
}
/// 右栗折叠的**目标档**变了(`pages/agent/state.rs` 的三个档位迁移里调,都在 update 侧)。
///
/// 返回 `true` = 起跑了过渡(调用方不必做事:帧基准由本模块自己重置)。
pub fn observe_rail(open: bool) -> bool {
let started = PUMP.with(|pump| pump.borrow_mut().observe_rail(open));
if started {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
started
}
/// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。
pub fn snap_rail(open: bool) {
let was_running = PUMP.with(|pump| pump.borrow_mut().snap_rail(open));
if was_running {
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
}
}
/// 这一帧的折叠进度(0 = 48 图标条,1 = 展开档)。**只采样、无副作用**。
///
/// `open` 是调用方**此刻**的目标档。泵记的目标与它不一致,说明有人改了档位却没走
/// [`observe_rail`](截图 harness / 单测直接构造状态)——那时直接给终值:
/// 宁可无动效,也不许把界面卡在中间。
pub fn rail(open: bool) -> f32 {
PUMP.with(|pump| {
let pump = pump.borrow();
if pump.rail_open() == open {
pump.rail_progress()
} else if open {
1.0
} else {
0.0
}
})
}
/// 把位移套到树上。
@@ -294,8 +249,6 @@ pub fn reset() {
LAST_FRAME.with(|last| last.set(None));
// 钉住也要清:「故意走真探针」的那条用例靠 `reset()` 回到未钉住状态。
FORCED_SCALE.with(|forced| forced.set(None));
// 状态层那条时钟是另一个 thread-local(`interaction::layer`),一起清。
super::interaction::layer::reset();
}
/// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。
@@ -10,20 +10,17 @@
//! |---|---|---|
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] |
//! | 折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) |
//!
//! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。
use unlucky_design::motion::{DurationToken, MotionScale};
mod modal;
mod rail;
use super::transition::{
enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec,
};
pub(crate) use modal::Modal;
pub(crate) use rail::RailTrack;
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
///
@@ -64,10 +61,6 @@ pub struct Pump {
pending_ms: f64,
page: Option<PageTrack>,
modal: Modal,
/// 右栗折叠轨道(M257):`Some` = 宽度正在 `48 ⇄ 展开档` 之间走。
rail: Option<RailTrack>,
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
rail_open: bool,
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
close_pending: bool,
screen: Option<u64>,
@@ -88,8 +81,6 @@ impl Pump {
pending_ms: 0.0,
page: None,
modal: Modal::Hidden,
rail: None,
rail_open: false,
close_pending: false,
screen: None,
rank: None,
@@ -113,9 +104,6 @@ impl Pump {
self.detail = None;
self.pending_ms = 0.0;
self.modal = Modal::Hidden;
// 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。
self.rail = None;
self.rail_open = false;
self.close_pending = false;
if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
@@ -141,9 +129,7 @@ impl Pump {
/// 现在有没有动画在跑(= 要不要订阅帧)。
pub fn is_animating(&self) -> bool {
self.page.is_some()
|| self.rail.is_some()
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
self.page.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
}
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
@@ -300,11 +286,6 @@ impl Pump {
self.page = None;
}
}
if let Some(track) = self.rail {
if now - track.start_ms >= track.duration_ms {
self.rail = None;
}
}
let spec = modal_spec();
match self.modal {
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
@@ -1,104 +0,0 @@
//! 右栗折叠轨道(M257 / A2):`Collapsed 48 ⇄ Expanded` 那一段的**时间轴**。
//!
//! 与页面 / 模态两条轨道并列的第三条:它只管「这一帧折叠到几成」,不碰宽度怎么算
//! (宽度是 `pages/agent/metrics.rs` 的纯函数)、也不碰画什么(那是视图的事)。
//!
//! | 项 | 值 | 出处 |
//! |---|---|---|
//! | 展开(进) | `medium1` 250ms + `Emphasized` | `14-motion-and-animation.md` §3 A2(token 列)+ §7-1「强调曲线优先」「进略长于出」 |
//! | 收起(出) | `short4` 200ms + `EmphasizedAccelerate` | 同上(退场比进场短 50ms) |
//!
//! ## 为什么拆出去档(`Detached`)**不**走这条轨道
//!
//! 拆分是**布局切换**(右栗消失、会话搬到独立页),不是宽度的连续变化:把 0 也塞进这条轨道,
//! 「48 → 0」会变成一次毫无意义的缩水动画,而且中途页面会按一个不存在的宽度重排。所以
//! [`Pump::snap_rail`] 是它的入口:目标记下来、轨道清空,进度**严格**是终值。
//!
//! ## 起跑只在 update 侧
//!
//! [`Pump::observe_rail`] 由 `pages/agent/state.rs` 的三个档位迁移(`collapse` / `expand` /
//! `dock`)调用——那三处都在 `update` 里发生。`view` 侧只经 `motion::rail(open)` **采样**。
use super::super::transition::rail_spec;
use super::{leg_fraction, Pump};
/// 一条正在跑的折叠轨道(0 = 图标条,1 = 展开档)。
#[derive(Debug, Clone, Copy)]
pub(crate) struct RailTrack {
/// 起跑时已经在几成(中途反向时不为 0/1,保证**不跳变**)。
pub(crate) from: f32,
/// 目标(0 或 1)。
pub(crate) to: f32,
/// 这一腿的时长(已按 `MotionScale` 缩放)。
pub(crate) duration_ms: f64,
/// 这一腿的曲线。
pub(crate) leg_easing: unlucky_design::motion::Easing,
pub(crate) start_ms: f64,
}
impl RailTrack {
/// `elapsed` 毫秒之后的进度(端点**逐位**等于 `from` / `to`)。
pub(crate) fn sample(&self, elapsed_ms: f64) -> f32 {
let t = leg_fraction(elapsed_ms, self.duration_ms);
if t >= 1.0 {
return self.to;
}
if t <= 0.0 {
return self.from;
}
self.from + (self.to - self.from) * self.leg_easing.sample(t)
}
}
impl Pump {
/// 折叠目标态(`true` = 展开档)。出厂是收起(`pages/agent/state.rs` 的默认档)。
pub fn rail_open(&self) -> bool {
self.rail_open
}
/// 起跑一次折叠 / 展开。返回 `true` = 真的起跑了轨道(调用方要重置帧基准)。
///
/// **关掉动效时不插轨道**(`MotionScale::NONE`):进度从此恒为终值,`needs_frames()` 不会
/// 因为折叠而变真——这是「NONE = 严格等于终值」在那一条轨道上的落点。
pub fn observe_rail(&mut self, open: bool) -> bool {
if self.rail_open == open && self.rail.is_none() {
return false;
}
let from = self.rail_progress();
self.rail_open = open;
if self.scale.is_off() {
// 关掉动效 = 直接落终态(不是跑一遍 0 时长)。
self.rail = None;
return false;
}
let spec = rail_spec();
let leg = if open { spec.enter } else { spec.exit };
self.rail = Some(RailTrack {
from,
to: if open { 1.0 } else { 0.0 },
duration_ms: f64::from(self.scale.ms(leg.duration)),
leg_easing: leg.easing,
start_ms: self.start_now(),
});
true
}
/// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。
///
/// 返回 `true` = 原来有轨道被掐掉了(那一帧要重画成终值)。
pub fn snap_rail(&mut self, open: bool) -> bool {
self.rail_open = open;
self.rail.take().is_some()
}
/// 这一帧的折叠进度(0 = 48 图标条,1 = 展开档)。
///
/// 没有轨道时是**严格**的 `0.0` / `1.0`(不是「差不多到位」)。
pub fn rail_progress(&self) -> f32 {
match self.rail {
Some(track) => track.sample(self.elapsed_ms() - track.start_ms),
None if self.rail_open => 1.0,
None => 0.0,
}
}
}
@@ -9,7 +9,6 @@
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
mod modal;
mod rail;
mod timeline;
use super::pump::Pump;
@@ -1,174 +0,0 @@
//! 右栗折叠轨道(M257 / A2)的判据:泵帧数 + 端点精确 + 中间态严格单调。
//!
//! 判据口径同 M256(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素只能证明「某一张图
//! 里宽度是多少」,证明不了「中间有 12 个相位」与「结束时不多要一帧」。
use super::{settle, standard};
use crate::app::components::motion::*;
use unlucky_design::motion::MotionScale;
/// 一路采样折叠进度(含终点),用完自己 settle。
fn samples(pump: &mut Pump) -> Vec<f32> {
let mut out = vec![pump.rail_progress()];
let mut guard = 0;
while pump.is_animating() {
pump.advance(FRAME_MS);
out.push(pump.rail_progress());
guard += 1;
assert!(guard < 200, "折叠轨道没在跑完,疑似终止条件失效");
}
out
}
/// **展开:帧数落在 12~20 之间、端点精确、中间严格单调**。
///
/// 抓得住:①有人把 `medium1`(250) 改成 600ms(帧数窜到 38)或改成 0(硬跳,帧数 0);
/// ②终止条件被删掉(`while is_animating` 直接跑满 200 帧的兜底断言红);③插值写成阶跃
/// (单调性断言里的 `>` 会变成 `==`)。
/// 抓不住:观感是否「像 M3E」——那要出图(D30),本用例只保证相位数量与端点。
#[test]
fn expanding_runs_the_expected_number_of_frames_and_lands_exactly_on_one() {
let mut pump = standard();
assert!(pump.observe_rail(true), "目标变了必须起跑");
assert_eq!(pump.rail_progress(), 0.0, "第一帧还在折叠档(端点精确)");
let values = samples(&mut pump);
let frames = values.len() - 1;
assert!(
(12..=20).contains(&frames),
"250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧"
);
assert_eq!(
values.last().copied(),
Some(1.0),
"结束时必须**严格**是 1.0(不是 0.99x)"
);
for pair in values.windows(2) {
assert!(
pair[1] > pair[0],
"展开必须严格单调:{:?} → {:?}",
pair[0],
pair[1]
);
}
assert!(!pump.is_animating(), "跑完必须清空轨道(否则空闲烧帧)");
assert_eq!(pump.rail_progress(), 1.0, "没有轨道时是终值");
}
/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 12~13 帧)。
#[test]
fn collapsing_is_strictly_monotone_downwards_and_stops_at_zero() {
let mut pump = standard();
pump.observe_rail(true);
settle(&mut pump);
assert_eq!(pump.rail_progress(), 1.0);
assert!(pump.observe_rail(false));
let values = samples(&mut pump);
let frames = values.len() - 1;
assert!(
(10..=20).contains(&frames),
"200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧"
);
assert_eq!(values.last().copied(), Some(0.0), "结束时必须严格是 0.0");
for pair in values.windows(2) {
assert!(pair[1] < pair[0], "收起必须严格单调下降");
}
assert!(!pump.is_animating());
}
/// **`MotionScale::NONE`:严格等于终值,且一帧都不要**(独立的用例,不靠别的用例顺带覆盖)。
///
/// 抓得住:把 `observe_rail` 里的 `is_off()` 分支删掉(那会插一条 0 时长轨道 ⇒ 进度仍是 1.0,
/// 但 `is_animating()` 会有一拍为真 ⇒ 本用例的「一帧都不要」断言红);或把 `is_off()`
/// 写成「跑一遍 0 时长」(同样红)。
/// 抓不住:中间帧**该不该**存在(NONE 下不该有中间帧,这是定义,不是观感)。
#[test]
fn motion_scale_none_puts_the_rail_strictly_at_the_end_value() {
let mut pump = Pump::new();
pump.configure(MotionScale::NONE, None);
assert!(
!pump.observe_rail(true),
"NONE 下不许起跑轨道(不是什么都没发生,是**不许有中间态**)"
);
assert_eq!(pump.rail_progress(), 1.0, "展开档的终值必须逐位是 1.0");
assert!(!pump.is_animating(), "NONE 下一帧都不许要");
assert!(!pump.observe_rail(false));
assert_eq!(pump.rail_progress(), 0.0, "折叠档的终值必须逐位是 0.0");
assert!(!pump.is_animating());
}
/// **中途反向不跳变**:折叠到一半改主意展开,下一帧的进度必须从当前值继续(不是回 0/1)。
///
/// 抓得住:把 `observe_rail` 里的 `from` 写成常量 0 / 1(那会让宽度在半路跳一下)。
/// 抓不住:真实手感(那个「拐点」顺不顺)——只保证数值连续。
#[test]
fn reversing_mid_flight_continues_from_the_current_progress() {
let mut pump = standard();
pump.observe_rail(true);
for _ in 0..4 {
pump.advance(FRAME_MS);
}
let before = pump.rail_progress();
assert!(
before > 0.1 && before < 0.9,
"前提:应该处在中间(实测 {before})"
);
pump.observe_rail(false);
let after = pump.rail_progress();
assert!(
(after - before).abs() < 1e-6,
"反向那一帧不许跳变:{before} → {after}"
);
}
/// **拆出档不走过渡**:`snap_rail` 直接落终值,且把在跑的轨道掐掉。
///
/// 抓得住:有人把 `Detached` 也接进这条轨道(那会做一次「48 → 0」的缩水动画)。
/// 抓不住:拆分本身的观感(那是布局切换,不在动效范围里)。
#[test]
fn detaching_snaps_instead_of_animating() {
let mut pump = standard();
pump.observe_rail(true);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
assert!(pump.snap_rail(false), "有轨道在跑时落档要报「掐掉了」");
assert_eq!(pump.rail_progress(), 0.0);
assert!(!pump.is_animating());
}
/// **帧订阅的委托**:状态层过渡在跑时 `motion::needs_frames()` 必须为真。
///
/// 这是 `app/subscription.rs` 唯一的判断点(`motion::needs_frames()`):A8 的时钟若没被委托进
/// 这里,产品里就一帧都不会来(状态层卡在起始值),而单元测试照样能靠 `advance_by` 摆出绿。
///
/// 抓得住:删掉 `motion::needs_frames` 里对 `interaction::layer::is_animating()` 的那一句。
/// 抓不住:真实窗口的帧率(那由 `window::frames()` 决定,见 `app/subscription.rs` 的表)。
#[test]
fn the_state_layer_clock_feeds_the_single_subscription_point() {
use crate::app::components::interaction::layer;
reset();
assert!(!needs_frames(), "前提:静息时不要帧");
let mut state = layer::Layer::new();
state.retarget(
crate::app::components::interaction::Interaction::Hovered,
MotionScale::STANDARD,
);
assert!(
needs_frames(),
"悬停起跑之后**同一拍**就要帧(否则 draw 侧起跑的 0 进度会一直画不出来)"
);
// 推进到 50ms 之后:必须自己退订(不是「一直要帧」)。
for _ in 0..6 {
layer::advance_by(FRAME_MS);
}
assert!(!needs_frames(), "过渡走完必须立刻不要帧");
layer::reset();
}
@@ -168,33 +168,6 @@ pub const fn modal_spec() -> ModalSpec {
}
}
/// 右栗折叠的进 / 出两腿(M257 / A2,唯一来源)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RailSpec {
/// 展开(进):`medium1` 250ms + `Emphasized`(M3E 强调曲线;进略长于出)。
pub enter: Leg,
/// 收起(出):`short4` 200ms + `EmphasizedAccelerate`(退场比进场短 50ms,不拖)。
pub exit: Leg,
}
/// 右栗折叠规格。
///
/// 时长口径:`14-motion-and-animation.md` §3 的 A2 行点名 `Medium1`(250)——展开用它;
/// 收起取下一档 `Short4`(200),落实 §7-1 的「进略长于出」。两条都 ≤300ms(§7-1 的硬线)。
/// 曲线取 `Emphasized*` 而不是 `Standard*`:宽度变化是「注意力转移」,不是屏内元素的常规起止。
pub const fn rail_spec() -> RailSpec {
RailSpec {
enter: Leg {
duration: DurationToken::Medium1,
easing: Easing::Emphasized,
},
exit: Leg {
duration: DurationToken::Short4,
easing: Easing::EmphasizedAccelerate,
},
}
}
/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageFrame {
@@ -96,38 +96,17 @@ impl<'a, M> Default for NavGroup<'a, M> {
}
}
/// 一项的颜色(按态 + 选中求值)。**药丸不在这里**:它随过渡逐帧变化,见 [`pill_color`]。
/// 一项的颜色(按态 + 选中求值)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct ItemPalette {
/// 药丸底色(`None` = 不画药丸 / 不叠状态层)。
pub(super) pill: Option<Color>,
/// 图标色。
pub(super) icon: Color,
/// 标签色。
pub(super) label: Color,
}
/// 药丸底色(**唯一**实现点;M257 起带时间轴)。
///
/// 与 M256 之前的**唯一**差别是它经 [`interaction::layer_color`] 求值:没有取样控件时逐位等于
/// 原来的 `overlay_color(...).or(...)`,有取样控件时按 `short1`(50ms) 的进度插值。
/// 选中项的静息药丸是**实色**(`SecondaryContainer`),所以缺层时要兜底到那个角色;
/// 未选中项静息不画药丸,兜底就必须是 `None`——这两条不许合成一条。
pub(super) fn pill_color(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Option<Color> {
if state.is_disabled() {
return None;
}
if selected {
interaction::layer_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.or_else(|| Some(ui.color(Role::SecondaryContainer)))
} else {
interaction::layer_color(ui, state, Role::OnSurface, base.role())
}
}
pub(super) fn palette(
ui: &Ui,
base: Base,
@@ -138,13 +117,22 @@ pub(super) fn palette(
if state.is_disabled() {
let ink = interaction::disabled_content_over_role(ui, base, Role::OnSurface, false);
return ItemPalette {
pill: None,
icon: ink,
label: ink,
};
}
if selected {
let pill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
pill: Some(pill),
icon: ui.color(Role::OnSecondaryContainer),
// rail 的选中标签是 `Secondary`(12b 5.6);drawer 是 `OnSecondaryContainer`(§21.7)
label: match layout {
@@ -160,6 +148,7 @@ pub(super) fn palette(
_ => ui.color(Role::OnSurface),
};
ItemPalette {
pill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
icon: ink,
label: ink,
}
@@ -197,7 +186,6 @@ pub(super) fn nav_row<'a, M: Clone + 'a>(
});
let state = forced.unwrap_or(Interaction::Enabled);
let colors = palette(ui, base, geometry.layout, state, item.selected);
let item_selected = item.selected;
let radius = iced::border::Radius::new(geometry.pill_height / 2.0);
let icon = layout::fixed(
@@ -206,16 +194,16 @@ pub(super) fn nav_row<'a, M: Clone + 'a>(
icons::view::<M>(item.icon, colors.icon),
);
// 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x。
// 底色在 `style` 闭包里求值(不是构造时算好):状态层过渡要逐帧问「这一帧到几成了」。
// 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x
let pill = move |content: Element<'a, M>| -> Element<'a, M> {
let fill = colors.pill;
container(content)
.width(Length::Fixed(geometry.pill_width))
.height(Length::Fixed(geometry.pill_height))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: pill_color(ui, base, state, item_selected).map(Background::Color),
background: fill.map(Background::Color),
border: Border {
radius,
..Border::default()
@@ -289,17 +277,10 @@ pub(super) fn nav_row<'a, M: Clone + 'a>(
shadow: Shadow::default(),
snap: true,
});
let enabled = item.on_press.is_some();
let element: Element<'a, M> = match item.on_press {
Some(message) => clickable.on_press(message).into(),
None => clickable.into(),
};
// A8:运行期态套状态取样控件(强制态 = 取证,不套;禁用不跟踪)。
let element = if item.state.is_some() {
element
} else {
interaction::tracked(element, enabled)
};
// drawer 的项外距 12(`ItemPadding`):药丸两侧由内缩给出,所以整行再包一层水平内边距
match geometry.layout {
@@ -1,7 +1,7 @@
//! `drawer` 的单元测试(单文件 ≤400 行,AGENTS.md §3-1 拆出来)。
use super::super::super::interaction::{self, Interaction};
use super::item::{palette, pill_color};
use super::item::palette;
use super::*;
use unlucky_design::color::SchemeFlavor;
@@ -27,10 +27,7 @@ fn ui() -> Ui {
fn drawer_selected_uses_secondary_container() {
let ui = ui();
let palette = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(
pill_color(&ui, BASE, Interaction::Enabled, true),
Some(ui.color(Role::SecondaryContainer))
);
assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer));
}
@@ -43,19 +40,9 @@ fn rail_selected_label_is_secondary() {
let drawer = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(rail.label, ui.color(Role::Secondary));
assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同");
// 图标与药丸相同(同一套语言)。
//
// 复审 P2-1:这里原先写成 `pill_color(..) == pill_color(..)`——比的是同一个表达式,恒真,
// 抓不到任何东西(注释却还在说「两条线的药丸一致」)。药丸从 M257 起**不吃 layout**
// (`pill_color` 的签名里根本没有 `NavLayout`),所以「两条线一致」由**签名**保证;
// 本用例改判真正要守的那件事:静息选中药丸就是 `SecondaryContainer` 这一档
// (换成 `Primary` 立刻红,变异原文见 M257 r2 报告)。
assert_eq!(rail.icon, drawer.icon, "图标与布局无关");
assert_eq!(
pill_color(&ui, BASE, Interaction::Enabled, true),
Some(ui.color(Role::SecondaryContainer)),
"两条线的静息选中药丸都必须来自 SecondaryContainer"
);
// 但图标与药丸相同(同一套语言)
assert_eq!(rail.icon, drawer.icon);
assert_eq!(rail.pill, drawer.pill);
}
/// 未选中:静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface` 且叠状态层。
@@ -64,7 +51,7 @@ fn inactive_lifts_to_on_surface() {
let ui = ui();
let resting = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, false);
assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant));
assert_eq!(pill_color(&ui, BASE, Interaction::Enabled, false), None);
assert_eq!(resting.pill, None);
for state in [
Interaction::Hovered,
Interaction::Focused,
@@ -73,10 +60,7 @@ fn inactive_lifts_to_on_surface() {
let active = palette(&ui, BASE, NavLayout::Drawer, state, false);
assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}");
assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}");
assert!(
pill_color(&ui, BASE, state, false).is_some(),
"{state:?} 必须叠状态层"
);
assert!(active.pill.is_some(), "{state:?} 必须叠状态层");
}
}
@@ -90,7 +74,7 @@ fn disabled_draws_no_pill() {
interaction::disabled_content_over_role(&ui, BASE, Role::OnSurface, false)
);
assert_eq!(palette.label, palette.icon);
assert_eq!(pill_color(&ui, BASE, Interaction::Disabled, true), None);
assert_eq!(palette.pill, None);
}
/// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。
+3 -45
View File
@@ -158,53 +158,11 @@ pub fn layout_for(
}
/// 右栗面板宽度(`App::view` 与 shell 都用这一个入口,避免两处各算一套)。
///
/// `progress` 是 M257 的折叠进度(0 = 折叠档 48,1 = 展开档):宽度在 `48 → 夹紧后的展开档`
/// 这一条线上**线性**插值,端点逐位等于静息值。为什么插值放在这里而不是视图里:宽度的**唯一**
/// 派生点是本文件,视图不该再写一次 `48 + (target − 48) * p`(那会变成第二份宽度公式)。
///
/// ⚠️ 方向只看 `progress`,**不看** `state` 的档位名:收起的**目标**档也是 `Collapsed`,若拿目标
/// 档去查静息宽(`layout_for`),收起那一路会当场返回 48(= 宽度硬跳,A2 一秒退回原形)。
/// `state` 只用来说明「拆出档根本不占宽」这一件事。
pub fn rail_width(
window_width: f32,
state: crate::shell::agent::RailState,
expanded: f32,
progress: f32,
) -> f32 {
use crate::shell::agent::RailState;
// 拆出档不占宽(布局切换,不是过渡对象)。
if state == RailState::Detached {
return 0.0;
}
// 展开档的目标宽(夹紧后)。窄窗里「展开」不成立 ⇒ 目标就是 48,没有可动的空间。
let target = layout_for(window_width, RailState::Expanded, expanded).rail;
if target <= COLLAPSED_WIDTH {
return COLLAPSED_WIDTH;
}
COLLAPSED_WIDTH + (target - COLLAPSED_WIDTH) * progress.clamp(0.0, 1.0)
pub fn rail_width(window_width: f32, state: crate::shell::agent::RailState, expanded: f32) -> f32 {
layout_for(window_width, state, expanded).rail
}
/// 拖拽缝在**这一帧**存不存在:只有面板比折叠档宽时才有那 8px。
///
/// 折叠到底时右栗就是 48 的图标条(没有可拖的东西),所以缝必须跟着宽度一起退场——
/// 否则 `48 + 8 = 56` 会和静息态的 48 差 8px,收尾那一帧看起来像「跳了一下」。
pub fn gutter_at(rail_width: f32) -> f32 {
if rail_width > COLLAPSED_WIDTH {
RESIZE_GUTTER
} else {
0.0
}
}
/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝(**静息档**,不跟动画走)。
///
/// 为什么它**不**吃折叠进度:这一条决定的是页面**模型**(断点 / 列数 / 每列宽),而模型每帧重建
/// 会让正文在 250ms 里重排十几次(折行跳动 = 任务里禁止的「布局抖动」)。物理宽度由 `row!` 的
/// 兄弟关系自动收缩,两边不会打架:动画期间页面只是「模型比实际宽」,右栗画在它右边且不透明,
/// 收窄的那一段被右栗盖住,看不到溢出。代价(已登记):收窄到一半时页面右侧可能短暂被盖住,
/// 想要「页面跟着重排」的 shared-axis 效果是另一条 mission 的事。
/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝。
pub fn content_width(
window_width: f32,
state: crate::shell::agent::RailState,
+93 -11
View File
@@ -42,7 +42,6 @@ mod metrics;
mod state;
mod conversation;
mod panel;
mod strip;
#[cfg(test)]
@@ -50,9 +49,14 @@ mod tests;
pub use state::{AgentUi, Focus, RailState};
use iced::widget::{container, mouse_area, space};
use iced::{mouse, Alignment, Element, Length};
use iced::widget::{column, container, mouse_area, row, rule, space, text, tooltip};
use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::agent::AgentRail;
@@ -62,6 +66,7 @@ use crate::pages::{self, layout};
use crate::theme::Ui;
use conversation::{Conversation, Host};
use metrics::RESIZE_GUTTER;
/// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。
#[derive(Debug, Clone)]
@@ -198,15 +203,9 @@ pub fn docked<'a>(
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match session.agent.effective_rail(window_width) {
// 拆出档不占宽(会话在独立页里)——M257 一字未动。
RailState::Detached => None,
// M257:**按这一帧的宽度**决定画哪一个,而不是按目标档。折叠末段(宽度回到 48)换成
// 图标条,展开初段(宽度刚过 48)换成面板——这就是任务里的「两段」里的第二段。
// 图标条是最右端的固定 48 列,所以它出现时**位置不会跳**(见 `metrics::rail_width`)。
_ if session.agent.rail_width(window_width) <= metrics::COLLAPSED_WIDTH + 0.5 => {
Some(strip::strip(ui, session.rail, catalog))
}
_ => Some(panel::panel(ui, session, catalog, window_width)),
RailState::Collapsed => Some(strip::strip(ui, session.rail, catalog)),
RailState::Expanded => Some(panel(ui, session, catalog, window_width)),
}
}
@@ -269,6 +268,89 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
)
}
// ---------------------------------------------------------------- 展开档:拖拽缝 + 面板
fn panel<'a>(
ui: &'a Ui,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Element<'a, Message> {
let width = session.agent.rail_width(window_width);
let body = container(conversation::view(
ui,
spec(session, catalog, Host::Rail),
catalog,
))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui, width, catalog), body]
.width(Length::Shrink)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。
///
/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key
/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出
/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。
fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> {
let line = container(
rule::vertical::<IcedTheme>(ui.metrics().divider_thickness).style(move |_theme| {
rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
}
}),
)
.width(Length::Fixed(RESIZE_GUTTER))
.height(Length::Fill)
.align_x(Alignment::Center);
let grab = mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart);
let tip_style = ui.style(TypeRole::BodySmall);
let pixels = format!("{width:.0}");
let tip = container(column![
text(catalog.text(Key::AgentResize))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurface)),
text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())]))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurfaceVariant)),
])
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(ShapeScale::Small),
..Border::default()
},
..container::Style::default()
});
tooltip(grab, tip, tooltip::Position::Left).into()
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> {
Conversation {
-127
View File
@@ -1,127 +0,0 @@
//! 右栗展开档的**面板 + 拖拽缝**(M257 从 `mod.rs` 纯搬移,加上折叠过渡的裁切层)。
//!
//! ## 折叠过渡在这里落地的两件事
//!
//! 1. **宽度**:`rail_width(window, .., progress)` 逐帧给宽度(端点逐位等于静息值);
//! 2. **内容**:会话视图按**目标宽**排版(每帧不重排 ⇒ 不抖),外面那层按**动画宽**裁切
//! (⇒ 不溢出)。宽度缩到折叠档时本函数根本不会被调——`docked` 已经切成 48 的图标条
//! (那 48 列贴在右栗最右端,位置不随宽度变,所以图标不跳)。
//!
//! 为什么不把内容也做成「淡出」:渲染器没有 opacity 原语(`motion/zoom.rs` 已登记:iced 0.14
//! 只有 clip / transformation),D27 又不许为此引入半透明面板。所以第二段是**裁切收尾 +
//! 换挡**,不是逐像素 α。这条在报告里明确划进「没做到的面」。
use iced::widget::{column, container, mouse_area, row, rule, text, tooltip};
use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::motion;
use crate::theme::Ui;
use super::conversation::{self, Host};
use super::metrics::{gutter_at, rail_width, RESIZE_GUTTER};
use super::{spec, Message, Session};
/// 展开档的面板(外面包一层按动画宽裁切的容器)。
pub(super) fn panel<'a>(
ui: &'a Ui,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Element<'a, Message> {
let agent = session.agent;
// 这一帧的宽度(过渡中)与目标宽度(静息)。两者只在过渡期间不同。
let width = agent.rail_width(window_width);
let settled = rail_width(window_width, agent.rail, agent.expanded(), 1.0);
let body = container(conversation::view(
ui,
spec(session, catalog, Host::Rail),
catalog,
))
// **目标宽**:内容按最终可用宽排版,动画期间不重排(否则每帧折行 = 抖动)。
.width(Length::Fixed(settled))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
// 动画宽:只负责**裁切**(内容比它宽的部分画不出来 ⇒ 不溢出到中栏)。
//
// 为什么不用 `container(..).clip(true)`:iced 会把父级 max 传给子级,正文于是每帧重新折行
// (第 02 张取证图实测:空态文案在过渡中折成两行)。[`motion::clipped`] 把「排版宽」与
// 「裁剪框宽」拆开,过渡期间正文的折行**一次都不变**。
let clipped = motion::clipped(body, width, settled);
// 拖拽缝跟着宽度走:宽度回到 48 时它是 0(那时右栗是图标条,没有可拖的东西)。
if gutter_at(width) > 0.0 {
row![splitter(ui, settled, catalog), clipped]
.width(Length::Shrink)
.height(Length::Fill)
.into()
} else {
clipped
}
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。
///
/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key
/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出
/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。
fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> {
let line = container(
rule::vertical::<IcedTheme>(ui.metrics().divider_thickness).style(move |_theme| {
rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
}
}),
)
.width(Length::Fixed(RESIZE_GUTTER))
.height(Length::Fill)
.align_x(Alignment::Center);
let grab = mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart);
let tip_style = ui.style(TypeRole::BodySmall);
let pixels = format!("{width:.0}");
let tip = container(column![
text(catalog.text(Key::AgentResize))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurface)),
text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())]))
.size(tip_style.size_px)
.font(ui.font_of(tip_style))
.color(ui.color(Role::OnSurfaceVariant)),
])
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(ShapeScale::Small),
..Border::default()
},
..container::Style::default()
});
tooltip(grab, tip, tooltip::Position::Left).into()
}
+1 -33
View File
@@ -31,8 +31,6 @@ use super::metrics::{
content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH,
};
use crate::app::components::motion;
/// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RailState {
@@ -206,30 +204,13 @@ impl AgentUi {
effective_state(window_width, self.rail, self.expanded)
}
/// 折叠过渡的**目标档**(`true` = 展开)。
pub fn rail_open(&self) -> bool {
self.rail == RailState::Expanded
}
/// 折叠进度(0 = 48 图标条,1 = 展开档)。**只采样**,起跑在下面四个档位迁移里。
pub fn collapse_progress(&self) -> f32 {
motion::rail(self.rail_open())
}
/// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。
///
/// M257 起它是**逐帧宽度**:档位切换的那 200~250ms 里返回过渡中的值(端点与静息值逐位相同)。
///
/// 名字故意不叫 `width`:`tests/no_hardcoded_tokens.rs` 会把任何 `.width(数字)` 当成
/// 「布局尺寸写死」拦下来,而测试里 `agent.width(1180.0)` 会被误判(那条门禁扫的是控件调用点,
/// 分不清是谁的方法)。
pub fn rail_width(&self, window_width: f32) -> f32 {
rail_width(
window_width,
self.rail,
self.expanded,
self.collapse_progress(),
)
rail_width(window_width, self.rail, self.expanded)
}
/// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。
@@ -246,8 +227,6 @@ impl AgentUi {
self.keyboard_mode = false;
if self.rail == RailState::Expanded {
self.rail = RailState::Collapsed;
// 起跑在 update 侧(本函数由 `app/update/agent.rs` 调)——折叠那 200ms 的宽度轨道。
motion::observe_rail(false);
self.focus = Focus::Collapse;
} else if self.rail == RailState::Detached {
self.dock();
@@ -257,14 +236,7 @@ impl AgentUi {
/// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。
pub fn expand(&mut self) {
self.keyboard_mode = false;
// 只有「从折叠档展开」才是宽度的连续变化;从独立页收回(`dock`)与首次落档都是**布局切换**。
let animate = self.rail == RailState::Collapsed;
self.rail = RailState::Expanded;
if animate {
motion::observe_rail(true);
} else {
motion::snap_rail(true);
}
self.focus = Focus::Collapse;
}
@@ -272,8 +244,6 @@ impl AgentUi {
pub fn detach(&mut self) {
self.keyboard_mode = false;
self.rail = RailState::Detached;
// 拆出档**不做过渡**(右栗消失、会话搬到独立页,是布局切换):目标落终值。
motion::snap_rail(false);
self.focus = Focus::Detach;
}
@@ -281,8 +251,6 @@ impl AgentUi {
pub fn dock(&mut self) {
self.keyboard_mode = false;
self.rail = RailState::Expanded;
// 从独立页收回到「常驻展开」:右栗是从 0 出现的布局切换,不是 48→320 的宽度过渡。
motion::snap_rail(true);
self.focus = Focus::Collapse;
}
+1 -3
View File
@@ -14,7 +14,6 @@ use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity}
use crate::theme::{Families, Ui};
mod collapse;
mod disclaimer;
mod drag;
@@ -137,8 +136,7 @@ fn the_empty_session_has_a_guidance_line() {
/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。
#[test]
fn both_hosts_render_the_same_conversation() {
// M257:右栗面板整段搬去了 `panel.rs`,所以「两个宿主各调一次」的扫描面是两个文件。
let hosts = concat!(include_str!("mod.rs"), include_str!("panel.rs"));
let hosts = include_str!("mod.rs");
let conversation = include_str!("conversation.rs");
let composer = include_str!("conversation/composer.rs");
@@ -1,237 +0,0 @@
//! 右栗折叠过渡的**几何判据**(M257 / A2):帧数 + 端点精确 + 严格单调 + 不溢出 + 键盘可达。
//!
//! 判据口径(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素能证明「某一帧的宽度是 92」,
//! 证明不了「中间有 13 个相位」「收尾那一帧不多要帧」。观感(好不好看)走 D30 出图。
use unlucky_design::motion::MotionScale;
use super::super::metrics::{gutter_at, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH};
use super::super::state::{AgentUi, Focus, RailState};
use crate::app::components::motion;
/// 取证窗口宽(与 D30 的产品默认一致)。
const WINDOW: f32 = 1180.0;
/// 帧泵的单帧步进(`motion::FRAME_MS` 的测试口径)。
const FRAME: f64 = 16.0;
/// 每个用例都从干净的动效全局态起跑(thread-local,互不影响,但同一用例内要确定)。
fn configure(scale: MotionScale) {
motion::reset();
motion::configure_for_test(scale, None);
}
/// 把一整个折叠 / 展开过程采样到底(返回**含起点与终点**的宽度序列)。
fn sample_widths(agent: &AgentUi) -> Vec<f32> {
let mut out = vec![agent.rail_width(WINDOW)];
let mut frames = 0;
while motion::needs_frames() {
motion::advance_by(FRAME);
out.push(agent.rail_width(WINDOW));
frames += 1;
assert!(frames < 60, "折叠轨道没停,疑似终止条件失效");
}
out
}
/// **展开:第一帧还是 48、中间严格单调、结束时严格等于 320、帧数落在 12~20。**
///
/// 抓得住:①把宽度改回硬跳(`rail_width` 直接返回静息值 ⇒ 第一帧就是 320,本用例的第一步红);
/// ②把终止条件删掉(`while` 后面的兜底断言红);③把时长改成 600ms(帧数超 20)。
/// 抓不住:中间帧「看起来」顺不顺(那是曲线与观感,走 D30 出图),也抓不住真实帧率。
#[test]
fn expanding_is_monotone_and_lands_exactly_on_the_settled_width() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
assert_eq!(agent.rail, RailState::Collapsed);
agent.expand();
let widths = sample_widths(&agent);
let frames = widths.len() - 1;
assert_eq!(
widths.first().copied(),
Some(COLLAPSED_WIDTH),
"第一帧必须还在折叠档(48,端点精确)"
);
assert_eq!(
widths.last().copied(),
Some(DEFAULT_EXPANDED_WIDTH),
"最后一帧必须是**严格** 320(不是 319.9x)"
);
assert!(
(12..=20).contains(&frames),
"250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧"
);
for pair in widths.windows(2) {
assert!(
pair[1] >= pair[0],
"宽度必须单调不减(不许来回抖):{:?} → {:?}",
pair[0],
pair[1]
);
}
assert!(
widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count() >= 8,
"必须有足够多的中间宽度(否则就是硬跳):{widths:?}"
);
assert!(!motion::needs_frames(), "走完必须退订帧");
motion::reset();
}
/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 10~13 帧)。
#[test]
fn collapsing_is_monotone_downwards_and_ends_exactly_on_the_strip_width() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
agent.expand();
sample_widths(&agent);
assert_eq!(agent.rail_width(WINDOW), DEFAULT_EXPANDED_WIDTH);
agent.collapse();
let widths = sample_widths(&agent);
let frames = widths.len() - 1;
assert_eq!(widths.first().copied(), Some(DEFAULT_EXPANDED_WIDTH));
assert_eq!(widths.last().copied(), Some(COLLAPSED_WIDTH));
assert!(
(10..=20).contains(&frames),
"200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧"
);
for pair in widths.windows(2) {
assert!(pair[1] <= pair[0], "收起必须单调不增:{pair:?}");
}
motion::reset();
}
/// **`MotionScale::NONE`:右栗宽度当帧就是终值**(独立用例,包含在「右栗」这个面上)。
///
/// 抓得住:把 `observe_rail` 的 `is_off()` 分支删掉 ⇒ 会插一条 0 时长轨道,本用例的
/// 「`needs_frames()` 为假」与「第一帧就是 320」两条会同时红。
/// 抓不住:`NONE` 以外的档位。
#[test]
fn motion_scale_none_puts_the_rail_at_the_end_value_on_the_first_frame() {
configure(MotionScale::NONE);
let mut agent = AgentUi::new();
agent.expand();
assert_eq!(
agent.rail_width(WINDOW),
DEFAULT_EXPANDED_WIDTH,
"NONE 下展开档当帧就是 320(不是「很快走完」)"
);
assert!(!motion::needs_frames(), "NONE 下一帧都不许要");
assert_eq!(
agent.content_width(WINDOW),
WINDOW - DEFAULT_EXPANDED_WIDTH - gutter_at(DEFAULT_EXPANDED_WIDTH),
"中栏也必须当帧拿到终值"
);
agent.collapse();
assert_eq!(agent.rail_width(WINDOW), COLLAPSED_WIDTH);
assert!(!motion::needs_frames());
motion::reset();
}
/// **拆出档:宽度 0、不走过场**(拆分是布局切换,不是过渡对象)。
///
/// 抓得住:有人把 `Detached` 也接进折叠轨道(那会做一次 48→0 的缩水,`needs_frames()` 为真)。
#[test]
fn detached_is_never_animated() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
agent.expand();
sample_widths(&agent);
agent.detach();
assert_eq!(agent.rail_width(WINDOW), 0.0, "拆出档不占宽");
assert!(!motion::needs_frames(), "拆出不许起过渡");
assert_eq!(agent.content_width(WINDOW), WINDOW, "中栗拿回整窗");
motion::reset();
}
/// **不溢出 + 不抖**:折叠全程的「右栗总占宽 = 面板 + 拖拽缝」单调、且不超过静息展开档。
///
/// 抓得住:①把 `gutter_at` 写成常量 8(收尾那一帧总宽会是 56 而不是 48 ⇒ 最后一步的严格相等红);
/// ②让宽度插值越过目标(回弹)——单调与上限两条同时红。
/// 抓不住:`row!` 里的兄弟顺序写错(那要出图看)。
#[test]
fn the_rail_never_wider_than_its_settled_footprint() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
agent.expand();
let mut totals = Vec::new();
loop {
let width = agent.rail_width(WINDOW);
totals.push(width + gutter_at(width));
if !motion::needs_frames() {
break;
}
motion::advance_by(FRAME);
}
let settled = DEFAULT_EXPANDED_WIDTH + gutter_at(DEFAULT_EXPANDED_WIDTH);
for total in &totals {
assert!(*total <= settled, "右栗总占宽越界:{total} > {settled}");
}
assert_eq!(totals.last().copied(), Some(settled), "收尾必须是静息总宽");
assert_eq!(
totals.first().copied(),
Some(COLLAPSED_WIDTH),
"起点是折叠档的 48(图标条没有拖拽缝)"
);
for pair in totals.windows(2) {
assert!(pair[1] >= pair[0], "总占宽必须单调:{pair:?}");
}
motion::reset();
}
/// **键盘可达性:过渡中途按 Tab 的落点与静息时完全相同**(键盘用户不许被动效拖慢)。
///
/// 抓得住:有人把焦点环也接进「进度」(例如按当前宽度决定环上有哪些控件)——那会让过渡中途的
/// 落点与静息不同,本用例红。也抓住「环空了 ⇒ 焦点掉到根」:环首尾相接,从任何一个格位都能走到
/// 一个真实控件。
/// 抓不住:Tab 的**时序**(真窗口里按 Tab 到焦点环被画出之间隔几帧,只能人试)。
#[test]
fn tab_lands_on_the_same_control_mid_transition() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
agent.expand();
// 走到中途(宽度既不是 48 也不是 320)。
for _ in 0..6 {
motion::advance_by(FRAME);
}
let width = agent.rail_width(WINDOW);
assert!(
width > COLLAPSED_WIDTH && width < DEFAULT_EXPANDED_WIDTH,
"前提:应该处在过渡中途(实测 {width})"
);
// 目标档是展开:环 = [Collapse, Detach, Send, Composer](与宽度无关)。
let mid = Focus::Collapse.step(false, RailState::Expanded, false, false);
assert_eq!(mid, Focus::Detach, "中途 Tab 的落点必须是真实控件");
// 收起到目标档是折叠:环 = [Collapse, Send, Composer],且首尾相接(不会掉到根)。
let collapsed = Focus::Composer.step(false, RailState::Collapsed, false, false);
assert_eq!(
collapsed,
Focus::Collapse,
"折叠档的环必须首尾相接(焦点不许掉到根)"
);
motion::reset();
}
/// 采样序列里至少要有 8 个「既不是起点也不是终点」的宽度——判定「不是硬切」的真正依据。
///
/// 单独一条是因为它比帧数更直接:帧数只说明时间被走完,中间宽度才说明**有中间态**。
#[test]
fn there_are_real_intermediate_widths() {
configure(MotionScale::STANDARD);
let mut agent = AgentUi::new();
agent.expand();
let widths = sample_widths(&agent);
let middle = widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count();
assert!(middle >= 8, "中间宽度只有 {middle} 个:{widths:?}");
motion::reset();
}
@@ -140,16 +140,10 @@ fn the_drag_recovers_when_a_release_is_missed() {
/// 抓不住:tooltip 真弹出来长什么样(那要 hover 出图)。
#[test]
fn the_gutter_and_strip_are_labelled() {
// M257:展开档的面板 + 拖拽缝整段搬去了 `panel.rs`(`mod.rs` 要留行数给它后面的折叠判档),
// 所以扫描面变成两个文件——判据本身没变(这两条 key 必须真的被视图用上)。
let hosts = include_str!("../mod.rs");
let panel = include_str!("../panel.rs");
let strip = include_str!("../strip.rs");
for key in ["Key::AgentResize", "Key::AgentWidthLabel"] {
assert!(
hosts.contains(key) || panel.contains(key),
"视图里没用上 {key}"
);
assert!(hosts.contains(key), "视图里没用上 {key}");
}
// M119:折叠条整段搬去了 `strip.rs`(`mod.rs` 顶到 400 行)
assert!(strip.contains("Key::AgentStripLabel"), "折叠条上没画标签");
+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()),
)],
)
}
@@ -116,10 +116,6 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_loading()));
return frame::card(ui, body);
}
// 来源那一行(M274):多根之后「这个实例的存档到底在哪个目录」必须看得见。
if saves.has_view && !saves.source.is_empty() {
body = body.push(note(ui, &saves.source));
}
if let Some(error) = saves.error.as_deref() {
body = body.push(frame::text_role(
ui,
@@ -86,13 +86,6 @@ pub struct Saves {
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 来源那一行(M274 多根):`存档目录:<绝对路径>`,**在这一层拼好**。
///
/// 便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例、各有一批世界。
/// 世界名分辨不出来源,所以把路径原样摆出来(也是排障时要问的第一件事)。
/// 为什么不是页面里现拼:iced 的 `Element` 只借这一帧的数据,`view()` 返回的树借的是局部
/// `String`,一出栈就 `E0515`(与 [`Saves::count`] 同一条理由)。
pub source: String,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
@@ -169,7 +162,6 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
};
if let Some(view) = &state.view {
out.has_view = true;
out.source = texts.saves_source(&view.dir);
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
@@ -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 扫描与旧串扫描共用)。
@@ -253,39 +253,5 @@ fn millisecond_stamps_are_rendered_in_utc() {
);
}
/// **来源那一行**(M274 多根):必须是这一份快照读的那个目录,两种语言都说出来。
///
/// 为什么非要有这一行:便携目录与官方启动器是两个不同的根,两个根里都可能有一个叫 `A` 的实例。
/// 世界名分辨不出来源,只有路径能分辨(也是排障时要问的第一件事)。
///
/// 抓得住:来源行拿了别的实例 / 别的根的路径(界面上一模一样的错),或切了根却没跟着变;
/// 抓不住:页面真的把这一行画出来(`Element` 没有内省接口,见模块文档)。
#[test]
fn the_source_line_names_the_folder_that_was_read() {
let mut state = bound_state("a");
state.changed(view("a"));
let zh = from_state(&state, Lang::ZhCn.catalog());
let en = from_state(&state, Lang::En.catalog());
assert!(
zh.source.contains("C:/mc/saves"),
"来源行没带目录:{:?}",
zh.source
);
assert!(en.source.contains("C:/mc/saves"), "{:?}", en.source);
assert_ne!(zh.source, en.source, "切换语言时这一行要变");
// 换一个根(另一个 `.minecraft`)⇒ 这一行跟着变(不许留在旧路径上)。
let mut other = view("a");
other.dir = "D:/official/.minecraft/saves".to_string();
let mut state = bound_state("a");
state.changed(other);
let moved = from_state(&state, Lang::ZhCn.catalog());
assert!(
moved.source.contains("D:/official"),
"换了根之后来源行没跟着变:{:?}",
moved.source
);
}
mod preview;
mod write;
@@ -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)
}
@@ -37,10 +37,6 @@ impl Texts {
pub(in crate::pages::instances) fn saves_missing_dir(self) -> &'static str {
self.text(Key::InstancesSavesMissingDir)
}
/// 「这一份列表读自哪个目录」(M274 多根:世界名分辨不出来源,绝对路径可以)。
pub(in crate::pages::instances) fn saves_source(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesSource, &[("dir", dir)])
}
pub(in crate::pages::instances) fn saves_worlds(self) -> &'static str {
self.text(Key::InstancesSavesWorlds)
}
+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};
+79 -28
View File
@@ -6,7 +6,7 @@
//!
//! | 平台 | 扫描位置 |
//! | --- | --- |
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、注册表两支(实现在 `discover/registry.rs`,入口 [`registry_homes`]):`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的)与 `HKLM\SOFTWARE\Microsoft\JDK\<ver>\<flavor>\MSI`(**Microsoft OpenJDK 的 MSI**,装到自定义目录时只有这里知道) |
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
//! | Termux | `$PREFIX/lib/jvm/*`(`java-17-openjdk` 这类;`$PREFIX` 由 [`crate::platform::termux`] 识别) |
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
@@ -22,18 +22,13 @@
use std::path::{Path, PathBuf};
use crate::platform::child::quiet;
use crate::platform::paths::{EnvSource, ProcessEnv};
use crate::platform::termux;
use super::extract::java_binary_name;
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
mod registry;
pub use registry::{
parse_java_home, parse_msi_paths, registry_homes, registry_homes_from, RegSource,
};
/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
#[must_use]
pub fn candidates(managed_root: &Path) -> Vec<JavaRuntime> {
@@ -58,28 +53,24 @@ pub fn standard_candidates() -> Vec<JavaRuntime> {
.collect()
}
/// 扫描标准安装位置 + 注册表,返回 `bin/java[.exe]` 路径表(按[`path_key`]稳定排序、去重,未探测)。
/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
#[must_use]
pub fn scan_java_binaries() -> Vec<PathBuf> {
java_binaries_in(&standard_roots(), &registry_homes())
}
/// 同 [`scan_java_binaries`],但两个来源都由调用方给(测试注入用)。
///
/// 去重口径与 [`dedupe`] 同一套([`path_key`]):同一个 JDK 被「扫目录」和「注册表」两支
/// 同时报上来时只留一条——注册表里的 `Path` 常带末尾反斜杠,`PathBuf` 直接比也能收敛,
/// 但大小写/`\\?\` 前缀这些写法差异只有规范化键兜得住。
#[must_use]
pub fn java_binaries_in(roots: &[PathBuf], registry_homes: &[PathBuf]) -> Vec<PathBuf> {
let name = java_binary_name();
let mut found: Vec<PathBuf> = roots
.iter()
.chain(registry_homes)
.map(|root| root.join("bin").join(name))
.filter(|binary| binary.is_file())
.collect();
found.sort_by_key(|binary| path_key(binary));
found.dedup_by_key(|binary| path_key(binary));
let mut found: Vec<PathBuf> = Vec::new();
for root in standard_roots() {
let binary = root.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
for home in registry_homes() {
let binary = home.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
found.sort();
found.dedup();
found
}
@@ -151,6 +142,48 @@ fn child_dirs(dir: &Path) -> Vec<PathBuf> {
.collect()
}
/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
///
/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
/// (` JavaHome REG_SZ C:\Program Files\Java\jdk-17`)。
#[must_use]
pub fn registry_homes() -> Vec<PathBuf> {
if !cfg!(windows) {
return Vec::new();
}
let mut homes = Vec::new();
for view in ["64", "32"] {
let Ok(output) = quiet("reg")
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
.arg(view)
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
}
homes.sort();
homes.dedup();
homes
}
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
#[must_use]
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("JavaHome")?;
let (_, value) = rest.split_once("REG_SZ")?;
let home = value.trim();
(!home.is_empty()).then(|| PathBuf::from(home))
})
.collect()
}
/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
#[must_use]
pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
@@ -192,7 +225,7 @@ pub fn path_key(path: &Path) -> String {
#[cfg(test)]
mod tests {
use super::{dedupe, path_key, standard_roots};
use super::{dedupe, parse_java_home, path_key, standard_roots};
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
// 这两个只有 Termux 扫描的两个测试用,而那两个测试是 `cfg(unix)`——导入必须同样 gate,
// 否则 Windows 上不编译测试、导入就成了未使用(`-D warnings` 直接判错)。
@@ -286,6 +319,24 @@ mod tests {
}
}
#[test]
fn parses_real_reg_query_output() {
// 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
let homes = parse_java_home(output);
assert_eq!(
homes,
vec![std::path::PathBuf::from(
r"C:\Program Files\Java\jdk1.8.0_402"
)]
);
}
#[test]
fn missing_registry_key_is_not_an_error() {
assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
}
#[test]
fn standard_roots_never_panic_on_missing_directories() {
// 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
@@ -1,377 +0,0 @@
//! `discover` 的「注册表」这一支:从 `HKLM` 里读出 JDK 的安装根。
//!
//! 两条来源,都按 64 位 + 32 位视图各查一遍(`reg query <键> /s /reg:64|32`):
//!
//! | 键 | 值 | 谁写的 |
//! | --- | --- | --- |
//! | `HKLM\SOFTWARE\JavaSoft\JDK\*` | `JavaHome`(`REG_SZ`) | 老 Oracle 安装器;新发行版通常不写 |
//! | `HKLM\SOFTWARE\Microsoft\JDK\<ver>\<flavor>\MSI` | `Path`(`REG_SZ`) | Microsoft OpenJDK 的 MSI |
//!
//! 第二条是本模块存在的理由:MS JDK 装到**自定义目录**(不在 Program Files 下)时,
//! 扫目录那支看不见它,只有注册表知道它在哪。
//!
//! 键不存在是常态(本机实测 `JavaSoft\JDK` 就没有)→ 静默跳过,绝不报错。
//! 读注册表走真 `reg.exe`(Win10+ 恒定存在),但**解析与合并都是纯函数 + [`RegSource`]
//! 注入**:单测喂真实 `reg query` 的输出样本,不依赖本机到底装了什么。
use std::path::PathBuf;
use crate::platform::child::quiet;
/// 老 Oracle 安装器登记 `JavaHome` 的键。
const JAVASOFT_KEY: &str = r"HKLM\SOFTWARE\JavaSoft\JDK";
/// Microsoft OpenJDK 的 MSI 登记安装路径的键(`<ver>\<flavor>\MSI` 下面才是 `Path`)。
const MICROSOFT_KEY: &str = r"HKLM\SOFTWARE\Microsoft\JDK";
/// 注册表视图,两个都要试:32 位 JDK 的 MSI 只写 32 位视图。
const VIEWS: [&str; 2] = ["64", "32"];
/// 一次 `reg query /s` 的原始输出来源。
///
/// `None` 与「键不存在」同义——调用方一律当「这一支没有东西」,不区分「查不到」与「查不了」。
pub trait RegSource {
/// 查 `key` 在 `view`(`"64"` / `"32"`)视图下的递归输出。
fn key_output(&self, key: &str, view: &str) -> Option<String>;
}
/// 真 `reg.exe`(生产路径)。
pub struct ProcessReg;
impl RegSource for ProcessReg {
fn key_output(&self, key: &str, view: &str) -> Option<String> {
let output = quiet("reg")
.args(["query", key, "/s", "/reg:"])
.arg(view)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
/// 注册表里登记的 JDK 根目录(两条来源合并,稳定排序去重;查不到返回空)。
#[must_use]
pub fn registry_homes() -> Vec<PathBuf> {
if !cfg!(windows) {
return Vec::new();
}
registry_homes_from(&ProcessReg)
}
/// 同 [`registry_homes`],但注册表可注入(测试喂假输出,不碰本机)。
#[must_use]
pub fn registry_homes_from(reg: &dyn RegSource) -> Vec<PathBuf> {
let mut homes = query_views(reg, JAVASOFT_KEY, parse_java_home);
homes.extend(query_views(reg, MICROSOFT_KEY, parse_msi_paths));
homes.sort();
homes.dedup();
homes
}
/// 一个键在两个视图下各查一次,解析结果拼接(单个视图失败不影响另一个)。
fn query_views(reg: &dyn RegSource, key: &str, parse: fn(&str) -> Vec<PathBuf>) -> Vec<PathBuf> {
let mut homes = Vec::new();
for view in VIEWS {
if let Some(output) = reg.key_output(key, view) {
homes.extend(parse(&output));
}
}
homes
}
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
#[must_use]
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("JavaHome")?;
let (_, value) = rest.split_once("REG_SZ")?;
let home = value.trim();
(!home.is_empty()).then(|| PathBuf::from(home))
})
.collect()
}
/// 从 `reg query /s` 的输出里抠出 MS JDK 的 `Path` 值。
///
/// 判据是**当前键的最后一段叫 `MSI`**(大小写不限),不是数层数——`<ver>\<flavor>\MSI`
/// 是标准层深,而安装器版本不同时会多一层(`<flavor>\x64\MSI`)或某一层为空,
/// 只要键尾还是 `MSI` 就照收。同层里别的值(`Main`/`EnvironmentPath` 这些 `REG_DWORD`)
/// 以及非 `MSI` 键下的 `Path` 都不认。
#[must_use]
pub fn parse_msi_paths(output: &str) -> Vec<PathBuf> {
let mut in_msi = false;
let mut paths = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("HKEY_") {
in_msi = trimmed
.rsplit('\\')
.next()
.is_some_and(|last| last.eq_ignore_ascii_case("MSI"));
continue;
}
if !in_msi {
continue;
}
let Some(rest) = trimmed.strip_prefix("Path") else {
continue;
};
// 值名得整段叫 `Path`(`PathFoo REG_SZ ...` 不算):名、类型、数据之间是空白。
if !rest.starts_with(char::is_whitespace) {
continue;
}
let Some((_, value)) = rest.split_once("REG_SZ") else {
continue;
};
let path = value.trim();
if !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
paths
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use super::{parse_java_home, parse_msi_paths, registry_homes_from, RegSource};
use crate::game::java::discover::java_binaries_in;
/// 真实 `reg query "HKLM\SOFTWARE\JavaSoft\JDK" /s` 的输出样本(本机没有这个键,
/// 所以这段是旧机上的实测形态,M49 起就钉在测试里)。
const JAVASOFT_SAMPLE: &str = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
/// 真实 `reg query "HKLM\SOFTWARE\Microsoft\JDK" /s /reg:64` 的输出样本
/// (本机实测原文,21 与 25 两条,`Path` 末尾带反斜杠;同键里 `JavaHome` 是 `REG_DWORD`)。
const MICROSOFT_SAMPLE: &str = concat!(
"\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21.0.11.10\r\n\r\n",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21.0.11.10\\hotspot\r\n\r\n",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21.0.11.10\\hotspot\\MSI\r\n",
" Path REG_SZ C:\\Program Files\\Microsoft\\jdk-21.0.11.10-hotspot\\\r\n",
" Main REG_DWORD 0x1\r\n",
" EnvironmentPath REG_DWORD 0x1\r\n",
" JavaHome REG_DWORD 0x1\r\n\r\n",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\25.0.3.9\r\n\r\n",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\25.0.3.9\\hotspot\r\n\r\n",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\25.0.3.9\\hotspot\\MSI\r\n",
" Path REG_SZ C:\\Program Files\\Microsoft\\jdk-25.0.3.9-hotspot\\\r\n",
" Main REG_DWORD 0x1\r\n",
);
/// `reg query` 的「键不存在」原文(GBK 解出来仍是这句)。
const MISSING_KEY: &str = "错误: 系统找不到指定的注册表项或值。\r\n";
/// 假注册表:给 (键, 视图) 配输出,没配的当「键不存在」。
struct FakeReg {
replies: Vec<(&'static str, &'static str, String)>,
}
impl FakeReg {
fn new(replies: Vec<(&'static str, &'static str, String)>) -> Self {
Self { replies }
}
}
impl RegSource for FakeReg {
fn key_output(&self, key: &str, view: &str) -> Option<String> {
self.replies
.iter()
.find(|(k, v, _)| *k == key && *v == view)
.map(|(_, _, output)| output.clone())
}
}
/// 记录被查过的 (键, 视图),一律回「没有」。
#[derive(Default)]
struct Recorder {
seen: RefCell<Vec<(String, String)>>,
}
impl RegSource for Recorder {
fn key_output(&self, key: &str, view: &str) -> Option<String> {
self.seen
.borrow_mut()
.push((key.to_owned(), view.to_owned()));
None
}
}
fn java_file() -> &'static str {
crate::game::java::extract::java_binary_name()
}
#[test]
fn parses_real_java_soft_output() {
assert_eq!(
parse_java_home(JAVASOFT_SAMPLE),
vec![PathBuf::from(r"C:\Program Files\Java\jdk1.8.0_402")]
);
}
#[test]
fn parses_real_microsoft_msi_output() {
// 同键里的 `JavaHome REG_DWORD` 不许被当成 JavaSoft 那支的 `JavaHome`。
assert!(
parse_java_home(MICROSOFT_SAMPLE).is_empty(),
"MSI 键里的 JavaHome 是 REG_DWORD,JavaSoft 的解析器不该收"
);
assert_eq!(
parse_msi_paths(MICROSOFT_SAMPLE),
vec![
PathBuf::from(r"C:\Program Files\Microsoft\jdk-21.0.11.10-hotspot\"),
PathBuf::from(r"C:\Program Files\Microsoft\jdk-25.0.3.9-hotspot\"),
]
);
}
#[test]
fn missing_registry_key_is_not_an_error() {
assert!(parse_java_home(MISSING_KEY).is_empty());
assert!(parse_msi_paths(MISSING_KEY).is_empty());
// 两个键都查不到 → 空表,不是错误。
let reg = FakeReg::new(Vec::new());
assert!(registry_homes_from(&reg).is_empty());
}
#[test]
fn msi_path_with_and_without_trailing_backslash_is_the_same_home() {
// 安装器写不写末尾反斜杠都有(本机 21/25 两条带),拼 `bin\java.exe` 的结果必须一致。
let with = parse_msi_paths(
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21\\hotspot\\MSI\r\n Path REG_SZ C:\\Program Files\\Microsoft\\jdk-21\\\r\n",
);
let without = parse_msi_paths(
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21\\hotspot\\MSI\r\n Path REG_SZ C:\\Program Files\\Microsoft\\jdk-21\r\n",
);
assert_eq!(with, without, "末尾反斜杠只是写法差异");
assert_eq!(with[0].join("bin"), without[0].join("bin"));
// 两条同时出现(两条支/两个视图各报一次)也要收敛成一条。
let mut both = with.clone();
both.extend(without);
both.sort();
both.dedup();
assert_eq!(both.len(), 1);
}
#[test]
fn msi_paths_survive_irregular_version_layers() {
let output = concat!(
// 多一层:`<ver>\<flavor>\x64\MSI`
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\21.0.11.10\\hotspot\\x64\\MSI\r\n",
" Path REG_SZ D:\\jdk\\ms-21\\\r\n\r\n",
// 空一层:版本目录名空着,直接 `<flavor>\MSI`
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\\\hotspot\\MSI\r\n",
" Path REG_SZ D:\\jdk\\ms-empty-ver\r\n\r\n",
// 键尾不是 MSI → 那个 `Path` 不认
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\25.0.3.9\\hotspot\r\n",
" Path REG_SZ D:\\jdk\\not-an-msi-layer\r\n\r\n",
// 值名不是整段 `Path` → 不认
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\26\\hotspot\\MSI\r\n",
" PathExtra REG_SZ D:\\jdk\\path-extra\r\n",
);
assert_eq!(
parse_msi_paths(output),
vec![
PathBuf::from(r"D:\jdk\ms-21\"),
PathBuf::from(r"D:\jdk\ms-empty-ver"),
]
);
}
#[test]
fn both_registry_views_are_queried_for_both_keys() {
// 与 JavaSoft 那支同口径:两个键 × 两个视图,一个都不能省。
// 键名在这里写**字面量**(不用常量):常量写错、键名被改,这两条就红。
let reg = Recorder::default();
let _ = registry_homes_from(&reg);
let seen = reg.seen.borrow();
for key in [
r"HKLM\SOFTWARE\JavaSoft\JDK",
r"HKLM\SOFTWARE\Microsoft\JDK",
] {
for view in ["64", "32"] {
assert!(
seen.iter().any(|(k, v)| k == key && v == view),
"少查了 {key} /reg:{view}:{seen:?}"
);
}
}
}
#[test]
fn injection_registry_only_lists_the_ms_jdks() {
// 注入式验收:Program Files / PATH / JAVA_HOME 三个来源全屏蔽(根表给空),
// 只让注册表这一支出声——本机装没装 MS JDK 不参与判定。
let base = std::env::temp_dir().join(format!("ull-ms-jdk-{}", std::process::id()));
let mut output = String::from("\r\n");
let mut expected: Vec<PathBuf> = Vec::new();
for version in ["21.0.11.10", "25.0.3.9"] {
let home = base.join(format!("jdk-{version}-hotspot"));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(java_file()), b"").unwrap();
// 照实测形态写:`Path` 末尾带反斜杠,注册表里是 Windows 分隔符。
output.push_str(&format!(
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\JDK\\{version}\\hotspot\\MSI\r\n Path REG_SZ {}{}\r\n\r\n",
home.display(),
std::path::MAIN_SEPARATOR
));
expected.push(home.join("bin").join(java_file()));
}
// `JavaSoft` 与 32 位视图都「键不存在」:只有 MS 键的 64 位视图有东西。
// 键名用字面量:常量被改坏(比如写成 `Microsoft\JRE`)时这一条必须红。
let reg = FakeReg::new(vec![(r"HKLM\SOFTWARE\Microsoft\JDK", "64", output)]);
let homes = registry_homes_from(&reg);
assert_eq!(homes.len(), 2, "MSI 的两条登记都要抠出来:{homes:?}");
let binaries = java_binaries_in(&[], &homes);
assert_eq!(binaries, expected, "只走注册表也要列出这两条 MS JDK");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn same_jdk_from_two_branches_is_listed_once() {
// 扫目录那支与注册表那支同时命中同一个 JDK:名字里多了末尾反斜杠也只是写法差异。
let base = std::env::temp_dir().join(format!("ull-ms-dup-{}", std::process::id()));
let home = base.join("jdk-21.0.11.10-hotspot");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(java_file()), b"").unwrap();
let from_registry = format!("{}{}", home.display(), std::path::MAIN_SEPARATOR);
let roots = [home.clone()];
let homes = [PathBuf::from(&from_registry), home.clone()];
let binaries = java_binaries_in(&roots, &homes);
assert_eq!(binaries.len(), 1, "同一个 JDK 不许列两次:{binaries:?}");
assert_eq!(binaries[0], home.join("bin").join(java_file()));
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn registry_homes_are_absolute_on_this_machine() {
// 真跑一遍 `reg.exe`(本机有没有 JDK 都算通过):路径必须绝对、且已去重。
let homes = super::registry_homes();
for home in &homes {
assert!(Path::new(home).is_absolute(), "注册表路径应绝对:{home:?}");
}
let mut keys: Vec<String> = homes
.iter()
.map(|home| crate::game::java::discover::path_key(home))
.collect();
let total = keys.len();
keys.sort();
keys.dedup();
assert_eq!(keys.len(), total, "注册表结果不该重复:{homes:?}");
}
}
@@ -5,7 +5,6 @@
//! 也抓不住「目录本来就在、但被重写/清空」的情形。
use std::path::{Path, PathBuf};
use std::time::Duration;
use unlucky_core::launch::natives::{self, NativeJar, NativesPlan};
@@ -20,9 +19,8 @@ use crate::prelude::*;
/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑
/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。
///
/// 时间戳口径:**精度 ≠ 可靠性**。NTFS / ext4 的 mtime 字段精度是 100ns 级,但「两个操作能不能
/// 分辨」取决于时间戳**何时被采样、以多大粒度更新**(同内容立即重写在 NTFS 上可能落在同一系统
/// 时钟 tick 内、句柄关闭时才按粗粒度落盘),所以快照只在「有人显式改过时间戳」时才是判据。
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的
/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。
/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。
fn tree_snapshot(root: &Path) -> Vec<String> {
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
@@ -213,22 +211,14 @@ fn dry_run_after_real_extract_reports_ready_without_writing() {
///
/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响
/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。
///
/// 时间维度也**不**靠「同内容立刻重写」:NTFS 上 mtime 不保证「每次写都变」——同一个系统
/// 时钟 tick 内的两次写、或时间戳只在句柄关闭时按粗粒度落盘,都会让重写后的 mtime 与写之前
/// **逐字节相同**。M275 的实际失败正是这一条:main 上 `test (windows-latest)` 偶发红在这行
/// `assert_ne!`(`crates\core\tests\launch\natives_dry_run_cases.rs:241`),本机永远复现不出来。
/// `sleep` + 重试轮询只是把概率压低(把「不可靠观察」换成「不可靠观察 + 更长的墙钟」),
/// 不是消除。确定性判据是**显式时间戳**:`File::set_modified` 把 mtime 直接写成
/// 「基线 + 5s」——值由我们指定,与时钟 tick / 落盘粒度无关;快照若看不见它,说明快照真的
/// 丢了时间维度,而不是运气不好。
/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变,
/// 不依赖父目录时间戳的合并行为)。
#[test]
fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
let root = temp_dir("natives-snapshot-probe");
let version_dir = root.join("versions/1.16.5");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
let manifest = version_dir.join("1.16.5.json");
std::fs::write(&manifest, b"{}").expect("放清单占位");
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
let baseline = tree_snapshot(&version_dir);
// 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。
@@ -240,20 +230,8 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
);
assert_ne!(baseline, with_dir);
// 2) 时间维度:显式把清单文件的 mtime 推到基线之后(节点集合不变 ⇒ 差异只可能来自时间戳)。
// 不改内容长度(那动的是 size,不是 mtime)、不 `sleep`、不重试。
let before = std::fs::metadata(&manifest)
.expect("清单元数据")
.modified()
.expect("基线 mtime");
let pushed = before + Duration::from_secs(5);
let file = std::fs::File::options()
.write(true)
.open(&manifest)
.expect("打开清单占位");
file.set_modified(pushed).expect("把 mtime 推到基线之后");
drop(file);
// 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位");
let rewritten = tree_snapshot(&version_dir);
assert_eq!(
with_dir.len(),
@@ -264,14 +242,4 @@ fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
with_dir, rewritten,
"快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿"
);
// 差异必须是我们推的那一下,而不是两次 `stat` 的偶然抖动(粗粒度文件系统会把 5s 取整到
// 4s 或 6s,所以判据是「至少前进 4s」而不是「恰好 5s」)。
let after = std::fs::metadata(&manifest)
.expect("清单元数据")
.modified()
.expect("推进后的 mtime");
assert!(
after.duration_since(before).expect("mtime 应当前进了") >= Duration::from_secs(4),
"mtime 应当被推到基线之后:{before:?} → {after:?}"
);
}
-7
View File
@@ -154,13 +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/,这次启动按独立目录运行(它自己的版本文件夹)。"),
("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"),
];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
-20
View File
@@ -216,26 +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/,这次启动按独立目录运行(它自己的版本文件夹)。"),
// ── 多根发现(M274):存档区标明**这一份列表读自哪个目录**。
// 一打开启动器就能看见两个 `.minecraft`(便携目录 + 官方启动器那个)各自的实例,
// 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源,
// 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。
InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"),
} }
};
}
+4 -53
View File
@@ -7,13 +7,10 @@ use crate::host::CoreHost;
use crate::host::HostEnv;
use crate::mock::MockBackend;
/// 启动选项(`--data-dir` 与 `--mock` 是产品开关;截图/演示也复用同一份选项)。
/// 启动选项(截图/演示通道用;正常启动全用默认值)。
#[derive(Debug, Clone, Default)]
pub struct AppOptions {
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析。
///
/// 走 [`HostEnv::at_data_root`]:**照常**发现官方启动器的标准游戏目录(M274)。钉数据根
/// 不等于「别看官方实例」——那是两个不同的开关。
/// 钉死数据根(`<root>/.ULL` + `<root>/.minecraft`);None = 按便携配置解析
pub data_root: Option<PathBuf>,
/// 强制 mock 后端(UI 回归/无数据根演示)
pub mock: bool,
@@ -24,62 +21,16 @@ pub fn config_path(root: &Path) -> PathBuf {
root.join(".ULL").join("config").join("launcher.json")
}
/// 钉了数据根时用哪个环境(`None` = 没钉,走解析链)。
///
/// **单独一格只为可测**:`--data-dir` 是产品通道,钉了数据根照样要看见官方启动器里的实例
/// (M274 r1 复审 P2-2)。退回 [`HostEnv::at_root`] 会让用户**静默**丢一半实例,而这层
/// `Box<dyn Backend>` 在用例里没有 env 取回口(`Backend` 没有访问器),所以判据就落在这个
/// 纯函数上(`backend::tests::data_root_keeps_discovering_the_official_launcher`)。
fn host_env_for(options: &AppOptions) -> Option<HostEnv> {
options.data_root.as_ref().map(HostEnv::at_data_root)
}
/// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
if options.mock {
return Box::new(MockBackend::new());
}
match host_env_for(options) {
Some(env) => Box::new(CoreHost::new(env)),
match &options.data_root {
Some(root) => Box::new(CoreHost::new(HostEnv::at_root(root))),
None => match CoreHost::open() {
Some(host) => Box::new(host),
None => Box::new(MockBackend::new()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `--data-dir`(产品通道)**保留**多根发现;`at_root`(截图/演示/用例)不保留。
///
/// 抓得住:`host_env_for` 被改回 `HostEnv::at_root`——用户钉一个数据根就永久静默看不到
/// 官方启动器里的实例(M274 r1 复审 P2-2)。
/// 抓不住:`--data-dir` 的命令行解析(app 侧);mounted 的数据根真的解析得出来(那是 resolver)。
#[test]
fn data_root_keeps_discovering_the_official_launcher() {
let root = std::env::temp_dir().join(format!("ull-backend-m274-{}", std::process::id()));
let options = AppOptions {
data_root: Some(root.clone()),
mock: false,
};
let env = host_env_for(&options).expect("钉了数据根就该有环境");
assert_eq!(
env.game.root(),
root.join(".minecraft"),
"落点与 `at_root` 一致"
);
assert_eq!(
env.standard_game,
unlucky_core::platform::paths::standard_game_home(
unlucky_core::platform::paths::Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
),
"`--data-dir` 是产品通道:不许顺手关掉官方根发现"
);
// 没钉数据根时这一格不参与(走解析链),`mock` 在前一步就分走了。
assert!(host_env_for(&AppOptions::default()).is_none());
std::fs::remove_dir_all(&root).ok();
}
}
-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 -8
View File
@@ -288,17 +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> {
// 装进**这个实例自己那个根**的运行目录(M274):官方根里的实例要把 mod 放进官方的
// `.minecraft/mods`(或它自己的版本目录),不是 ULL 便携根。
let game = super::super::game::game_for(env, &instance.id);
let base = super::super::game::base_mode(env, &game, &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(&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}")))
}
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
+13 -9
View File
@@ -280,19 +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 game = super::super::game::game_for(env, &instance.id);
let base = super::super::game::base_mode(env, &game, &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(&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`,
@@ -301,9 +306,8 @@ pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnap
let instance = super::super::game::instances(env)
.into_iter()
.find(|instance| &instance.id == id)?;
// 隔离实例的 `versions/` 在**它自己那个根**下(M274:官方根里的版本清单不在配置根下)
let game = super::super::game::game_for(env, &instance.id);
read_instance_snapshot(game.root(), Some(&instance.version_id)).ok()
// 隔离实例的 `versions/` 在全局根下,所以试全局根
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
}
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
+12 -94
View File
@@ -30,16 +30,10 @@ pub struct HostEnv {
pub data: ResolvedData,
/// `.ULL/` 布局
pub layout: LauncherLayout,
/// `.minecraft/` 布局(**配置的这个根**;多根发现的判据在 `game/roots.rs`)
/// `.minecraft/` 布局
pub game: GameLayout,
/// 平台
pub platform: Platform,
/// 官方启动器的标准游戏目录(`paths::standard_game_home`):多根发现的**第二个候选**。
///
/// `None` = 不去发现([`HostEnv::at_root`] 钉根时就是这样:截图/演示/用例钉了一个根,
/// 就不该顺手把用户真实的 `%APPDATA%\.minecraft` 也读进来,否则同一份用例在不同机器上
/// 结果不同)。生产路径由 [`HostEnv::from_data`] 填。
pub standard_game: Option<PathBuf>,
}
impl HostEnv {
@@ -54,33 +48,20 @@ impl HostEnv {
Some(Self::from_data(data))
}
/// **钉根但照常做多根发现**:`--data-dir`(生产通道)与 [`HostEnv::at_root`] 同一个落点,
/// 差别只有一格——`standard_game` 保留(用户钉的是数据根,不是「别看官方启动器」)。
///
/// 为什么两个入口必须分开(M274 r1 复审 P2-2):`--data-dir` 是**产品**开关,之前它走
/// [`HostEnv::at_root`],于是用户一钉数据根就永久静默看不到官方启动器里的实例。而
/// [`HostEnv::at_root`] 还被截图 / 演示 / 七十多处用例用着,那些地方**必须**确定性
/// (不能因为开发机上装了官方启动器就让用例结果变),所以它继续不填 `standard_game`。
pub fn at_data_root(root: impl AsRef<Path>) -> Self {
Self::from_data(portable_data(root.as_ref()))
}
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`。
///
/// **给谁用**:截图 / 演示 / 单元测试——凡是「结果必须与这台机器上装没装官方启动器无关」的地方。
/// **生产路径不要用这个**:产品里钉数据根(`--data-dir`)走 [`HostEnv::at_data_root`],
/// 那条会照常发现官方启动器里的实例;用错这里,用户会**静默**看不到官方实例(M274 r1 复审 P2-2)。
/// 直接钉一个根:`<root>/.ULL` + `<root>/.minecraft`(截图/演示通道)。
///
/// 根下已有 `config/launcher.json` 时**尊重其中的 `core.game_home` / `core.launcher_home`**:
/// 演示根常用它把游戏目录指到既有真数据上(避免复制几 GB 的 assets/libraries)。
///
/// 实现上**委托**给 `at_data_root` 再关掉发现(不是复制一份构造):两个入口的落点/模式因此
/// 物理上不可能漂开。
pub fn at_root(root: impl AsRef<Path>) -> Self {
let mut env = Self::at_data_root(root);
// 钉一个根的**测试/截图**通道不做多根发现:见 `standard_game` 的文档。
env.standard_game = None;
env
let root = root.as_ref();
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
Self::from_data(ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
})
}
/// 由解析结果建环境。
@@ -93,17 +74,10 @@ impl HostEnv {
pub fn from_data(data: ResolvedData) -> Self {
let layout = data.layout();
unlucky_core::launch::launcher_log::set_dir(layout.logs());
let platform = Platform::current();
Self {
layout,
game: GameLayout::new(data.game_home.clone()),
platform,
// M274:多根发现的第二个候选永远是**官方启动器**的标准位置(HMCL / PCL 用的是
// 同一个地方),与配置的 `game_home` 是不是同一个由 `roots::all` 的去重判据管。
standard_game: unlucky_core::platform::paths::standard_game_home(
platform,
&unlucky_core::platform::paths::ProcessEnv,
),
platform: Platform::current(),
data,
}
}
@@ -149,18 +123,6 @@ impl HostEnv {
}
}
/// 便携钉根的一份 `ResolvedData`(`at_root` / `at_data_root` 共用,两者只在 `standard_game` 上不同)。
fn portable_data(root: &Path) -> ResolvedData {
let request = request_from_portable(root);
let absolute = |path: Option<PathBuf>| path.filter(|path| path.is_absolute());
ResolvedData {
mode: DataMode::Portable,
launcher_home: absolute(request.launcher_home).unwrap_or_else(|| root.join(".ULL")),
game_home: absolute(request.game_home).unwrap_or_else(|| root.join(".minecraft")),
redirected_from: None,
}
}
/// 便携目录那份 `launcher.json` → 数据根请求(只读 `core.*` 四键)。
pub(crate) fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
@@ -206,50 +168,6 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
/// **多根发现的两条入口必须分开**(M274 r1 复审 P2-2):
///
/// | 入口 | 谁用 | `standard_game` |
/// |---|---|---|
/// | [`HostEnv::at_data_root`] | 产品 `--data-dir`(`backend.rs`) | `Some(官方位置)`:钉数据根 ≠ 别看官方实例 |
/// | [`HostEnv::at_root`] | 截图 / 演示 / 七十多处用例 | `None`:同一份用例在任何机器上结果一样 |
/// | [`HostEnv::from_data`] | 生产解析链(便携 / 标准 / 自定义) | `Some(官方位置)` |
///
/// 抓得住:`--data-dir` 退回 `at_root`(用户钉数据根后永久看不到官方实例,且没有任何报错)、
/// 或 `at_root` 顺手开始发现(用例在装了官方启动器的机器上变红/变绿)。
/// 抓不住:`paths::standard_game_home` 自己算错(那是 core 的用例)。
///
/// 「两个入口不会各自漂开」是**结构性**的:`at_root` 委托 `at_data_root` 再关一格,
/// 不是复制一份构造——所以下面那三行落点断言只是把这条结构钉成行为,而不是唯一防线。
#[test]
fn only_the_product_entries_discover_the_official_root() {
let root = std::env::temp_dir().join(format!("ull-host-env-m274-{}", std::process::id()));
let expected = unlucky_core::platform::paths::standard_game_home(
Platform::current(),
&unlucky_core::platform::paths::ProcessEnv,
);
assert!(
expected.is_some(),
"这个平台连标准游戏目录都算不出来,用例的前提不成立"
);
assert_eq!(
HostEnv::at_root(&root).standard_game,
None,
"截图/用例钉根不许做多根发现"
);
assert_eq!(
HostEnv::at_data_root(&root).standard_game,
expected,
"`--data-dir` 是产品通道:钉数据根不等于别看官方实例"
);
// 两条入口除了 `standard_game` 之外必须**完全一致**(落点、模式)。
let pinned = HostEnv::at_root(&root);
let product = HostEnv::at_data_root(&root);
assert_eq!(pinned.layout.home(), product.layout.home());
assert_eq!(pinned.game.root(), product.game.root());
std::fs::remove_dir_all(&root).ok();
}
/// M188 的守门②:**日志根随数据模式变化**(便携 / 标准 / 自定义各一条断言)。
///
/// 三条走的都是 `HostEnv` 真正用的那条解析链(`Resolver` → `ResolvedData::layout`):
+36 -174
View File
@@ -1,24 +1,17 @@
//! 实例快照:`profiles.json` ∪ 盘上的 `versions/*` + 版本 JSON + 会话索引 + 实例设置
//! → [`InstanceSummary`]。
//! 实例快照:`profiles.json` + 版本 JSON + 会话索引 + 实例设置 → [`InstanceSummary`]。
//!
//! 「磁贴上的每个字」都在这里翻译完:版本号取自继承链根、加载器取自版本 id(解析在
//! [`unlucky_core::game::loader`],本文件只做 DTO 翻译)、最后游玩时间取自会话索引、
//! 隔离档位取自实例设置(缺省跟全局 `instances.mode`)。
//!
//! **列表是并集**(M273):档案 + 盘上有清单但没有档案绑定的版本目录,见 [`instances`] 的文档。
//!
//! **根是多个**(M274):配置的 `game_home` + 官方启动器的标准位置(`%APPDATA%\.minecraft` 等),
//! 规范化真实路径去重([`roots`])。每个自动发现的实例因此带**自己的根**——读版本清单、派生运行
//! 目录、列存档 / 模组都走 [`game_for`],不全是 `env.game`。
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::{GameLayout, InstanceProfile, ProfileId, ProfileList, VersionJson};
use unlucky_core::game::{ProfileList, VersionJson};
use crate::dto::{
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, NoticeLevel, NoticeView,
@@ -26,13 +19,6 @@ use crate::dto::{
use super::env::HostEnv;
mod discovered;
mod roots;
pub(super) use discovered::{base_mode, game_for};
use discovered::{display_name, external_summary, external_version_of};
/// 全局默认隔离档位(`instances.mode`;缺键/坏值按 D20 默认 `isolated`)。
pub fn global_mode(env: &HostEnv) -> IsolationMode {
env.store()
@@ -64,15 +50,13 @@ pub fn last_played(env: &HostEnv) -> BTreeMap<String, String> {
}
/// 版本 JSON 的继承链根 id + 加载器(读不到返回 `(version_id, 原版)`)。
///
/// **按传入的那个根读**(M274):外部实例的版本清单在自己那个根下,拿配置根去读会读空。
pub fn describe_version(game: &GameLayout, version_id: &str) -> (String, LoaderInfo) {
pub fn describe_version(env: &HostEnv, version_id: &str) -> (String, LoaderInfo) {
let loader = loader_from_id(version_id);
let mut current = version_id.to_string();
let mut seen = 0;
while seen < 16 {
seen += 1;
let path = game.version_json(&current);
let path = env.game.version_json(&current);
let Ok(text) = std::fs::read_to_string(&path) else {
break;
};
@@ -121,168 +105,57 @@ fn dto_kind(kind: unlucky_core::game::LoaderKind) -> LoaderKind {
})
}
/// 实例的隔离档位(实例文件优先,缺省 = [`base_mode`])。
///
/// `game` 是这个实例**自己那个根**的布局(多根之后同一个版本 id 在两个根里可以有不同基底档)。
pub fn isolation_of(env: &HostEnv, game: &GameLayout, id: &str, version_id: &str) -> String {
let base = base_mode(env, game, 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),版本目录来自**多个游戏根**(M274)。
///
/// ## 为什么是并集(M273)
///
/// 用户实测:HMCL 能看见 `<.minecraft>/versions/樱落怡然客户端`,ULL 的实例页却是空的——
/// 因为这里以前只列 `profiles.json` 里的档案。第三方启动器(HMCL / PCL / 官方启动器)把实例
/// 存在 `versions/<id>/<id>.json`,**没有**我们的档案记录。只读档案 ⇒ 已装好的实例在界面上
/// 不存在,用户以为「读不到这个 .minecraft」。
///
/// ## 为什么是多个根(M274)
///
/// 官方启动器用的是 `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——**两个
/// 不同的目录**。只扫配置根等于「装了官方启动器也看不见它的实例」。所以这里遍历
/// `roots::all(env)`:配置根 + 官方启动器的标准位置(规范化真实路径去重,见 `roots::key_of`)。
///
/// 那一批根里**只有配置根**有档案语义:`profiles.json` 的 `version_id` 指的是配置根下的版本,
/// 所以「已被档案绑定 ⇒ 不重复列」这条只对配置根成立;别的根里的同名版本是**另一个实例**
/// (id 里带着根,见 `discovered::external_instance_id`)。
///
/// ## 三条不变量
///
/// 1. **只读**:合成视图不建目录、不写 `profiles.json`、不碰任何 `versions/` 里一个字节
/// (`instances()` 会被每次刷新调用,写盘会变成「一打开启动器就改别人的文件夹」);
/// 2. **档案优先**:配置根里某个版本已经有档案绑定(不论档案 id 是什么)⇒ 只按档案出**一条**,
/// 不重复挂合成视图(用户改过的名字/隔离档不会因为盘上有同名版本被顶掉);
/// 3. **坏东西不当实例**:清单缺 / JSON 坏 / `id` 与目录名不一致 / 缺父版本 / 越界符号链接
/// ⇒ 不进列表(`AdoptCandidate::adoptable()`);某个根整个读不出来也只是少列它那几条,
/// 不让实例页因此报错。
/// 实例快照。
pub fn instances(env: &HostEnv) -> Vec<InstanceSummary> {
let profiles = profiles(env);
let played = last_played(env);
// **每个根只扫一遍**(M274 r1 复审 P2-1):这一拍的「根 × 版本」清单读一次,之后
// ①每份档案属于哪个根、②它是否占住配置根的同名版本,都是内存查表。
// 反查写进循环里是 `#根 × (1 + #ext 档案)` 次全树扫描——官方根 200 版本 + 10 份 ext 档案
// 时 `instances()` 会从 236ms 涨到 1.35s(复审实测 ×5.7)。
let found = discovered::Found::scan(env);
let mut homes: Vec<Option<GameLayout>> = Vec::with_capacity(profiles.profiles.len());
let mut bound: Vec<&str> = Vec::new();
for profile in &profiles.profiles {
// 两件事用**同一份**判据(`binds_configured_root` 与写盘那道门共用一个函数):
// ① 这条档案属于哪个根(磁贴按它描述自己);② 它是否占住配置根的同名版本。
if discovered::binds_configured_root(&found, profile) {
bound.push(profile.version_id.as_str());
}
homes.push(found.game_of(profile.id.as_str()).cloned());
}
let mut snapshot: Vec<InstanceSummary> = profiles
profiles
.profiles
.iter()
.zip(&homes)
.map(|(profile, home)| profile_summary(env, profile, home.as_ref(), &played))
.collect();
let known: Vec<&str> = profiles
.profiles
.iter()
.map(|profile| profile.id.as_str())
.collect();
for (root, versions) in found.roots() {
let configured = root.configured();
for version_id in versions {
let id = discovered::external_instance_id(root.key(), version_id);
if known.contains(&id.as_str()) {
continue;
.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,
}
if configured && bound.contains(&version_id.as_str()) {
continue;
}
snapshot.push(external_summary(env, root, version_id, &played));
}
}
snapshot
}
/// 一条档案 → 磁贴(档案路径的唯一翻译点)。
///
/// `home` 是这条档案**自己那个根**([`discovered::Found`] 查出来的;`None` = 配置根):
/// 懒创建出来的 `ext-` 档案可能属于官方根,那时「版本在不在盘上 / 隔离档 / MC 版本 / 版本门槛」
/// 都必须按那个根算——否则磁贴说「文件缺失 · 共用目录」,而存档与启动实际读的是官方根
/// (M274 r1 复审 P1-1:显示与行为分叉,最坏的一种)。
fn profile_summary(
env: &HostEnv,
profile: &InstanceProfile,
home: Option<&GameLayout>,
played: &BTreeMap<String, String>,
) -> InstanceSummary {
let version_id = profile.version_id.clone();
let game = home.unwrap_or(&env.game);
let (mc_version, loader) = describe_version(game, &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, game, id, &version_id),
version_present: game.is_version_installed(&version_id),
launch: super::launch_args::view(env, game, 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((root, version_id)) = external_version_of(env, &list, id) else {
return Err(format!("找不到实例 `{id}`"));
};
let profile = InstanceProfile {
id: ProfileId::new(id),
// 显示名与磁贴同源:别的根里的同名版本带上根路径,否则两条磁贴分不清(M274)。
name: display_name(&root, &version_id),
version_id,
..InstanceProfile::default()
};
list.profiles.push(profile.clone());
write_profiles(&env.layout, &list).map_err(|error| format!("写实例清单失败:{error}"))?;
Ok(profile)
})
.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(|| {
@@ -333,14 +206,3 @@ pub fn short_path(path: &Path) -> String {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_union;
#[cfg(test)]
mod tests_multiroot;
#[cfg(test)]
mod tests_multiroot2;
#[cfg(test)]
mod tests_multiroot3;
@@ -1,300 +0,0 @@
//! 自动发现的实例(M273 · 多根 M274):盘上扫出来的版本 → 磁贴,以及它们的**运行目录判据**。
//!
//! 从 [`super::instances`] 里拆出来只为守 400 行硬线(那边装了档案侧的翻译)。本文件三件事:
//!
//! 1. **哪些盘上的版本算实例**:`versions/<id>/<id>.json` 能解析(判据来自 `core::game::adopt::scan`;
//! 本文件只负责「每个根扫一遍」并把结果装进 [`Found`]);
//! 2. **它们从哪个目录跑**:[`base_mode`]——`versions/<id>/` 里有痕迹(`mods` / `saves` /
//! `config` / `options.txt`)就按独立目录,否则按共用目录(**它自己那个根**);
//! 3. **身份与判据**:[`external_instance_id`](id 里带根)、[`game_for`](读盘走哪个根)、
//! [`binds_configured_root`](这条档案占住的是**配置根**里那个版本吗——列表侧与写盘那道门共用)。
//!
//! 「哪个版本已经被档案绑定」这一步**不在本文件**:它属于 [`super::instances`] 的并集规则,
//! 而写盘那道门([`external_version_of`])用的是同一个 [`binds_configured_root`]。
//!
//! # 多根(M274):身份 = (根, 版本)
//!
//! 两个根里都可能有 `1.20.4`,所以版本 id **不再唯一**。身份的落点只有一个:
//! [`external_instance_id`](把根的规范化路径喂进哈希)。于是「同一个根 + 同一个版本目录」跨重启
//! 拿到同一个 id,而两个根里的同名版本拿到两个不同 id——列表自然出两条。
//!
//! 反查(id → 根 + 版本)只能靠**重算**:哈希不可逆,所以 [`Found::scan`] 枚举全部根、每个根扫一遍
//! `versions/`,逐个比 id。**一拍只扫一遍**:`instances()` 建一份 [`Found`] 给整轮用(扫描次数 =
//! #根,r1 复审实测过旧形状是 `#根 × (1 + #ext 档案)`),单实例动作(存档 / 模组 / 启动 / 改设置)
//! 各建一份(同样是 #根 次)。
//!
//! 两条都**只读**:本文件不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节,
//! 更不往官方启动器的根里写任何东西。
use std::collections::BTreeMap;
use unlucky_core::game::isolation::IsolationMode;
use unlucky_core::game::{GameLayout, InstanceProfile, ProfileList};
use crate::dto::{InstanceRuntime, InstanceSummary};
use super::super::env::HostEnv;
use super::roots::{self, GameRoot};
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"];
/// 版本目录里有没有「它自己用过」的痕迹(只读)。
///
/// **必须传这个实例自己的根**:同一个版本 id 在两个根里的痕迹可以不同(官方根里跑过、ULL 根里没有)。
fn has_own_run_dir(game: &GameLayout, version_id: &str) -> bool {
let dir = 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` 处理会让它突然读到根下别人的存档 ——
/// 两种错都真实存在,所以判据只能是**盘上的事实**。
///
/// **M274**:痕迹按**这个实例自己的根**判(`game` 参数)。换根之后判定不变:官方根里那个
/// 带 `mods/` 的版本仍是 `isolated`,ULL 根里那个干净的版本仍是 `shared`。
///
/// 只读:这里不建目录、不写任何文件(写盘只发生在用户第一次改设置时,见 [`super::ensure_profile`])。
pub(in crate::host) fn base_mode(
env: &HostEnv,
game: &GameLayout,
id: &str,
version_id: &str,
) -> IsolationMode {
if !crate::dto::is_discovered_instance(id) {
return global_mode(env);
}
if has_own_run_dir(game, version_id) {
IsolationMode::Isolated
} else {
IsolationMode::Shared
}
}
/// 一个根下「盘上有版本、可登记」的那些版本 id(字典序,来自 core 的扫描)。
///
/// 扫描失败(`versions/` 读不出来 / 断链联接)时返回空:列表退回「只有档案」的旧行为。
/// 这里**不**把错误往上抛——实例页打不开是比「少列几个外部实例」严重得多的故障。
///
/// 档案绑定(`profile.version_id`)**不在这一层过滤**:那只对配置根有意义,由 [`super::instances`] 做。
pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Vec<String> {
let Ok(scan) = unlucky_core::game::adopt::scan(game, &env.layout) else {
return Vec::new();
};
scan.candidates
.into_iter()
.filter(|candidate| candidate.adoptable())
.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**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let bytes = root_key
.as_bytes()
.iter()
.copied()
.chain(std::iter::once(0))
.chain(version_id.as_bytes().iter().copied());
for byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
}
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
///
/// 为什么非带不可:两个根里都可能有一个叫 `A` 的版本,列表上两条同名磁贴,用户无从分辨点哪一条
/// (而两条的存档在不同的目录里)。路径是**事实**,直接摆出来;只对配置根保持原样是为了不让
/// 绝大多数用户看到一堆长路径。
pub(in crate::host) fn display_name(root: &GameRoot, version_id: &str) -> String {
if root.configured() {
return version_id.to_string();
}
format!("{version_id} · {}", root.path().display())
}
/// 一条外部版本 → 磁贴(**只读**,不落任何文件)。
pub(in crate::host) fn external_summary(
env: &HostEnv,
root: &GameRoot,
version_id: &str,
played: &BTreeMap<String, String>,
) -> InstanceSummary {
let id = external_instance_id(root.key(), version_id);
let game = root.game();
let (mc_version, loader) = describe_version(game, version_id);
InstanceSummary {
last_played: played.get(&id).cloned(),
isolation: isolation_of(env, game, &id, version_id),
launch: super::super::launch_args::view(env, game, &id, version_id),
version_present: game.is_version_installed(version_id),
name: display_name(root, version_id),
version_id: version_id.to_string(),
mc_version,
loader,
runtime: InstanceRuntime::Idle,
icon: None,
id,
}
}
/// **一拍之内**的发现快照:每个根只扫一遍 `versions/`,之后所有「这个 id 属于哪个根」都是内存查表。
///
/// 为什么要有它(M274 r1 复审 P2-1 的实测):把反查直接写进 `instances()` 的循环里,扫描次数是
/// `#根 × (1 + #ext 档案)`;官方根 200 个版本 + 10 份 `ext-` 档案时 `instances()` 从 236ms 涨到
/// 1.35s(×5.7)。这里把「根 × 版本」的清单一次性读完,反查退化成线性的字符串比较
/// (`ext-` id 是 16 位十六进制,逐条比一遍远比再扫一次盘便宜)。
///
/// 快照是**快照**:一拍里盘变了(用户同时在装版本)不会看到,下一次刷新重建。
pub(in crate::host) struct Found {
roots: Vec<(GameRoot, Vec<String>)>,
}
impl Found {
/// 扫一遍全部根(配置根在前,顺序与 `roots::all` 一致)。
pub(in crate::host) fn scan(env: &HostEnv) -> Self {
let roots = roots::all(env)
.into_iter()
.map(|root| {
let versions = external_versions(env, root.game());
(root, versions)
})
.collect();
Self { roots }
}
/// 逐个根 + 它这一拍扫到的版本 id(字典序,已滤掉不可登记的)。
pub(in crate::host) fn roots(&self) -> impl Iterator<Item = (&GameRoot, &[String])> {
self.roots
.iter()
.map(|(root, versions)| (root, versions.as_slice()))
}
/// id → (根, 版本 id)。不是 `ext-` 前缀 / 扫不到 ⇒ `None`。
pub(in crate::host) fn find(&self, id: &str) -> Option<(&GameRoot, &str)> {
if !crate::dto::is_discovered_instance(id) {
return None;
}
self.roots().find_map(|(root, versions)| {
versions
.iter()
.find(|version_id| external_instance_id(root.key(), version_id) == id)
.map(|version_id| (root, version_id.as_str()))
})
}
/// id → 那个根的布局(查不到 ⇒ `None`,由调用方决定退回哪个根)。
pub(in crate::host) fn game_of(&self, id: &str) -> Option<&GameLayout> {
self.find(id).map(|(root, _)| root.game())
}
}
/// 实例所属的那个根的布局(读盘路径的唯一入口)。
///
/// 档案实例、以及「反查不到」的 id 一律退回配置根 `env.game`——**没有第二个答案**:
/// 宁可读配置根(旧行为),也不把实例指向一个猜出来的目录。
///
/// # 退回的后果(M274 r2 复审 P2-1,任务⑥点名要写)
///
/// `is_discovered_instance(id)` 却反查不到,只有两种情形:
/// 1. **老档案**(M273 时代的 `ext-` id,哈希里没有根)——退回配置根是对的;
/// 2. **那个版本此刻不可登记**:官方启动器正在更新(清单半写 / 被锁 / 目录被移走)⇒
/// `adopt::scan` 判 `adoptable() == false` ⇒ 这里查不到。
///
/// 第 2 种情形下退回配置根是有代价的:**若配置根里恰好有同名版本**,装配会去读它、
/// `gameDir` 也落在配置根 ⇒ 可能**启动/读档到另一个根**。所以这里留一行启动器日志
/// (`logs/launcher.log`,分类 `multiroot`),让这条回落可观测,而不是让调用方无感。
pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
let found = Found::scan(env);
if let Some(game) = found.game_of(id) {
return game.clone();
}
if crate::dto::is_discovered_instance(id) {
// 两因并列,**不要**只写后一种:`ext-` 前缀在 M273 时代就有,那时 id 只哈希版本目录名
// (没有根),所以「M274 之前合成的老档案」**永远**反查不到——那是所有老用户的常态,
// 不是故障。只写「清单半写/被锁」会让每次点开详情页都往日志里加一条像是在报错的假警报
// (M274 r3 复审 P2-1)。
unlucky_core::launch::launcher_log::record(
"multiroot",
&format!(
"实例 {id} 反查不到所属根(老档案:M273 之前合成,id 不含根;或该版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
env.game.root().display()
),
);
}
env.game.clone()
}
/// 这条档案**占住的是配置根里的那个版本吗**(`instances()` 的 `bound` 与写盘那道门共用同一份判据)。
///
/// - 反查到某个根 ⇒ 看那个根是不是配置根;
/// - 反查不到 ⇒ 按配置根口径算(老 M273 的 `ext-` id、用户/向导建的自建档案、那批根已不在盘上)。
///
/// 为什么必须是**一个**函数(M274 r2 复审 P1-1):这两处曾经各写一份,列表侧对、写盘侧只比
/// `version_id`——于是官方根那个 `A` 被懒创建成档案后,**配置根**里同名那个 `A` 被判成
/// 「已被绑定」,`ensure_profile` 返回 `找不到实例 ext-…`,而磁贴还好端端列着。
/// 判据分叉的代价是「有时能改有时不能改」,最难排的那一种。
pub(in crate::host) fn binds_configured_root(found: &Found, profile: &InstanceProfile) -> bool {
found
.find(profile.id.as_str())
.is_none_or(|(root, _)| root.configured())
}
/// 视图 id → (所属根, 版本 id) 的反查(只认「可登记、且**还没有档案绑定**」的版本)。
///
/// 与 `instances()` 同一份判据(含 [`binds_configured_root`]):合成的视图 id 只在那个集合里有意义,
/// 于是「界面上出现过的外部实例」与「能懒创建的外部实例」永远是同一批(有了档案就走档案,
/// 不再重复列一条)。
#[must_use]
pub(in crate::host) fn external_version_of(
env: &HostEnv,
profiles: &ProfileList,
id: &str,
) -> Option<(GameRoot, String)> {
// 一拍只扫一次:下面「查自己」与「查绑定者」都用这一份。
let found = Found::scan(env);
let (root, version_id) = found
.find(id)
.map(|(root, version_id)| (root.clone(), version_id.to_string()))?;
// 档案绑定只对**配置根**有意义(别处的同名版本是另一个实例,id 不会撞)。
// 而「绑定者」是不是配置根里的那一条,也按它**自己的根**判——不许只比 `version_id`:
// 官方根的 `A` 与配置根的 `A` 是两个实例(r2 复审 P1-1 的现场)。
if root.configured() {
let taken = profiles.profiles.iter().any(|profile| {
profile.version_id == version_id
&& (profile.id.as_str() == id || binds_configured_root(&found, profile))
});
if taken {
return None;
}
}
Some((root, version_id))
}
-206
View File
@@ -1,206 +0,0 @@
//! 游戏根的发现与去重(M274):**当前配置的根** + **官方启动器的标准位置**。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft` / `~/Library/Application Support/minecraft` / `~/.minecraft`,
//! 而便携模式下的 ULL 用的是 `<exe>/.minecraft`——**两个根,不是一回事**。所以列表要能同时读两边。
//!
//! # 只有一件事不是显然的:什么算「同一个根」
//!
//! 同一个目录可以有四种写法:大小写不同、`\` 与 `/` 混用、8.3 短名(`PROGRA~1`)、
//! 符号链接 / 目录联接(Windows 的 junction)。按字面量比较会让**同一批实例列两遍**,
//! 所以判据落在 [`key_of`] 的**规范化真实路径**上:
//!
//! 1. `canonicalize`(Windows 上是 `GetFinalPathNameByHandle`:短名展开成长名,联接解析到目标);
//! 2. `\` → `/`;
//! 3. **去掉 `\\?\` 前缀**——Windows 的 `canonicalize` 一律返回 verbatim 形式(`\\?\C:\…`),
//! 不去掉的话「同一路径的两种写法」永远不相等(这是个已知坑,用例
//! `junction_to_the_same_folder_is_one_root` 钉住);
//! 4. Windows / macOS 折成小写。
//!
//! # 已知取舍(这是**我们**的选择,不是文件系统性质)
//!
//! 第 4 步折小写是为了让 `C:\Mc` 与 `c:\mc`(Windows 上同一个目录)算同一个根。代价在 macOS:
//! **APFS 可以按卷选择区分大小写**,那种卷上 `/a` 与 `/A` 是两个真实不同的目录,折小写会把它们
//! 并成一个键 ⇒ 第二个根(官方位置)被**静默跳过**(不报错、不列它的实例)。触发条件只有
//! 「用户在区分大小写的 APFS 卷上,且两个根只差大小写」——那种布局几乎不可能是真实安装,
//! 所以接受这个代价:宁可漏一个根,也不要把同一批实例列两遍(后者是更常见的错)。
//!
//! 另外三条边界(都不是缺陷,记在这里免得下次重新推一遍):
//! - `\\?\UNC\server\share` 剥前缀后成 `unc/server/share`:仍然**唯一**、不会误合并,
//! 只是丢了 UNC 语义(键只用于相等比较,不再当路径用);
//! - `\\?\C:\` 去掉尾斜杠之后是 `c:`:与 `c:/foo` 这类前缀不相同,不会撞;
//! - Linux 的 `/`:去尾斜杠会得到空串,有兜底写回 `"/"`。
//!
//! `canonicalize` 失败(根还不存在)时退化成「字面量归一」:`\`→`/`、去尾斜杠、折小写。
//! 尚不存在的目录没有短名/链接可解析,这一步够用;代价是那个目录**创建之后**键可能变一次
//! (短名 → 长名、大小写归到真实名字),于是该根下已发现的实例 id 会变一次。
//!
//! # 只读
//!
//! 本模块只 `is_dir()` / `canonicalize()`:不建目录、不写一个字节。官方启动器那个根上尤其要紧
//! ——那是**别人的**目录。
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
use super::super::env::HostEnv;
/// 一个被发现的游戏根。
///
/// `configured` 那一格区分「用户在设置里指定的根」与「官方启动器的标准位置」:档案绑定、
/// 新建/安装版本都只对前者有意义(我们**从不往官方根写东西**,见 M274 的只读纪律)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::host) struct GameRoot {
key: String,
game: GameLayout,
configured: bool,
}
impl GameRoot {
/// 这个根的布局(`versions/` / `libraries/` / `assets/` 都在它下面)。
pub(in crate::host) fn game(&self) -> &GameLayout {
&self.game
}
/// 规范化后的比较键(实例 id 的哈希输入之一,见 [`super::discovered::external_instance_id`])。
pub(in crate::host) fn key(&self) -> &str {
&self.key
}
/// 是不是**当前配置**的那个根(`env.game`)。
pub(in crate::host) fn configured(&self) -> bool {
self.configured
}
/// 根目录本身(显示用)。
pub(in crate::host) fn path(&self) -> &Path {
self.game.root()
}
}
/// 全部游戏根:**配置根永远第一位**,官方启动器的标准位置(存在、且不是同一个物理目录)第二位。
///
/// 官方位置不存在 ⇒ 不列。「没装官方启动器」不该在列表上留一个空根——那是「这里本来就没有」,
/// 与「有个空目录」是两件事。
pub(in crate::host) fn all(env: &HostEnv) -> Vec<GameRoot> {
let mut roots = vec![GameRoot {
key: key_of(env.game.root()),
game: env.game.clone(),
configured: true,
}];
let mut seen: BTreeSet<String> = roots.iter().map(|root| root.key.clone()).collect();
for extra in env.standard_game.iter() {
if !extra.is_dir() {
continue;
}
let key = key_of(extra);
// 同一个物理目录(符号链接 / 目录联接 / 大小写 / 短名)⇒ 只留配置根那一条。
if !seen.insert(key.clone()) {
continue;
}
roots.push(GameRoot {
key,
game: GameLayout::new(extra.clone()),
configured: false,
});
}
roots
}
/// 路径 → **比较键**(判据的完整理由见模块文档)。
///
/// 同一个物理目录的所有写法必须给同一个键;不同的目录必须给不同的键(大小写不敏感的平台除外,
/// 那里它们本来就是同一个目录)。
pub(in crate::host) fn key_of(path: &Path) -> String {
let resolved: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let text = resolved.to_string_lossy().replace('\\', "/");
// verbatim 前缀(`\\?\C:\…` 在上一步已经变成 `//?/C:/…`):不去掉就与普通写法永不相等。
// UNC 会因此变成 `unc/server/share`(仍唯一,见模块文档的「已知取舍」)。
let text = text
.strip_prefix("//?/")
.or_else(|| text.strip_prefix("//./"))
.unwrap_or(text.as_str());
let text = text.trim_end_matches('/');
let text = if text.is_empty() { "/" } else { text };
if cfg!(any(windows, target_os = "macos")) {
text.to_lowercase()
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::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("建临时目录");
path
}
/// 斜杠方向 / 尾斜杠 / 大小写不影响比较键(`canonicalize` 之后三者本来就该相等)。
#[test]
fn key_ignores_separators_case_and_trailing_slash() {
let root = temp_root("ull-roots-key");
let nested = root.join("Minecraft/.minecraft");
std::fs::create_dir_all(&nested).expect("建目录");
let plain = key_of(&nested);
assert_eq!(plain, key_of(&PathBuf::from(format!("{}/", plain))));
let mixed = PathBuf::from(plain.replace('/', "\\"));
assert_eq!(plain, key_of(&mixed), "反斜杠写法必须是同一个根");
assert!(
!plain.starts_with("//?/"),
"verbatim 前缀没去掉,两种写法永远不相等:{plain}"
);
std::fs::remove_dir_all(&root).ok();
}
/// **去重判据**:`root_b` 是指向 `root_a` 的目录联接(junction)⇒ 两个写法是同一个根。
///
/// 这一条是整个多根发现最容易假绿的地方:只比字面量时它必然「通过」两条,而用户看到同一批
/// 实例列了两遍。Windows 上用 `mklink /J`(不需要管理员),其它平台用符号链接。
#[test]
fn junction_to_the_same_folder_is_one_root() {
let root = temp_root("ull-roots-junction");
let target = root.join("official/.minecraft");
std::fs::create_dir_all(target.join("versions")).expect("建目录");
let link = root.join("linked");
if !make_dir_link(&link, &target) {
// 造不出链接就**不假绿**:这一条判据在没链接的盘上等于没验,如实标出来。
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
std::fs::remove_dir_all(&root).ok();
return;
}
assert_eq!(
key_of(&target),
key_of(&link),
"联接与目标是同一个根:{} vs {}",
key_of(&target),
key_of(&link)
);
std::fs::remove_dir_all(&root).ok();
}
/// 造一个目录链接:Windows 用 junction(无管理员权限也能建),Unix 用符号链接。
#[cfg(windows)]
fn make_dir_link(link: &Path, target: &Path) -> bool {
std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
fn make_dir_link(link: &Path, target: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
}
+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, &env.game, &id, "1.20.1"), "isolated");
assert_eq!(isolation_of(&env, &id), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &env.game, &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,347 +0,0 @@
//! M274:**多根发现**(配置根 + 官方启动器的标准位置)的判据用例。
//!
//! 只读那条用例搬到了 `tests_multiroot2.rs` 并升级成内容级快照(M274 r1 复审 P2-3);
//! 写设置那道门的两条方向用例在 `tests_multiroot3.rs`(M274 r2 复审 P1-1)。
//!
//! 用户原话:「其他第三方启动器甚至能自动读到官方启动器的版本和存档」。官方启动器用的是
//! `%APPDATA%\.minecraft`,便携模式的 ULL 用的是 `<exe>/.minecraft`——两个不同的目录。
//!
//! **本机没有装官方启动器**(`paths::standard_game_home()` 解析出的路径不存在),所以这一组的
//! 每个根都是**临时目录里造出来的夹具**:`<root>/.minecraft/versions/A/A.json`。这不是缺陷,
//! 是环境事实;「真的去读 `%APPDATA%`」那一步**未验**(见报告)。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) |
//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) |
//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::Found`) |
//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) |
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
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));
}
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
let root = temp::Root::new(tag);
let mut env = HostEnv::at_root(&root.0);
let official = root.0.join("official").join(".minecraft");
std::fs::create_dir_all(&official).expect("官方根目录");
env.standard_game = Some(official);
(root, env)
}
/// 官方根(=第二个候选根)的**字面**路径。
fn official(env: &HostEnv) -> PathBuf {
env.standard_game.clone().expect("用例里一定钉了官方根")
}
fn write_version(game: &GameLayout, id: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
}
/// 世界目录(`<根>/saves/<name>/level.dat`)。
fn write_world(root: &Path, name: &str) {
let dir = root.join("saves").join(name);
std::fs::create_dir_all(&dir).expect("世界目录");
std::fs::write(dir.join("level.dat"), b"fixture").expect("level.dat");
}
/// 目录链接(Windows 用 junction:不需要管理员;其它平台符号链接)。造不出来返回 `false`。
#[cfg(windows)]
fn link_dir(link: &Path, target: &Path) -> bool {
std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
fn link_dir(link: &Path, target: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
// ------------------------------------------------------------------ 判据①:两个根各一条
/// 两个临时根各放一个同名实例(`versions/A/A.json`)⇒ 列表出 **2 条**,各自指向自己的根。
#[test]
fn the_same_version_in_two_roots_is_two_instances() {
let (_root, env) = two_roots("ull-host-multiroot-two");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
2,
"两个根里的同名版本必须是 2 条:{snapshot:?}"
);
let ids: std::collections::BTreeSet<&str> =
snapshot.iter().map(|tile| tile.id.as_str()).collect();
assert_eq!(
ids.len(),
2,
"两个根的 id 撞了(哈希没喂根?):{snapshot:?}"
);
for tile in &snapshot {
assert_eq!(tile.version_id, "A");
assert!(
unlucky_core::platform::layout::is_valid_instance_id(&tile.id),
"合成的实例 id 不合法:{}",
tile.id
);
let game = game_for(&env, &tile.id);
assert!(
game.root() == env.game.root() || game.root() == official(&env),
"实例 {} 的根既不是配置根也不是官方根:{}",
tile.id,
game.root().display()
);
}
// 两条磁贴的**名字**必须分辨得出来(否则用户不知道点哪一条,而两条的存档在不同目录里)。
assert_ne!(
snapshot[0].name, snapshot[1].name,
"两条磁贴同名,用户无从分辨:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.name == "A"),
"配置根里的那条仍叫版本目录名(绝大多数用户看到的就是它):{snapshot:?}"
);
let other = snapshot
.iter()
.find(|tile| tile.name != "A")
.expect("另一条存在");
assert!(
other.name.contains("official"),
"另一条的名字得带上它的根(现在是 {})",
other.name
);
// 同一个 (根, 版本) 反查稳定:换一次快照,id 不变(懒创建幂等的前提)。
let again = instances(&env);
assert_eq!(
again.iter().map(|tile| tile.id.clone()).collect::<Vec<_>>(),
snapshot
.iter()
.map(|tile| tile.id.clone())
.collect::<Vec<_>>(),
"同一批盘上内容两次快照给了不同 id(跨重启就会长出第二条档案)"
);
}
/// **存在但是空的**官方根(本机的真实形状)⇒ 一条实例都不许多,也不许报错。
///
/// 这条不是假想:开发机上 `%APPDATA%\.minecraft` **目录是在的**,里面只有一个空的 `cache/`
/// (没有 `versions/`、没有 `launcher_profiles.json`)。判据必须把「有个空目录」与「装了官方启动器」
/// 分开——否则用户会看到一条来路不明的实例(或者实例页整页报错)。
#[test]
fn an_empty_official_root_adds_no_instances() {
let (_root, env) = two_roots("ull-host-multiroot-empty");
write_version(&env.game, "A");
std::fs::create_dir_all(official(&env).join("cache")).expect("cache 目录");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 1, "空的官方根不该长出实例:{snapshot:?}");
assert_eq!(snapshot[0].version_id, "A");
assert_eq!(
game_for(&env, &snapshot[0].id).root(),
env.game.root(),
"唯一那条属于配置根"
);
}
// ------------------------------------------------------- 判据②:同一路径的两种写法只算一个根
/// 让两个路径指向同一个目录(junction / 符号链接)⇒ 列表只能出 **1 条**。
#[test]
fn a_junction_to_the_configured_root_is_not_a_second_root() {
let root = temp::Root::new("ull-host-multiroot-dedupe");
let mut env = HostEnv::at_root(&root.0);
write_version(&env.game, "A");
let link = root.0.join("linked");
if !link_dir(&link, env.game.root()) {
// 造不出链接就**不假绿**:这一条判据在造不出链接的盘上等于没验。
eprintln!("跳过:这个环境造不出目录链接({})", link.display());
return;
}
env.standard_game = Some(link);
assert_eq!(
super::roots::all(&env).len(),
1,
"联接与配置根是同一个物理目录,不该算两个根"
);
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
1,
"同一批实例列了两遍(去重没落到真实路径上):{snapshot:?}"
);
}
// -------------------------------------------------------- 判据③:存档按各自的根解析
/// 两个根各一个同名实例 + 各一个世界 ⇒ 每条实例的存档列表只来自**它自己那个根**。
#[test]
fn saves_come_from_the_instance_own_root() {
let (_root, env) = two_roots("ull-host-multiroot-saves");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
write_world(env.game.root(), "configured-world");
write_world(&official(&env), "official-world");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 2, "{snapshot:?}");
let mut seen: Vec<PathBuf> = Vec::new();
for tile in &snapshot {
let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录");
let view = super::super::saves::snapshot(tile, &dir, None).expect("列存档");
let worlds: Vec<&str> = view.worlds.iter().map(|world| world.dir.as_str()).collect();
if dir == env.game.root() {
assert_eq!(
worlds,
vec!["configured-world"],
"配置根那条读串了:{view:?}"
);
} else {
assert_eq!(dir, official(&env), "外部实例的运行目录应当是它自己的根");
assert_eq!(worlds, vec!["official-world"], "官方根那条读串了:{view:?}");
}
seen.push(dir);
}
assert_ne!(
seen[0], seen[1],
"两个根的实例派生出同一个运行目录(存档会互相覆盖)"
);
}
// ------------------------------------------- 判据⑤:懒创建的档案仍认自己那个根(不吞同名)
/// 用户第一次给官方根里的实例改设置 ⇒ 懒创建档案,但:
/// 1. id → 根的反查**不退化**(存档 / 模组 / 启动还读官方根);
/// 2. 配置根里那个同名版本**还在**(它是另一个实例,档案不该连它一起吞掉)。
#[test]
fn a_lazily_created_profile_stays_on_its_own_root() {
let (_root, env) = two_roots("ull-host-multiroot-lazy");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
// 官方根里那个版本带 `mods/`(「版本隔离」形态)⇒ 基底档是 isolated,换根之后不许变。
std::fs::create_dir_all(
GameLayout::new(official(&env))
.version_dir("A")
.join("mods"),
)
.expect("mods 目录");
let snapshot = instances(&env);
let tile = snapshot
.iter()
.find(|tile| tile.name.contains("official"))
.expect("官方根那条在列表里")
.clone();
let game = game_for(&env, &tile.id);
assert_eq!(game.root(), official(&env), "反查没落到官方根上");
assert_eq!(
isolation_of(&env, &game, &tile.id, "A"),
"isolated",
"官方根里 `versions/A/mods/` 是「自带数据」的痕迹 ⇒ 独立目录"
);
set_isolation(&env, &tile.id, "shared").expect("第一次改档位 = 懒创建档案");
assert_eq!(
game_for(&env, &tile.id).root(),
official(&env),
"懒创建之后反查退回了配置根(存档会读串)"
);
let after = instances(&env);
assert_eq!(
after.len(),
2,
"懒创建之后配置根那个同名版本被档案吞掉了:{after:?}"
);
assert_eq!(
after
.iter()
.filter(|tile| tile.id == tile_id(&snapshot, "official"))
.count(),
1,
"懒创建之后官方根那条被列了两遍(档案 + 合成视图)"
);
// 档案里的 version_id 只是版本名,**根只从 id 反查**——这条用例钉住的就是这一点。
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1);
assert_eq!(profiles.profiles[0].version_id, "A");
}
/// 列表里「名字带 `needle` 的那一条」的 id(懒创建前后的同一个实例)。
fn tile_id(snapshot: &[crate::dto::InstanceSummary], needle: &str) -> String {
snapshot
.iter()
.find(|tile| tile.name.contains(needle))
.expect("那一条在列表里")
.id
.clone()
}
// ------------------------------------------------ 判据⑥:启动装配按实例自己的根读清单
/// 清单**只在官方根里** ⇒ 装配仍要成功,且 `gameDir` 是官方根(共用目录档)。
#[test]
fn the_launch_reads_the_manifest_from_the_instance_root() {
let (_root, env) = two_roots("ull-host-multiroot-launch");
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
write_version(&GameLayout::new(official(&env)), "A");
let snapshot = instances(&env);
assert_eq!(snapshot.len(), 1, "只有官方根里有版本:{snapshot:?}");
let tile = &snapshot[0];
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: "A".to_string(),
account: None,
};
let (request, _warnings) =
super::super::launch::build_request(&env, &target).expect("清单在官方根里,装配应当成功");
assert_eq!(
request.dirs.game_dir,
official(&env),
"共用目录档的运行目录应当是**它自己那个根**(不是 ULL 便携根)"
);
assert_eq!(request.instance.id, tile.id);
}
@@ -1,332 +0,0 @@
//! M274 **r1 复审**补的判据(P1-1 / P1-2 / P2-3)。
//!
//! 上一轮(`tests_multiroot.rs`)钉的是「两个根里的同名版本是两条实例、各自的书架/存档/启动按自己
//! 那个根解析」。复审把「同一句话没走完」的两处揪了出来——**根身份有了,但两处「描述 / 前置要求」
//! 还在读配置根**——这一组就是那两处,加上「只读」那条升级成内容级快照。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`the_tile_describes_a_lazily_created_profile_from_its_own_root`] | 官方根实例被懒创建成档案之后,磁贴的 `version_present` / `isolation` / `mc_version` **逐项不变**(复审 P1-1 的三项同时错:显示与行为分叉) | 磁贴之外的显示(`element` 没有内省接口,见 `pages/instances/tests` 的口径) |
//! | [`pick_java_reads_the_requirement_from_the_instance_own_root`] | 启动挑 Java 时读的是**实例自己那个根**的 `javaVersion`(复审 P1-2:配置根读出来 8,实例是 21,实际选中 17) | 真的有 21 的 JDK 被选中(那要装 JDK;这里钉的是「要求」与回落,不是 `pick` 的最优选择) |
//! | [`nothing_is_written_into_the_official_root`] | 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根的**文件内容、大小、mtime** 一个都没变(复审 P2-3:原来只比条目名) | natives 解压(那发生在 `Launcher::spawn`,用例不拉进程);官方启动器同时在写的并发(TOCTOU) |
use std::path::{Path, PathBuf};
use unlucky_core::game::GameLayout;
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));
}
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
let root = temp::Root::new(tag);
let mut env = HostEnv::at_root(&root.0);
let official = root.0.join("official").join(".minecraft");
std::fs::create_dir_all(&official).expect("官方根目录");
env.standard_game = Some(official);
(root, env)
}
/// 官方根(=第二个候选根)的**字面**路径。
fn official(env: &HostEnv) -> PathBuf {
env.standard_game.clone().expect("用例里一定钉了官方根")
}
/// 官方根 + 配置根各自的布局。
fn official_layout(env: &HostEnv) -> GameLayout {
GameLayout::new(official(env))
}
fn write_manifest(game: &GameLayout, id: &str, json: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
// ---------------------------------------------------------------- 判据①(P1-1):磁贴按自己那个根描述
/// 官方根里的实例被懒创建成档案之后,磁贴的三格必须**逐项不变**。
///
/// 复审 P1-1 的探针(夹具几乎一样):`version_present` true→false、`isolation` isolated→shared、
/// `mc_version` `1.20.4`→`A`。三格同时错,而实际读盘(saves / mods / launch)走 `game_for` 是对的
/// ⇒ **显示与行为分叉**:用户以为存档在 ULL 便携根,实际在官方根。
///
/// 变异证明:把 `profile_summary` 的 `home.unwrap_or(&env.game)` 改回 `&env.game` 就红。
#[test]
fn the_tile_describes_a_lazily_created_profile_from_its_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-tile");
// 官方根:`A` 继承 `1.20.4`,且 `versions/A/mods/` 存在(版本隔离形态)。配置根里**没有** `A`。
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","type":"release"}"#,
);
std::fs::create_dir_all(official.version_dir("A").join("mods")).expect("mods 目录");
// `1.20.4` 本身也是个可登记的版本 ⇒ 列表里会有它;这里只盯 `A`。
let before = instances(&env);
let before = before
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里")
.clone();
assert_eq!(before.mc_version, "1.20.4", "继承链要走到父版本");
assert!(before.version_present, "清单在官方根里");
assert_eq!(
before.isolation, "isolated",
"`versions/A/mods/` 是自带数据的痕迹"
);
// 用户第一次改这个实例(写启动参数 = 懒创建档案,**不**写隔离档)——正是「官方实例」的常态路径。
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(&before.id, &patch)
.expect("官方根实例写启动参数不该报「找不到实例」");
let after = instances(&env);
let after = after
.iter()
.find(|tile| tile.id == before.id)
.expect("懒创建之后同一条实例还在");
assert_eq!(
instances(&env).len(),
2,
"懒创建之后不该多出第三条(`1.20.4` + `A` 就是全部)"
);
assert_eq!(
after.version_present, before.version_present,
"`version_present` 变了(磁贴会显示「文件缺失」):{before:?} → {after:?}"
);
assert_eq!(
after.isolation, before.isolation,
"`isolation` 变了(启动提示会说错运行目录):{before:?} → {after:?}"
);
assert_eq!(
after.mc_version, before.mc_version,
"`mc_version` 变了(继承链丢了):{before:?} → {after:?}"
);
assert_eq!(
after.launch.jvm_args, "-XX:+UseG1GC",
"写回的那一项要读得到"
);
}
// ---------------------------------------------------------------- 判据②(P1-2):挑 Java 按自己那个根
/// 两个根里各一个**同名**版本,`javaVersion` 要求不同 ⇒ 各自按自己那个根的要求回落。
///
/// 复审 P1-2 的探针:官方根里的自定义名版本声明 `javaVersion.majorVersion = 21`,配置根读不到清单
/// ⇒ 退回静态表 ⇒ `minimum = 8` ⇒ 实际选中 17,把要 21 的 modpack 拉起来。
///
/// 用「显式路径探测失败」这条确定性回落(`probe` 拿不到版本 ⇒ `major = required.minimum`),
/// 所以不需要机器上真的装着 JDK 8/21:断言的是**要求**与回落,不是 `pick` 的最优选择。
#[test]
fn pick_java_reads_the_requirement_from_the_instance_own_root() {
let (_root, env) = two_roots("ull-host-multiroot2-java");
let official = official_layout(&env);
write_manifest(
&env.game,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":8}}"#,
);
write_manifest(
&official,
"A",
r#"{"id":"A","javaVersion":{"majorVersion":21}}"#,
);
let tiles = instances(&env);
assert_eq!(tiles.len(), 2, "{tiles:?}");
// 探测必然失败(路径不存在)⇒ 落回「版本要求」。
let missing = Path::new("Z:/ull-no-such-java/bin/java.exe");
let mut majors: Vec<u32> = tiles
.iter()
.map(|tile| {
let game = game_for(&env, &tile.id);
super::super::launch::pick_java(&env, &game, "A", Some(missing))
.selection
.major
})
.collect();
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"两个根里的同名版本要求不同,挑 Java 必须各读各自的清单"
);
// 变异证明:拿配置根去读官方根那个实例 ⇒ 只会得到 8(复审实测这条会导致 17 去跑要 21 的包)。
let wrong = super::super::launch::pick_java(&env, &env.game, "A", Some(missing));
assert_eq!(
wrong.selection.major, 8,
"按配置根读会挑错——这条断言钉住「错在哪」"
);
// **走真启动装配**(`build_request` → `pick_java`):这条才抓得住「调用点传错根」。
// 每个实例在自己的设置里钉一个探测必然失败的 java 路径 ⇒ 生效的 major 就是它自己那个根的要求。
let mut majors: Vec<u32> = Vec::new();
for tile in &tiles {
crate::host::launch_args::apply(
&env,
&tile.id,
&tile.version_id,
&crate::dto::LaunchArgsPatch {
java_path: Some(missing.display().to_string()),
..crate::dto::LaunchArgsPatch::default()
},
)
.expect("钉 java 路径");
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let (request, _warnings) =
super::super::launch::build_request(&env, &target).expect("装配应当成功");
majors.push(request.java.major);
}
majors.sort_unstable();
assert_eq!(
majors,
vec![8, 21],
"启动装配读的必须是实例自己那个根的要求(调用点传 `env.game` 就会全是 8)"
);
}
// ---------------------------------------------------------------- 判据③(P2-3):只读发现升级成内容级
/// **内容级**快照:相对路径 + 字节数 + mtime(ms) + 内容哈希(fnv-1a)。
///
/// 为什么不是只比条目名(复审 P2-3):只比名字抓得住「多出新文件」,抓不住「已有文件被覆盖」
/// ——natives 的 dll 被改写、mtime 被刷新这两种都漏。这里四样一起比:内容改一个字节、大小变、
/// mtime 变都会红。
fn content_snapshot(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();
let Ok(relative) = path.strip_prefix(dir) else {
continue;
};
let name = relative.to_string_lossy().replace('\\', "/");
if path.is_dir() {
out.push(format!("{name}|dir"));
stack.push(path);
continue;
}
let meta = std::fs::metadata(&path).ok();
let bytes = meta.as_ref().map_or(0, std::fs::Metadata::len);
let mtime = meta
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0);
let content = std::fs::read(&path).unwrap_or_default();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in content {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
out.push(format!("{name}|{bytes}|{mtime}|{hash:016x}"));
}
}
out.sort();
out
}
/// 列实例 / 列存档 / 装配启动 / 懒创建档案之后,官方根里**一个字节都不许变**,也不许凭空建档案。
///
/// 升级点(复审 P2-3):原来只比条目名集合,现在比「相对路径 + 大小 + mtime + 内容哈希」,
/// 并且把「懒创建档案」这条写盘路径也带进来(那是本 mission 唯一会在用户动作后写盘的地方,
/// 而它必须写进 `.ULL`、不碰官方根)。
///
/// 抓不住:`Launcher::spawn` 解压 natives(用例不拉进程);官方启动器同时在写的并发(TOCTOU)。
#[test]
fn nothing_is_written_into_the_official_root() {
let (_root, env) = two_roots("ull-host-multiroot2-readonly");
let official = official_layout(&env);
write_manifest(&official, "1.20.4", r#"{"id":"1.20.4","type":"release"}"#);
write_manifest(
&official,
"A",
r#"{"id":"A","inheritsFrom":"1.20.4","mainClass":"net.minecraft.client.main.Main"}"#,
);
// 一个已经在的 natives 文件(正是「被覆写」这种错要抓的东西)。
let natives = official.version_dir("A").join("natives");
std::fs::create_dir_all(&natives).expect("natives 目录");
std::fs::write(natives.join("lwjgl.dll"), b"original-content").expect("natives 落盘");
let world = official.root().join("saves").join("world");
std::fs::create_dir_all(&world).expect("世界目录");
std::fs::write(world.join("level.dat"), b"fixture").expect("level.dat");
let before = content_snapshot(official.root());
// ① 列实例 ② 列存档(含截图目录扫) ③ 装配启动请求(natives 解压发生在 spawn,不在装配里)
let snapshot = instances(&env);
let tile = snapshot
.iter()
.find(|tile| tile.version_id == "A")
.expect("`A` 在列表里");
for tile in &snapshot {
if let Ok(dir) = super::super::saves::run_dir(&env, tile) {
let _ = super::super::saves::snapshot(tile, &dir, None);
}
let target = super::super::launch::LaunchTarget {
id: tile.id.clone(),
name: tile.name.clone(),
version_id: tile.version_id.clone(),
account: None,
};
let _ = super::super::launch::build_request(&env, &target);
}
// ④ 用户改一次设置 ⇒ 懒创建档案(写盘必须落 `.ULL`)
set_isolation(&env, &tile.id, "isolated").expect("懒创建档案");
let _ = instances(&env);
assert_eq!(
before,
content_snapshot(official.root()),
"官方根的条目 / 大小 / mtime / 内容有任何一格变了(发现与设置都只许读它)"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1, "档案落在 .ULL 里");
assert!(
env.layout.profiles_config().starts_with(env.layout.home()),
"档案路径必须在 .ULL 内:{}",
env.layout.profiles_config().display()
);
}
@@ -1,306 +0,0 @@
//! M274 **r2 复审 P1-1**:写设置那道门(`ensure_profile` → `external_version_of`)必须按
//! **(根, 版本)** 判「这条档案是否占住配置根的同名版本」,不许只比 `version_id`。
//!
//! 现场(复审探针,夹具就是 mission 判据⑤那份「两根各一个同名 `A`」):
//!
//! ```text
//! 官方那条 set_isolation => Ok(())
//! 配置根那条 set_isolation => Err("找不到实例 `ext-cf5883f9d893ad24`") ← 而磁贴还好好列着
//! ```
//!
//! 方向还是不对称的(先改配置根那条再改官方那条是通的)——同一台机器上「有时能改有时不能改」,
//! 最难排的那种。根因是列表侧(`instances()` 的 `bound`)按根判、写侧(`external_version_of`)
//! 只比 `version_id`;现在两处共用 [`super::discovered::binds_configured_root`]。
//!
//! | 用例 | 抓得住什么 | 抓不住什么 |
//! |---|---|---|
//! | [`both_same_named_instances_take_settings_in_either_order`] | 两个根的**同名**实例都改得动设置,两个方向都行,且写进去的值读得回来 | 图形界面点按钮那一路(`Element` 无内省;这里走的是同一条 host 函数) |
use std::path::PathBuf;
use unlucky_core::game::install::instance::{read_instance_config, Isolation};
use unlucky_core::game::GameLayout;
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));
}
}
}
/// 造两个根:配置根 = `<root>/.minecraft`,官方位置 = `<root>/official/.minecraft`。
fn two_roots(tag: &str) -> (temp::Root, HostEnv) {
let root = temp::Root::new(tag);
let mut env = HostEnv::at_root(&root.0);
let official = root.0.join("official").join(".minecraft");
std::fs::create_dir_all(&official).expect("官方根目录");
env.standard_game = Some(official);
(root, env)
}
fn official(env: &HostEnv) -> PathBuf {
env.standard_game.clone().expect("用例里一定钉了官方根")
}
fn write_version(game: &GameLayout, id: &str) {
let path = game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, format!(r#"{{"id":"{id}"}}"#)).expect("写版本 JSON");
}
/// 列表里满足 `hit` 的那一条的 id(两条磁贴都含 `A`,所以判据写在调用处)。
fn tile_id(env: &HostEnv, hit: impl Fn(&crate::dto::InstanceSummary) -> bool) -> String {
instances(env)
.into_iter()
.find(|tile| hit(tile))
.unwrap_or_else(|| panic!("列表里没有那一条:{:?}", instances(env)))
.id
}
/// 两个根的**同名**实例都改得动设置:正序、反序各跑一遍,每条都断言「写进去读得回来」。
///
/// 变异证明(复审给的形状):把 `external_version_of` 的 `bound` 改回
/// 「只比 `version_id`」⇒ 正序那条(先官方、后配置根)立刻红,报
/// `找不到实例 ext-…`,而两个方向的第二条都还是绿——这正是它被抓到的现场形状。
#[test]
fn both_same_named_instances_take_settings_in_either_order() {
for official_first in [true, false] {
let (_root, env) = two_roots("ull-host-multiroot3-order");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
// 官方根那条的名字带根路径;配置根那条保持版本目录名(见 `discovered::display_name`)。
let official_id = tile_id(&env, |tile| tile.name.contains("official"));
let configured_id = tile_id(&env, |tile| tile.name == "A");
assert_ne!(official_id, configured_id, "两个根的同名版本必须是两条实例");
let order: [(&str, &str); 2] = if official_first {
[(&official_id, "official"), (&configured_id, "configured")]
} else {
[(&configured_id, "configured"), (&official_id, "official")]
};
for (index, (id, which)) in order.iter().enumerate() {
// 两条生产入口铺开用:一条走 `set_isolation`,另一条走 `apply_launch_args`。
if index == 0 {
set_isolation(&env, id, "isolated").expect("改隔离档不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.isolation,
Some(Isolation::Isolated),
"{which} 那条写进去的隔离档读不回来"
);
} else {
let patch = crate::dto::LaunchArgsPatch {
jvm_args: Some("-XX:+UseG1GC".to_string()),
..crate::dto::LaunchArgsPatch::default()
};
let mut host = super::super::CoreHost::headless(env.clone());
host.apply_launch_args(id, &patch)
.expect("写启动参数不该报「找不到实例」");
let config = read_instance_config(&env.layout, id)
.expect("读实例设置")
.expect("实例设置文件应当已写盘");
assert_eq!(
config.launch.jvm_args.as_deref(),
Some("-XX:+UseG1GC"),
"{which} 那条写进去的启动参数读不回来"
);
}
// 写完一次之后,另一条**还在列表里**、而且还能继续改(不许被吞掉)。
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
2,
"写了 {which} 那条之后列表变了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == official_id),
"官方根那条消失了:{snapshot:?}"
);
assert!(
snapshot.iter().any(|tile| tile.id == configured_id),
"配置根那条消失了:{snapshot:?}"
);
}
// 两条都写过之后:id → 根的反查不许漂(写盘与读盘同一份身份)。
assert_eq!(
game_for(&env, &official_id).root(),
official(&env),
"官方根那条写完设置之后反查退回了配置根"
);
assert_eq!(
game_for(&env, &configured_id).root(),
env.game.root(),
"配置根那条反查错了根"
);
let profiles = read_profiles(&env.layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少");
}
}
// ------------------------------------------------- 反查回落(M274 r3 复审 P2-1 / ②)
/// M273 时代的 `ext-` id:**只哈希版本目录名**(不含根)。M274 之后这种 id 永远反查不到。
fn m273_external_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}")
}
/// 在**我们自己的日志目录**里数这个实例的回落行(重试到「登记窗口没被并行用例抢走」为止)。
///
/// `launcher_log` 的目录是**进程级全局**(`set_dir` 以最后一次为准),而 presenter 的用例并行跑、
/// 七十多处 `HostEnv::at_root` 都会重新登记。所以这里每次尝试换一个**新目录**:抢到了就能在
/// 一个全新文件里看到**恰好一行**;没抢到(文件不存在)就再试。抢不到 64 次视为用例失败(不当跳过)。
fn fallback_lines(env: &HostEnv, id: &str) -> Vec<String> {
let file = unlucky_core::launch::launcher_log::FILE_NAME;
let mut saw_file = false;
for attempt in 0..64 {
let dir = std::env::temp_dir().join(format!(
"ull-host-multiroot3-log-{}-{attempt}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
unlucky_core::launch::launcher_log::set_dir(&dir);
let _ = game_for(env, id);
let Ok(text) = std::fs::read_to_string(dir.join(file)) else {
// 文件没建出来:这一拍的登记窗口被并行用例抢走了(它们也会 `set_dir`),再试。
continue;
};
let lines: Vec<String> = text
.lines()
.filter(|line| line.contains(id))
.map(str::to_string)
.collect();
std::fs::remove_dir_all(&dir).ok();
if !lines.is_empty() {
return lines;
}
// 文件在、却没有我们的行:可能是别人的行占了这个文件,也可能这条路径**根本没写**。
// 再试几次,全都不中就当「0 行」交给断言去红(比在这里 panic 好读)。
saw_file = true;
}
if saw_file {
return Vec::new();
}
panic!("64 次都没看到日志文件:回落日志没写,或登记窗口每次都被并行用例抢走");
}
/// **老 `ext-` 档案**(M273 形状)反查不到 ⇒ 落配置根,并且日志里**恰好一行** `[multiroot]`,
/// 文案**两因并列**(老档案 ‖ 此刻不可登记)。
///
/// 这条路径此前零覆盖(M274 r3 复审指出):它是所有 M273 老用户升级后的**常态**,不是故障;
/// 文案只写「清单半写/被锁/目录已不在」会让每次点开详情页都往日志里加一条假警报。
///
/// 变异证明:把文案只留「此刻不可登记」那半句 ⇒ 下面的关键词断言红;把回落日志整段删掉 ⇒
/// 「恰好一行」红(0 行)。
#[test]
fn an_old_m273_instance_falls_back_with_one_two_cause_log_line() {
let (_root, env) = two_roots("ull-host-multiroot3-fallback");
write_version(&env.game, "A");
let old_id = m273_external_id("A");
assert!(
crate::dto::is_discovered_instance(&old_id),
"老 id 也带 `ext-` 前缀(这正是「永远反查不到」的来源)"
);
assert_eq!(
game_for(&env, &old_id).root(),
env.game.root(),
"老档案反查不到 ⇒ 只能按配置根解析"
);
let lines = fallback_lines(&env, &old_id);
assert_eq!(
lines.len(),
1,
"一次回落只该写一行(多了会把日志刷成噪声):{lines:?}"
);
let line = &lines[0];
assert!(line.contains("[multiroot]"), "分类名要可 grep:{line}");
assert!(
line.contains("老档案"),
"M273 老 id 是**常态**,文案必须并列这一因,不能只说「清单半写/被锁」:{line}"
);
assert!(
line.contains("不可登记"),
"另一因(官方启动器正在更新)也要在:{line}"
);
}
/// **已知边界**(不是设计意图):官方根的版本目录消失后,那条死档案会「吞掉」配置根的同名版本。
///
/// 现场(M274 r3 复审 ②):两根各一个 `A`,官方那条先懒创建;官方根的 `versions/` 随后不见
/// (官方启动器升级 / 清理 / 卸载 / 换盘)。此时:
/// - 列表只剩 **1 条**,名字还写着 `A · …\official\.minecraft`(指向官方根);
/// - 而 `version_present` / 读盘路径却全从**配置根**读出来(`game_for` 回落);
/// - ⇒ 真正的配置根那条 `A` **从列表消失**(被 `bound` 判成「已被档案占住」)。
///
/// 这是 r1 判 P1 的那种「显示与行为分叉」的反向形态。**这轮不改结构**:真正的修法是给
/// `InstanceProfile` 加「根」字段(`profiles.json` schema + 迁移),代价超出本 mission。
/// 所以这条用例钉的是**现状**——将来做 schema 迁移时它会红,届时要跟着改成「两条都在」。
#[test]
fn a_dead_official_root_is_swallowed_by_its_stale_profile() {
let (_root, env) = two_roots("ull-host-multiroot3-deadroot");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
let official_id = tile_id(&env, |tile| tile.name.contains("official"));
set_isolation(&env, &official_id, "shared").expect("官方那条先懒创建档案");
// 官方启动器把那个版本清理掉了(目录整个不见)。
std::fs::remove_dir_all(GameLayout::new(official(&env)).versions_dir())
.expect("删官方 versions");
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
1,
"现状:只有档案那一条(配置根的同名版本被 bound 吞掉):{snapshot:?}"
);
let tile = &snapshot[0];
assert_eq!(tile.id, official_id);
assert!(
tile.name.contains("official"),
"名字还指着**已经不存在的**官方根:{tile:?}"
);
assert!(
tile.version_present && game_for(&env, &tile.id).root() == env.game.root(),
"而读盘/显示已经悄悄落回配置根(显示与行为分叉的反向形态):{tile:?}"
);
let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录");
assert_eq!(
dir,
env.game.root(),
"存档也落回配置根——这就是「死档案吞掉同名版本」的后果"
);
}
@@ -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, &env.game, &id, "A"),
"shared",
"`versions/A/` 里没有 mods / saves / config / options.txt ⇒ 基底档是 shared(按共用目录读)"
);
set_isolation(&env, &id, "isolated").expect("第一次改档位 = 懒创建档案");
assert_eq!(
isolation_of(&env, &env.game, &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 -10
View File
@@ -19,18 +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| {
// 版本清单在**它自己那个根**下(M274):官方根里的版本,配置根里读不到要求。
let game = super::game::game_for(env, &instance.id);
java::requirement_for_version(&game, &instance.version_id)
.map(|profile| {
java::requirement_for_version(&env.game, &profile.version_id)
.minimum
.max(8)
})
+19 -33
View File
@@ -63,10 +63,7 @@ pub fn build_request(
target: &LaunchTarget,
) -> Result<(LaunchRequest, Vec<String>), LaunchFailureView> {
let version_id = target.version_id.clone();
// **这个实例自己的根**(M274):官方根 / 另一个 `.minecraft` 里的版本,清单、库、natives、
// assets 全在它自己那个根下。拿 `env.game` 去读会读空(甚至读到同名的另一个版本)。
let game = super::game::game_for(env, &target.id);
let manifest_path = game.version_json(&version_id);
let manifest_path = env.game.version_json(&version_id);
let text = std::fs::read_to_string(&manifest_path).map_err(|error| {
LaunchFailureView::new(
LaunchFailureStage::VersionManifest,
@@ -80,7 +77,7 @@ pub fn build_request(
)
})?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game.version_json(id))
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
@@ -124,22 +121,22 @@ pub fn build_request(
}
};
let path = if library.is_local() {
game.version_library(&version_id, &coordinate)
env.game.version_library(&version_id, &coordinate)
} else {
game.library(&coordinate)
env.game.library(&coordinate)
};
libraries.push(LibraryInput::new(path, name));
}
let natives = unlucky_core::launch::natives::collect(
&game,
&env.game,
&version_id,
resolved.libraries.as_deref().unwrap_or_default(),
&environment,
);
let game_dir = run_dir(env, &game, target)?;
let game_dir = run_dir(env, target)?;
let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone());
let assets_root = game.assets_dir();
let assets_root = env.game.assets_dir();
let assets_index = resolved
.assets
.clone()
@@ -148,7 +145,7 @@ pub fn build_request(
// `26w14a-fabric-0.16.5` 这种 id 在 `GameVersion::is_weekly_snapshot`(6 字符形态)上判不出来,
// 周快照的 `--graphicsBackend` 会在拼装链里被静默跳过——而详情页的门槛
// (`host::launch_args::backend_gate`)与 CLI 用的都是根版本,两边必须同一口径。
let (mc_version, _) = super::game::describe_version(&game, &version_id);
let (mc_version, _) = super::game::describe_version(env, &version_id);
let snapshot = SnapshotInput {
version_id: &version_id,
version_name: Some(&target.name),
@@ -160,18 +157,17 @@ pub fn build_request(
legacy_arguments: resolved.minecraft_arguments.as_deref(),
libraries: &libraries,
natives: &natives,
primary_jar: game.version_jar(&jar_id),
primary_jar: env.game.version_jar(&jar_id),
game_dir,
assets_index: assets_index.as_deref(),
natives_dir: game.natives_dir(&version_id),
library_dir: game.libraries_dir(),
natives_dir: env.game.natives_dir(&version_id),
library_dir: env.game.libraries_dir(),
assets_root: assets_root.clone(),
game_assets: assets_root,
};
let overrides = super::launch_args::resolved(env, &target.id);
// 挑 Java 的**版本要求**也按实例自己的根读(M274 r1 复审 P1-2)。
let java = pick_java(env, &game, &version_id, overrides.java_path.as_deref());
let java = pick_java(env, &version_id, overrides.java_path.as_deref());
let (auth, identity_line) = super::identity::resolve(env, target.account.as_deref())?;
let options = AdapterOptions {
java: java.selection,
@@ -197,26 +193,19 @@ pub fn build_request(
Ok((request, warnings))
}
/// 派生运行目录(D20 覆盖链:实例 `isolation` > **基底档**,见 [`super::game::base_mode`])。
///
/// 基底档对自动发现的实例是「盘上有没有痕迹」,对其它实例就是全局 `instances.mode`——
/// 于是这里不需要知道实例是从哪来的,只有一处判据。
fn run_dir(
env: &HostEnv,
game: &unlucky_core::game::GameLayout,
target: &LaunchTarget,
) -> Result<PathBuf, LaunchFailureView> {
let base = super::game::base_mode(env, game, &target.id, &target.version_id);
/// 派生运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
fn run_dir(env: &HostEnv, target: &LaunchTarget) -> Result<PathBuf, LaunchFailureView> {
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(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())
})?;
if let Some(mut config) = config {
let at = format_rfc3339_millis(now());
if observe_mode(&mut config, game, &at, derivation.isolation).unwrap_or(false) {
if observe_mode(&mut config, &env.game, &at, derivation.isolation).unwrap_or(false) {
let _ =
unlucky_core::game::install::instance::write_instance_config(&env.layout, &config);
}
@@ -235,14 +224,11 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
let logs_dir = env.layout.logs();
launcher_log::set_dir(&logs_dir);
let started_at = std::time::SystemTime::now();
// 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了,
// 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。
let game = super::game::game_for(&env, &target.id);
let mut report = ReportContext::new(
&target.id,
&target.name,
&target.version_id,
game.root(),
&env.data.game_home,
&logs_dir,
started_at,
unlucky_core::platform::os_info::current().summary_line(),
@@ -272,7 +258,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
}
};
report.run_dir = request.dirs.game_dir.clone();
report.version_json = Some(game.version_json(&target.version_id));
report.version_json = Some(env.game.version_json(&target.version_id));
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
for warning in &warnings {
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
+2 -13
View File
@@ -6,7 +6,6 @@
use std::path::PathBuf;
use unlucky_core::game::GameLayout;
use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv;
@@ -22,21 +21,11 @@ pub struct JavaPick {
/// 选一个能用的 Java:显式路径(实例 → 全局 `java.selected_path`)> 托管 > JAVA_HOME > PATH。
///
/// `game` 是这个实例**自己那个根**的布局(M274):版本清单里的 `javaVersion` 只在那个根下,
/// 拿配置根读不到 ⇒ 退回「按 id 的静态表」,而第三方启动器的自定义名版本(`樱落怡然客户端`)
/// `version_parts` 为空 ⇒ `minimum = 8`,一个要 21 的 modpack 会被拉去用 17 跑
/// (M274 r1 复审 P1-2 的探针:实例自己根 minimum=21,配置根读出来 8,实际选中 17)。
///
/// 显式路径**先探测**(`java -version`):探测失败不报错,按版本要求的基准主版本兜底——
/// 与自动挑的退路同一口径(启动路径不该因为探测不到就拒绝启动)。
pub fn pick_java(
env: &HostEnv,
game: &GameLayout,
version_id: &str,
explicit: Option<&std::path::Path>,
) -> JavaPick {
pub fn pick_java(env: &HostEnv, version_id: &str, explicit: Option<&std::path::Path>) -> JavaPick {
let store = env.store();
let required = unlucky_core::game::java::requirement_for_version(game, version_id);
let required = unlucky_core::game::java::requirement_for_version(&env.game, version_id);
if let Some(path) = explicit {
let runtime = unlucky_core::game::java::probe::probe(
path,
+21 -23
View File
@@ -18,7 +18,7 @@ use unlucky_core::game::launch_overrides::{
resolve as resolve_overrides, GlobalLaunchDefaults, GraphicsBackend, LaunchOverrides,
LaunchOverridesPatch, ResolvedLaunch,
};
use unlucky_core::game::{self, GameLayout, VersionCapabilities, VersionJson};
use unlucky_core::game::{self, VersionCapabilities, VersionJson};
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
use unlucky_core::launch::game_version::GameVersion;
@@ -73,13 +73,11 @@ pub fn resolved(env: &HostEnv, instance_id: &str) -> ResolvedLaunch {
}
/// 版本能力位(解析继承链之后取库表;读不到版本 JSON = 什么能力都没有)。
///
/// **按实例自己那个根读**(M274):官方根里的版本清单不在配置根下。
fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option<VersionJson> {
let text = std::fs::read_to_string(game_layout.version_json(version_id)).ok()?;
fn capabilities(env: &HostEnv, version_id: &str) -> Option<VersionJson> {
let text = std::fs::read_to_string(env.game.version_json(version_id)).ok()?;
let manifest = VersionJson::from_json_str(&text).ok()?;
let source = |id: &str| -> Option<VersionJson> {
std::fs::read_to_string(game_layout.version_json(id))
std::fs::read_to_string(env.game.version_json(id))
.ok()
.and_then(|text| VersionJson::from_json_str(&text).ok())
};
@@ -90,9 +88,9 @@ fn capabilities(game_layout: &GameLayout, version_id: &str) -> Option<VersionJso
///
/// 判据是 core 的两件套(库表能力位 + 版本门槛),**UI 不复制**:
/// `graphics_backend_allowed(GameVersion, has_vulkan_backend)`。
pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String) {
let mc_version = super::game::describe_version(game_layout, version_id).0;
let has_vulkan = capabilities(game_layout, version_id)
pub fn backend_gate(env: &HostEnv, version_id: &str) -> (bool, String) {
let mc_version = super::game::describe_version(env, version_id).0;
let has_vulkan = capabilities(env, version_id)
.map(|json| VersionCapabilities::from_json(&json).has_vulkan_backend)
.unwrap_or(false);
let allowed = graphics_backend_allowed(&GameVersion::parse(&mc_version), has_vulkan);
@@ -100,15 +98,10 @@ pub fn backend_gate(game_layout: &GameLayout, version_id: &str) -> (bool, String
}
/// 详情页「启动参数」区的读取视图。
pub fn view(
env: &HostEnv,
game_layout: &GameLayout,
instance_id: &str,
version_id: &str,
) -> LaunchArgsView {
pub fn view(env: &HostEnv, instance_id: &str, version_id: &str) -> LaunchArgsView {
let instance = instance_overrides(env, instance_id);
let effective = resolved(env, instance_id);
let (allowed, mc_version) = backend_gate(game_layout, version_id);
let (allowed, mc_version) = backend_gate(env, version_id);
LaunchArgsView {
instance_id: instance_id.to_string(),
jvm_args: instance.jvm_args.unwrap_or_default(),
@@ -179,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)
}
}
@@ -247,18 +245,18 @@ mod tests {
fn gate_uses_core_capability_and_version_threshold() {
let (env, _dir) = env();
write_version(&env, "26.2-snapshot-2", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env.game, "26.2-snapshot-2");
let (allowed, mc) = backend_gate(&env, "26.2-snapshot-2");
assert!(allowed, "26.2-snapshot-2 应当放行");
assert_eq!(mc, "26.2-snapshot-2");
// 26.1:版本不够(即使库表里硬塞了 vulkan 也不放行——精确门限优先)。
write_version(&env, "26.1", VULKAN_VERSION);
let (allowed, mc) = backend_gate(&env.game, "26.1");
let (allowed, mc) = backend_gate(&env, "26.1");
assert!(!allowed, "26.1 不该放行");
assert_eq!(mc, "26.1");
// 没有版本 JSON:能力位全假 ⇒ 不放行(不 panic)。
let (allowed, _) = backend_gate(&env.game, "1.20.4");
let (allowed, _) = backend_gate(&env, "1.20.4");
assert!(!allowed);
}
@@ -343,7 +341,7 @@ mod tests {
&LaunchArgsPatch::backend("vulkan"),
)
.expect("写回");
let view = view(&env, &env.game, "inst-1", "26.2-snapshot-2");
let view = view(&env, "inst-1", "26.2-snapshot-2");
assert_eq!(view.graphics_backend, "vulkan");
assert_eq!(view.graphics_backend_effective, "vulkan");
assert!(view.graphics_backend_allowed);
+11 -8
View File
@@ -5,7 +5,7 @@
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
//!
//! ## 为什么不复用启动路径的 `run_dir()`
@@ -184,16 +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> {
// **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录;
// 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。
let game = super::game::game_for(env, &instance.id);
let base = super::game::base_mode(env, &game, &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(&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)
}
+11 -8
View File
@@ -5,7 +5,7 @@
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
@@ -240,16 +240,19 @@ pub(super) fn target(
Ok((instance.clone(), dir))
}
/// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
// **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录;
// 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。
let game = super::game::game_for(env, &instance.id);
let base = super::game::base_mode(env, &game, &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(&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)
}