feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)
三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。 r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。 core(crates/core/src/addon/mods/) - suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名), 收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant` 改为委派/再导出,签名与行为不变。 - list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤, 符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。 - toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做 copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。 presenter - dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。 - host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置; 游戏在跑时 host 侧直接挡。 - state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。 app-iced(实例详情页) - MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。 - 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。 - MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。 - i18n:19 条中英文案(`catalog/instances.rs` + 金表)。 rebase 冲突解法(三个文件,全是"两侧同段新增",都保留): - `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的 InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。 - `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归 `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs` (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。 r1 四条文档疵: 1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么; 2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods` + 文字说明 Ok 落快照 / Err 落具名失败; 3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空"; 4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 → 改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。 顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条 `clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
1 parent
71144dfca8
commit
a0f61d8bb5
40 files changed
+2762
-91
No files matched your search
@@ -67,7 +67,9 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
|
||||
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
mod boot_probe;
|
||||
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
|
||||
mod construct;
|
||||
mod mods_shot;
|
||||
mod refresh;
|
||||
mod theme;
|
||||
|
||||
@@ -180,6 +182,11 @@ pub struct App {
|
||||
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
|
||||
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
|
||||
pub(super) launch_ends_seen: u64,
|
||||
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
|
||||
///
|
||||
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
|
||||
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
|
||||
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
|
||||
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
|
||||
///
|
||||
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
|
||||
|
||||
@@ -139,6 +139,7 @@ impl App {
|
||||
adopt_form: super::super::adopt::Form::default(),
|
||||
install_cancelled_seen: 0,
|
||||
launch_ends_seen: 0,
|
||||
mods_failed_seen: None,
|
||||
dialog_card: None,
|
||||
dialog_model: None,
|
||||
content: pages::content::PageState::default(),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE=1`)。
|
||||
//!
|
||||
//! # 它解决什么
|
||||
//!
|
||||
//! D30 的 MODS 区截图需要一屏**有内容的列表**。产品路径的列表来自后台线程(列 `mods/` → 快照),
|
||||
//! 而截图通道在第 3 帧就出图(`CAPTURE_AT_FRAME`):那条后台往返来不及完成,截出来会是
|
||||
//! 「正在读取 mods/…」。M157 的启动参数区、M222 的下载页、M223 的导入都是同一个问题,
|
||||
//! 处理方式一致:**状态注入**,且注入点放在「快照 → 页面模型」之前,于是转换逻辑照样被执行。
|
||||
//!
|
||||
//! # 边界(不要把它读成产品能力)
|
||||
//!
|
||||
//! - 只在环境变量存在时生效;产品路径读不到它 ⇒ 一帧都不影响;
|
||||
//! - 它注入的是**快照**([`ModListView`]),不是页面模型——`Mods::from_state` 的转换、
|
||||
//! 计数、`.old` 徽标、在飞置灰走的都是产品代码;
|
||||
//! - 真实链路由两条端到端用例钉住(`host::mods::tests` 在真盘上列目录 + 启停)。
|
||||
|
||||
/// 夹具开关是否打开(`ULL_MODS_FIXTURE` 非空且不为 `0`)。
|
||||
#[must_use]
|
||||
pub fn enabled() -> bool {
|
||||
std::env::var("ULL_MODS_FIXTURE")
|
||||
.map(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 一份固定快照(实例 id 用调用方给的那个)。
|
||||
///
|
||||
/// `dir` 故意留空:夹具不碰路径(这一版页面也不画它),写一个假路径反而会被当成"真的派生结果"。
|
||||
#[must_use]
|
||||
pub fn view(instance: &str) -> unlucky_presenter::dto::ModListView {
|
||||
use unlucky_presenter::dto::{ModEntryView, ModListView};
|
||||
|
||||
let entry = |file: &str, name: &str, enabled: bool, bytes: u64, old: bool| ModEntryView {
|
||||
file: file.to_string(),
|
||||
name: name.to_string(),
|
||||
enabled,
|
||||
bytes,
|
||||
old,
|
||||
};
|
||||
// 夹具不碰路径:`dir` 留空(页面这一版不画它,它只是 DTO 的一部分)。
|
||||
let dir = String::new();
|
||||
|
||||
ModListView {
|
||||
instance: instance.to_string(),
|
||||
dir,
|
||||
exists: true,
|
||||
entries: vec![
|
||||
entry(
|
||||
"sodium-fabric-0.5.11.jar",
|
||||
"sodium-fabric-0.5.11.jar",
|
||||
true,
|
||||
1_048_576,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"iris-fabric-1.8.5.jar",
|
||||
"iris-fabric-1.8.5.jar",
|
||||
true,
|
||||
2_621_440,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"jei-1.20.4-forge-17.3.0.49.jar.disabled",
|
||||
"jei-1.20.4-forge-17.3.0.49.jar",
|
||||
false,
|
||||
1_363_148,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"aether-1.20.1-1.0.0.jar.old",
|
||||
"aether-1.20.1-1.0.0.jar",
|
||||
true,
|
||||
7_340_032,
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
"create-1.20.1-0.5.1.f.jar.disabled",
|
||||
"create-1.20.1-0.5.1.f.jar",
|
||||
false,
|
||||
4_194_304,
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
"fabric-api-0.92.2+1.20.4.jar",
|
||||
"fabric-api-0.92.2+1.20.4.jar",
|
||||
true,
|
||||
2_097_152,
|
||||
false,
|
||||
),
|
||||
],
|
||||
skipped: Vec::new(),
|
||||
running: false,
|
||||
known: true,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
|
||||
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
|
||||
|
||||
use unlucky_presenter::dto::{AppCommand, ModsCommand};
|
||||
|
||||
use super::mods_shot;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::view;
|
||||
@@ -57,6 +60,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// M224:详情页 ⇄ MODS 区的绑定与取数(换实例就重新列一次 mods/)。
|
||||
// **必须在建模型之前**:它会把快照写进 presenter 状态,而下一行 `model()` 就读那份状态
|
||||
// (放在 `model()` 之后等于这一帧画的是旧快照——M224 出图实测:注入的夹具要等下一帧)。
|
||||
self.sync_mods();
|
||||
// M224:启停失败的提示语(presenter 只发结构,文案在这里按 i18n 现求)。
|
||||
self.note_mods_failure();
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
@@ -110,4 +120,58 @@ impl App {
|
||||
None => self.launch_form.bound = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情页 ⇄ MODS 区的绑定与取数。
|
||||
///
|
||||
/// 判据只有一条:**绑定变了**(进详情页 / 换实例 / 退出详情页)。
|
||||
/// - 绑定函数自己会清掉旧快照(A 的列表不许画在 B 的详情页上,见 presenter 的 `ModsState::bind`);
|
||||
/// - 绑定之后手上没有快照 ⇒ 派一条 `AppCommand::Mods(List)` 去后台列目录。
|
||||
///
|
||||
/// 为什么放在 `refresh_view` 里:它是**唯一**的「路由变了」漏斗(点行、返回、首屏自动选中都走它)。
|
||||
/// 派发会再进一次 `refresh_view`(`dispatch` → `pump` → 重建),但那时 `bound` 已经相等,
|
||||
/// 这一支直接返回,不会重复发请求、也不会无限递归。
|
||||
fn sync_mods(&mut self) {
|
||||
let wanted = match self.state.route() {
|
||||
Route::Detail(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if self.state.mods.bound == wanted {
|
||||
return;
|
||||
}
|
||||
self.state.bind_mods(wanted.as_deref());
|
||||
let Some(id) = wanted else {
|
||||
return;
|
||||
};
|
||||
// 出图取证(`ULL_MODS_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
|
||||
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `mods_shot` 的模块文档)。
|
||||
if mods_shot::enabled() {
|
||||
self.state.mods.changed(mods_shot::view(&id));
|
||||
return;
|
||||
}
|
||||
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒(`watch_until`),
|
||||
// 而这里只是"进详情页顺手后台列一次目录"——结果在下一拍心跳(1 Hz)落地就够了。
|
||||
// 走观察窗会把 M192 的「空闲不烧帧」守卫打红(`subscription` 的三条用例立刻红:实测过)。
|
||||
self.backend
|
||||
.dispatch(AppCommand::Mods(Box::new(ModsCommand::List(id))));
|
||||
}
|
||||
|
||||
/// 启停失败的提示语:每条**新**失败弹一次 Toast。
|
||||
///
|
||||
/// 判据是 `(实例, 文件, 大类)` 三元组:同一个文件连点两次、每次都失败在同一个大类上时,
|
||||
/// **只弹一次**——第二次是同一件事的重复,再弹就是刷屏(用户已经看见了那句红字)。
|
||||
/// 换了文件、换了实例、或失败大类变了(例如"不覆盖冲突"变成"被占用"),三元组就变了,
|
||||
/// 于是照样会再弹一次:提示语跟的是"新事实",不是"点击次数"。
|
||||
fn note_mods_failure(&mut self) {
|
||||
let Some(failure) = self.state.mods.failed.as_ref() else {
|
||||
self.mods_failed_seen = None;
|
||||
return;
|
||||
};
|
||||
let key = (failure.instance.clone(), failure.file.clone(), failure.kind);
|
||||
if self.mods_failed_seen.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let text = pages::mods_failure_line(failure, self.lang().catalog());
|
||||
self.state.toasts.push(ToastKind::Error, text);
|
||||
self.mods_failed_seen = Some(key);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
|
||||
|
||||
use iced::Task;
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch};
|
||||
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
|
||||
|
||||
use crate::pages::InstanceMessage;
|
||||
|
||||
@@ -19,17 +19,29 @@ use super::super::message::Message;
|
||||
use super::super::state::App;
|
||||
|
||||
impl App {
|
||||
/// 启动参数区的一条消息。
|
||||
/// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
|
||||
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
|
||||
let Some(instance) = self.selected_instance_id() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self.launch_form.bound_to(&instance) {
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match message {
|
||||
// M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰),
|
||||
// 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。
|
||||
//
|
||||
// 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」,
|
||||
// 与启动参数表单播没播种无关(守卫是给表单草稿用的,见下一条)。
|
||||
InstanceMessage::SetModEnabled { file, enabled } => {
|
||||
self.state.mods.mark_pending(&file);
|
||||
self.dispatch(AppCommand::Mods(Box::new(ModsCommand::SetEnabled {
|
||||
id: instance,
|
||||
file,
|
||||
enabled,
|
||||
})));
|
||||
return Task::none();
|
||||
}
|
||||
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
|
||||
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
|
||||
InstanceMessage::Jvm(text) => {
|
||||
self.launch_form.jvm = text;
|
||||
self.launch_form.dirty = true;
|
||||
@@ -137,6 +149,34 @@ mod tests {
|
||||
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘");
|
||||
}
|
||||
|
||||
/// **MODS 区的启停在表单守卫之前**(M224):表单还没播种也照样能启停。
|
||||
///
|
||||
/// 抓得住:把 `SetModEnabled` 放回 `bound_to` 守卫之后——那时首帧的详情页点了开关
|
||||
/// 什么都不会发生(用户看到开关纹丝不动,也没有任何提示)。
|
||||
#[test]
|
||||
fn mod_toggles_work_before_the_launch_form_is_bound() {
|
||||
let mut app = app_with_form();
|
||||
// 页面已经打开(真路径上 `refresh_view` 的 `sync_mods` 会做这一步),但表单还没播种。
|
||||
app.state.bind_mods(Some(INSTANCE));
|
||||
app.launch_form = LaunchForm::from_view(
|
||||
Some("other"),
|
||||
&LaunchArgsView {
|
||||
instance_id: "other".to_string(),
|
||||
..LaunchArgsView::default()
|
||||
},
|
||||
);
|
||||
|
||||
let _ = app.on_instance_message(InstanceMessage::SetModEnabled {
|
||||
file: "sodium.jar".to_string(),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
assert!(
|
||||
app.state.mods.is_pending("sodium.jar"),
|
||||
"启停被表单守卫挡住了(开关点了没反应)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
|
||||
#[test]
|
||||
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
|
||||
|
||||
@@ -255,6 +255,8 @@ pub(super) fn model(
|
||||
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
|
||||
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
|
||||
logs: logs_model(state).with_status(lang),
|
||||
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
|
||||
mods: instances::mods_of(state, catalog),
|
||||
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
|
||||
update: update_of(&state.update),
|
||||
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||
|
||||
@@ -23,8 +23,9 @@ use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
use crate::pages::{Detail, LaunchFacts, Meta, Mods};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
@@ -264,3 +265,11 @@ pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地 Mod 的展示模型(M224):presenter 的视图态 → 页面要画的东西。
|
||||
///
|
||||
/// 转换本体在 [`crate::pages::instances::mods`] 的 `from_state`(文案要用 `Texts`,那是页面层的
|
||||
/// 取词入口)——这里只把"当前语言"给过去,与 [`detail_of`] 同一条接法。
|
||||
pub(super) fn mods_of(state: &AppState, catalog: Catalog) -> Mods {
|
||||
crate::pages::mods_from_state(&state.mods, catalog)
|
||||
}
|
||||
Reference in new issue
Block a user