feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)
用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。
core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
(图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
platform 而不是业务模块,并要一条可执行判据防退化
(`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
`"explorer"` / `"xdg-open"` 字面量)。
presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
(结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
`isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。
app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。
i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。
证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
(相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
(1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。
没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
1 parent
3789cb7e61
commit
f217d83680
36 files changed
+2702
-17
No files matched your search
@@ -71,6 +71,8 @@ mod boot_probe;
|
||||
mod construct;
|
||||
mod mods_shot;
|
||||
mod refresh;
|
||||
// M235: 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)
|
||||
mod saves_shot;
|
||||
mod theme;
|
||||
|
||||
/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
|
||||
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand};
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
|
||||
|
||||
use super::mods_shot;
|
||||
use super::{mods_shot, saves_shot};
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
@@ -64,6 +64,8 @@ impl App {
|
||||
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
|
||||
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
|
||||
self.sync_mods();
|
||||
// M235:详情页 ⇄ SAVES 区的绑定与取数(换实例就重新列一次存档)。同样是**建模型之前**。
|
||||
self.sync_saves();
|
||||
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
|
||||
self.note_mods_failure();
|
||||
|
||||
@@ -155,6 +157,37 @@ impl App {
|
||||
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 详情页 ⇄ SAVES 区的绑定与取数(M235)。
|
||||
///
|
||||
/// 判据与 `sync_mods` **一字不差**:绑定变了(进详情页 / 换实例 / 退出详情页)才动;
|
||||
/// 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Saves(List)` 去后台列目录。
|
||||
///
|
||||
/// 不弹 Toast:存档区的失败就地画在卡片里(带「重试」),再弹一条是同一件事说两遍。
|
||||
fn sync_saves(&mut self) {
|
||||
let wanted = match self.state.route() {
|
||||
Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if self.state.saves.bound == wanted {
|
||||
return;
|
||||
}
|
||||
self.state.bind_saves(wanted.as_deref());
|
||||
let Some(id) = wanted else {
|
||||
return;
|
||||
};
|
||||
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
|
||||
if saves_shot::enabled() {
|
||||
self.state.saves.changed(saves_shot::view(&id));
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
|
||||
// 这里只是"进详情页顺手后台列一次目录",结果在下一拍心跳落地就够了(M224 同款理由:
|
||||
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红)。
|
||||
self.backend
|
||||
.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
|
||||
///
|
||||
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 出图取证用的 SAVES 夹具(`ULL_SAVES_FIXTURE=1`)。
|
||||
//!
|
||||
//! # 它解决什么
|
||||
//!
|
||||
//! D30 的 SAVES 区截图需要一屏**有内容的列表**(世界 + 截图)。产品路径的列表来自后台线程
|
||||
//! (列 `saves/` → 快照),而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及
|
||||
//! 完成,截出来会是「正在读取 saves/…」。M157 / M222 / M223 / M224 都是同一个问题,处理方式一致:
|
||||
//! **状态注入**,注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
|
||||
//!
|
||||
//! # 边界(不要把它读成产品能力)
|
||||
//!
|
||||
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
|
||||
//! - 它注入的是**快照**([`SavesListView`]),不是页面模型——`Saves::from_state` 的时间格式化、
|
||||
//! 计数、「有图标」标记走的都是产品代码;
|
||||
//! - 真实链路由端到端用例钉住:`core::game::saves::tests`(真盘只读列举)与
|
||||
//! `presenter::host::saves::tests`(派生运行目录 → 快照)。
|
||||
//!
|
||||
//! ```text
|
||||
//! ULL_SAVES_FIXTURE=1 ULL_INSTANCE_STATE=demo ULL_START_ROUTE=instances \
|
||||
//! unlucky-launcher-iced --screenshot=saves.png --software-renderer
|
||||
//! ```
|
||||
|
||||
/// 夹具开关是否打开(`ULL_SAVES_FIXTURE` 非空且不为 `0`)。
|
||||
#[must_use]
|
||||
pub fn enabled() -> bool {
|
||||
std::env::var("ULL_SAVES_FIXTURE")
|
||||
.map(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 2024-05-01 10:00 UTC(写死的 UTC 毫秒,不是"现在")。
|
||||
///
|
||||
/// 写死有两个原因:两张主题图要能逐像素对照;而且它能证明「毫秒 → `YYYY-MM-DD HH:MM UTC`」
|
||||
/// 这条换算在页面上真的走通了(`docs` 里的期望值就是这个时刻)。
|
||||
const NEWEST: u64 = 1_714_557_600_000;
|
||||
/// 比 [`NEWEST`] 早 32 天。
|
||||
const MIDDLE: u64 = NEWEST - 32 * 24 * 3_600_000;
|
||||
/// 比 [`NEWEST`] 早 42 天。
|
||||
const OLDEST: u64 = NEWEST - 42 * 24 * 3_600_000;
|
||||
|
||||
/// 一份固定快照(实例 id 用调用方给的那个)。
|
||||
///
|
||||
/// 其中一个世界/一张截图的 mtime 是 `None`:那是「时间未知」这条分支的唯一出图证据。
|
||||
#[must_use]
|
||||
pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
|
||||
use unlucky_presenter::dto::{SavesListView, ScreenshotView, WorldView};
|
||||
|
||||
let world = |dir: &str, icon: bool, modified_ms: Option<u64>| WorldView {
|
||||
dir: dir.to_string(),
|
||||
icon,
|
||||
modified_ms,
|
||||
};
|
||||
let shot = |file: &str, bytes: u64, modified_ms: Option<u64>| ScreenshotView {
|
||||
file: file.to_string(),
|
||||
bytes,
|
||||
modified_ms,
|
||||
};
|
||||
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "D:/ULL/.ULL/data/instances/Better-MC/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![
|
||||
world("Better World", true, Some(NEWEST)),
|
||||
world("Redstone Lab", true, Some(MIDDLE)),
|
||||
world("Skyblock", false, Some(OLDEST)),
|
||||
world("Test Flat", false, None),
|
||||
],
|
||||
screenshots_dir: "D:/ULL/.ULL/data/instances/Better-MC/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![
|
||||
shot("2024-05-01_10.00.00.png", 1_887_436, Some(NEWEST)),
|
||||
shot("2024-04-30_21.14.05.png", 2_097_152, Some(MIDDLE)),
|
||||
shot("2024-04-20_08.30.00.png", 964_608, None),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -40,6 +40,22 @@ impl App {
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// M235:重新列一次存档(失败后的「重试」也走这条)。
|
||||
//
|
||||
// 与 `SetModEnabled` 一样放在 `bound_to` 守卫**之前**:存档区只认"当前选中实例",
|
||||
// 与启动参数表单播没播种无关(那条守卫是给表单草稿用的)。
|
||||
InstanceMessage::ReloadSaves => {
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::List(instance))));
|
||||
return Task::none();
|
||||
}
|
||||
// M235:打开存档文件夹——拉起哪个目录由 host 定(`saves/` → `screenshots/` → 运行目录),
|
||||
// 真正的 opener 在 `core::platform::opener`(平台差异不散到 app 这一层)。
|
||||
InstanceMessage::OpenSavesFolder => {
|
||||
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::OpenFolder {
|
||||
id: instance,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
|
||||
@@ -257,6 +257,7 @@ pub(super) fn model(
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
|
||||
mods: instances::mods_of(state, catalog),
|
||||
saves: instances::saves_of(state, catalog),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||
|
||||
@@ -25,7 +25,7 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods};
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods, Saves};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -273,3 +273,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
|
||||
crate::pages::mods_from_state(&state.mods, catalog)
|
||||
}
|
||||
|
||||
/// 存档 / 截图的展示模型(M235):presenter 的视图态 → 页面要画的东西。
|
||||
///
|
||||
/// 与 [`mods_of`] 同一条接法:转换本体在 `pages::instances::saves::from_state`(文案要用
|
||||
/// `Texts`),这里只把"当前语言"给过去。
|
||||
pub(super) fn saves_of(state: &AppState, catalog: Catalog) -> Saves {
|
||||
crate::pages::saves_from_state(&state.saves, catalog)
|
||||
}
|
||||
Reference in new issue
Block a user