Merge branch 'feat/instance-saves-read-only-list-mvp-storem'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
a9d1cb4c44
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)
|
||||
}
|
||||
@@ -51,6 +51,8 @@ mod detail;
|
||||
mod launch_args;
|
||||
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
|
||||
mod mods;
|
||||
// M235: 详情页的 SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
mod saves;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
@@ -61,6 +63,17 @@ mod tests;
|
||||
pub(crate) use data::{DEMO, NOTE, SELECTED};
|
||||
pub(crate) use demo::{demo, demo_form};
|
||||
pub use mods::{from_state as mods_from_state, Mods};
|
||||
pub use saves::{from_state as saves_from_state, Saves};
|
||||
|
||||
/// 存档区失败的一句话(app 层就地画红字用;取词入口仍是 [`Texts`])。
|
||||
///
|
||||
/// 本版**不弹 Toast**:只读列表的失败离用户的眼睛很近(就在卡片里),再弹一条是刷屏。
|
||||
pub fn saves_failure_line(
|
||||
failure: &unlucky_presenter::dto::SavesFailureView,
|
||||
catalog: unlucky_i18n::Catalog,
|
||||
) -> String {
|
||||
saves::failure_line(failure, Texts::of(catalog))
|
||||
}
|
||||
|
||||
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
|
||||
pub fn mods_failure_line(
|
||||
@@ -142,16 +155,30 @@ pub fn view<'a>(
|
||||
metrics,
|
||||
list_column(ui, block, forced, texts),
|
||||
match &block.detail {
|
||||
Some(detail) => {
|
||||
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
Some(detail) => detail::detail_column(
|
||||
ui,
|
||||
metrics,
|
||||
detail,
|
||||
form,
|
||||
&model.mods,
|
||||
&model.saves,
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => detail::detail_empty(ui, texts),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
Some(detail) => {
|
||||
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts)
|
||||
}
|
||||
Some(detail) => detail::narrow_detail(
|
||||
ui,
|
||||
metrics,
|
||||
detail,
|
||||
form,
|
||||
&model.mods,
|
||||
&model.saves,
|
||||
forced,
|
||||
texts,
|
||||
),
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ pub(super) fn detail_column<'a>(
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
saves: &'a super::Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -51,6 +52,9 @@ pub(super) fn detail_column<'a>(
|
||||
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
|
||||
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
// M235:SAVES 区紧跟 MODS 区(同为"内容"),理由与 MODS 一样:排在启动参数卡后面会落到
|
||||
// 折叠线以下——详情列是一个滚动区,而启动参数卡本身就有一屏高。
|
||||
body = body.push(super::saves::section(ui, saves, forced, texts));
|
||||
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
@@ -69,6 +73,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
detail: &'a Detail,
|
||||
form: &'a LaunchForm,
|
||||
mods: &'a super::Mods,
|
||||
saves: &'a super::Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -93,6 +98,7 @@ pub(super) fn narrow_detail<'a>(
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
body = body.push(super::saves::section(ui, saves, forced, texts));
|
||||
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
|
||||
|
||||
body = body.push(frame::text_role(
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
|
||||
//!
|
||||
//! # 位置与形状
|
||||
//!
|
||||
//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。
|
||||
//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
|
||||
//!
|
||||
//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区,
|
||||
//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是:
|
||||
//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。
|
||||
//!
|
||||
//! # 为什么不自己再套一层滚动区
|
||||
//!
|
||||
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
|
||||
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
|
||||
//!
|
||||
//! # 只读(U3 的边界,页面固定画出来)
|
||||
//!
|
||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
|
||||
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
|
||||
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
|
||||
//!
|
||||
//! # 失败与重试
|
||||
//!
|
||||
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
|
||||
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
|
||||
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::Catalog;
|
||||
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
use super::forced_state;
|
||||
use super::state::Message as PageMessage;
|
||||
use super::Texts;
|
||||
|
||||
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// 世界目录名或截图文件名(磁盘原样)。
|
||||
pub name: String,
|
||||
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// SAVES 区的展示模型(`Model::saves`)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Saves {
|
||||
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
|
||||
pub bound: Option<String>,
|
||||
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
|
||||
pub loading: bool,
|
||||
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
|
||||
pub error: Option<String>,
|
||||
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
|
||||
pub retryable: bool,
|
||||
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
|
||||
pub has_view: bool,
|
||||
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
||||
pub missing: bool,
|
||||
/// 世界行。
|
||||
pub worlds: Vec<Row>,
|
||||
/// 截图行(新的在前,顺序由 core 给)。
|
||||
pub screenshots: Vec<Row>,
|
||||
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
|
||||
///
|
||||
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
|
||||
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`]
|
||||
/// 的 `count` 同一条做法。
|
||||
pub count: String,
|
||||
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
|
||||
pub skipped: usize,
|
||||
/// 跳过提示(`跳过 3 项`)。
|
||||
pub skipped_note: String,
|
||||
}
|
||||
|
||||
impl Saves {
|
||||
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
|
||||
#[must_use]
|
||||
pub fn loaded(&self) -> bool {
|
||||
self.bound.is_some() && !self.loading
|
||||
}
|
||||
|
||||
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
|
||||
#[must_use]
|
||||
pub fn nothing_yet(&self) -> bool {
|
||||
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
|
||||
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
|
||||
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
|
||||
#[must_use]
|
||||
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
|
||||
let texts = Texts::of(catalog);
|
||||
let mut out = Saves {
|
||||
bound: state.bound.clone(),
|
||||
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
|
||||
..Saves::default()
|
||||
};
|
||||
if let Some(view) = &state.view {
|
||||
out.has_view = true;
|
||||
out.missing = !view.exists && !view.screenshots_exists;
|
||||
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
|
||||
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
|
||||
out.worlds = view
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| Row {
|
||||
name: world.dir.clone(),
|
||||
detail: world_detail(world.icon, world.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.screenshots = view
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| Row {
|
||||
name: shot.file.clone(),
|
||||
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
|
||||
})
|
||||
.collect();
|
||||
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
|
||||
out.skipped = view.skipped_count();
|
||||
out.skipped_note = texts.saves_skipped(out.skipped);
|
||||
}
|
||||
if let Some(failure) = &state.failed {
|
||||
out.loading = false;
|
||||
out.error = Some(failure_line(failure, texts));
|
||||
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
|
||||
#[must_use]
|
||||
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
|
||||
let reason = texts.saves_reason(failure.kind);
|
||||
let sentence = match failure.kind {
|
||||
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
|
||||
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
|
||||
texts.saves_failed(reason)
|
||||
}
|
||||
};
|
||||
if failure.detail.is_empty() {
|
||||
return sentence;
|
||||
}
|
||||
format!("{sentence}({})", failure.detail)
|
||||
}
|
||||
|
||||
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
|
||||
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
let stamp = stamp(modified_ms, texts);
|
||||
if icon {
|
||||
format!("{stamp} · {}", texts.saves_world_icon())
|
||||
} else {
|
||||
stamp
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
|
||||
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
|
||||
}
|
||||
|
||||
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
|
||||
///
|
||||
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
|
||||
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
|
||||
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
|
||||
let Some(text) = modified_ms.and_then(stamp_utc) else {
|
||||
return texts.saves_time_unknown().to_string();
|
||||
};
|
||||
text
|
||||
}
|
||||
|
||||
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
|
||||
fn stamp_utc(ms: u64) -> Option<String> {
|
||||
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
|
||||
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
|
||||
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
|
||||
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
|
||||
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
|
||||
let date = text.get(0..10)?;
|
||||
let time = text.get(11..16)?;
|
||||
Some(format!("{date} {time} UTC"))
|
||||
}
|
||||
|
||||
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
|
||||
pub(super) fn section<'a>(
|
||||
ui: &'a Ui,
|
||||
saves: &'a Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let mut body = column![head(ui, saves, forced, texts)]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill);
|
||||
// 只读边界固定画在第一行(见模块文档)。
|
||||
body = body.push(note(ui, texts.saves_read_only()));
|
||||
|
||||
if saves.loading {
|
||||
body = body.push(note(ui, texts.saves_loading()));
|
||||
return frame::card(ui, body);
|
||||
}
|
||||
if let Some(error) = saves.error.as_deref() {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
error,
|
||||
ui.color(Role::Error),
|
||||
));
|
||||
if saves.retryable {
|
||||
body = body.push(buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new(
|
||||
texts.saves_retry(),
|
||||
Message::Instances(PageMessage::ReloadSaves),
|
||||
),
|
||||
forced,
|
||||
),
|
||||
));
|
||||
}
|
||||
// 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西;
|
||||
// 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。
|
||||
if !saves.has_view {
|
||||
return frame::card(ui, body);
|
||||
}
|
||||
}
|
||||
if saves.missing {
|
||||
body = body.push(note(ui, texts.saves_missing_dir()));
|
||||
return frame::card(ui, body);
|
||||
}
|
||||
if saves.nothing_yet() {
|
||||
body = body.push(note(ui, texts.saves_empty()));
|
||||
return frame::card(ui, body);
|
||||
}
|
||||
|
||||
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
|
||||
body = body.push(sub_head(ui, texts.saves_worlds()));
|
||||
if saves.worlds.is_empty() {
|
||||
body = body.push(note(ui, texts.saves_no_worlds()));
|
||||
} else {
|
||||
for item in &saves.worlds {
|
||||
body = body.push(entry_row(ui, item));
|
||||
}
|
||||
}
|
||||
body = body.push(sub_head(ui, texts.saves_screenshots()));
|
||||
if saves.screenshots.is_empty() {
|
||||
body = body.push(note(ui, texts.saves_no_screenshots()));
|
||||
} else {
|
||||
for item in &saves.screenshots {
|
||||
body = body.push(entry_row(ui, item));
|
||||
}
|
||||
}
|
||||
if saves.skipped > 0 {
|
||||
body = body.push(note(ui, &saves.skipped_note));
|
||||
}
|
||||
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。
|
||||
fn head<'a>(
|
||||
ui: &'a Ui,
|
||||
saves: &'a Saves,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
// 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开,
|
||||
// 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。
|
||||
let mut left = row![frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
texts.saves_title(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
|
||||
let show_count = saves.has_view
|
||||
&& saves.error.is_none()
|
||||
&& !saves.missing
|
||||
&& !(saves.worlds.is_empty() && saves.screenshots.is_empty());
|
||||
if show_count {
|
||||
left = left.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
saves.count.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
|
||||
// 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明
|
||||
// (见 `host::saves::open_target`)。
|
||||
let open = buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new(
|
||||
texts.saves_open_folder(),
|
||||
Message::Instances(PageMessage::OpenSavesFolder),
|
||||
),
|
||||
forced,
|
||||
),
|
||||
);
|
||||
row![
|
||||
container(left)
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
open
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelSmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
|
||||
fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
item.name.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.detail.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
@@ -163,6 +163,13 @@ pub enum Message {
|
||||
/// 期望的启用状态
|
||||
enabled: bool,
|
||||
},
|
||||
/// 重新列一次存档(M235):读盘失败时的「重试」,也用于用户手动刷新。
|
||||
///
|
||||
/// 只发一条 `SavesCommand::List`——没有别的状态要复位(失败不清绑定,见 presenter 的
|
||||
/// `SavesState::failed`)。
|
||||
ReloadSaves,
|
||||
/// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。
|
||||
OpenSavesFolder,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,6 +7,9 @@ mod i18n;
|
||||
/// M157 的门限判据(三态 chips 在版本不够时谁可点)。
|
||||
mod launch_args;
|
||||
|
||||
/// M235 的存档区模型判据(快照 → 行 / 计数 / 失败线 / UTC 时间)。
|
||||
mod saves;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -29,14 +29,24 @@ use super::super::Texts;
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &[
|
||||
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
|
||||
"Java",
|
||||
"pid",
|
||||
"JVM",
|
||||
"OpenGL",
|
||||
"Vulkan",
|
||||
"MiB",
|
||||
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
|
||||
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
|
||||
"mods",
|
||||
// M235:`saves` / `screenshots` 同样是**目录名**(文件系统事实,玩家要照着去找)。
|
||||
// 「正在读取 saves/…」「还没有 saves/ 或 screenshots/ 目录」里的这两个词不许翻——
|
||||
// 翻成「存档目录」用户就对不上那两层文件夹了。
|
||||
"saves",
|
||||
"screenshots",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
const PAGE_SOURCES: [(&str, &str); 8] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
@@ -51,6 +61,8 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
),
|
||||
// M224:MODS 区(本地 mod 列表 + 启停开关)
|
||||
("instances/mods.rs", include_str!("../mods.rs")),
|
||||
// M235:SAVES 区(存档 / 截图的只读列表 + 打开文件夹)
|
||||
("instances/saves.rs", include_str!("../saves.rs")),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
@@ -68,7 +80,7 @@ const PAGE_SOURCES: [(&str, &str); 7] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 28] = [
|
||||
const ALLOWED_LITERALS: [&str; 30] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
@@ -110,6 +122,9 @@ const ALLOWED_LITERALS: [&str; 28] = [
|
||||
"disabled",
|
||||
"count",
|
||||
"file",
|
||||
// M235:存档区计数那条的**占位符名**(`{worlds}` / `{shots}`),同样不是文案。
|
||||
"worlds",
|
||||
"shots",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! 「存档区」(M235)的**模型**判据:快照 → 页面要画的东西。
|
||||
//!
|
||||
//! 为什么只测一个纯函数:iced 0.14 的 `Element` 没有内省接口(与 `tests/launch_args.rs`
|
||||
//! 同一取舍),画出来的行在测试里拿不到。所以把"快照 → 行/计数/失败线"全部压在
|
||||
//! [`saves::from_state`] 里,在这里逐条钉住——页面那一支只负责把这些字段画出来。
|
||||
//!
|
||||
//! 守门形状:`detail.rs` 必须调 `saves::section(ui, saves, …)`(`instances/tests.rs` 的源码
|
||||
//! 结构断言盯着 `detail_column` 里 MODS 与 SAVES 的先后;这里守模型)。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
SavesArea, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView,
|
||||
ScreenshotView, WorldView,
|
||||
};
|
||||
use unlucky_presenter::state::SavesState;
|
||||
|
||||
use super::super::saves::{failure_line, from_state};
|
||||
use super::super::Texts;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
/// 2024-05-01 10:00 UTC(UTC 毫秒)。
|
||||
const STAMP: u64 = 1_714_557_600_000;
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "Better World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(STAMP),
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 2_048,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 已绑定实例的视图态(`bind` 是 presenter 内部的 `pub(super)`,这里直接写字段——
|
||||
/// 它本来就是公开状态的一部分,与 `AppState::bind_saves` 走的是同一条路)。
|
||||
fn bound_state(instance: &str) -> SavesState {
|
||||
SavesState {
|
||||
bound: Some(instance.to_string()),
|
||||
..SavesState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照 → 行:世界带「有图标」,截图带大小;时间**按 UTC 切**,取不到就说"时间未知"。
|
||||
#[test]
|
||||
fn the_snapshot_becomes_rows_with_stamps_and_badges() {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
let saves = from_state(&state, Lang::ZhCn.catalog());
|
||||
|
||||
assert!(!saves.loading);
|
||||
assert!(!saves.retryable, "成功的快照不该带重试");
|
||||
assert_eq!(saves.worlds.len(), 1);
|
||||
assert_eq!(saves.worlds[0].name, "Better World");
|
||||
let zh_icon = Texts::of(Lang::ZhCn.catalog()).saves_world_icon();
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains("2024-05-01 10:00 UTC"),
|
||||
"世界的副文案要带 UTC 时间:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.worlds[0].detail.contains(zh_icon),
|
||||
"有 icon.png 的世界要带标记:{:?}",
|
||||
saves.worlds[0].detail
|
||||
);
|
||||
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
assert!(
|
||||
saves.screenshots[0].detail.contains("2.0 KB"),
|
||||
"截图的副文案要带大小:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
assert!(
|
||||
saves.screenshots[0]
|
||||
.detail
|
||||
.contains(Texts::of(Lang::ZhCn.catalog()).saves_time_unknown()),
|
||||
"mtime 缺失时要说『时间未知』,不许拿 0 或当前时间糊:{:?}",
|
||||
saves.screenshots[0].detail
|
||||
);
|
||||
|
||||
// 计数与跳过:3 个世界 / 12 张截图这种句子在这一层拼好。
|
||||
assert_eq!(saves.count, "1 个世界 · 1 张截图");
|
||||
assert_eq!(saves.skipped, 1);
|
||||
assert_eq!(saves.skipped_note, "跳过 1 项");
|
||||
}
|
||||
|
||||
/// 首帧(已绑定、还没有快照)是 `loading`;没有绑定则什么都不算"已加载"。
|
||||
#[test]
|
||||
fn a_bound_instance_without_a_snapshot_is_loading() {
|
||||
let state = bound_state("a");
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.loading);
|
||||
assert!(!saves.loaded());
|
||||
|
||||
let idle = from_state(&SavesState::default(), Lang::En.catalog());
|
||||
assert!(!idle.loading, "不在详情页不该画『正在读取』");
|
||||
assert!(!idle.loaded());
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ `missing`(画"还没有目录"那句,而不是"这里空空的")。
|
||||
/// 目录在但里面什么都没有 ⇒ `nothing_yet`。
|
||||
#[test]
|
||||
fn missing_directories_and_empty_ones_are_different_states() {
|
||||
let mut state = bound_state("a");
|
||||
let mut gone = view("a");
|
||||
gone.exists = false;
|
||||
gone.screenshots_exists = false;
|
||||
gone.worlds.clear();
|
||||
gone.screenshots.clear();
|
||||
gone.skipped.clear();
|
||||
state.changed(gone);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.missing);
|
||||
assert!(!saves.nothing_yet(), "缺目录不该被当成『目录在但是空的』");
|
||||
|
||||
let mut state = bound_state("a");
|
||||
let mut empty = view("a");
|
||||
empty.worlds.clear();
|
||||
empty.screenshots.clear();
|
||||
empty.skipped.clear();
|
||||
state.changed(empty);
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.missing);
|
||||
assert!(saves.nothing_yet());
|
||||
}
|
||||
|
||||
/// 失败:读盘失败**可重试**,打开文件夹失败**不可重试**(重试按钮会去重新列目录,那是错的动作)。
|
||||
///
|
||||
/// 抓得住:把两种失败合成一条「重试」;也抓得住"失败顺手把列表清掉"(用户看不见上一次读到的东西)。
|
||||
#[test]
|
||||
fn only_list_failures_offer_a_retry() {
|
||||
for (kind, retryable) in [
|
||||
(SavesFailureKind::DirUnusable, true),
|
||||
(SavesFailureKind::InstanceUnavailable, true),
|
||||
(SavesFailureKind::OpenFailed, false),
|
||||
] {
|
||||
let mut state = bound_state("a");
|
||||
state.changed(view("a"));
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(!saves.loading);
|
||||
assert_eq!(saves.retryable, retryable, "{kind:?} 的重试口径错了");
|
||||
assert!(saves.error.is_some(), "{kind:?} 没有画出失败");
|
||||
assert_eq!(saves.worlds.len(), 1, "{kind:?} 把旧列表清掉了");
|
||||
assert_eq!(saves.screenshots.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// **只拿到失败、一份快照都没有**时,不许被画成"这里还没有世界"。
|
||||
///
|
||||
/// 抓得住:`nothing_yet()` 只看"两个列表都空"——那样"读不了"会顶着两句"这里空空的",
|
||||
/// 用户会把权限问题读成"我没有存档"。
|
||||
#[test]
|
||||
fn a_failure_without_a_snapshot_is_not_an_empty_list() {
|
||||
let mut state = bound_state("a");
|
||||
state.failed(SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
});
|
||||
let saves = from_state(&state, Lang::En.catalog());
|
||||
assert!(saves.error.is_some());
|
||||
assert!(!saves.has_view, "没有快照却记成了有");
|
||||
assert!(!saves.nothing_yet(), "纯失败态被画成了空列表");
|
||||
assert_eq!(saves.count, "", "没有快照不该画计数");
|
||||
}
|
||||
|
||||
/// 失败主句跟着语言与大类走,OS 原文只进括号(M161 的文案纪律)。
|
||||
#[test]
|
||||
fn the_failure_line_follows_the_language_and_keeps_the_detail_in_brackets() {
|
||||
let failure = SavesFailureView {
|
||||
instance: "a".to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
};
|
||||
let zh = failure_line(&failure, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(zh.starts_with("读取 saves/ 失败:"), "{zh}");
|
||||
assert!(zh.ends_with("(Access is denied.)"), "{zh}");
|
||||
|
||||
let en = failure_line(&failure, Texts::of(Lang::En.catalog()));
|
||||
assert!(en.starts_with("Could not read saves/:"), "{en}");
|
||||
assert!(en.contains("Access is denied."), "{en}");
|
||||
|
||||
// 没有原文时不留一对空括号。
|
||||
let bare = SavesFailureView {
|
||||
detail: String::new(),
|
||||
..failure
|
||||
};
|
||||
let text = failure_line(&bare, Texts::of(Lang::ZhCn.catalog()));
|
||||
assert!(!text.contains('('), "空原文留下了一对括号:{text}");
|
||||
}
|
||||
|
||||
/// 时间换算:毫秒 → `YYYY-MM-DD HH:MM UTC`(跨年/闰日各钉一条,别只信一条)。
|
||||
#[test]
|
||||
fn millisecond_stamps_are_rendered_in_utc() {
|
||||
let stamp = |ms: u64| {
|
||||
let mut state = bound_state("a");
|
||||
let mut view = view("a");
|
||||
view.worlds[0].modified_ms = Some(ms);
|
||||
state.changed(view);
|
||||
from_state(&state, Lang::En.catalog()).worlds[0]
|
||||
.detail
|
||||
.clone()
|
||||
};
|
||||
// 1970-01-01 00:00 UTC(纪元)
|
||||
assert!(stamp(0).starts_with("1970-01-01 00:00 UTC"), "{}", stamp(0));
|
||||
// 2024-05-01 10:00 UTC
|
||||
assert!(
|
||||
stamp(STAMP).starts_with("2024-05-01 10:00 UTC"),
|
||||
"{}",
|
||||
stamp(STAMP)
|
||||
);
|
||||
// 2024-02-29 23:59 UTC(闰日)
|
||||
assert!(
|
||||
stamp(1_709_251_140_000).starts_with("2024-02-29 23:59 UTC"),
|
||||
"{}",
|
||||
stamp(1_709_251_140_000)
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::ModFailureKind;
|
||||
use unlucky_presenter::dto::{ModFailureKind, SavesFailureKind};
|
||||
|
||||
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -212,10 +212,85 @@ impl Texts {
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 存档区(M235)
|
||||
pub(super) fn saves_title(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesTitle)
|
||||
}
|
||||
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
|
||||
pub(super) fn saves_read_only(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesReadOnly)
|
||||
}
|
||||
/// 「3 个世界 · 12 张截图」。
|
||||
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
|
||||
self.0.render(
|
||||
Key::InstancesSavesCount,
|
||||
&[
|
||||
("worlds", &worlds.to_string()),
|
||||
("shots", &shots.to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
pub(super) fn saves_loading(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesLoading)
|
||||
}
|
||||
pub(super) fn saves_empty(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesEmpty)
|
||||
}
|
||||
pub(super) fn saves_missing_dir(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesMissingDir)
|
||||
}
|
||||
pub(super) fn saves_worlds(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesWorlds)
|
||||
}
|
||||
pub(super) fn saves_screenshots(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesScreenshots)
|
||||
}
|
||||
pub(super) fn saves_no_worlds(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesNoWorlds)
|
||||
}
|
||||
pub(super) fn saves_no_screenshots(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesNoScreenshots)
|
||||
}
|
||||
/// 「有图标」:只判 `icon.png` 存不存在(不解码,缩略图墙不在 U3 范围内)。
|
||||
pub(super) fn saves_world_icon(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesWorldIcon)
|
||||
}
|
||||
pub(super) fn saves_time_unknown(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesTimeUnknown)
|
||||
}
|
||||
/// 「跳过 3 项」。
|
||||
pub(super) fn saves_skipped(self, count: usize) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesSkipped, &[("count", &count.to_string())])
|
||||
}
|
||||
pub(super) fn saves_open_folder(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesOpenFolder)
|
||||
}
|
||||
pub(super) fn saves_retry(self) -> &'static str {
|
||||
self.text(Key::InstancesSavesRetry)
|
||||
}
|
||||
/// 读目录失败的主句:`读取 saves/ 失败:目录不可用`。
|
||||
pub(super) fn saves_failed(self, reason: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 打开文件夹失败的主句:`打开文件夹失败:文件管理器没起来`。
|
||||
pub(super) fn saves_open_failed(self, reason: &str) -> String {
|
||||
self.0
|
||||
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
|
||||
}
|
||||
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
|
||||
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
|
||||
self.text(match kind {
|
||||
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
|
||||
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
|
||||
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
|
||||
})
|
||||
}
|
||||
|
||||
fn text(self, key: Key) -> &'static str {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
|
||||
///
|
||||
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
|
||||
|
||||
@@ -128,7 +128,10 @@ pub(crate) mod settings;
|
||||
mod tests;
|
||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||
pub(crate) use instances::demo_form as demo_launch_form;
|
||||
pub use instances::{mods_failure_line, mods_from_state, InstanceMessage, LaunchForm, Mods};
|
||||
pub use instances::{
|
||||
mods_failure_line, mods_from_state, 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};
|
||||
pub use message::{Action, Message, UpdateAction};
|
||||
|
||||
@@ -145,6 +145,11 @@ pub struct Model {
|
||||
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
|
||||
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
|
||||
pub mods: super::instances::Mods,
|
||||
/// 存档 / 截图(M235):详情页 SAVES 区的只读列表(见 [`super::instances::Saves`])。
|
||||
///
|
||||
/// 谁填:`app/view/instances.rs` 的 `saves_of`(presenter 的 `AppState::saves` → 展示字段)。
|
||||
/// 空态(不在详情页 / 还没请求)是 `Saves::default()`,页面照旧画这一次请求前的样子。
|
||||
pub saves: super::instances::Saves,
|
||||
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
|
||||
///
|
||||
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
|
||||
@@ -175,6 +180,7 @@ impl Model {
|
||||
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
|
||||
settings: Vec::new(),
|
||||
mods: super::instances::Mods::default(),
|
||||
saves: super::instances::Saves::default(),
|
||||
lang: Lang::En,
|
||||
forced: None,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
//! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区,
|
||||
//! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。
|
||||
//!
|
||||
//! 存档与截图(M235,[`saves`]):**只读**列 `<run_dir>/saves`(世界目录)与
|
||||
//! `<run_dir>/screenshots`(图片),不建目录、不写盘;「打开文件夹」走
|
||||
//! [`crate::platform::opener::open_dir`](平台差异不在这里)。缩略图墙与导入不在这一版(塔裁决 U3)。
|
||||
//!
|
||||
//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI:
|
||||
//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。
|
||||
|
||||
@@ -45,6 +49,7 @@ pub mod library;
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
pub mod profiles;
|
||||
pub mod saves;
|
||||
pub mod snapshot;
|
||||
pub mod unlisted;
|
||||
pub mod version;
|
||||
@@ -75,6 +80,10 @@ pub use library::{Library, MavenCoordinate};
|
||||
pub use loader::{InstanceLoader, LoaderKind};
|
||||
pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson};
|
||||
pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION};
|
||||
pub use saves::{
|
||||
Area, SavesError, SavesScan, SavesSkip, ScreenshotEntry, SkipKind, WorldEntry,
|
||||
IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||||
};
|
||||
pub use snapshot::{Draft, Snapshot, Store};
|
||||
pub use version::{
|
||||
LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 「目录存在但不可用」的失败形状(带 OS 原文)。
|
||||
//!
|
||||
//! 只有**一层**:`<run_dir>/saves` 或 `<run_dir>/screenshots` 本身不可用。项目级的问题
|
||||
//! (某个世界目录读不出元数据、某个文件不是图片)不是错误,逐条进 `skipped`——列表宁可短,
|
||||
//! 也不能因为一个坏文件整份读不出来。
|
||||
//!
|
||||
//! 为什么不折叠成 `io::Error`:调用方要分得清「这个实例没有存档目录」与「有但读不了」,
|
||||
//! 前者是空列表加一句说明,后者是带重试按钮的红字。`dir` 字段是这两者的唯一区分依据。
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 一次列举失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesError {
|
||||
/// 出问题的那个目录(`<run_dir>/saves` 或 `<run_dir>/screenshots`)。
|
||||
pub dir: PathBuf,
|
||||
/// 人读原因(OS 原文或"是符号链接"这类判据)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl SavesError {
|
||||
/// 建一条(内部用;`dir` 一律是**被列的那个目录**,不是它的父目录)。
|
||||
pub(super) fn unusable(dir: &std::path::Path, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
dir: dir.to_path_buf(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SavesError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "目录不可用({}):{}", self.dir.display(), self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SavesError {}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! 列一个运行目录下的 `saves/`(世界)与 `screenshots/`(图片),**纯只读**。
|
||||
//!
|
||||
//! 只碰三种系统调用:`symlink_metadata`(判目录形态)、`read_dir`(取子项)、`metadata`
|
||||
//! (取大小/时间)。没有 `create_dir`、没有 `write`、没有 `rename`、没有 `remove`——`saves/`
|
||||
//! 不存在时也**不替玩家建**(建出来的空目录会让「这实例还没玩过」这件事再也看不出来)。
|
||||
//!
|
||||
//! 回看「调用前后版本树不变」的判据见 `tests::scan_does_not_touch_the_disk`:
|
||||
//! 它在调用前后各拍一次 `(相对路径, 是否目录, 字节数, mtime)` 的指纹并逐条比对。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use super::error::SavesError;
|
||||
|
||||
/// `saves` 目录名(游戏与社区一致的口径)。
|
||||
pub const SAVES_DIR: &str = "saves";
|
||||
|
||||
/// `screenshots` 目录名(原版游戏把 F2 截图放这里)。
|
||||
pub const SCREENSHOTS_DIR: &str = "screenshots";
|
||||
|
||||
/// 世界图标文件名(**只判存在**,不解码:U3 不做缩略图墙)。
|
||||
pub const WORLD_ICON: &str = "icon.png";
|
||||
|
||||
/// 认作截图的扩展名(大小写不敏感)。
|
||||
///
|
||||
/// 原版游戏只写 `.png`;其余几个是玩家手动塞进去/其它 mod 的常见形态。**不收** `.gif` /
|
||||
/// 视频——它们是"别的东西",与"我们忘了列"要区分开(见 [`SkipKind::NotAnImage`])。
|
||||
pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp"];
|
||||
|
||||
/// 某个运行目录下的 `saves/`(纯路径拼接,不建、不检查)。
|
||||
#[must_use]
|
||||
pub fn saves_dir(run_dir: &Path) -> PathBuf {
|
||||
run_dir.join(SAVES_DIR)
|
||||
}
|
||||
|
||||
/// 某个运行目录下的 `screenshots/`(纯路径拼接,不建、不检查)。
|
||||
#[must_use]
|
||||
pub fn screenshots_dir(run_dir: &Path) -> PathBuf {
|
||||
run_dir.join(SCREENSHOTS_DIR)
|
||||
}
|
||||
|
||||
/// `saves/` 下的一个世界目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorldEntry {
|
||||
/// 磁盘上的目录名(原样)。**这一版它就是展示名**:`LevelName` 在 `level.dat` 里,
|
||||
/// 解析 NBT 不在 U3 范围内(做缩略图墙时一起做)。
|
||||
pub dir: String,
|
||||
/// 有没有 `icon.png`(只判存在,不读内容)。
|
||||
pub icon: bool,
|
||||
/// 目录 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// `screenshots/` 下的一张图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotEntry {
|
||||
/// 磁盘文件名(原样,含扩展名)。
|
||||
pub file: String,
|
||||
/// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。
|
||||
pub bytes: u64,
|
||||
/// 文件 mtime(毫秒 since UNIX_EPOCH;取不到为 `None`)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Area {
|
||||
/// `<run_dir>/saves/`
|
||||
Saves,
|
||||
/// `<run_dir>/screenshots/`
|
||||
Screenshots,
|
||||
}
|
||||
|
||||
/// 没收进列表的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkipKind {
|
||||
/// 符号链接(不跟随:跟随会把实例外的东西算成存档)。
|
||||
SymbolicLink,
|
||||
/// `saves/` 下的非目录(散落文件),或 `screenshots/` 下的目录。
|
||||
NotADirectoryExpected,
|
||||
/// `screenshots/` 下的非普通文件(设备 / 管道 / 目录)。
|
||||
NotAFile,
|
||||
/// 名字过不了路径规则(含 `\`、`:`、`..` 之类;判据复用整合包侧那一份)。
|
||||
UnsafeName,
|
||||
/// 元数据读不出来。
|
||||
Unreadable,
|
||||
/// `screenshots/` 下的普通文件,但扩展名不是 [`IMAGE_EXTENSIONS`]。
|
||||
NotAnImage,
|
||||
}
|
||||
|
||||
/// 一个没能进列表的条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesSkip {
|
||||
/// 来自哪个目录。
|
||||
pub area: Area,
|
||||
/// 磁盘名字(原样;读不出来时给能拿到的那个串)。
|
||||
pub file: String,
|
||||
/// 为什么没收。
|
||||
pub reason: SkipKind,
|
||||
}
|
||||
|
||||
/// 一次列举的结果(两个目录一次读回,UI 只收这一份快照)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SavesScan {
|
||||
/// 被列的 `saves/` 目录。
|
||||
pub dir: PathBuf,
|
||||
/// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。
|
||||
pub exists: bool,
|
||||
/// 世界列表(按名字排序,大小写不敏感)。
|
||||
pub worlds: Vec<WorldEntry>,
|
||||
/// 被列的 `screenshots/` 目录。
|
||||
pub screenshots_dir: PathBuf,
|
||||
/// `screenshots/` 存不存在。
|
||||
pub screenshots_exists: bool,
|
||||
/// 截图列表(**新的在前**:文件名就是时间戳,倒序即时间倒序)。
|
||||
pub screenshots: Vec<ScreenshotEntry>,
|
||||
/// 跳过的条目与原因(两个目录并在一份里,靠 [`SavesSkip::area`] 区分)。
|
||||
pub skipped: Vec<SavesSkip>,
|
||||
}
|
||||
|
||||
impl SavesScan {
|
||||
/// `saves/` 与 `screenshots/` 一个都不存在(新实例的常态)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 列一个运行目录下的 `saves/` 与 `screenshots/`。
|
||||
///
|
||||
/// 目录不存在 → `exists = false` 的空结果(新实例不是错误),**且不建那个目录**。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`SavesError`]。
|
||||
pub fn scan(run_dir: &Path) -> Result<SavesScan, SavesError> {
|
||||
let dir = saves_dir(run_dir);
|
||||
let (exists, children) = read_children(&dir)?;
|
||||
let mut worlds = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for child in children {
|
||||
let raw = child.file_name().to_string_lossy().to_string();
|
||||
let Ok(file_type) = child.file_type() else {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::Unreadable));
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::SymbolicLink));
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_dir() {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::NotADirectoryExpected));
|
||||
continue;
|
||||
}
|
||||
if !plain_name(&raw) {
|
||||
skipped.push(skip(Area::Saves, raw, SkipKind::UnsafeName));
|
||||
continue;
|
||||
}
|
||||
let icon = is_regular_file(&dir.join(&raw).join(WORLD_ICON));
|
||||
let modified_ms = child.metadata().ok().and_then(|meta| modified_ms(&meta));
|
||||
worlds.push(WorldEntry {
|
||||
dir: raw,
|
||||
icon,
|
||||
modified_ms,
|
||||
});
|
||||
}
|
||||
// 名字排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。
|
||||
worlds.sort_by(|left, right| {
|
||||
left.dir
|
||||
.to_lowercase()
|
||||
.cmp(&right.dir.to_lowercase())
|
||||
.then_with(|| left.dir.cmp(&right.dir))
|
||||
});
|
||||
|
||||
let shots_dir = screenshots_dir(run_dir);
|
||||
let (shots_exists, shots) = read_children(&shots_dir)?;
|
||||
let mut screenshots = Vec::new();
|
||||
for child in shots {
|
||||
let raw = child.file_name().to_string_lossy().to_string();
|
||||
let Ok(file_type) = child.file_type() else {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::Unreadable));
|
||||
continue;
|
||||
};
|
||||
if file_type.is_symlink() {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::SymbolicLink));
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAFile));
|
||||
continue;
|
||||
}
|
||||
if !plain_name(&raw) {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::UnsafeName));
|
||||
continue;
|
||||
}
|
||||
if !is_image(&raw) {
|
||||
skipped.push(skip(Area::Screenshots, raw, SkipKind::NotAnImage));
|
||||
continue;
|
||||
}
|
||||
let meta = child.metadata().ok();
|
||||
screenshots.push(ScreenshotEntry {
|
||||
bytes: meta.as_ref().map_or(0, std::fs::Metadata::len),
|
||||
modified_ms: meta.as_ref().and_then(modified_ms),
|
||||
file: raw,
|
||||
});
|
||||
}
|
||||
// 新的在前:截图文件名就是 `2024-01-02_12.00.00.png`,字典序倒排就是时间倒排。
|
||||
screenshots.sort_by(|left, right| right.file.cmp(&left.file));
|
||||
skipped.sort_by(|left, right| {
|
||||
area_key(left.area)
|
||||
.cmp(&area_key(right.area))
|
||||
.then_with(|| left.file.cmp(&right.file))
|
||||
});
|
||||
|
||||
Ok(SavesScan {
|
||||
dir,
|
||||
exists,
|
||||
worlds,
|
||||
screenshots_dir: shots_dir,
|
||||
screenshots_exists: shots_exists,
|
||||
screenshots,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
/// 列一个目录的子项(**不存在 ⇒ `(false, 空)`**;不可用 ⇒ 具名错误;符号链接不跟随)。
|
||||
fn read_children(dir: &Path) -> Result<(bool, Vec<std::fs::DirEntry>), SavesError> {
|
||||
let meta = match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok((false, Vec::new()));
|
||||
}
|
||||
Err(error) => return Err(SavesError::unusable(dir, error.to_string())),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(SavesError::unusable(
|
||||
dir,
|
||||
"是符号链接(不跟随:会把实例目录之外的内容算成存档)",
|
||||
));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Err(SavesError::unusable(dir, "不是目录"));
|
||||
}
|
||||
let read =
|
||||
std::fs::read_dir(dir).map_err(|error| SavesError::unusable(dir, error.to_string()))?;
|
||||
let mut children: Vec<_> = read
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| SavesError::unusable(dir, error.to_string()))?;
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
Ok((true, children))
|
||||
}
|
||||
|
||||
fn skip(area: Area, file: String, reason: SkipKind) -> SavesSkip {
|
||||
SavesSkip { area, file, reason }
|
||||
}
|
||||
|
||||
/// 名字是不是**裸名字**(判据复用整合包侧那一份,不在这里写第二份规则)。
|
||||
///
|
||||
/// `read_dir` 给的已经是单段名字,所以只需判它过不过得了路径规则:`\`、`:`、`..` 这类会被
|
||||
/// 拒——它们既可能在展示里被误读成路径分隔,也可能在将来接"删除/重命名"时落到别处。
|
||||
fn plain_name(raw: &str) -> bool {
|
||||
crate::addon::pack::path::normalize_file(raw).is_ok()
|
||||
}
|
||||
|
||||
/// 扩展名是不是认得的图片(大小写不敏感)。
|
||||
fn is_image(raw: &str) -> bool {
|
||||
Path::new(raw)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| {
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
IMAGE_EXTENSIONS.iter().any(|known| *known == ext)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 是不是**普通文件**(不跟随符号链接:`icon.png` 指到别处不算这个世界的图标)。
|
||||
fn is_regular_file(path: &Path) -> bool {
|
||||
std::fs::symlink_metadata(path)
|
||||
.map(|meta| meta.file_type().is_file())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// mtime → 毫秒(早于 1970 或取不到 ⇒ `None`,不报错)。
|
||||
fn modified_ms(meta: &std::fs::Metadata) -> Option<u64> {
|
||||
let since = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
|
||||
u64::try_from(since.as_millis()).ok()
|
||||
}
|
||||
|
||||
/// 跳过条目的排序键(先 `saves/` 再 `screenshots/`)。
|
||||
fn area_key(area: Area) -> u8 {
|
||||
match area {
|
||||
Area::Saves => 0,
|
||||
Area::Screenshots => 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
|
||||
//!
|
||||
//! # 这一版做到哪、不做什么(塔裁决 U3)
|
||||
//!
|
||||
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
|
||||
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
|
||||
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
|
||||
//!
|
||||
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
|
||||
//! 2. 截图/导入的产物形态还没定型,先不进产品。
|
||||
//!
|
||||
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
|
||||
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
|
||||
//!
|
||||
//! # 单根语义(D20)
|
||||
//!
|
||||
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
|
||||
//! 本模块只往下取两层:`<run_dir>/saves/` 与 `<run_dir>/screenshots/`。
|
||||
//! 不自己去猜游戏根,也不做「隔离侧 + 共享侧合并」——派生是启动、mod 列表、存档列表**同一个**
|
||||
//! 答案的唯一来源。与 [`crate::addon::mods`] 同一条纪律。
|
||||
//!
|
||||
//! # 目录不存在不是错误
|
||||
//!
|
||||
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
|
||||
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
|
||||
//!
|
||||
//! # 收纳口径
|
||||
//!
|
||||
//! | 位置 | 收什么 | 不收什么(逐条登记原因,不静默丢) |
|
||||
//! |---|---|---|
|
||||
//! | `saves/` | 子目录 = 一个世界 | 符号链接(不跟随)、非目录(散落文件)、名字过不了路径规则、元数据读不出 |
|
||||
//! | `screenshots/` | 扩展名是 [`IMAGE_EXTENSIONS`] 的普通文件 | 同上 + 不是图片的文件(`NotAnImage`) |
|
||||
//!
|
||||
//! 「不静默丢」是 M224 定下的口径:列表要能和磁盘对得上账,用户看到「跳过 3 项」时得知道
|
||||
//! 那 3 项不是我们忘了。
|
||||
//!
|
||||
//! # 模块
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
|
||||
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
|
||||
//!
|
||||
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
|
||||
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
|
||||
|
||||
mod error;
|
||||
mod list;
|
||||
|
||||
pub use error::SavesError;
|
||||
pub use list::{
|
||||
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
|
||||
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 只读判据的用例:**真的建盘上目录**再读回来(不 mock 文件系统)。
|
||||
//!
|
||||
//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里(M224 的写法):
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`scan_lists_worlds_and_screenshots_read_only`] | 收纳口径、排序、`icon.png` 只判存在、**调用前后盘上指纹不变** | 大世界的耗时(不测性能) |
|
||||
//! | [`missing_dirs_are_empty_and_not_created`] | 「不存在 ⇒ 空列表」且**不建目录** | 权限不足导致的 `NotFound`(本机跑不出来) |
|
||||
//! | [`unusable_dirs_are_errors`] | 目录被占成文件 ⇒ 具名错误 | Windows 上建符号链接要开发者模式,见下一条 |
|
||||
//! | [`symlinks_are_refused`] | 符号链接不被跟随(目录级与条目级) | 非 unix 平台(`cfg(unix)`;Windows 建链接要提权) |
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 建一个运行目录(`<tmp>/run`)。
|
||||
///
|
||||
/// `create_dir_all` 这一句不是冗余:`addon::temp::TempRoot::under` 返回时它内部的 `base`
|
||||
/// 已经析构、把那棵树删掉了(M235 已作为 finding 上报,`addon/` 不在本 mission 的改动范围)。
|
||||
/// 修好之后这一句会退化成 no-op。
|
||||
fn run_dir(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "run");
|
||||
std::fs::create_dir_all(&root).expect("建运行目录");
|
||||
root
|
||||
}
|
||||
|
||||
/// 盘上指纹:`(相对路径, 是否目录, 字节数, mtime)` 逐条排序后的列表。
|
||||
///
|
||||
/// 这就是「调用前后版本树快照不变」的判据本体——只要列举路径里多了一次 `create_dir`、
|
||||
/// 一次 `write`、一次 `remove`,或者顺手动了 mtime,这个列表就会不同。
|
||||
fn fingerprint(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut entries: Vec<_> = read.flatten().collect();
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |since| since.as_millis());
|
||||
out.push(format!(
|
||||
"{rel}|dir={}|len={}|mtime={mtime}",
|
||||
meta.is_dir(),
|
||||
meta.len()
|
||||
));
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// 收纳口径 + 排序 + `icon.png` + **只读**(M235 的核心判据)。
|
||||
#[test]
|
||||
fn scan_lists_worlds_and_screenshots_read_only() {
|
||||
let root = run_dir("m235-scan");
|
||||
let saves = saves_dir(&root);
|
||||
std::fs::create_dir_all(saves.join("World A")).expect("建世界 A");
|
||||
std::fs::write(saves.join("World A/level.dat"), b"nbt").expect("写 level.dat");
|
||||
std::fs::write(saves.join("World A/icon.png"), b"png").expect("写 icon");
|
||||
std::fs::create_dir_all(saves.join("world b")).expect("建世界 B");
|
||||
// `saves/` 下的散落文件:不是世界,登记原因。
|
||||
std::fs::write(saves.join("readme.txt"), b"hi").expect("写散落文件");
|
||||
|
||||
let shots = screenshots_dir(&root);
|
||||
std::fs::create_dir_all(&shots).expect("建截图目录");
|
||||
std::fs::write(shots.join("2024-05-01_10.00.00.png"), b"aa").expect("写截图 1");
|
||||
std::fs::write(shots.join("2024-06-02_11.00.00.PNG"), b"bbb").expect("写截图 2");
|
||||
std::fs::write(shots.join("notes.txt"), b"x").expect("写非图片");
|
||||
|
||||
let before = fingerprint(&root);
|
||||
let scan = scan(&root).expect("列存档");
|
||||
let after = fingerprint(&root);
|
||||
assert_eq!(before, after, "列举改动了磁盘:{before:?} → {after:?}");
|
||||
|
||||
assert!(scan.exists);
|
||||
assert!(scan.screenshots_exists);
|
||||
assert_eq!(scan.dir, save_path(&root));
|
||||
assert_eq!(scan.screenshots_dir, shot_path(&root));
|
||||
|
||||
// 世界:名字排序大小写不敏感;`icon.png` 只判存在。
|
||||
let worlds: Vec<(&str, bool)> = scan
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| (world.dir.as_str(), world.icon))
|
||||
.collect();
|
||||
assert_eq!(worlds, [("World A", true), ("world b", false)]);
|
||||
assert!(
|
||||
scan.worlds.iter().all(|world| world.modified_ms.is_some()),
|
||||
"世界的 mtime 应当取得到:{:?}",
|
||||
scan.worlds
|
||||
);
|
||||
|
||||
// 截图:新的在前(文件名倒序),扩展名大小写不敏感,非图片登记原因。
|
||||
let shots_listed: Vec<&str> = scan
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| shot.file.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shots_listed,
|
||||
["2024-06-02_11.00.00.PNG", "2024-05-01_10.00.00.png"]
|
||||
);
|
||||
assert_eq!(scan.screenshots[0].bytes, 3, "字节数来自 metadata");
|
||||
assert_eq!(scan.screenshots[1].bytes, 2);
|
||||
|
||||
let skipped: Vec<(Area, &str, SkipKind)> = scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| (skip.area, skip.file.as_str(), skip.reason))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
skipped,
|
||||
[
|
||||
(Area::Saves, "readme.txt", SkipKind::NotADirectoryExpected),
|
||||
(Area::Screenshots, "notes.txt", SkipKind::NotAnImage),
|
||||
],
|
||||
"跳过必须逐条登记原因(不静默丢)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 「目录不存在 ⇒ 空列表」**且不替玩家建目录**。
|
||||
#[test]
|
||||
fn missing_dirs_are_empty_and_not_created() {
|
||||
let root = run_dir("m235-missing");
|
||||
let before = fingerprint(&root);
|
||||
|
||||
let scan = scan(&root).expect("两个目录都不存在不是错误");
|
||||
assert!(!scan.exists);
|
||||
assert!(!scan.screenshots_exists);
|
||||
assert!(scan.worlds.is_empty());
|
||||
assert!(scan.screenshots.is_empty());
|
||||
assert!(scan.skipped.is_empty());
|
||||
assert!(scan.is_empty());
|
||||
|
||||
assert_eq!(fingerprint(&root), before, "列举顺手建了目录");
|
||||
assert!(!saves_dir(&root).exists(), "`saves/` 被建出来了");
|
||||
assert!(
|
||||
!screenshots_dir(&root).exists(),
|
||||
"`screenshots/` 被建出来了"
|
||||
);
|
||||
// 路径本身照样是"拼得出来的"(UI 的「打开文件夹」需要它)。
|
||||
assert_eq!(scan.dir, saves_dir(&root));
|
||||
assert_eq!(scan.screenshots_dir, screenshots_dir(&root));
|
||||
}
|
||||
|
||||
/// 「存在但不是目录」⇒ 具名错误(不折叠成空列表:那不是"没存档",是"读不了")。
|
||||
#[test]
|
||||
fn unusable_dirs_are_errors() {
|
||||
let root = run_dir("m235-unusable");
|
||||
std::fs::write(saves_dir(&root), b"not a dir").expect("用文件占住 saves 这个名字");
|
||||
|
||||
let error = scan(&root).expect_err("saves 是个文件,居然列成功了");
|
||||
assert_eq!(error.dir, saves_dir(&root));
|
||||
assert!(error.detail.contains("不是目录"), "原因:{}", error.detail);
|
||||
|
||||
// `screenshots/` 那一侧单独判(saves 正常、shot 被占)。
|
||||
let other = run_dir("m235-unusable-shots");
|
||||
std::fs::create_dir_all(saves_dir(&other)).expect("建 saves");
|
||||
std::fs::write(screenshots_dir(&other), b"x").expect("用文件占住 screenshots");
|
||||
let error = scan(&other).expect_err("screenshots 是个文件,居然列成功了");
|
||||
assert_eq!(error.dir, screenshots_dir(&other));
|
||||
}
|
||||
|
||||
/// 符号链接不跟随(目录级 = 错误;条目级 = 跳过并登记原因)。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_are_refused() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
// 目录级:`saves/` 指到别处 ⇒ 拒(列下去会把实例外的东西算成存档)。
|
||||
let root = run_dir("m235-symlink-dir");
|
||||
let elsewhere = root.join("elsewhere");
|
||||
std::fs::create_dir_all(elsewhere.join("Outside")).expect("建实例外目录");
|
||||
symlink(&elsewhere, saves_dir(&root)).expect("建符号链接");
|
||||
let error = scan(&root).expect_err("符号链接的 saves 居然列成功了");
|
||||
assert!(error.detail.contains("符号链接"), "原因:{}", error.detail);
|
||||
|
||||
// 条目级:世界目录 / 截图文件各一个链接 ⇒ 跳过,不跟随。
|
||||
let root = run_dir("m235-symlink-entry");
|
||||
let saves = saves_dir(&root);
|
||||
std::fs::create_dir_all(&saves).expect("建 saves");
|
||||
std::fs::create_dir_all(elsewhere.join("Real")).expect("建真实世界");
|
||||
symlink(elsewhere.join("Real"), saves.join("Linked")).expect("建世界链接");
|
||||
let shots = screenshots_dir(&root);
|
||||
std::fs::create_dir_all(&shots).expect("建 screenshots");
|
||||
std::fs::write(elsewhere.join("shot.png"), b"png").expect("写真实截图");
|
||||
symlink(elsewhere.join("shot.png"), shots.join("linked.png")).expect("建截图链接");
|
||||
|
||||
let scan = scan(&root).expect("列存档");
|
||||
assert!(scan.worlds.is_empty(), "跟随了世界符号链接");
|
||||
assert!(scan.screenshots.is_empty(), "跟随了截图符号链接");
|
||||
let reasons: Vec<(Area, SkipKind)> = scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| (skip.area, skip.reason))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
reasons,
|
||||
[
|
||||
(Area::Saves, SkipKind::SymbolicLink),
|
||||
(Area::Screenshots, SkipKind::SymbolicLink),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// 名字过不了路径规则的条目:跳过并登记(不静默丢)。
|
||||
///
|
||||
/// `:` 在 unix 上是合法文件名字符,在 Windows 上建不出来——所以这条只在 unix 跑得起来。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unsafe_names_are_skipped() {
|
||||
let root = run_dir("m235-unsafe");
|
||||
let scenes = saves_dir(&root);
|
||||
std::fs::create_dir_all(&scenes).expect("建 saves");
|
||||
std::fs::create_dir_all(scenes.join("bad:name")).expect("建带冒号的世界");
|
||||
std::fs::create_dir_all(scenes.join("good")).expect("建正常世界");
|
||||
|
||||
let scan = scan(&root).expect("列存档");
|
||||
let worlds: Vec<&str> = scan.worlds.iter().map(|world| world.dir.as_str()).collect();
|
||||
assert_eq!(worlds, ["good"]);
|
||||
assert_eq!(scan.skipped.len(), 1);
|
||||
assert_eq!(scan.skipped[0].file, "bad:name");
|
||||
assert_eq!(scan.skipped[0].reason, SkipKind::UnsafeName);
|
||||
}
|
||||
|
||||
/// 路径拼接口径:两个目录都挂在**运行目录**下面(不是游戏根、不是 `.minecraft`)。
|
||||
#[test]
|
||||
fn paths_hang_off_the_run_dir() {
|
||||
let run = Path::new("D:/ULL/.ULL/data/instances/better-mc");
|
||||
assert_eq!(saves_dir(run).file_name().unwrap(), SAVES_DIR);
|
||||
assert_eq!(screenshots_dir(run).file_name().unwrap(), SCREENSHOTS_DIR);
|
||||
assert!(saves_dir(run).starts_with(run));
|
||||
assert!(screenshots_dir(run).starts_with(run));
|
||||
}
|
||||
|
||||
fn save_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join(SAVES_DIR)
|
||||
}
|
||||
|
||||
fn shot_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join(SCREENSHOTS_DIR)
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
//! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带
|
||||
//! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上
|
||||
//! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。
|
||||
//! - [`opener`]:交出去的两件事——[`opener::open_url`](授权码流)与 [`opener::open_dir`]
|
||||
//! (实例详情页的「打开文件夹」,M235)。平台分支只在这里出现一次,业务模块一律调它。
|
||||
//!
|
||||
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
|
||||
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
|
||||
@@ -40,6 +42,7 @@ pub mod hash;
|
||||
pub mod layout;
|
||||
pub mod linux_caps;
|
||||
pub mod macos_version;
|
||||
pub mod opener;
|
||||
pub mod os_info;
|
||||
pub mod paths;
|
||||
pub mod receipt;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 用**系统默认程序**打开一个 URL 或一个目录(M235)。
|
||||
//!
|
||||
//! # 为什么收敛到 `core::platform`
|
||||
//!
|
||||
//! `DevDocs/AGENTS.md` §6:平台差异判断收敛到 `core::platform`,不在各模块里散写
|
||||
//! `cfg(target_os)` 补丁。本文件里的两种"交出去":
|
||||
//!
|
||||
//! | 目标 | 程序 | 谁在用 |
|
||||
//! |---|---|---|
|
||||
//! | 目录 | Windows `explorer` / macOS `open` / 其它 `xdg-open` | 实例详情页「打开文件夹」(`game::saves`,M235) |
|
||||
//! | URL(`http`/`https`) | Windows `rundll32 url.dll,FileProtocolHandler` / macOS `open` / 其它 `xdg-open` | 微软授权码流(**当前仍走** `presenter::host::opener`,见下) |
|
||||
//!
|
||||
//! `open_url` 放在这里是为了给 URL 通道一个归宿:它现在还在 `presenter::host::opener`,因为
|
||||
//! 把那份实现搬过来会同时改到 `crates/core/tests/child_quiet_guard.rs` 的角色登记(M217 把
|
||||
//! 那个文件钉在"裸构造 1 处",不在 M235 的改动范围),所以本 mission **只搬目录这一支**,
|
||||
//! 迁移 URL 那支留给后续(见 M235 的 handoff)。
|
||||
//!
|
||||
//! `app-iced/src/app/dialog/export.rs` 是第三处目录打开器(崩溃对话框的「打开日志文件夹」,
|
||||
//! M217 特意留裸);它与这里的 `open_dir` 是同一件事,迁移同样留给后续。
|
||||
//!
|
||||
//! # 子进程纪律(M213 / M217)
|
||||
//!
|
||||
//! 所有启动都经 [`child::quiet`]:Windows 上带 `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn
|
||||
//! 控制台程序会给子进程新建控制台(桌面上是"黑框闪一下")。本文件是本 crate 里**唯一**出现
|
||||
//! `explorer` / `xdg-open` / `open` 这三个字面量的地方,[`tests::the_launcher_literals_live_here_only`]
|
||||
//! 扫源码钉住这一条(防"有人又在某个业务模块里塞一个平台分支")。
|
||||
//!
|
||||
//! # 不等待、不判退出码
|
||||
//!
|
||||
//! 打开浏览器/文件管理器是**把意图交出去**:用户可能在那边停留很久,`explorer.exe` 即便成功
|
||||
//! 也常返回非 0 退出码。所以这里只 `spawn` 不 `wait`,`Ok(())` 的含义是「拉起来了」。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::child::quiet;
|
||||
|
||||
/// 拉起系统默认浏览器打开 `url`(只接受 `http` / `https`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// URL 不是 `http`/`https`,或进程拉不起来时返回用户可读文案。
|
||||
pub fn open_url(url: &str) -> Result<(), String> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return Err(format!("不是可打开的网址:{url}"));
|
||||
}
|
||||
let (program, args) = url_launcher(url);
|
||||
launch(program, args)
|
||||
}
|
||||
|
||||
/// 用系统文件管理器打开一个**已存在的目录**。
|
||||
///
|
||||
/// 先确认它真是一个目录:拿一个不存在的路径去问 `explorer` 会得到一个"我的电脑"窗口或一句
|
||||
/// OS 报错,两种都不是用户想要的答案——所以在这里就说清楚。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 路径不存在 / 不是目录 / 进程拉不起来时返回用户可读文案(含 OS 原文)。
|
||||
pub fn open_dir(dir: &Path) -> Result<(), String> {
|
||||
let meta = std::fs::metadata(dir)
|
||||
.map_err(|error| format!("找不到目录({}):{error}", dir.display()))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(format!("不是目录({})", dir.display()));
|
||||
}
|
||||
let (program, args) = dir_launcher(dir);
|
||||
launch(program, args)
|
||||
}
|
||||
|
||||
/// 真去拉进程(两个入口共用)。
|
||||
fn launch(program: &'static str, args: Vec<std::ffi::OsString>) -> Result<(), String> {
|
||||
quiet(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map(|_child| ())
|
||||
.map_err(|error| format!("拉起失败({program}):{error}"))
|
||||
}
|
||||
|
||||
/// 打开 URL 的程序与参数。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn url_launcher(url: &str) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
// `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。
|
||||
(
|
||||
"rundll32",
|
||||
vec!["url.dll,FileProtocolHandler".into(), url.into()],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn url_launcher(url: &str) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
("open", vec![url.into()])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn url_launcher(url: &str) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
("xdg-open", vec![url.into()])
|
||||
}
|
||||
|
||||
/// 打开目录的程序与参数。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dir_launcher(dir: &Path) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
// `explorer` 吃的是**一个**路径参数;别拼引号(它不是 shell,引号会变成名字的一部分)。
|
||||
("explorer", vec![dir.as_os_str().to_os_string()])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn dir_launcher(dir: &Path) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
("open", vec![dir.as_os_str().to_os_string()])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn dir_launcher(dir: &Path) -> (&'static str, Vec<std::ffi::OsString>) {
|
||||
("xdg-open", vec![dir.as_os_str().to_os_string()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{dir_launcher, open_dir, open_url, url_launcher};
|
||||
|
||||
/// URL 通道只收 http/https(不在单测里真去拉浏览器,只验命令形态)。
|
||||
#[test]
|
||||
fn rejects_non_http_urls() {
|
||||
assert!(open_url("javascript:alert(1)").is_err());
|
||||
assert!(open_url("").is_err());
|
||||
let (program, args) = url_launcher("http://localhost:1234/auth?code=a&state=b");
|
||||
assert!(!program.is_empty());
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|arg| arg.to_string_lossy().contains("http://localhost:1234/auth")));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_opens_urls_through_the_file_protocol_handler() {
|
||||
let (program, args) = url_launcher("https://example.invalid/");
|
||||
assert_eq!(program, "rundll32");
|
||||
assert_eq!(args[0], "url.dll,FileProtocolHandler");
|
||||
assert_eq!(args[1], "https://example.invalid/");
|
||||
}
|
||||
|
||||
/// 目录通道:不存在的路径直接拒(**不 spawn**,否则会弹出"我的电脑"或一句 OS 报错)。
|
||||
#[test]
|
||||
fn missing_dirs_are_rejected_before_spawning() {
|
||||
let gone = PathBuf::from("ull-no-such-dir-m235/definitely-missing");
|
||||
let error = open_dir(&gone).expect_err("不存在的目录居然打开了?");
|
||||
assert!(error.contains("找不到目录"), "错误文案:{error}");
|
||||
|
||||
// 存在但不是目录(用一个文件)也要拒。
|
||||
let file = std::env::temp_dir().join(format!("ull-opener-m235-{}", std::process::id()));
|
||||
std::fs::write(&file, b"x").expect("写临时文件");
|
||||
let error = open_dir(&file).expect_err("文件居然被当目录打开了?");
|
||||
let _ = std::fs::remove_file(&file);
|
||||
assert!(error.contains("不是目录"), "错误文案:{error}");
|
||||
}
|
||||
|
||||
/// 目录通道的命令形态(各平台一支,只验"程序名 + 一个路径参数",不 spawn)。
|
||||
#[test]
|
||||
fn dir_launcher_passes_the_path_as_a_single_argument() {
|
||||
let (program, args) = dir_launcher(&PathBuf::from("D:/ULL/.ULL/data/instances/x/saves"));
|
||||
assert!(!program.is_empty());
|
||||
assert_eq!(args.len(), 1, "路径必须是**一个**参数:{args:?}");
|
||||
assert_eq!(
|
||||
args[0].to_string_lossy().replace('\\', "/"),
|
||||
"D:/ULL/.ULL/data/instances/x/saves"
|
||||
);
|
||||
if cfg!(target_os = "windows") {
|
||||
assert_eq!(program, "explorer");
|
||||
} else if cfg!(target_os = "macos") {
|
||||
assert_eq!(program, "open");
|
||||
} else {
|
||||
assert_eq!(program, "xdg-open");
|
||||
}
|
||||
}
|
||||
|
||||
/// **防退化判据**(`AGENTS.md` §6):`explorer` / `xdg-open` 这两个字面量只许出现在本文件。
|
||||
///
|
||||
/// 抓得住:有人在 `game::saves` 或别的业务模块里再写一份平台分支(tower 在 M235 明确要求
|
||||
/// 把这条写成可执行判据,而不是只写在提交信息里)。
|
||||
/// 抓不住:经由变量间接拼出的程序名、以及本 crate 之外的 crate(presenter/app 侧另有
|
||||
/// `tests/child_quiet_guard.rs` 管"是否经 `quiet` 起进程",不管"在哪儿起")。
|
||||
#[test]
|
||||
fn the_launcher_literals_live_here_only() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut hits = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
if path == root.join("platform").join("opener.rs") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
// 注释里提到这两个名字(文档、handoff)不算退化。
|
||||
let code = line.split("//").next().unwrap_or("");
|
||||
if code.contains("\"explorer\"") || code.contains("\"xdg-open\"") {
|
||||
hits.push(format!("{}:{}", path.display(), index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hits.is_empty(),
|
||||
"平台 opener 必须只在 platform/opener.rs 里:{}",
|
||||
hits.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,26 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"),
|
||||
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
|
||||
("InstancesModsReasonRename", "the rename failed", "改名失败"),
|
||||
("InstancesSavesTitle", "SAVES", "存档"),
|
||||
("InstancesSavesReadOnly", "Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
||||
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
|
||||
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
("InstancesSavesMissingDir", "This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"),
|
||||
("InstancesSavesWorlds", "WORLDS", "世界"),
|
||||
("InstancesSavesScreenshots", "SCREENSHOTS", "截图"),
|
||||
("InstancesSavesNoWorlds", "No worlds yet.", "还没有世界。"),
|
||||
("InstancesSavesNoScreenshots", "No screenshots yet.", "还没有截图。"),
|
||||
("InstancesSavesWorldIcon", "has icon", "有图标"),
|
||||
("InstancesSavesTimeUnknown", "time unknown", "时间未知"),
|
||||
("InstancesSavesSkipped", "{count} entries skipped", "跳过 {count} 项"),
|
||||
("InstancesSavesOpenFolder", "Open saves folder", "打开存档文件夹"),
|
||||
("InstancesSavesRetry", "Retry", "重试"),
|
||||
("InstancesSavesFailed", "Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"),
|
||||
("InstancesSavesOpenFailed", "Could not open the folder: {reason}", "打开文件夹失败:{reason}"),
|
||||
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
|
||||
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
|
||||
("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
|
||||
@@ -154,6 +154,30 @@ macro_rules! instances_entries {
|
||||
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
|
||||
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
|
||||
InstancesModsReasonRename => ("the rename failed", "改名失败"),
|
||||
// ── 存档区(M235 · STOREMVP):**只读**列 `saves/` 与 `screenshots/` + 打开文件夹。
|
||||
// U3 的边界写在 `InstancesSavesReadOnly` 里,页面固定画出来——用户点开就看见
|
||||
// 「这一版只读」,不会以为漏了删除按钮。`saves/` / `screenshots/` 是**目录名**,
|
||||
// 两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
||||
InstancesSavesTitle => ("SAVES", "存档"),
|
||||
InstancesSavesReadOnly => ("Read-only in this version: nothing here is moved or deleted.", "这一版只读:这里不移动、也不删除任何东西。"),
|
||||
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
|
||||
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
InstancesSavesMissingDir => ("This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"),
|
||||
InstancesSavesWorlds => ("WORLDS", "世界"),
|
||||
InstancesSavesScreenshots => ("SCREENSHOTS", "截图"),
|
||||
InstancesSavesNoWorlds => ("No worlds yet.", "还没有世界。"),
|
||||
InstancesSavesNoScreenshots => ("No screenshots yet.", "还没有截图。"),
|
||||
InstancesSavesWorldIcon => ("has icon", "有图标"),
|
||||
InstancesSavesTimeUnknown => ("time unknown", "时间未知"),
|
||||
InstancesSavesSkipped => ("{count} entries skipped", "跳过 {count} 项"),
|
||||
InstancesSavesOpenFolder => ("Open saves folder", "打开存档文件夹"),
|
||||
InstancesSavesRetry => ("Retry", "重试"),
|
||||
InstancesSavesFailed => ("Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"),
|
||||
InstancesSavesOpenFailed => ("Could not open the folder: {reason}", "打开文件夹失败:{reason}"),
|
||||
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
|
||||
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
|
||||
InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ use crate::dto::{
|
||||
NoticeView,
|
||||
PackCommand,
|
||||
PackEvent,
|
||||
SavesCommand,
|
||||
SavesFailureView,
|
||||
SavesListView,
|
||||
SessionId,
|
||||
SettingGroupView,
|
||||
// M159: 自更新 —— 一条快照整体替换
|
||||
@@ -168,6 +171,10 @@ pub enum CoreEvent {
|
||||
Update(Box<UpdateView>),
|
||||
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
|
||||
Mods(Box<Result<ModListView, ModFailureView>>),
|
||||
/// 实例存档 / 截图的只读列表(M235):`Ok` = 整份快照;`Err` = 具名原因 + 原文。
|
||||
///
|
||||
/// 与 [`Self::Mods`] 分开:这一族**没有任何写路径**(U3 只做只读列表 + 打开文件夹)。
|
||||
Saves(Box<Result<SavesListView, SavesFailureView>>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
@@ -299,6 +306,8 @@ pub enum AppCommand {
|
||||
SetUpdateAutoCheck(bool),
|
||||
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
|
||||
Mods(Box<ModsCommand>),
|
||||
/// 实例存档 / 截图的只读列表(M235;子枚举与语义见 [`SavesCommand`] 的文档)。
|
||||
Saves(Box<SavesCommand>),
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
|
||||
@@ -32,6 +32,8 @@ mod notices;
|
||||
mod pack;
|
||||
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
|
||||
mod mods;
|
||||
// M235: 实例存档 / 截图的只读列表(详情页的「存档」区)
|
||||
mod saves;
|
||||
mod settings;
|
||||
// M159: 自更新(检查结论 / 候选 / 就位状态)
|
||||
mod update;
|
||||
@@ -84,6 +86,11 @@ pub use pack::{
|
||||
pub use settings::{
|
||||
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
|
||||
};
|
||||
// M235: 实例存档 / 截图的只读列表
|
||||
pub use saves::{
|
||||
SavesArea, SavesCommand, SavesFailureKind, SavesFailureView, SavesListView, SavesSkipKind,
|
||||
SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
// M159: 自更新
|
||||
pub use update::{
|
||||
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! 实例**存档 / 截图的只读快照**与「打开文件夹」的 IPC 面(M235 · STOREMVP)。
|
||||
//!
|
||||
//! # 三层里的第二层
|
||||
//!
|
||||
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙。三层是:
|
||||
//! `core::game::saves`(读盘,唯一的真相)→ 本模块(IPC 形状)→
|
||||
//! `crates/app-iced/src/pages/instances/saves.rs`(画)。
|
||||
//!
|
||||
//! | 方向 | 类型 |
|
||||
//! |---|---|
|
||||
//! | app → host | [`SavesCommand`](列目录 / 打开文件夹) |
|
||||
//! | host → app | [`SavesListView`](整份快照)或 [`SavesFailureView`](具名原因) |
|
||||
//!
|
||||
//! # 为什么另开一族,不并进 `ModListView`
|
||||
//!
|
||||
//! 两者形状相近(都是"列一个实例的某个目录"),但语义不同:mod 有启停(会写盘、要看游戏在不在跑),
|
||||
//! 存档这一版**没有任何写路径**。合并会让 mod 那边的 `pending` / `running` 字段在存档页变成
|
||||
//! 永远为假的分支(M224 拒绝给内容页加第五个 `ContentKind` 是同一条理由:别让穷尽 match 为不相干
|
||||
//! 的字段服务)。
|
||||
//!
|
||||
//! # 文案纪律(M161)
|
||||
//!
|
||||
//! 本模块**不带人读散文**:失败给的是结构([`SavesFailureKind`] + 底层 `detail` 串),
|
||||
//! 时间戳给的是**毫秒数**(UTC 毫秒,UI 自己格式化)。文案一律由 app 层按 i18n key 现求。
|
||||
//!
|
||||
//! # 时间戳为什么是 `Option<u64>` 的毫秒
|
||||
//!
|
||||
//! 取不到(早于 1970 / 文件系统不给)就是 `None`——**不拿 0 或当前时间填**:那会让"时间未知"
|
||||
//! 看起来像"1970 年"或"刚刚"。格式化交给 UI(`dto::logs` 的时戳同理)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
/// app → host 的存档指令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesCommand {
|
||||
/// 列某个实例的 `saves/` 与 `screenshots/`(进详情页时发、点「重试」时再发)。
|
||||
List(InstanceId),
|
||||
/// 用系统文件管理器打开存档目录。
|
||||
///
|
||||
/// 打开**哪个**目录由 host 决定(`saves/` → `screenshots/` → 运行目录,依次退化),
|
||||
/// 因为那是"磁盘上到底有什么"的事实,不该让 UI 猜。
|
||||
OpenFolder {
|
||||
/// 实例 id
|
||||
id: InstanceId,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一个世界目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorldView {
|
||||
/// 磁盘上的目录名(这一版它就是展示名:`LevelName` 在 `level.dat` 里,本版不解析 NBT)。
|
||||
pub dir: String,
|
||||
/// 有没有 `icon.png`(只判存在,不解码——缩略图墙不在 U3 范围内)。
|
||||
pub icon: bool,
|
||||
/// 目录 mtime(UTC 毫秒;`None` = 取不到)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 一张截图(**只给元数据**,不给像素:解码图片要引依赖,U3 明确不做)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotView {
|
||||
/// 磁盘文件名(含扩展名)。
|
||||
pub file: String,
|
||||
/// 字节数(UI 自己格式化,不在这里拼 `1.2 MiB`)。
|
||||
pub bytes: u64,
|
||||
/// 文件 mtime(UTC 毫秒;`None` = 取不到)。
|
||||
pub modified_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 被跳过的条目来自哪个目录。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesArea {
|
||||
/// `<run_dir>/saves/`
|
||||
Saves,
|
||||
/// `<run_dir>/screenshots/`
|
||||
Screenshots,
|
||||
}
|
||||
|
||||
/// 没收进列表的原因(与 `core::game::saves::SkipKind` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesSkipKind {
|
||||
/// 符号链接(不跟随)。
|
||||
SymbolicLink,
|
||||
/// `saves/` 下的非目录。
|
||||
NotADirectory,
|
||||
/// `screenshots/` 下的非普通文件。
|
||||
NotAFile,
|
||||
/// 名字过不了路径规则。
|
||||
UnsafeName,
|
||||
/// 元数据读不出来。
|
||||
Unreadable,
|
||||
/// 不是认得的图片扩展名。
|
||||
NotAnImage,
|
||||
}
|
||||
|
||||
/// 一个没能进列表的条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesSkipView {
|
||||
/// 来自哪个目录。
|
||||
pub area: SavesArea,
|
||||
/// 磁盘名字。
|
||||
pub file: String,
|
||||
/// 为什么没收。
|
||||
pub reason: SavesSkipKind,
|
||||
}
|
||||
|
||||
/// 一次列举的完整快照(**整体替换**,不做增量)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesListView {
|
||||
/// 实例 id
|
||||
pub instance: InstanceId,
|
||||
/// 被列的 `saves/` 目录(绝对路径)。
|
||||
pub dir: String,
|
||||
/// `saves/` 存不存在(`false` = 这实例还没存过档,不是错误)。
|
||||
pub exists: bool,
|
||||
/// 世界列表(按名字排序)。
|
||||
pub worlds: Vec<WorldView>,
|
||||
/// 被列的 `screenshots/` 目录(绝对路径)。
|
||||
pub screenshots_dir: String,
|
||||
/// `screenshots/` 存不存在。
|
||||
pub screenshots_exists: bool,
|
||||
/// 截图列表(**新的在前**)。
|
||||
pub screenshots: Vec<ScreenshotView>,
|
||||
/// 跳过的条目(UI 只需计数 + 一句"为什么")。
|
||||
pub skipped: Vec<SavesSkipView>,
|
||||
}
|
||||
|
||||
impl SavesListView {
|
||||
/// 世界数。
|
||||
#[must_use]
|
||||
pub fn world_count(&self) -> usize {
|
||||
self.worlds.len()
|
||||
}
|
||||
|
||||
/// 截图数。
|
||||
#[must_use]
|
||||
pub fn screenshot_count(&self) -> usize {
|
||||
self.screenshots.len()
|
||||
}
|
||||
|
||||
/// 跳过数。
|
||||
#[must_use]
|
||||
pub fn skipped_count(&self) -> usize {
|
||||
self.skipped.len()
|
||||
}
|
||||
|
||||
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty() && self.screenshots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败(app 按 key 现求文案)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavesFailureView {
|
||||
/// 实例 id(迟到/串台的结果靠它判)。
|
||||
pub instance: InstanceId,
|
||||
/// 失败大类。
|
||||
pub kind: SavesFailureKind,
|
||||
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 失败大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SavesFailureKind {
|
||||
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
|
||||
InstanceUnavailable,
|
||||
/// `saves/` / `screenshots/` 存在但不可用(符号链接 / 不是目录 / 读不了)。
|
||||
DirUnusable,
|
||||
/// 「打开文件夹」失败(目录不存在 / 拉不起文件管理器)。
|
||||
OpenFailed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn view() -> SavesListView {
|
||||
SavesListView {
|
||||
instance: "inst".to_string(),
|
||||
dir: "/tmp/run/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: true,
|
||||
modified_ms: Some(1_700_000_000_000),
|
||||
}],
|
||||
screenshots_dir: "/tmp/run/screenshots".to_string(),
|
||||
screenshots_exists: true,
|
||||
screenshots: vec![ScreenshotView {
|
||||
file: "2024-05-01_10.00.00.png".to_string(),
|
||||
bytes: 1024,
|
||||
modified_ms: None,
|
||||
}],
|
||||
skipped: vec![SavesSkipView {
|
||||
area: SavesArea::Screenshots,
|
||||
file: "notes.txt".to_string(),
|
||||
reason: SavesSkipKind::NotAnImage,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
|
||||
#[test]
|
||||
fn view_counts_and_round_trips() {
|
||||
let view = view();
|
||||
assert_eq!(view.world_count(), 1);
|
||||
assert_eq!(view.screenshot_count(), 1);
|
||||
assert_eq!(view.skipped_count(), 1);
|
||||
assert!(!view.is_empty());
|
||||
|
||||
let text = serde_json::to_string(&view).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesListView>(&text).expect("反序列化"),
|
||||
view
|
||||
);
|
||||
}
|
||||
|
||||
/// 两条指令都要能过 IPC(含「打开文件夹」——它的载荷只有实例 id)。
|
||||
#[test]
|
||||
fn commands_round_trip() {
|
||||
for command in [
|
||||
SavesCommand::List("inst".to_string()),
|
||||
SavesCommand::OpenFolder {
|
||||
id: "inst".to_string(),
|
||||
},
|
||||
] {
|
||||
let text = serde_json::to_string(&command).expect("序列化");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SavesCommand>(&text).expect("反序列化"),
|
||||
command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个目录都不存在 ⇒ 空(`is_empty` 只看内容,不看 `exists`:一个目录有空世界也算非空)。
|
||||
#[test]
|
||||
fn empty_means_no_worlds_and_no_screenshots() {
|
||||
let mut view = view();
|
||||
view.worlds.clear();
|
||||
view.screenshots.clear();
|
||||
assert!(view.is_empty(), "没有世界也没有截图就该算空");
|
||||
view.screenshots.push(ScreenshotView {
|
||||
file: "a.png".to_string(),
|
||||
bytes: 0,
|
||||
modified_ms: None,
|
||||
});
|
||||
assert!(!view.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ use crate::toast::ToastKind;
|
||||
|
||||
use super::accounts::{self, LoginJob};
|
||||
use super::{
|
||||
addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost,
|
||||
addon, game, install, java, launch, loader_versions, logs, mods, saves, settings, versions,
|
||||
CoreHost,
|
||||
};
|
||||
|
||||
impl CoreHost {
|
||||
@@ -289,6 +290,11 @@ impl CoreHost {
|
||||
let running = self.in_flight(&id);
|
||||
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表 + 打开文件夹
|
||||
// (只读,所以不必问"游戏在不在跑";拉起文件管理器在 core 的 platform opener 里)
|
||||
AppCommand::Saves(command) => {
|
||||
saves::dispatch(self.env.clone(), self.tx.clone(), *command);
|
||||
}
|
||||
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
|
||||
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
|
||||
self.chat.dispatch(other);
|
||||
|
||||
@@ -233,6 +233,12 @@ impl CoreHost {
|
||||
}
|
||||
self.send(CoreEvent::Mods(Box::new(list)));
|
||||
}
|
||||
// M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。
|
||||
// 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界,
|
||||
// 切回来这一拍就跟上)。
|
||||
HostTask::Saves(report) => {
|
||||
self.send(CoreEvent::Saves(Box::new(report.list)));
|
||||
}
|
||||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ pub enum HostTask {
|
||||
Update(Box<crate::host::update::Report>),
|
||||
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
|
||||
Mods(Box<super::mods::Report>),
|
||||
/// 实例存档 / 截图的只读列表(M235):快照或具名失败(见 `host::saves::Report`)
|
||||
Saves(Box<super::saves::Report>),
|
||||
}
|
||||
|
||||
/// 后台线程用的发送端(`Sender` 克隆即可)。
|
||||
|
||||
@@ -34,6 +34,7 @@ mod logs;
|
||||
mod meter;
|
||||
mod mods;
|
||||
mod opener;
|
||||
mod saves;
|
||||
mod settings;
|
||||
mod update;
|
||||
mod update_state;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! 实例存档 / 截图的宿主实现(M235 · STOREMVP)。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! | 纪律 | 落点 |
|
||||
//! |---|---|
|
||||
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] 走 `job::spawn`,UI 只收快照 |
|
||||
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
|
||||
//! | 只读 | 本模块一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
|
||||
//!
|
||||
//! ## 为什么不复用启动路径的 `run_dir()`
|
||||
//!
|
||||
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例
|
||||
//! 设置(`observe_mode`)。**列存档不该因为用户点开详情页就写盘**,所以这里只取派生结果。
|
||||
//!
|
||||
//! ## 「打开文件夹」为什么由 host 决定打开哪个目录
|
||||
//!
|
||||
//! `saves/` 可能还不存在(新实例),而用户点「打开文件夹」想看到的是**能放存档的地方**。
|
||||
//! 于是退化成:`saves/` → `screenshots/` → 运行目录,取第一个存在的。这个判断是"磁盘上到底
|
||||
//! 有什么"的事实,放在 host 这一侧;UI 只发一条 [`crate::dto::SavesCommand::OpenFolder`]。
|
||||
//!
|
||||
//! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6:
|
||||
//! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。
|
||||
//!
|
||||
//! ## 失败口径
|
||||
//!
|
||||
//! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按
|
||||
//! i18n key 现求(M161 的纪律)。三类失败见 [`crate::dto::SavesFailureKind`]。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
use unlucky_core::game::saves::{self, SavesError, SavesScan};
|
||||
use unlucky_core::platform::opener;
|
||||
|
||||
use crate::dto::{
|
||||
locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind,
|
||||
SavesFailureView, SavesListView, SavesSkipKind, SavesSkipView, ScreenshotView, WorldView,
|
||||
};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::job::{spawn, JobSender};
|
||||
|
||||
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。
|
||||
pub instance: InstanceId,
|
||||
/// 列表结果:成功 = 整份快照;失败 = 具名原因。
|
||||
pub list: Result<SavesListView, SavesFailureView>,
|
||||
}
|
||||
|
||||
/// 后台列一个实例的 `saves/` 与 `screenshots/`。
|
||||
pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId) {
|
||||
spawn("saves-list", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: list(&env, &id),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 后台用系统文件管理器打开存档目录,**并把刷新后的快照一起带回来**。
|
||||
///
|
||||
/// 带快照回来不是顺手:用户常在文件管理器里删掉旧世界再切回来,页面这一拍就能跟着更新,
|
||||
/// 不用等下一次进详情页。
|
||||
pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) {
|
||||
spawn("saves-open", move || {
|
||||
let report = Report {
|
||||
instance: id.clone(),
|
||||
list: open_folder(&env, &id),
|
||||
};
|
||||
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
|
||||
});
|
||||
}
|
||||
|
||||
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
|
||||
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
|
||||
match command {
|
||||
SavesCommand::List(id) => spawn_list(env, tx, id),
|
||||
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列目录(后台线程里调)。
|
||||
fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
||||
Ok(view(&instance, &scan))
|
||||
}
|
||||
|
||||
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
|
||||
fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
|
||||
let (instance, run_dir) = target(env, id)?;
|
||||
let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?;
|
||||
let target = open_target(&scan, &run_dir);
|
||||
opener::open_dir(&target)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?;
|
||||
Ok(view(&instance, &scan))
|
||||
}
|
||||
|
||||
/// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。
|
||||
fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf {
|
||||
if scan.exists {
|
||||
return scan.dir.clone();
|
||||
}
|
||||
if scan.screenshots_exists {
|
||||
return scan.screenshots_dir.clone();
|
||||
}
|
||||
run_dir.to_path_buf()
|
||||
}
|
||||
|
||||
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
|
||||
fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
|
||||
let instances = super::game::instances(env);
|
||||
let Some(instance) = locate(&instances, id) else {
|
||||
return Err(failure(
|
||||
id,
|
||||
SavesFailureKind::InstanceUnavailable,
|
||||
format!("实例 {id} 不在快照里(可能刚被删除)"),
|
||||
));
|
||||
};
|
||||
let dir = run_dir(env, instance)
|
||||
.map_err(|detail| failure(&instance.id, SavesFailureKind::InstanceUnavailable, detail))?;
|
||||
Ok((instance.clone(), dir))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
|
||||
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let global = super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
let derivation = derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map_err(|error| format!("派生运行目录失败:{error}"))?;
|
||||
Ok(derivation.game_dir)
|
||||
}
|
||||
|
||||
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
|
||||
fn view(instance: &InstanceSummary, scan: &SavesScan) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.id.clone(),
|
||||
dir: scan.dir.display().to_string(),
|
||||
exists: scan.exists,
|
||||
worlds: scan
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| WorldView {
|
||||
dir: world.dir.clone(),
|
||||
icon: world.icon,
|
||||
modified_ms: world.modified_ms,
|
||||
})
|
||||
.collect(),
|
||||
screenshots_dir: scan.screenshots_dir.display().to_string(),
|
||||
screenshots_exists: scan.screenshots_exists,
|
||||
screenshots: scan
|
||||
.screenshots
|
||||
.iter()
|
||||
.map(|shot| ScreenshotView {
|
||||
file: shot.file.clone(),
|
||||
bytes: shot.bytes,
|
||||
modified_ms: shot.modified_ms,
|
||||
})
|
||||
.collect(),
|
||||
skipped: scan
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|skip| SavesSkipView {
|
||||
area: match skip.area {
|
||||
saves::Area::Saves => SavesArea::Saves,
|
||||
saves::Area::Screenshots => SavesArea::Screenshots,
|
||||
},
|
||||
file: skip.file.clone(),
|
||||
reason: match skip.reason {
|
||||
saves::SkipKind::SymbolicLink => SavesSkipKind::SymbolicLink,
|
||||
saves::SkipKind::NotADirectoryExpected => SavesSkipKind::NotADirectory,
|
||||
saves::SkipKind::NotAFile => SavesSkipKind::NotAFile,
|
||||
saves::SkipKind::UnsafeName => SavesSkipKind::UnsafeName,
|
||||
saves::SkipKind::Unreadable => SavesSkipKind::Unreadable,
|
||||
saves::SkipKind::NotAnImage => SavesSkipKind::NotAnImage,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的错误 → 「目录不可用」+ 原文(**不翻译**,翻译在 app 层)。
|
||||
fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
|
||||
failure(
|
||||
instance,
|
||||
SavesFailureKind::DirUnusable,
|
||||
format!("{}:{}", error.dir.display(), error.detail),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,199 @@
|
||||
//! `host::saves` 的用例:**真盘端到端**(派生运行目录 → core 只读列目录 → 快照回来)。
|
||||
//!
|
||||
//! 端到端那条是这个模块存在的主要理由:只有"真的建一个实例根、真的放几个世界和截图再读回来"
|
||||
//! 才能验「派生出来的目录就是玩家看到的那个目录」。
|
||||
//!
|
||||
//! 抓不住什么:`OpenFolder` **不在这里真跑**(它会拉起用户的文件管理器);它唯一的判断
|
||||
//! (打开哪个目录)由 [`open_target_degrades_from_saves_to_run_dir`] 这条纯函数用例钉住,
|
||||
//! 真正 spawn 的那一步在 core 的 `platform::opener` 用例里。
|
||||
|
||||
use super::*;
|
||||
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
|
||||
use unlucky_core::game::saves::{SavesScan, SavesSkip, SkipKind};
|
||||
|
||||
const ID: &str = "survival";
|
||||
const VERSION: &str = "1.20.4-fabric-0.16.5";
|
||||
|
||||
/// 一次性临时根(与本 crate 其它 host 测试同款:不引第三方 tempfile)。
|
||||
struct TempRoot(PathBuf);
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个可用的便携根 + 一个 `custom` 档位的实例(运行目录由实例自己指,D20 里唯一这样表达的档位)。
|
||||
///
|
||||
/// 走 `custom` 是**故意**的:用例不必造 `versions/<id>/` 就能验「派生出来的目录就是读的那个」。
|
||||
fn fixture(tag: &str) -> (TempRoot, HostEnv, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
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);
|
||||
let root = TempRoot(path);
|
||||
std::fs::create_dir_all(&root.0).expect("建临时目录");
|
||||
let env = HostEnv::at_root(&root.0);
|
||||
let run = root.0.join("run");
|
||||
std::fs::create_dir_all(&run).expect("建运行目录");
|
||||
std::fs::create_dir_all(env.layout.config()).expect("建 config");
|
||||
let profile = format!(r#"{{"id":"{ID}","name":"生存","versionId":"{VERSION}"}}"#);
|
||||
std::fs::write(
|
||||
env.layout.profiles_config(),
|
||||
format!(r#"{{"schemaVersion":1,"profiles":[{profile}]}}"#),
|
||||
)
|
||||
.expect("写 profiles");
|
||||
let config = format!(
|
||||
r#"{{"schemaVersion":1,"id":"{ID}","versionId":"{VERSION}","isolation":"custom","gameDir":{}}}"#,
|
||||
serde_json::to_string(&run).expect("路径转 JSON")
|
||||
);
|
||||
let path = env.layout.instance_config(ID).expect("实例 id");
|
||||
std::fs::create_dir_all(path.parent().expect("instances 目录")).expect("建 instances");
|
||||
std::fs::write(path, config).expect("写实例设置");
|
||||
(root, env, run)
|
||||
}
|
||||
|
||||
fn instance() -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: ID.to_string(),
|
||||
name: "生存".to_string(),
|
||||
mc_version: "1.20.4".to_string(),
|
||||
loader: LoaderInfo {
|
||||
kind: LoaderKind::Fabric,
|
||||
version: "0.16.5".to_string(),
|
||||
},
|
||||
last_played: None,
|
||||
runtime: InstanceRuntime::Idle,
|
||||
icon: None,
|
||||
isolation: "custom".to_string(),
|
||||
version_id: VERSION.to_string(),
|
||||
version_present: true,
|
||||
launch: LaunchArgsView::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// **端到端**(真盘 + 真派生):世界 / 截图 / 跳过原因都过桥,且快照里的路径就是派生目录。
|
||||
///
|
||||
/// 抓得住:派生用错目录、`icon.png` 被当成"读到了"、跳过原因被折叠、字节数丢了。
|
||||
#[test]
|
||||
fn list_round_trips_on_a_real_root() {
|
||||
let (_root, env, run) = fixture("saves-e2e");
|
||||
std::fs::create_dir_all(run.join("saves/My World")).expect("建世界");
|
||||
std::fs::write(run.join("saves/My World/icon.png"), b"png").expect("写图标");
|
||||
std::fs::create_dir_all(run.join("saves/Bare World")).expect("建世界 2");
|
||||
std::fs::create_dir_all(run.join("screenshots")).expect("建截图目录");
|
||||
std::fs::write(run.join("screenshots/2024-05-01_10.00.00.png"), b"abc").expect("写截图");
|
||||
std::fs::write(run.join("screenshots/notes.txt"), b"x").expect("写非图片");
|
||||
|
||||
let listed = list(&env, ID).expect("列存档");
|
||||
assert!(listed.exists && listed.screenshots_exists);
|
||||
assert_eq!(std::path::PathBuf::from(&listed.dir), run.join("saves"));
|
||||
assert_eq!(
|
||||
std::path::PathBuf::from(&listed.screenshots_dir),
|
||||
run.join("screenshots")
|
||||
);
|
||||
|
||||
let worlds: Vec<(&str, bool)> = listed
|
||||
.worlds
|
||||
.iter()
|
||||
.map(|world| (world.dir.as_str(), world.icon))
|
||||
.collect();
|
||||
assert_eq!(worlds, [("Bare World", false), ("My World", true)]);
|
||||
assert_eq!(listed.world_count(), 2);
|
||||
|
||||
assert_eq!(listed.screenshot_count(), 1);
|
||||
assert_eq!(listed.screenshots[0].bytes, 3);
|
||||
assert_eq!(
|
||||
listed.skipped_count(),
|
||||
1,
|
||||
"非图片文件要登记原因,不能静默丢"
|
||||
);
|
||||
assert_eq!(listed.skipped[0].reason, SavesSkipKind::NotAnImage);
|
||||
assert_eq!(listed.skipped[0].area, SavesArea::Screenshots);
|
||||
}
|
||||
|
||||
/// 「打开文件夹」打开哪个目录:`saves/` → `screenshots/` → 运行目录。
|
||||
///
|
||||
/// 纯函数,**不 spawn**(真拉起文件管理器那一步在 core 的 opener 用例里)。
|
||||
#[test]
|
||||
fn open_target_degrades_from_saves_to_run_dir() {
|
||||
let run = PathBuf::from("D:/ULL/.ULL/data/instances/x");
|
||||
let mut scan = SavesScan {
|
||||
dir: run.join("saves"),
|
||||
exists: true,
|
||||
worlds: Vec::new(),
|
||||
screenshots_dir: run.join("screenshots"),
|
||||
screenshots_exists: true,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
assert_eq!(open_target(&scan, &run), run.join("saves"));
|
||||
|
||||
scan.exists = false;
|
||||
assert_eq!(
|
||||
open_target(&scan, &run),
|
||||
run.join("screenshots"),
|
||||
"saves 不在时退到 screenshots"
|
||||
);
|
||||
|
||||
scan.screenshots_exists = false;
|
||||
assert_eq!(open_target(&scan, &run), run, "两个都没有时打开运行目录");
|
||||
}
|
||||
|
||||
/// 找不到实例 ⇒ 具名失败(不是"空列表":那会让页面以为这实例没存档)。
|
||||
#[test]
|
||||
fn missing_instance_is_a_named_failure() {
|
||||
let (_root, env, _run) = fixture("saves-unknown");
|
||||
let failure = list(&env, "no-such-instance").expect_err("不存在的实例居然列成功了");
|
||||
assert_eq!(failure.kind, SavesFailureKind::InstanceUnavailable);
|
||||
assert_eq!(failure.instance, "no-such-instance");
|
||||
assert!(!failure.detail.is_empty(), "原因被吃掉了");
|
||||
}
|
||||
|
||||
/// `saves/` 存在但不可用 ⇒ `DirUnusable`,且原文里带着那个路径(用户能自己去看)。
|
||||
#[test]
|
||||
fn unusable_dir_maps_to_dir_unusable() {
|
||||
let (_root, env, run) = fixture("saves-unusable");
|
||||
std::fs::write(run.join("saves"), b"not a dir").expect("用文件占住 saves");
|
||||
|
||||
let failure = list(&env, ID).expect_err("saves 是个文件,居然列成功了");
|
||||
assert_eq!(failure.kind, SavesFailureKind::DirUnusable);
|
||||
assert!(
|
||||
failure.detail.contains("saves"),
|
||||
"原文里没有路径:{}",
|
||||
failure.detail
|
||||
);
|
||||
}
|
||||
|
||||
/// 跳过原因一一对应(`NotADirectoryExpected` → `NotADirectory` 这条尤其容易抄错)。
|
||||
#[test]
|
||||
fn every_core_skip_reason_keeps_its_own_kind() {
|
||||
let cases = [
|
||||
(SkipKind::SymbolicLink, SavesSkipKind::SymbolicLink),
|
||||
(
|
||||
SkipKind::NotADirectoryExpected,
|
||||
SavesSkipKind::NotADirectory,
|
||||
),
|
||||
(SkipKind::NotAFile, SavesSkipKind::NotAFile),
|
||||
(SkipKind::UnsafeName, SavesSkipKind::UnsafeName),
|
||||
(SkipKind::Unreadable, SavesSkipKind::Unreadable),
|
||||
(SkipKind::NotAnImage, SavesSkipKind::NotAnImage),
|
||||
];
|
||||
for (reason, expected) in cases {
|
||||
let scan = SavesScan {
|
||||
dir: PathBuf::from("s"),
|
||||
exists: true,
|
||||
worlds: Vec::new(),
|
||||
screenshots_dir: PathBuf::from("sh"),
|
||||
screenshots_exists: true,
|
||||
screenshots: Vec::new(),
|
||||
skipped: vec![SavesSkip {
|
||||
area: saves::Area::Saves,
|
||||
file: "x".to_string(),
|
||||
reason,
|
||||
}],
|
||||
};
|
||||
assert_eq!(view(&instance(), &scan).skipped[0].reason, expected);
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,13 @@ mod agent;
|
||||
mod live;
|
||||
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
|
||||
mod mods;
|
||||
// M235: 实例存档 / 截图的只读快照(状态落地 + 迟到丢弃)
|
||||
mod saves;
|
||||
mod tiles;
|
||||
|
||||
pub use live::MAX_LIVE_LINES;
|
||||
pub use mods::ModsState;
|
||||
pub use saves::SavesState;
|
||||
pub use tiles::{grid_positions, TileCell};
|
||||
|
||||
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
|
||||
@@ -141,6 +144,8 @@ pub struct AppState {
|
||||
pub adopt: AdoptState,
|
||||
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
|
||||
pub mods: ModsState,
|
||||
/// 实例存档 / 截图(M235):当前实例的只读快照 / 失败(可重试)
|
||||
pub saves: SavesState,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -188,6 +193,7 @@ impl AppState {
|
||||
pack: PackState::default(),
|
||||
adopt: AdoptState::default(),
|
||||
mods: ModsState::default(),
|
||||
saves: SavesState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +322,8 @@ impl AppState {
|
||||
}
|
||||
// M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记)
|
||||
CoreEvent::Mods(result) => self.mods.apply(*result),
|
||||
// M235:实例存档 / 截图(落地判据在 `state/saves.rs`,含迟到丢弃)
|
||||
CoreEvent::Saves(result) => self.saves.apply(*result),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 实例存档 / 截图快照的落地(M235 · STOREMVP)。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出(那个文件贴着 400 行硬线),形状与 [`super::mods`] 一致:
|
||||
//! **另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
|
||||
//!
|
||||
//! # 为什么快照住在这里而不是页面
|
||||
//!
|
||||
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
|
||||
//!
|
||||
//! - 快照走 [`crate::dto::CoreEvent::Saves`] 的 `Ok` 一支,**整体替换**(不做增量合并)——
|
||||
//! host 每次都是重新扫一遍目录才发的,合并只会两处真相;
|
||||
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案;
|
||||
//! - **可重试**:失败时不清 `bound`,页面据此画"重试"按钮(再发一条
|
||||
//! [`crate::dto::SavesCommand::List`] 就行,没有别的状态要复位)。
|
||||
//!
|
||||
//! # 迟到结果
|
||||
//!
|
||||
//! 用户很可能点开 A 的详情页、还没等后台回来就切到 B。所以 `view.instance` 与
|
||||
//! [`SavesState::bound`] 不一致的结果**直接丢掉**(与 [`super::mods`] 同一条纪律)。
|
||||
|
||||
use crate::dto::{SavesFailureView, SavesListView};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// 存档区的视图态。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SavesState {
|
||||
/// 当前详情页的实例 id(`None` = 不在详情页)。
|
||||
pub bound: Option<String>,
|
||||
/// 最近一份快照(`None` = 还没请求过)。
|
||||
pub view: Option<SavesListView>,
|
||||
/// 最近一条失败(具名原因;成功一次就清掉)。
|
||||
pub failed: Option<SavesFailureView>,
|
||||
}
|
||||
|
||||
impl SavesState {
|
||||
/// 切到另一个实例(换实例必须清空:A 的存档不能画在 B 的详情页上)。
|
||||
pub(super) fn bind(&mut self, instance: Option<&str>) {
|
||||
if self.bound.as_deref() == instance {
|
||||
return;
|
||||
}
|
||||
self.bound = instance.map(str::to_string);
|
||||
self.view = None;
|
||||
self.failed = None;
|
||||
}
|
||||
|
||||
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
|
||||
pub fn apply(&mut self, result: Result<SavesListView, SavesFailureView>) {
|
||||
match result {
|
||||
Ok(view) => self.changed(view),
|
||||
Err(failure) => self.failed(failure),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快照落地(整体替换;迟到/串台的丢掉)。
|
||||
pub fn changed(&mut self, view: SavesListView) {
|
||||
if self.bound.as_deref() != Some(view.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = None;
|
||||
self.view = Some(view);
|
||||
}
|
||||
|
||||
/// 失败落地(只记**当前绑定实例**的;old 列表保留——用户还能看见旧列表,只是多一条红字)。
|
||||
pub fn failed(&mut self, failure: SavesFailureView) {
|
||||
if self.bound.as_deref() != Some(failure.instance.as_str()) {
|
||||
return;
|
||||
}
|
||||
self.failed = Some(failure);
|
||||
}
|
||||
|
||||
/// 当前绑定实例的列表是否已经有一份快照。
|
||||
#[must_use]
|
||||
pub fn has_view(&self) -> bool {
|
||||
self.view.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `bind_mods` 同一处生命周期)。
|
||||
pub fn bind_saves(&mut self, instance: Option<&str>) {
|
||||
self.saves.bind(instance);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{SavesFailureKind, WorldView};
|
||||
|
||||
fn view(instance: &str) -> SavesListView {
|
||||
SavesListView {
|
||||
instance: instance.to_string(),
|
||||
dir: "C:/mc/saves".to_string(),
|
||||
exists: true,
|
||||
worlds: vec![WorldView {
|
||||
dir: "World".to_string(),
|
||||
icon: false,
|
||||
modified_ms: None,
|
||||
}],
|
||||
screenshots_dir: "C:/mc/screenshots".to_string(),
|
||||
screenshots_exists: false,
|
||||
screenshots: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(instance: &str) -> SavesFailureView {
|
||||
SavesFailureView {
|
||||
instance: instance.to_string(),
|
||||
kind: SavesFailureKind::DirUnusable,
|
||||
detail: "Access is denied.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换实例必须清空(A 的存档不许画在 B 的详情页上)。
|
||||
#[test]
|
||||
fn binding_another_instance_drops_the_old_view() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.has_view());
|
||||
|
||||
saves.bind(Some("b"));
|
||||
assert!(!saves.has_view(), "换实例后还留着 A 的存档");
|
||||
assert_eq!(saves.bound.as_deref(), Some("b"));
|
||||
|
||||
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
|
||||
saves.changed(view("b"));
|
||||
saves.bind(Some("b"));
|
||||
assert!(saves.has_view());
|
||||
}
|
||||
|
||||
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败(重试成功后红字要消失)。
|
||||
#[test]
|
||||
fn late_results_are_dropped_and_success_clears_the_failure() {
|
||||
let mut saves = SavesState::default();
|
||||
saves.bind(Some("a"));
|
||||
saves.changed(view("b"));
|
||||
assert!(!saves.has_view(), "B 的列表画到了 A 上");
|
||||
|
||||
saves.failed(failure("a"));
|
||||
assert!(saves.failed.is_some());
|
||||
assert!(saves.bound.is_some(), "失败不许把绑定清掉(要能重试)");
|
||||
saves.changed(view("a"));
|
||||
assert!(saves.failed.is_none(), "成功后旧失败没清掉");
|
||||
|
||||
// 不在详情页(`bound = None`)时任何结果都不落地。
|
||||
let mut idle = SavesState::default();
|
||||
idle.changed(view("a"));
|
||||
idle.failed(failure("a"));
|
||||
assert!(!idle.has_view());
|
||||
assert!(idle.failed.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user