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:
tower-worker committed 2026-11-10 21:09:37 +08:00
1 parent 71144dfca8
commit a0f61d8bb5
39 files changed
+2760 -89

No files matched your search

+7
View File
@@ -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,
}
}
+64
View File
@@ -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);
}
}
+46 -6
View File
@@ -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() {
+2
View File
@@ -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` 的回流结果)→ 页面视图。设置页的下载组从这里按
+10 -1
View File
@@ -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)
}
+17 -2
View File
@@ -49,6 +49,8 @@ mod data;
mod demo;
mod detail;
mod launch_args;
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
mod mods;
mod state;
mod text;
@@ -58,6 +60,15 @@ mod tests;
#[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(crate) use demo::{demo, demo_form};
pub use mods::{from_state as mods_from_state, Mods};
/// 启停失败/读目录失败的一句话(app 层弹 Toast 用;取词入口仍是 [`Texts`])。
pub fn mods_failure_line(
failure: &unlucky_presenter::dto::ModFailureView,
catalog: unlucky_i18n::Catalog,
) -> String {
mods::failure_line(failure, Texts::of(catalog))
}
pub use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts;
@@ -131,12 +142,16 @@ pub fn view<'a>(
metrics,
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, form, forced, texts),
Some(detail) => {
detail::detail_column(ui, metrics, detail, form, &model.mods, forced, texts)
}
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, form, forced, texts),
Some(detail) => {
detail::narrow_detail(ui, metrics, detail, form, &model.mods, forced, texts)
}
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
@@ -33,6 +33,7 @@ pub(super) fn detail_column<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -45,6 +46,10 @@ pub(super) fn detail_column<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
// M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高,
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
// 顺序因此是:身份(页头 + 元信息)→ 内容(MODS)→ 选项(启动参数)→ 动作。
body = body.push(super::mods::section(ui, mods, forced, texts));
// M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
@@ -62,6 +67,7 @@ pub(super) fn narrow_detail<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
form: &'a LaunchForm,
mods: &'a super::Mods,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
@@ -84,6 +90,8 @@ pub(super) fn narrow_detail<'a>(
.width(Length::Fill);
body = push_text(ui, metrics, body, detail);
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
body = body.push(super::mods::section(ui, mods, forced, texts));
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role(
+314
View File
@@ -0,0 +1,314 @@
//! 实例详情页的 **MODS 区**(M224 · MODTOGGLE):本地 mod 列表 + 启停开关。
//!
//! # 位置与形状
//!
//! 挂在详情列里、元信息网格之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`),
//! 动作行仍在最后。与启动参数区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
//!
//! **为什么在启动参数之前**:详情列整列是一个滚动区,而启动参数卡本身就有近一屏高。MODS 排在它
//! 后面时,1180×1000 下 MODS 区整块落在折叠线以下、根本看不见(M224 出图实测踩到过)。
//!
//! # 为什么不自己再套一层滚动区
//!
//! 详情列**整列**已经是滚动区(M157 r2 修的「Max memory 以下够不着」)。这里再套一个滚动条
//! 会出现「滚动条里的滚动条」——1180×1000 下两个条都认得出来,用户不知道该拖哪一个。
//! 列表多长就排多长,交给外层那一条。
//!
//! # 开关三档
//!
//! | 状态 | 开关 | 为什么 |
//! |---|---|---|
//! | 正常 | 可点(发 [`PageMessage::SetModEnabled`]) | — |
//! | 游戏在跑 | 禁用(不带消息) | Windows 上进程正持有 jar,rename 必然失败(host 侧也挡了一层) |
//! | 请求在飞 | 禁用 | 同一文件连点两次会发出两条 rename,第二条必然报「文件不在」 |
//!
//! **不做乐观回显**:开关画的是快照里的 `enabled`,点完等 host 回一份新快照才翻面。
//! 与启动/停止同一条纪律(`app/update/instances.rs` 的 Save 分支)。
use iced::widget::{column, 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, ModFailureView};
use unlucky_presenter::state::ModsState;
use crate::app::components::interaction::Interaction;
use crate::app::components::selection::{switch, Switch};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::state::Message as PageMessage;
use super::Texts;
/// 一条 mod(纯展示字段,来源是 `presenter::dto::ModEntryView`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
/// 磁盘文件名(开关消息原样带回去)。
pub file: String,
/// 展示名(去掉 `.disabled` / `.old`)。
pub name: String,
/// 是否启用。
pub enabled: bool,
/// 右侧副文案(大小 + 「旧版本残留」标记,已由 app 层拼好)。
pub detail: String,
/// 这个文件的启停请求在飞(开关先禁用,不做乐观翻面)。
pub pending: bool,
}
/// MODS 区的展示模型(`Model::mods`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Mods {
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
pub bound: Option<String>,
/// 正在读盘(详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 游戏在该实例上运行(开关只读)。
pub running: bool,
/// 实例有 `mods/` 目录吗(`false` 且没有错误 ⇒ 还没装过模组)。
pub exists: bool,
/// 读目录失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 列表。
pub items: Vec<Item>,
/// 跳过条目数(符号链接 / 目录 / 读不出)。
pub skipped: usize,
/// 计数行(`12 启用 · 3 禁用`):**在这一层算好**,页面只借 `&str`。
///
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。所以"要显示的那句话"
/// 一律在视图模型里备好(与 `Detail::status` / `Row::supporting` 同一条做法)。
pub count: String,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
}
impl Mods {
/// 启用中的条数。
#[must_use]
pub fn enabled_count(&self) -> usize {
self.items.iter().filter(|item| item.enabled).count()
}
/// 禁用中的条数。
#[must_use]
pub fn disabled_count(&self) -> usize {
self.items.len() - self.enabled_count()
}
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
#[must_use]
pub fn loaded(&self) -> bool {
self.bound.is_some() && !self.loading
}
/// 把某个文件的开关标成「请求在飞」(不做乐观翻面:`enabled` 不动)。
pub fn mark_pending(&mut self, file: &str) {
if let Some(item) = self.items.iter_mut().find(|item| item.file == file) {
item.pending = true;
}
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 两条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `items`(用户还能看见列表,只是多一条红字),
/// 只有"从来没有过快照"时才是一条纯错误态;
/// 2. **不做乐观翻面**:`enabled` 只来自快照,`pending` 只用来把开关置灰。
#[must_use]
pub fn from_state(state: &ModsState, catalog: Catalog) -> Mods {
let texts = Texts::of(catalog);
let mut out = Mods {
bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
..Mods::default()
};
if let Some(view) = &state.view {
out.running = view.running;
out.exists = view.exists;
out.skipped = view.skipped.len();
// **先装列表再算计数**:`enabled_count()` 读的是 `out.items`(顺序反了会画出 `0 启用 · 0 禁用`
// ——M224 出图实测抓到过一次)。
out.items = view
.entries
.iter()
.map(|entry| Item {
file: entry.file.clone(),
name: entry.name.clone(),
enabled: entry.enabled,
detail: item_detail(entry.bytes, entry.old, texts),
pending: state.is_pending(&entry.file),
})
.collect();
out.count = texts.mods_count(out.enabled_count(), out.disabled_count());
out.skipped_note = texts.mods_skipped(out.skipped);
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
}
out
}
/// 启停失败/读目录失败的一句话(**唯一产地**:Toast 与页面内红字共用)。
#[must_use]
pub fn failure_line(failure: &ModFailureView, texts: Texts) -> String {
let reason = texts.mods_reason(failure.kind);
let sentence = if failure.file.is_empty() {
texts.mods_failed(reason)
} else {
texts.mods_toggle_failed(&failure.file, reason)
};
if failure.detail.is_empty() {
return sentence;
}
format!("{sentence}({})", failure.detail)
}
/// 右侧副文案:`1.2 MiB` 或 `1.2 MiB · 旧版本残留`。
fn item_detail(bytes: u64, old: bool, texts: Texts) -> String {
let size = human_bytes(bytes);
if old {
format!("{size} · {}", texts.mods_old_badge())
} else {
size
}
}
/// 画 MODS 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
mods: &'a Mods,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![head(ui, mods, texts)]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
if mods.loading {
body = body.push(note(ui, texts.mods_loading()));
} else if let Some(error) = mods.error.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
error,
ui.color(Role::Error),
));
} else if !mods.exists {
body = body.push(note(ui, texts.mods_missing_dir()));
} else if mods.items.is_empty() {
body = body.push(note(ui, texts.mods_empty()));
} else {
// 只读原因放在列表**上面**:用户先看到"为什么点不动",再去点。
if mods.running {
body = body.push(note(ui, texts.mods_running()));
}
for item in &mods.items {
body = body.push(entry_row(ui, item, mods.running, forced));
}
if mods.skipped > 0 {
body = body.push(note(ui, &mods.skipped_note));
}
}
frame::card(ui, body)
}
/// 小标行:`MODS` + 计数(`12 启用 · 3 禁用`)。
fn head<'a>(ui: &'a Ui, mods: &'a Mods, texts: Texts) -> Element<'a, Message> {
let mut line = row![frame::text_role(
ui,
TypeRole::LabelMedium,
false,
texts.mods_title(),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill);
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
let show_count = mods.loaded() && mods.error.is_none() && !mods.items.is_empty();
if show_count {
line = line.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
mods.count.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
line.into()
}
/// 一条 mod 的行:名字 + 副文案 + 开关。
fn entry_row<'a>(
ui: &'a Ui,
item: &'a Item,
running: bool,
forced: Option<Interaction>,
) -> Element<'a, Message> {
let label = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
item.name.as_str(),
ui.color(if item.enabled {
Role::OnSurface
} else {
Role::OnSurfaceVariant
}),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
let toggle = if running || item.pending {
Switch::disabled("", item.enabled)
} else {
Switch::new(
"",
item.enabled,
Message::Instances(PageMessage::SetModEnabled {
file: item.file.clone(),
enabled: !item.enabled,
}),
)
};
// 出图取证(`--force-state`)把开关也钉到同一档:否则截图里只有按钮是 hover、
// 开关还是静息态,视觉复审没法判"悬停时的开关长什么样"。
let toggle = match forced {
Some(state) => toggle.state(state),
None => toggle,
};
row![label, switch(ui, toggle)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.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),
)
}
@@ -154,6 +154,15 @@ pub enum Message {
Save,
/// 恢复跟随全局(清表单,仍需保存)。
Reset,
/// 启停一个 mod(M224):`file` 是**磁盘文件名**(含后缀),`enabled` 是期望状态。
///
/// 页面只发意图,落盘由 host 在后台线程做;回程是一份新快照(不做乐观回显)。
SetModEnabled {
/// 磁盘文件名
file: String,
/// 期望的启用状态
enabled: bool,
},
}
#[cfg(test)]
@@ -28,10 +28,15 @@ use super::super::Texts;
///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"];
const ALLOWED: &[&str] = &[
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
"mods",
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 6] = [
const PAGE_SOURCES: [(&str, &str); 7] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
@@ -44,6 +49,8 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
"app/view/instances.rs",
include_str!("../../../app/view/instances.rs"),
),
// M224:MODS 区(本地 mod 列表 + 启停开关)
("instances/mods.rs", include_str!("../mods.rs")),
];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -61,7 +68,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 24] = [
const ALLOWED_LITERALS: [&str; 28] = [
"{date} {time} UTC",
"isolated",
"shared",
@@ -97,6 +104,12 @@ const ALLOWED_LITERALS: [&str; 24] = [
"ULL_LAUNCH_ARGS_FIXTURE",
// M161:示例「被门槛挡下」的取值(夹具)。
"gated",
// M224:MODS 区四个 key 的**占位符名**(`{enabled}` / `{disabled}` / `{count}` / `{file}`),
// 不是文案——`Catalog::render` 按名字取值,换成别的词就渲染不出来了。
"enabled",
"disabled",
"count",
"file",
];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -26,6 +26,7 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ModFailureKind;
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -151,6 +152,66 @@ impl Texts {
self.text(Key::InstancesLaunchReset)
}
// ------------------------------------------------------------- MODS 区(M224)
pub(super) fn mods_title(self) -> &'static str {
self.text(Key::InstancesModsTitle)
}
/// 「12 启用 · 3 禁用」。
pub(super) fn mods_count(self, enabled: usize, disabled: usize) -> String {
self.0.render(
Key::InstancesModsCount,
&[
("enabled", &enabled.to_string()),
("disabled", &disabled.to_string()),
],
)
}
pub(super) fn mods_loading(self) -> &'static str {
self.text(Key::InstancesModsLoading)
}
pub(super) fn mods_empty(self) -> &'static str {
self.text(Key::InstancesModsEmpty)
}
pub(super) fn mods_missing_dir(self) -> &'static str {
self.text(Key::InstancesModsMissingDir)
}
pub(super) fn mods_running(self) -> &'static str {
self.text(Key::InstancesModsRunning)
}
pub(super) fn mods_old_badge(self) -> &'static str {
self.text(Key::InstancesModsOldBadge)
}
/// 「跳过 3 项」。
pub(super) fn mods_skipped(self, count: usize) -> String {
self.0
.render(Key::InstancesModsSkipped, &[("count", &count.to_string())])
}
/// 读目录失败的主句:`读取 mods/ 失败:没有可用的 mods/ 目录`。
pub(super) fn mods_failed(self, reason: &str) -> String {
self.0
.render(Key::InstancesModsFailed, &[("reason", reason)])
}
/// 启停失败的主句:`启停 x.jar 失败:同名文件已存在`。
pub(super) fn mods_toggle_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesModsToggleFailed,
&[("file", file), ("reason", reason)],
)
}
/// 启停失败大类的短句(`ModFailureKind` → 一句话;未知取值不猜,原样上屏)。
pub(super) fn mods_reason(self, kind: ModFailureKind) -> &'static str {
self.text(match kind {
ModFailureKind::Running => Key::InstancesModsReasonRunning,
ModFailureKind::InstanceUnavailable => Key::InstancesModsReasonInstance,
ModFailureKind::UnsafeName => Key::InstancesModsReasonName,
ModFailureKind::ModsDirUnusable => Key::InstancesModsReasonDirectory,
ModFailureKind::NotFound => Key::InstancesModsReasonMissing,
ModFailureKind::NotARegularFile => Key::InstancesModsReasonNotFile,
ModFailureKind::Collision => Key::InstancesModsReasonCollision,
ModFailureKind::Rename => Key::InstancesModsReasonRename,
})
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
+1 -1
View File
@@ -128,7 +128,7 @@ 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::{InstanceMessage, LaunchForm};
pub use instances::{mods_failure_line, mods_from_state, InstanceMessage, LaunchForm, Mods};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction};
+6
View File
@@ -140,6 +140,11 @@ pub struct Model {
/// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是
/// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。
pub lang: Lang,
/// 本地 Mod(M224):详情页 MODS 区的列表 / 启停(见 [`super::instances::Mods`])。
///
/// 谁填:`app/view/instances.rs` 的 `mods_of`(presenter 的 `AppState::mods` → 展示字段)。
/// 空态(不在详情页 / 还没请求)是 `Mods::default()`,页面不画这一区。
pub mods: super::instances::Mods,
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
@@ -169,6 +174,7 @@ impl Model {
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(),
mods: super::instances::Mods::default(),
lang: Lang::En,
forced: None,
}
+4 -3
View File
@@ -181,9 +181,10 @@ fn relative_name(root: &Path, path: &Path) -> String {
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
///
/// M224 收敛:判据只有 [`crate::addon::mods::suffix`] 一处(启停也要用它,见
/// `DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。这里保留函数名与签名,行为逐字不变。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
let lowered = path.trim_end().to_ascii_lowercase();
let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered);
without_old.ends_with(".disabled")
crate::addon::mods::is_disabled_path(path)
}
+7 -35
View File
@@ -6,45 +6,17 @@
use super::super::report::{keys, ImportIssue};
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
///
/// M224 收敛:类型本体只有 [`crate::addon::mods::suffix`] 一处,这里原样再导出,于是
/// `paths::SuffixVariant` 与启停用的是**同一个类型**(零行为变化,调用点不用改)。
pub use crate::addon::mods::SuffixVariant;
/// 识别四种路径后缀变体。
///
/// 委派到唯一判据(见模块文档);保留函数名与签名。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".old").unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(".disabled");
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
crate::addon::mods::suffix_variant(path)
}
/// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。
+10 -1
View File
@@ -25,11 +25,16 @@
//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。
//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。
//!
//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。
//! - [`mods`](P2 · M224):**本地 Mod 列表与启停**——`.disabled` / `.old` 后缀的唯一判据
//! (导出扫描与导入计划两侧委派到这里)、`<实例运行目录>/mods/` 的单根列表、启停 = 在
//! `mods/` 里改名(五条守卫、幂等、不覆盖)。
//!
//! 规划中:CurseForge 客户端(§5)。
pub mod export;
pub mod import;
pub mod loader;
pub mod mods;
pub mod online;
pub mod pack;
@@ -47,6 +52,10 @@ pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
};
pub use mods::{
ModEntry, ModError, ModScan, ModSkip, ModToggle, SkipReason, SuffixVariant as ModSuffixVariant,
MODS_DIR,
};
pub use online::{
download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher,
EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage,
+92
View File
@@ -0,0 +1,92 @@
//! 本地 Mod 读写的失败形状(CLI/UI 据此给具名原因,不折叠成一句「失败」)。
use std::fmt;
use std::path::PathBuf;
/// 一次列目录 / 启停失败。
#[derive(Debug)]
pub enum ModError {
/// 文件名不是**裸文件名**(含分隔符 / `..` / 盘符 / NUL)。
///
/// 判据直接复用整合包侧的七条路径规则([`crate::addon::pack::path::normalize_file`]),
/// 再要求结果里没有 `/`:启停只接受 `mods/` 下**直接**的一个文件,
/// 不收相对路径——那等于让 UI 决定落到哪个子目录。
UnsafeName {
/// 调用方给的名字。
file: String,
/// 人读原因。
detail: String,
},
/// `mods` 目录本身不可用:是符号链接 / 不是目录 / 读不了。
///
/// 符号链接**不跟随**:`<run>/mods` 指到别处时,改名会落到实例目录之外。
ModsDirUnusable {
/// 那个目录。
dir: PathBuf,
/// 人读原因。
detail: String,
},
/// 要启停的文件不在 `mods/` 里。
NotFound {
/// 文件名。
file: String,
/// 找过的目录。
dir: PathBuf,
},
/// 目标存在但不是普通文件(目录 / 符号链接 / 设备)。
NotARegularFile {
/// 文件名。
file: String,
/// 人读原因。
detail: String,
},
/// 目标名字已被占用(**不覆盖**:那可能是另一个 mod)。
Collision {
/// 原名字。
file: String,
/// 想要的(已存在的)目标名。
target: String,
/// 目录。
dir: PathBuf,
},
/// 改名本身失败(占用 / 权限 / 磁盘异常)。
Rename {
/// 原名字。
file: String,
/// 目标名。
target: String,
/// 底层原因。
detail: String,
},
}
impl fmt::Display for ModError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsafeName { file, detail } => {
write!(f, "文件名不可用于启停({file:?}):{detail}")
}
Self::ModsDirUnusable { dir, detail } => {
write!(f, "mods 目录不可用({}):{detail}", dir.display())
}
Self::NotFound { file, dir } => {
write!(f, "mods 目录里没有 {file}({})", dir.display())
}
Self::NotARegularFile { file, detail } => {
write!(f, "{file} 不是普通文件:{detail}")
}
Self::Collision { file, target, dir } => write!(
f,
"{file} 改不成 {target}:同名文件已存在({})——不覆盖,请先处理那个文件",
dir.join(target).display()
),
Self::Rename {
file,
target,
detail,
} => write!(f, "{file} → {target} 改名失败:{detail}"),
}
}
}
impl std::error::Error for ModError {}
+204
View File
@@ -0,0 +1,204 @@
//! 列一个实例 `mods/` 目录(**只读**、只看这一个目录)。
//!
//! # 单根语义(D20)
//!
//! 入口是**已经派生好的运行目录**(`game::isolation::derive_game_dir` 的结果),本模块只往下
//! 取一层 `mods/`:不自己去猜游戏根、不做「隔离侧 + 共享侧合并」。派生是启动与列表**同一个**
//! 答案的唯一来源,第二处派生就是第二套真相。
//!
//! # 收哪些条目
//!
//! 与导出扫描同一条纪律:`mods/` 下**不按扩展名过滤**(`.jar` / `.jar.disabled` / `.zip` /
//! 认不出的后缀一律列出)。丢掉"我不认识的东西"正是用户点名不许的——启停开关对认不出的
//! 文件同样有意义(禁用它 = 让游戏别加载它)。
//!
//! 不收的东西只有三类,且都**逐条登记原因**(不是静默跳过):
//! 符号链接(不跟随)、非普通文件(目录 / 设备)、名字过不了路径规则([`crate::addon::pack::path`])。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::suffix::{display_name, suffix_variant, SuffixVariant};
/// `mods` 目录名(游戏与社区一致的口径)。
pub const MODS_DIR: &str = "mods";
/// 某个运行目录下的 `mods/`(不建、不检查——纯路径拼接)。
#[must_use]
pub fn mods_dir(run_dir: &Path) -> PathBuf {
run_dir.join(MODS_DIR)
}
/// 列表里的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModEntry {
/// 磁盘上的文件名(含 `.disabled` / `.old` 后缀,原样)。
pub file: String,
/// 展示名(去掉两个后缀,不做 `-版本号` 猜解)。
pub name: String,
/// 是否启用(`.disabled` 缺席即启用)。
pub enabled: bool,
/// 字节数。
pub bytes: u64,
/// 后缀变体(UI 用它区分"旧残留")。
pub variant: SuffixVariant,
}
/// 没能进列表的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModSkip {
/// 文件名(读不到时给能拿到的原样串)。
pub file: String,
/// 为什么没列。
pub reason: SkipReason,
}
/// 没列出来的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 符号链接(不跟随:跟随会把实例外的东西算成实例内容)。
SymbolicLink,
/// 不是普通文件(目录 / 设备 / 管道)。
NotAFile,
/// 名字过不了路径规则(含 `\`、`:`、`..` 之类)。
UnsafeName,
/// 元数据读不出来。
Unreadable,
}
/// 一次列目录的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModScan {
/// 被列的那个 `mods/` 目录。
pub dir: PathBuf,
/// 目录存不存在(`false` = 这个实例还没装过 mod,不是错误)。
pub exists: bool,
/// 列表(按展示名排序,大小写不敏感)。
pub entries: Vec<ModEntry>,
/// 跳过的条目与原因。
pub skipped: Vec<ModSkip>,
}
impl ModScan {
/// 启用中的条数。
#[must_use]
pub fn enabled_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.enabled).count()
}
/// 禁用中的条数。
#[must_use]
pub fn disabled_count(&self) -> usize {
self.entries.len() - self.enabled_count()
}
}
/// 列一个运行目录下的 `mods/`。
///
/// 目录不存在 → `exists = false` 的空结果(空实例不是错误)。
///
/// # Errors
///
/// `mods` 存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`ModError::ModsDirUnusable`]。
pub fn scan(run_dir: &Path) -> Result<ModScan, ModError> {
let dir = mods_dir(run_dir);
let meta = match std::fs::symlink_metadata(&dir) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ModScan {
dir,
exists: false,
entries: Vec::new(),
skipped: Vec::new(),
});
}
Err(error) => {
return Err(ModError::ModsDirUnusable {
dir,
detail: error.to_string(),
});
}
};
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "不是目录".to_string(),
});
}
let read = std::fs::read_dir(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
let mut children: Vec<_> =
read.collect::<Result<Vec<_>, _>>()
.map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
children.sort_by_key(std::fs::DirEntry::file_name);
let mut entries = 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(ModSkip {
file: raw,
reason: SkipReason::Unreadable,
});
continue;
};
if file_type.is_symlink() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::SymbolicLink,
});
continue;
}
if !file_type.is_file() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::NotAFile,
});
continue;
}
if crate::addon::pack::path::normalize_file(&raw).is_err() || raw.contains('/') {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::UnsafeName,
});
continue;
}
let bytes = child.metadata().map(|meta| meta.len()).unwrap_or(0);
entries.push(ModEntry {
name: display_name(&raw).to_string(),
enabled: !suffix_variant(&raw).is_disabled(),
variant: suffix_variant(&raw),
file: raw,
bytes,
});
}
// 展示名排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。
entries.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.file.cmp(&right.file))
});
skipped.sort_by(|left, right| left.file.cmp(&right.file));
Ok(ModScan {
dir,
exists: true,
entries,
skipped,
})
}
+60
View File
@@ -0,0 +1,60 @@
//! 本地 Mod 的**列表**与**启停**(M224 · MODTOGGLE)。
//!
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md` §3(三条结论原样落地)。
//!
//! # 三层里的第一层
//!
//! §3 的结论是「Mod 启停 = 新建三层(core 读写目录 + presenter DTO + 实例详情页),不是给
//! 内容页接一个开关」,并特别点名**不要**给内容页加第五个 `ContentKind`(会打断 4 处穷尽 match)。
//! 本模块就是那第一层,另外两层见 `crates/presenter/src/dto/mods.rs` 与
//! `crates/app-iced/src/pages/instances/mods.rs`。
//!
//! # 唯一 `.disabled` 判据
//!
//! 收敛前仓里有两份逐字同构的后缀判据(`addon::export::scan::is_disabled_path` 与
//! `addon::import::plan::paths::suffix_variant`)。M218 的要求是「做这块时第一步先收敛成一份,
//! 别让第三份出生」,所以判据只有 [`suffix`] 一处,导出/导入两侧改为**委派**(签名与行为不变,
//! 收敛 diff 为零行为变化)。启停要改写文件名,比"判一下"多两件事——保住原始大小写、保住
//! `.old` 的位置——这两件也只能和判据同源,否则会出现「判禁用用一套、改名字用另一套」。
//!
//! # 单根语义(D20)
//!
//! 入口一律是**派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
//! 本模块只碰 `<run_dir>/mods/`:
//!
//! - 不自己去猜游戏根,也不做「隔离侧 `versions/<id>/mods` + 共享侧 `.minecraft/mods` 合并」
//! 这种双根视图——派生是启动与列表**同一个**答案的唯一来源;
//! - 只往下取一层:`mods/` 下的子目录不递归(游戏也不递归);
//! - `mods/` 本身是符号链接时**拒绝**(不跟随),否则改名会落到实例目录之外。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`suffix`] | `.disabled` / `.old` 的唯一判据与改名目标名(含四态枚举) |
//! | [`list`] | 列 `<run_dir>/mods/`(只读、逐条登记跳过原因) |
//! | [`toggle`] | 启停 = 在 `mods/` 里改名(五条守卫、幂等、不覆盖) |
//!
//! # 已知边界
//!
//! - **不判断游戏是否在跑**:进程正持有这些 jar(Windows 上 rename 会失败),失败按
//! [`error::ModError::Rename`] 如实报出来。要不要在上层挡「运行中不许改」是宿主层的策略
//! (presenter 侧已挡,见 `host::mods`)。
//! - **不解析 jar**:列表给的是文件名,`-版本号` 猜解仍归 `diagnose::environment::ModRecord`,
//! 这里不复制第二份启发式。
mod error;
mod list;
mod suffix;
mod toggle;
pub use error::ModError;
pub use list::{mods_dir, scan, ModEntry, ModScan, ModSkip, SkipReason, MODS_DIR};
pub use suffix::{
disabled_name, display_name, enabled_name, is_disabled_path, renamed_for, suffix_variant,
SuffixVariant, DISABLED_SUFFIX, OLD_SUFFIX,
};
pub use toggle::{ensure_dir, set_enabled, ModToggle};
#[cfg(test)]
mod tests;
+263
View File
@@ -0,0 +1,263 @@
//! `.disabled` / `.old` 后缀的**唯一判据**(M224 · MODTOGGLE)。
//!
//! # 为什么这里是一份而不是三份
//!
//! 收敛前仓里有两份同构实现,各自 lowercase → 剥 `.old` → `ends_with(".disabled")`:
//!
//! | 位置 | 形状 | 用途 |
//! |---|---|---|
//! | `addon::export::scan::is_disabled_path` | 只给 bool | 导出扫描判禁用位 |
//! | `addon::import::plan::paths::suffix_variant` | 四态枚举 | 导入计划打注记 |
//!
//! 两份逐字同构,而 M218 的结论是「做启停时第一步先收敛成一份,别让第三份出生」
//! (`DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。启停要**改写**文件名,比"判一下"多两件事:
//! ①必须保住原始大小写(`X.JAR.DISABLED` 禁用后不能变全小写);②必须保住 `.old` 的位置
//! (`.disabled` 只能出现在 `.old` **之前**,否则 [`suffix_variant`] 认不出来)。
//!
//! 所以本模块是唯一出口:判据([`suffix_variant`] / [`is_disabled_path`])与改写目标名
//! ([`renamed_for`])用**同一套剥离逻辑**,不会出现「判禁用用一套、改名字用另一套」。
//! 导出与导入两侧改为委派到本模块(函数名与签名保持不变)。
//!
//! # 认哪些形状(写死四种)
//!
//! | 磁盘上的名字 | 变体 | 禁用 | `.old` |
//! |---|---|---|---|
//! | `x.jar` | [`SuffixVariant::Plain`] | ❌ | ❌ |
//! | `x.jar.disabled` | [`SuffixVariant::Disabled`] | ✅ | ❌ |
//! | `x.jar.old` | [`SuffixVariant::Old`] | ❌ | ✅ |
//! | `x.jar.disabled.old` | [`SuffixVariant::DisabledOld`] | ✅ | ✅ |
//!
//! 大小写不敏感(Windows 上 `X.JAR.DISABLED` 与 `x.jar.disabled` 是同一个文件);`.old` 只
//! 识别与保留(不清除、不改名——它是 HMCL 生态的旧版本残留,删掉等于替用户做决定)。
/// 禁用位的路径后缀(官方 Modrinth 导出的表达,也是唯一跨启动器保真的那条)。
pub const DISABLED_SUFFIX: &str = ".disabled";
/// 旧版本残留的路径后缀(只识别与保留)。
pub const OLD_SUFFIX: &str = ".old";
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
/// 识别四种路径后缀变体。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(OLD_SUFFIX).unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(DISABLED_SUFFIX);
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
suffix_variant(path).is_disabled()
}
/// `enabled = true` 时该改成什么名字(`None` = 已经是启用态,不用动)。
///
/// `x.jar.disabled` → `x.jar`;`x.jar.disabled.old` → `x.jar.old`(`.old` 原位保留)。
/// 原始大小写按**逐字符比较**剥掉后缀,所以 `X.JAR.DISABLED` → `X.JAR`,不会被整串小写化。
#[must_use]
pub fn enabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
// **先剥 `.old` 再剥 `.disabled`**:反过来会漏 `x.jar.disabled.old`(结尾是 `.old`,
// 直接找结尾的 `.disabled` 找不到 ⇒ 明明禁用着却说"已是启用态")。这一条由
// `toggling_round_trips_every_variant` / `enabling_only_strips_the_disabled_suffix` 钉住。
let (body, old) = match strip_ascii_ci(trimmed, OLD_SUFFIX) {
Some(base) => (base, Some(&trimmed[base.len()..])),
None => (trimmed, None),
};
let stripped = strip_ascii_ci(body, DISABLED_SUFFIX)?;
// 剥完只剩空串或只剩 `.old` 都不是文件名(`.disabled` / `.old.disabled` 这种畸形)。
if stripped.is_empty() || stripped.eq_ignore_ascii_case(OLD_SUFFIX) {
return None;
}
Some(match old {
Some(old) => format!("{stripped}{old}"),
None => stripped.to_string(),
})
}
/// `enabled = false` 时该改成什么名字(`None` = 已经是禁用态,不用动)。
///
/// `x.jar` → `x.jar.disabled`;`x.jar.old` → `x.jar.disabled.old`——`.disabled` **插在** `.old`
/// 之前,否则 [`suffix_variant`] 读回来是 `Old`("有旧残留但没禁用"),禁用位就丢了。
#[must_use]
pub fn disabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
if trimmed.is_empty() {
return None;
}
if suffix_variant(trimmed).is_disabled() {
return None;
}
// 只认原串里那个 `.old`(大小写可能不同),按字节位置插进去,保住其余部分原样。
if let Some(base) = strip_ascii_ci(trimmed, OLD_SUFFIX) {
if base.is_empty() {
return None;
}
return Some(format!("{base}{DISABLED_SUFFIX}{OLD_SUFFIX}"));
}
Some(format!("{trimmed}{DISABLED_SUFFIX}"))
}
/// 启停后应当落成的文件名(`None` = 已经是这个状态,无需 rename)。
#[must_use]
pub fn renamed_for(name: &str, enabled: bool) -> Option<String> {
if enabled {
enabled_name(name)
} else {
disabled_name(name)
}
}
/// 展示名(去掉 `.disabled` / `.old` 两个后缀,其余原样)。
///
/// **不**做 `-版本号` 的猜解:那是 `diagnose::environment::ModRecord` 的活,这里再猜一次就是
/// 第二份启发式。列表要的是"这个文件叫什么",不是"它可能是哪个 mod"。
#[must_use]
pub fn display_name(name: &str) -> &str {
let trimmed = name.trim_end();
// 与 [`enabled_name`] 同一个顺序:先 `.old`、再 `.disabled`(`x.jar.disabled.old` 两个都要剥)。
let without_old = strip_ascii_ci(trimmed, OLD_SUFFIX).unwrap_or(trimmed);
strip_ascii_ci(without_old, DISABLED_SUFFIX).unwrap_or(without_old)
}
/// 去掉**结尾**的 ASCII 后缀(大小写不敏感),保住前半段原始大小写。
fn strip_ascii_ci<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
let cut = text.len().checked_sub(suffix.len())?;
if !text.is_char_boundary(cut) {
return None;
}
text.get(cut..)
.filter(|tail| tail.eq_ignore_ascii_case(suffix))
.map(|_| &text[..cut])
}
#[cfg(test)]
mod tests {
use super::*;
/// 四种形状都认得,且禁用位只看 `.disabled`(`.old` 单独出现不是禁用)。
#[test]
fn recognises_four_variants() {
assert_eq!(suffix_variant("mods/x.jar"), SuffixVariant::Plain);
assert_eq!(
suffix_variant("mods/x.jar.disabled"),
SuffixVariant::Disabled
);
assert_eq!(suffix_variant("mods/x.jar.old"), SuffixVariant::Old);
assert_eq!(
suffix_variant("mods/x.jar.disabled.old"),
SuffixVariant::DisabledOld
);
assert!(is_disabled_path("mods/x.jar.disabled"));
assert!(is_disabled_path("mods/x.jar.disabled.old"));
assert!(!is_disabled_path("mods/x.jar.old"));
assert!(!is_disabled_path("mods/x.jar"));
}
/// 大小写不敏感(Windows 上 `X.JAR.DISABLED` 就是禁用),尾随空白不算名字的一部分。
#[test]
fn suffix_matching_ignores_ascii_case() {
assert_eq!(suffix_variant("X.JAR.DISABLED"), SuffixVariant::Disabled);
assert_eq!(suffix_variant("X.Disabled.Old"), SuffixVariant::DisabledOld);
assert_eq!(suffix_variant("x.jar.disabled \n"), SuffixVariant::Disabled);
// 不是子串匹配:`.disabled` 必须真在结尾。
assert_eq!(suffix_variant("x.disabled.jar"), SuffixVariant::Plain);
assert_eq!(suffix_variant("x.jar.notdisabled"), SuffixVariant::Plain);
}
/// 启用 = 剥 `.disabled`,`.old` 原位保留,**原始大小写不动**。
#[test]
fn enabling_only_strips_the_disabled_suffix() {
assert_eq!(enabled_name("x.jar.disabled").as_deref(), Some("x.jar"));
assert_eq!(
enabled_name("x.jar.disabled.old").as_deref(),
Some("x.jar.old")
);
assert_eq!(enabled_name("X.JAR.DISABLED").as_deref(), Some("X.JAR"));
// 已经是启用态 / 只剩畸形后缀 ⇒ None(不猜、不改)。
assert_eq!(enabled_name("x.jar"), None);
assert_eq!(enabled_name(".disabled"), None);
assert_eq!(enabled_name(".old.disabled"), None);
}
/// 禁用 = 把 `.disabled` **插在** `.old` 之前(插在后面判据读不回来)。
#[test]
fn disabling_inserts_before_the_old_suffix() {
assert_eq!(disabled_name("x.jar").as_deref(), Some("x.jar.disabled"));
assert_eq!(
disabled_name("x.jar.old").as_deref(),
Some("x.jar.disabled.old")
);
assert_eq!(
disabled_name("X.JAR.OLD").as_deref(),
Some("X.JAR.disabled.old")
);
assert_eq!(disabled_name("x.jar.disabled"), None);
assert_eq!(disabled_name("x.jar.disabled.old"), None);
assert_eq!(disabled_name(""), None);
assert_eq!(disabled_name(".old"), None);
}
/// 两个方向互为逆(同一条剥离逻辑的自证):往返回来名字一模一样。
#[test]
fn toggling_round_trips_every_variant() {
for plain in ["x.jar", "X.JAR", "风.jar", "a.b.c"] {
let off = renamed_for(plain, false).expect("禁用目标名");
assert_eq!(suffix_variant(&off), SuffixVariant::Disabled);
assert_eq!(renamed_for(&off, true).as_deref(), Some(plain));
}
// `.old` 变体往返也不掉 `.old`。
let off = renamed_for("x.jar.old", false).expect("禁用目标名");
assert_eq!(off, "x.jar.disabled.old");
assert_eq!(renamed_for(&off, true).as_deref(), Some("x.jar.old"));
// 已经在目标状态 ⇒ None(幂等,不产生多余 rename)。
assert_eq!(renamed_for("x.jar", true), None);
assert_eq!(renamed_for("x.jar.disabled", false), None);
}
/// 展示名剥两个后缀,但**不**猜 `-版本号`。
#[test]
fn display_name_strips_suffixes_only() {
assert_eq!(
display_name("sodium-0.5.8.jar.disabled"),
"sodium-0.5.8.jar"
);
assert_eq!(display_name("x.jar.disabled.old"), "x.jar");
assert_eq!(display_name("jei.jar"), "jei.jar");
assert_eq!(display_name("X.JAR.DISABLED"), "X.JAR");
}
}
+254
View File
@@ -0,0 +1,254 @@
//! 列表与启停的用例:**真的建盘上文件**再读回来(不 mock 文件系统)。
//!
//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里:抓得住的是判据与守卫本身,
//! 抓不住的是 Windows 与 Linux 上 rename 的细节差异(那条靠真实运行,见 handoff)。
use std::path::Path;
use super::suffix::SuffixVariant;
use super::*;
use crate::addon::temp::TempRoot;
/// 建一个运行目录 + `mods/`,并把给定文件写进去。
fn fixture(tag: &str, files: &[(&str, &[u8])]) -> TempRoot {
let root = TempRoot::under(tag, "run");
let mods = mods_dir(&root);
std::fs::create_dir_all(&mods).expect("建 mods");
for (name, body) in files {
std::fs::write(mods.join(name), body).expect("写 mod 文件");
}
root
}
/// 列目录:四种后缀变体都认、禁用位只看 `.disabled`、目录不进列表。
#[test]
fn scan_lists_every_variant_and_skips_directories() {
let root = fixture(
"mods-scan",
&[
("sodium.jar", b"a"),
("jei.jar.disabled", b"bb"),
("opt.jar.old", b"ccc"),
("aether.jar.disabled.old", b"dddd"),
("notes.txt", b"whatever"),
],
);
std::fs::create_dir(root.join("mods/sub")).expect("建子目录");
let scan = scan(&root).expect("列目录");
assert!(scan.exists);
assert_eq!(scan.dir, root.join("mods"));
let files: Vec<&str> = scan
.entries
.iter()
.map(|entry| entry.file.as_str())
.collect();
assert_eq!(
files,
[
"aether.jar.disabled.old",
"jei.jar.disabled",
"notes.txt",
"opt.jar.old",
"sodium.jar"
]
);
let entry = |file: &str| {
scan.entries
.iter()
.find(|entry| entry.file == file)
.unwrap_or_else(|| panic!("{file} 不在列表里"))
};
assert!(entry("sodium.jar").enabled);
assert!(!entry("jei.jar.disabled").enabled);
assert!(entry("opt.jar.old").enabled, "`.old` 单独出现不是禁用");
assert!(!entry("aether.jar.disabled.old").enabled);
// 展示名剥两个后缀(不做 -版本号 猜解)。
assert_eq!(entry("aether.jar.disabled.old").name, "aether.jar");
assert_eq!(entry("sodium.jar").variant, SuffixVariant::Plain);
assert_eq!(entry("jei.jar.disabled").bytes, 2);
assert_eq!(scan.skipped.len(), 1);
assert_eq!(scan.skipped[0].file, "sub");
assert_eq!(scan.skipped[0].reason, SkipReason::NotAFile);
assert_eq!(scan.enabled_count(), 3);
assert_eq!(scan.disabled_count(), 2);
}
/// 空实例(没有 `mods/`)不是错误:`exists = false` 的空结果。
#[test]
fn scan_treats_a_missing_mods_dir_as_empty() {
let root = TempRoot::under("mods-empty", "run");
let scan = scan(&root).expect("列目录");
assert!(!scan.exists);
assert!(scan.entries.is_empty());
assert!(scan.skipped.is_empty());
}
/// `mods` 是符号链接就拒(不跟随)。Windows 上建目录链接要特权,建不出来就跳过这一条。
#[test]
fn scan_refuses_a_symlinked_mods_dir() {
let root = TempRoot::under("mods-link", "run");
let outside = root.join("elsewhere");
std::fs::create_dir_all(&outside).expect("建外部目录");
std::fs::write(outside.join("x.jar"), b"x").expect("写外部文件");
let linked = link_dir(&outside, &mods_dir(&root));
if !linked {
eprintln!("[skip] 本机建不出目录符号链接(Windows 需要开发者模式/特权)");
return;
}
let error = scan(&root).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
// 启停同样拒(同一条守卫,不另记一遍)。
let error = set_enabled(&root, "x.jar", false).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
}
/// 启停往返:改名真的发生、读回来状态对、重复点幂等。
#[test]
fn toggling_renames_on_disk_and_is_idempotent() {
let root = fixture("mods-toggle", &[("sodium.jar", b"jar")]);
let off = set_enabled(&root, "sodium.jar", false).expect("禁用");
assert!(off.renamed);
assert_eq!(off.to, "sodium.jar.disabled");
assert!(!off.enabled);
assert!(root.join("mods/sodium.jar.disabled").is_file());
assert!(!root.join("mods/sodium.jar").exists());
// 再禁一次:不是错误,也不改名。
let again = set_enabled(&root, "sodium.jar.disabled", false).expect("幂等");
assert!(!again.renamed);
assert_eq!(again.from, again.to);
assert!(!again.enabled);
// 启用回来:名字与字节都回到原样。
let on = set_enabled(&root, "sodium.jar.disabled", true).expect("启用");
assert!(on.renamed);
assert_eq!(on.to, "sodium.jar");
assert!(on.enabled);
assert_eq!(
std::fs::read(root.join("mods/sodium.jar")).expect("读回"),
b"jar"
);
let scan = scan(&root).expect("列目录");
assert_eq!(scan.entries.len(), 1);
assert!(scan.entries[0].enabled);
}
/// `.disabled` 只能插在 `.old` **之前**,否则读回来丢禁用位(往返用例抓的就是这个)。
#[test]
fn toggling_keeps_the_old_suffix_in_place() {
let root = fixture("mods-old", &[("jei.jar.old", b"old")]);
let off = set_enabled(&root, "jei.jar.old", false).expect("禁用");
assert_eq!(off.to, "jei.jar.disabled.old");
let listed = scan(&root).expect("列目录");
assert_eq!(listed.entries[0].variant, SuffixVariant::DisabledOld);
assert!(!listed.entries[0].enabled, "禁用位被 `.old` 吃掉了");
let on = set_enabled(&root, "jei.jar.disabled.old", true).expect("启用");
assert_eq!(on.to, "jei.jar.old");
assert_eq!(
scan(&root).expect("列目录").entries[0].variant,
SuffixVariant::Old
);
}
/// 目标名已被占用 ⇒ 报冲突,**不覆盖**那个文件。
#[test]
fn toggling_refuses_to_overwrite_an_existing_file() {
let root = fixture(
"mods-collision",
&[("x.jar", b"one"), ("x.jar.disabled", b"two")],
);
let error = set_enabled(&root, "x.jar", false).expect_err("必须报冲突");
assert!(matches!(error, ModError::Collision { .. }), "{error}");
// 两个文件都还在、内容没被踩。
assert_eq!(
std::fs::read(root.join("mods/x.jar")).expect("读 a"),
b"one"
);
assert_eq!(
std::fs::read(root.join("mods/x.jar.disabled")).expect("读 b"),
b"two"
);
}
/// 路径类名字一律拒:启停只接受 `mods/` 下的裸文件名。
#[test]
fn toggling_rejects_anything_that_is_not_a_bare_file_name() {
let root = fixture("mods-names", &[("x.jar", b"x")]);
for bad in [
"",
"../x.jar",
"mods/x.jar",
"sub/x.jar",
r"mods\x.jar",
"C:/x.jar",
".hidden/../x.jar",
] {
let error = set_enabled(&root, bad, false)
.err()
.unwrap_or_else(|| panic!("{bad:?} 必须被拒"));
assert!(
matches!(
error,
ModError::UnsafeName { .. } | ModError::NotFound { .. }
),
"{bad:?} → {error}"
);
}
// 越界改名一次都没发生。
assert!(root.join("mods/x.jar").is_file());
}
/// 不存在的文件、目录、符号链接都不是"可启停的 mod"。
#[test]
fn toggling_refuses_missing_and_non_file_targets() {
let root = fixture("mods-targets", &[("x.jar", b"x")]);
std::fs::create_dir(root.join("mods/adir")).expect("建目录");
let missing = set_enabled(&root, "nope.jar", false).expect_err("不存在的必须报");
assert!(matches!(missing, ModError::NotFound { .. }), "{missing}");
let dir = set_enabled(&root, "adir", false).expect_err("目录必须报");
assert!(matches!(dir, ModError::NotARegularFile { .. }), "{dir}");
assert!(root.join("mods/adir").is_dir(), "目录被改名了");
}
/// 没有 `mods/` 时启停报「目录不可用」(不替用户建目录——建目录是装 mod 的事)。
#[test]
fn toggling_requires_an_existing_mods_dir() {
let root = TempRoot::under("mods-nodir", "run");
let error = set_enabled(&root, "x.jar", false).expect_err("没有 mods 目录");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
assert!(!root.join("mods").exists(), "启停不该顺手建目录");
// `ensure_dir` 才是建目录那条路(装 mod 用)。
ensure_dir(&root).expect("建 mods");
assert!(root.join("mods").is_dir());
}
/// 建目录链接:Windows 上可能没特权,返回是否建成(用例据此决定跳不跳)。
fn link_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link).is_ok()
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
false
}
}
+166
View File
@@ -0,0 +1,166 @@
//! 启停一个 mod:**在 `mods/` 里改名**(`.disabled` 后缀就是禁用位)。
//!
//! # 为什么是改名而不是写配置文件
//!
//! 游戏本身不读任何"禁用列表"——它只加载 `mods/*.jar`。`.disabled` 后缀让文件**不再匹配**
//! 加载器的 glob,于是"禁用"是**文件系统状态**:换启动器、换机器、手动 `git checkout` 之后
//! 依然成立。这也是 Modrinth 官方导出表达禁用位的唯一方式(见
//! [`crate::addon::pack::manifest::PackFile::disabled`])。
//!
//! # 五条守卫(都不猜、都不覆盖)
//!
//! | 守卫 | 理由 |
//! |---|---|
//! | 只收**裸文件名**(过七条路径规则且不含 `/`) | 启停不接受相对路径,否则 UI 能指挥落点 |
//! | `mods/` 是符号链接就拒 | 改名会落到实例目录之外 |
//! | 目标必须是**普通文件**(非目录 / 非链接) | 改一个目录或链接不是"启停一个 mod" |
//! | 目标名已存在就拒([`ModError::Collision`]) | 覆盖掉的是**另一个 mod**,用户没同意 |
//! | rename 不做跨盘兜底(不 copy+delete) | copy 半截失败会留下两个文件,语义比报错更差 |
//!
//! # 幂等
//!
//! 「已经启用还要启用」不是错误:返回 `renamed = false` 的成功结果(UI 的开关重复点不会
//! 变成红字)。这一条由 [`super::suffix::renamed_for`] 的 `None` 表达。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::list::mods_dir;
use super::suffix::{is_disabled_path, renamed_for};
/// 一次启停的结果(`from == to` 且 `renamed == false` ⇒ 本来就是那个状态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModToggle {
/// 原文件名的磁盘名(含后缀)。
pub from: String,
/// 改名后的磁盘名(没改名时与 `from` 相同)。
pub to: String,
/// 这一步之后的启用状态。
pub enabled: bool,
/// 是否真的在磁盘上改了名。
pub renamed: bool,
}
/// 把 `mods/` 下的某个文件切到 `enabled` 状态(改名)。
///
/// # Errors
///
/// 见模块文档的五条守卫:[`ModError::UnsafeName`] / [`ModError::ModsDirUnusable`] /
/// [`ModError::NotFound`] / [`ModError::NotARegularFile`] / [`ModError::Collision`] /
/// [`ModError::Rename`]。
pub fn set_enabled(run_dir: &Path, file: &str, enabled: bool) -> Result<ModToggle, ModError> {
let name = bare_name(file)?;
let dir = mods_dir(run_dir);
usable_dir(&dir)?;
let from_path = dir.join(&name);
let meta = std::fs::symlink_metadata(&from_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ModError::NotFound {
file: name.clone(),
dir: dir.clone(),
}
} else {
ModError::NotARegularFile {
file: name.clone(),
detail: error.to_string(),
}
}
})?;
if meta.file_type().is_symlink() || !meta.is_file() {
return Err(ModError::NotARegularFile {
file: name.clone(),
detail: "不是普通文件(符号链接 / 目录 / 设备都不动)".to_string(),
});
}
let Some(target) = renamed_for(&name, enabled) else {
// 已经是目标状态:幂等返回,不产生多余 rename(Windows 上同大小写改名还可能失败)。
return Ok(ModToggle {
enabled: !is_disabled_path(&name),
from: name.clone(),
to: name,
renamed: false,
});
};
let to_path = dir.join(&target);
if std::fs::symlink_metadata(&to_path).is_ok() {
return Err(ModError::Collision {
file: name,
target,
dir,
});
}
std::fs::rename(&from_path, &to_path).map_err(|error| ModError::Rename {
file: name.clone(),
target: target.clone(),
detail: error.to_string(),
})?;
Ok(ModToggle {
from: name,
to: target,
enabled,
renamed: true,
})
}
/// 文件名必须是**裸文件名**:先过整合包侧的七条路径规则,再要求没有 `/`。
fn bare_name(file: &str) -> Result<String, ModError> {
let trimmed = file.trim();
let normalized = crate::addon::pack::path::normalize_file(trimmed).map_err(|error| {
ModError::UnsafeName {
file: file.to_string(),
detail: error.to_string(),
}
})?;
if normalized.contains('/') || normalized != trimmed {
return Err(ModError::UnsafeName {
file: file.to_string(),
detail: "只接受 mods/ 下的裸文件名(不含路径分隔符、不做归一)".to_string(),
});
}
Ok(normalized)
}
/// `mods/` 必须存在、是目录、且**不是符号链接**。
fn usable_dir(dir: &Path) -> Result<(), ModError> {
let meta = std::fs::symlink_metadata(dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: if error.kind() == std::io::ErrorKind::NotFound {
"不存在(这个实例还没有 mods 目录)".to_string()
} else {
error.to_string()
},
})?;
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "不是目录".to_string(),
});
}
Ok(())
}
/// 建 `mods/` 目录(**只在装 mod 时用**;列目录/启停不建)。
///
/// # Errors
///
/// 建不出来 / 建出来是符号链接以外的怪形状时给 [`ModError::ModsDirUnusable`]。
pub fn ensure_dir(run_dir: &Path) -> Result<PathBuf, ModError> {
let dir = mods_dir(run_dir);
std::fs::create_dir_all(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
usable_dir(&dir)?;
Ok(dir)
}
+18
View File
@@ -87,6 +87,24 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AdoptInstanceName", "Instance name", "实例名"),
("AdoptDoneTitle", "Imported", "已导入"),
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
("InstancesModsTitle", "MODS", "模组"),
("InstancesModsCount", "{enabled} enabled · {disabled} disabled", "{enabled} 启用 · {disabled} 禁用"),
("InstancesModsLoading", "Reading mods/…", "正在读取 mods/…"),
("InstancesModsEmpty", "No mods here yet — put jars into mods/ and they show up.", "这里还没有模组——把模组文件放进 mods/ 就会出现在这里。"),
("InstancesModsMissingDir", "This instance has no mods/ directory yet.", "这个实例还没有 mods/ 目录。"),
("InstancesModsRunning", "The game is running — toggles are read-only until it exits.", "游戏正在运行——退出前开关只读。"),
("InstancesModsOldBadge", "legacy file", "旧版本残留"),
("InstancesModsSkipped", "{count} entries skipped", "跳过 {count} 项"),
("InstancesModsFailed", "Could not read mods/: {reason}", "读取 mods/ 失败:{reason}"),
("InstancesModsToggleFailed", "Could not toggle {file}: {reason}", "启停 {file} 失败:{reason}"),
("InstancesModsReasonRunning", "the game is running", "游戏正在运行"),
("InstancesModsReasonInstance", "the instance is gone", "实例已不存在"),
("InstancesModsReasonName", "the file name is not usable", "文件名不可用"),
("InstancesModsReasonDirectory", "there is no usable mods/ directory", "没有可用的 mods/ 目录"),
("InstancesModsReasonMissing", "the file is gone", "文件已不在"),
("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"),
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
("InstancesModsReasonRename", "the rename failed", "改名失败"),
];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+22
View File
@@ -132,6 +132,28 @@ macro_rules! instances_entries {
AdoptInstanceName => ("Instance name", "实例名"),
AdoptDoneTitle => ("Imported", "已导入"),
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
// ── MODS 区(M224):本地 mod 列表 + 启停开关。
// `mods` 是**目录名**(文件系统事实,不翻译),所以中文那栏里也出现它;
// 页面守门的 ALLOWED 里因此有一条 `mods`(见 `pages/instances/tests/i18n.rs`)。
InstancesModsTitle => ("MODS", "模组"),
InstancesModsCount => ("{enabled} enabled · {disabled} disabled", "{enabled} 启用 · {disabled} 禁用"),
InstancesModsLoading => ("Reading mods/…", "正在读取 mods/…"),
InstancesModsEmpty => ("No mods here yet — put jars into mods/ and they show up.", "这里还没有模组——把模组文件放进 mods/ 就会出现在这里。"),
InstancesModsMissingDir => ("This instance has no mods/ directory yet.", "这个实例还没有 mods/ 目录。"),
InstancesModsRunning => ("The game is running — toggles are read-only until it exits.", "游戏正在运行——退出前开关只读。"),
InstancesModsOldBadge => ("legacy file", "旧版本残留"),
InstancesModsSkipped => ("{count} entries skipped", "跳过 {count} 项"),
InstancesModsFailed => ("Could not read mods/: {reason}", "读取 mods/ 失败:{reason}"),
InstancesModsToggleFailed => ("Could not toggle {file}: {reason}", "启停 {file} 失败:{reason}"),
// 启停失败的**具名原因**(`ModFailureKind` → 一句话;`{detail}` 是 OS 原文,只进括号)
InstancesModsReasonRunning => ("the game is running", "游戏正在运行"),
InstancesModsReasonInstance => ("the instance is gone", "实例已不存在"),
InstancesModsReasonName => ("the file name is not usable", "文件名不可用"),
InstancesModsReasonDirectory => ("there is no usable mods/ directory", "没有可用的 mods/ 目录"),
InstancesModsReasonMissing => ("the file is gone", "文件已不在"),
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
InstancesModsReasonRename => ("the rename failed", "改名失败"),
} }
};
}
+10 -18
View File
@@ -33,6 +33,9 @@ use crate::dto::{
LogLine,
LogOutcome,
LogSession,
ModFailureView,
ModListView,
ModsCommand,
NoticeView,
PackCommand,
PackEvent,
@@ -45,6 +48,9 @@ use crate::dto::{
};
use crate::toast::Toast;
// M224:`AppCommand` 的构造器(从本文件搬出去守 400 行硬线,纯搬移)
mod commands;
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveLogBatch {
@@ -160,6 +166,8 @@ pub enum CoreEvent {
///
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
Update(Box<UpdateView>),
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
Mods(Box<Result<ModListView, ModFailureView>>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -289,24 +297,8 @@ pub enum AppCommand {
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
/// 读到的是同一个值。
SetUpdateAutoCheck(bool),
}
impl AppCommand {
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
pub fn set_isolation(id: &str, mode: &str) -> Self {
Self::SetIsolation {
id: id.to_string(),
mode: mode.to_string(),
}
}
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
Self::SetInstanceLaunchArgs {
id: id.to_string(),
patch,
}
}
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
Mods(Box<ModsCommand>),
}
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
@@ -0,0 +1,26 @@
//! `AppCommand` 的**构造器**(从 `dto/events.rs` 搬出:那边 406 行越了 400 行硬线)。
//!
//! **纯搬移**:函数体、文档、可见性一字未改,只是换了文件;`impl AppCommand` 的另一个块,
//! 于是调用点 `AppCommand::set_isolation(..)` / `AppCommand::set_launch_args(..)` 一个字都不用改。
//! 这样 `events.rs` 只留两个枚举本体(`AppCommand` / `CoreEvent`),M224 才能在里面加
//! `Mods` 变体而不越线。
use crate::dto::{AppCommand, LaunchArgsPatch};
impl AppCommand {
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
pub fn set_isolation(id: &str, mode: &str) -> Self {
Self::SetIsolation {
id: id.to_string(),
mode: mode.to_string(),
}
}
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
Self::SetInstanceLaunchArgs {
id: id.to_string(),
patch,
}
}
}
+7
View File
@@ -30,6 +30,8 @@ mod logs;
mod notices;
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
mod pack;
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
mod mods;
mod settings;
// M159: 自更新(检查结论 / 候选 / 就位状态)
mod update;
@@ -63,6 +65,11 @@ pub use instances::{
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
// M224: 本地 Mod 列表与启停
pub use mods::{
locate, ModEntryView, ModFailureKind, ModFailureView, ModListView, ModSkipKind, ModSkipView,
ModsCommand,
};
pub use notices::{NoticeLevel, NoticeView};
// M113: import/export
pub use pack::{
+238
View File
@@ -0,0 +1,238 @@
//! 本地 Mod 列表与启停的 IPC 面(M224 · MODTOGGLE)。
//!
//! M218 的结论是这块要**新建三层**(core 读写目录 + presenter DTO + 实例详情页),且**不许**
//! 给内容页加第五个 `ContentKind`(会打断 4 处穷尽 match)。本文件是中间那层:
//!
//! | 方向 | 类型 |
//! |---|---|
//! | app → host | [`ModsCommand`](列目录 / 启停一个文件) |
//! | host → app | [`ModListView`](整份快照)或 [`ModFailureView`](具名原因) |
//!
//! ## 为什么不复用内容页的 `ContentInstallRequest`
//!
//! 那条通路是「远端 → 本地」,入口在内容页;启停是「本地 → 本地」,入口在实例详情页。
//! 把两者塞进一个 DTO 会让内容页为了一个它不画的字段多一次穷尽 match——正是 §3 点名要避免的。
//!
//! ## 文案纪律(M161)
//!
//! 本模块**不带人读散文**:失败给的是结构([`ModFailureKind`] + 底层 detail 串),
//! 由 app 层按 i18n key 现求。`detail` 是 OS 原文(`Access is denied.` 之类),只进括号,
//! 不进主句——主句必须能翻译。
use serde::{Deserialize, Serialize};
use crate::dto::{InstanceId, InstanceSummary};
/// app → host 的本地 Mod 指令。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModsCommand {
/// 列某个实例的 `mods/`(进详情页时发、启停成功后 host 自己再发一次)。
List(InstanceId),
/// 启停一个文件(`file` 是**磁盘上的文件名**,含后缀)。
SetEnabled {
/// 实例 id
id: InstanceId,
/// 磁盘文件名(`sodium.jar.disabled`)
file: String,
/// 期望的启用状态
enabled: bool,
},
}
/// 列表里的一条。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModEntryView {
/// 磁盘文件名(含 `.disabled` / `.old`,UI 的开关消息原样带回去)。
pub file: String,
/// 展示名(去掉两个后缀)。
pub name: String,
/// 是否启用。
pub enabled: bool,
/// 字节数(UI 自己格式化,不在这里拼 "1.2 MiB")。
pub bytes: u64,
/// 是否带 `.old`(旧版本残留,只展示不清理)。
pub old: bool,
}
/// 没列出来的一条(UI 只需计数 + 一句"为什么")。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModSkipView {
/// 文件名。
pub file: String,
/// 跳过原因(结构,不翻译)。
pub reason: ModSkipKind,
}
/// 跳过的原因(与 core 的 `addon::mods::SkipReason` 一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModSkipKind {
/// 符号链接(不跟随)。
SymbolicLink,
/// 不是普通文件(目录 / 设备)。
NotAFile,
/// 名字过不了路径规则。
UnsafeName,
/// 元数据读不出来。
Unreadable,
}
/// 一次列目录的完整快照(**整体替换**,不做增量)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModListView {
/// 实例 id
pub instance: InstanceId,
/// 被列的 `mods/` 目录(绝对路径,UI 的"打开文件夹"用)。
pub dir: String,
/// 目录存不存在(`false` = 这个实例还没装过 mod)。
pub exists: bool,
/// 列表(按展示名排序)。
pub entries: Vec<ModEntryView>,
/// 跳过条目。
pub skipped: Vec<ModSkipView>,
/// 这一份快照采样的时刻,游戏是否在该实例上运行(运行中开关只读)。
pub running: bool,
/// 采样时的实例快照里有没有这个实例(`false` = 实例被删了,页面该退回列表)。
pub known: bool,
}
impl ModListView {
/// 启用中的条数。
#[must_use]
pub fn enabled_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.enabled).count()
}
/// 禁用中的条数。
#[must_use]
pub fn disabled_count(&self) -> usize {
self.entries.len() - self.enabled_count()
}
}
/// 一次启停失败的具名原因(app 按 key 现求文案)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModFailureView {
/// 实例 id(迟到/串台的结果靠它判)。
pub instance: InstanceId,
/// 涉及的文件(列表里没有时为空串)。
pub file: String,
/// 失败大类。
pub kind: ModFailureKind,
/// 底层原文(OS 错误 / 路径细节);只进括号,不进主句。
pub detail: String,
}
/// 失败大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModFailureKind {
/// 文件名不是裸文件名。
UnsafeName,
/// `mods/` 不可用(不存在 / 符号链接 / 不是目录 / 读不了)。
ModsDirUnusable,
/// 文件不在了。
NotFound,
/// 目标不是普通文件。
NotARegularFile,
/// 目标名已被占用(不覆盖)。
Collision,
/// 改名失败。
Rename,
/// 实例不在了(快照里找不到),或隔离档位派生不出运行目录。
InstanceUnavailable,
/// 游戏正在这个实例上运行(host 侧主动挡:Windows 上 rename 会失败,别让用户猜)。
Running,
}
/// 实例 id 在快照里的最小定位结果(`None` = 实例已不在)。
#[must_use]
pub fn locate<'a>(instances: &'a [InstanceSummary], id: &str) -> Option<&'a InstanceSummary> {
instances
.iter()
.find(|instance| instance.id == id || instance.name == id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::InstanceRuntime;
fn summary(id: &str, name: &str) -> InstanceSummary {
InstanceSummary {
id: id.to_string(),
name: name.to_string(),
mc_version: "1.20.1".to_string(),
loader: crate::dto::LoaderInfo {
kind: crate::dto::LoaderKind::Vanilla,
version: String::new(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.1".to_string(),
version_present: true,
launch: crate::dto::LaunchArgsView::default(),
}
}
/// 快照计数与 IPC round-trip(AGENTS §6:跨界类型必须能序列化)。
#[test]
fn view_counts_and_round_trips() {
let view = ModListView {
instance: "inst".to_string(),
dir: "/tmp/run/mods".to_string(),
exists: true,
entries: vec![
ModEntryView {
file: "a.jar".to_string(),
name: "a.jar".to_string(),
enabled: true,
bytes: 10,
old: false,
},
ModEntryView {
file: "b.jar.disabled".to_string(),
name: "b.jar".to_string(),
enabled: false,
bytes: 20,
old: false,
},
],
skipped: Vec::new(),
running: false,
known: true,
};
assert_eq!(view.enabled_count(), 1);
assert_eq!(view.disabled_count(), 1);
let text = serde_json::to_string(&view).expect("序列化");
let back: ModListView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, view);
let command = ModsCommand::SetEnabled {
id: "inst".to_string(),
file: "a.jar".to_string(),
enabled: false,
};
let text = serde_json::to_string(&command).expect("序列化");
assert_eq!(
serde_json::from_str::<ModsCommand>(&text).expect("反序列化"),
command
);
}
/// 定位:id 优先、名字兜底(与 `launch_target` 同口径)。
#[test]
fn locate_matches_by_id_then_name() {
let instances = vec![summary("survival", "生存"), summary("other", "别的")];
assert_eq!(
locate(&instances, "survival").map(|entry| entry.name.as_str()),
Some("生存")
);
assert_eq!(
locate(&instances, "别的").map(|entry| entry.id.as_str()),
Some("other")
);
assert!(locate(&instances, "gone").is_none());
}
}
+12 -1
View File
@@ -9,7 +9,7 @@ use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{
addon, game, install, java, launch, loader_versions, logs, settings, versions, CoreHost,
addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost,
};
impl CoreHost {
@@ -278,6 +278,17 @@ impl CoreHost {
self.toast(ToastKind::Warn, reason);
}
},
// M224:本地 Mod 列表 / 启停(磁盘在后台线程碰;这里只算"游戏在不在跑")
AppCommand::Mods(command) => {
let id = match &*command {
crate::dto::ModsCommand::List(id) => id.clone(),
crate::dto::ModsCommand::SetEnabled { id, .. } => id.clone(),
};
// 运行中挡在 host 这一侧(不只是 UI 灰掉):Windows 上进程正持有 jar,
// rename 必然失败,与其让用户读一句 OS 报错,不如直说。
let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
+24 -1
View File
@@ -5,7 +5,7 @@
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
use crate::toast::ToastKind;
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
impl CoreHost {
/// 后台消息 → 事件。
@@ -210,6 +210,29 @@ impl CoreHost {
self.update.adopt(*report);
self.publish_update();
}
// M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。
// 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。
HostTask::Mods(report) => {
let mods::Report {
instance,
list,
toggled,
} = *report;
if let Some(toggled) = toggled {
// 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。
eprintln!(
"[mods] {instance}: {} → {} ({})",
toggled.from,
toggled.to,
if toggled.enabled {
"enabled"
} else {
"disabled"
}
);
}
self.send(CoreEvent::Mods(Box::new(list)));
}
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
+2
View File
@@ -153,6 +153,8 @@ pub enum HostTask {
Sessions(Vec<LogSession>),
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
Update(Box<crate::host::update::Report>),
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
Mods(Box<super::mods::Report>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
+1
View File
@@ -32,6 +32,7 @@ mod launch_args;
mod loader_versions;
mod logs;
mod meter;
mod mods;
mod opener;
mod settings;
mod update;
+269
View File
@@ -0,0 +1,269 @@
//! 本地 Mod 列表与启停的宿主实现(M224 · MODTOGGLE)。
//!
//! ## 三条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
//!
//! ## 为什么不复用启动路径的 `run_dir()`
//!
//! `host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例设置(`observe_mode`)。列 mod /
//! 启停**不该因为用户点开详情页就写盘**,所以这里只取派生结果、不落任何设置。
//!
//! ## 失败口径
//!
//! 一律走 [`crate::dto::ModFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 i18n key
//! 现求(M161 的纪律,`dto::events` 的 `LaunchFinished` 同款)。
use std::path::PathBuf;
use unlucky_core::addon::mods::{ModError, ModScan, SkipReason};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use crate::dto::{
locate, InstanceId, InstanceSummary, ModEntryView, ModFailureKind, ModFailureView, ModListView,
ModSkipKind, ModSkipView, ModsCommand,
};
use super::env::HostEnv;
use super::job::{spawn, JobSender};
/// 后台线程回报的一次结果(`HostTask::Mods` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
/// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。
pub instance: InstanceId,
/// 列表结果:成功 = 整份快照;失败 = 具名原因。
pub list: Result<ModListView, ModFailureView>,
/// 这一拍是启停操作的结果时带上「改了什么」(host 只记日志;开关跟快照翻)。
pub toggled: Option<Toggled>,
}
/// 一次成功的启停(`renamed == false` ⇒ 本来就是那个状态,什么也没动)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toggled {
/// 原磁盘名。
pub from: String,
/// 新磁盘名。
pub to: String,
/// 现在的启用状态。
pub enabled: bool,
/// 是否真的改了名。
pub renamed: bool,
}
/// 后台列一个实例的 `mods/`。
pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId, running: bool) {
spawn("mods-list", move || {
let report = Report {
instance: id.clone(),
list: list(&env, &id, running),
toggled: None,
};
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 后台启停一个文件,然后把**新快照**一起带回来(app 不必再请求一次)。
pub fn spawn_set_enabled(
env: HostEnv,
tx: JobSender,
id: InstanceId,
file: String,
enabled: bool,
running: bool,
) {
spawn("mods-toggle", move || {
let report = toggle(&env, &id, &file, enabled, running);
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: ModsCommand, running: bool) {
match command {
ModsCommand::List(id) => spawn_list(env, tx, id, running),
ModsCommand::SetEnabled { id, file, enabled } => {
spawn_set_enabled(env, tx, id, file, enabled, running)
}
}
}
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
fn target(
env: &HostEnv,
id: &str,
file: &str,
) -> Result<(InstanceSummary, PathBuf), ModFailureView> {
let instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else {
return Err(failure(
id,
file,
ModFailureKind::InstanceUnavailable,
format!("实例 {id} 不在快照里(可能刚被删除)"),
));
};
let dir = run_dir(env, instance).map_err(|detail| {
failure(
&instance.id,
file,
ModFailureKind::InstanceUnavailable,
detail,
)
})?;
Ok((instance.clone(), dir))
}
/// 列目录(后台线程里调)。
fn list(env: &HostEnv, id: &str, running: bool) -> Result<ModListView, ModFailureView> {
let (instance, dir) = target(env, id, "")?;
let scan = unlucky_core::addon::mods::scan(&dir).map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance.id, "", kind, detail)
})?;
Ok(view(&instance, &scan, running))
}
/// 启停一个文件 + 回来时带新快照。
fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) -> Report {
let (instance, dir) = match target(env, id, file) {
Ok(found) => found,
Err(view) => {
return Report {
instance: id.to_string(),
list: Err(view),
toggled: None,
};
}
};
let instance_id = instance.id.clone();
if running {
return Report {
instance: instance_id,
list: Err(failure(
id,
file,
ModFailureKind::Running,
"游戏进程正在跑(Windows 会拒绝改名)",
)),
toggled: None,
};
}
let toggled = match unlucky_core::addon::mods::set_enabled(&dir, file, enabled) {
Ok(result) => Toggled {
from: result.from,
to: result.to,
enabled: result.enabled,
renamed: result.renamed,
},
Err(error) => {
let (kind, detail) = classify(&error);
return Report {
instance: instance_id,
list: Err(failure(id, file, kind, detail)),
toggled: None,
};
}
};
// 改名之后**重新扫一遍**再发:权威状态来自磁盘,app 不做乐观回显(与启动/停止同一条纪律)。
let list = unlucky_core::addon::mods::scan(&dir)
.map(|scan| view(&instance, &scan, running))
.map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance_id, file, kind, detail)
});
Report {
instance: instance_id,
list,
toggled: Some(toggled),
}
}
/// 实例的运行目录(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: &ModScan, running: bool) -> ModListView {
ModListView {
instance: instance.id.clone(),
dir: scan.dir.display().to_string(),
exists: scan.exists,
entries: scan
.entries
.iter()
.map(|entry| ModEntryView {
file: entry.file.clone(),
name: entry.name.clone(),
enabled: entry.enabled,
bytes: entry.bytes,
old: entry.variant.is_old(),
})
.collect(),
skipped: scan
.skipped
.iter()
.map(|skip| ModSkipView {
file: skip.file.clone(),
reason: match skip.reason {
SkipReason::SymbolicLink => ModSkipKind::SymbolicLink,
SkipReason::NotAFile => ModSkipKind::NotAFile,
SkipReason::UnsafeName => ModSkipKind::UnsafeName,
SkipReason::Unreadable => ModSkipKind::Unreadable,
},
})
.collect(),
running,
known: true,
}
}
fn failure(
instance: &str,
file: &str,
kind: ModFailureKind,
detail: impl Into<String>,
) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: file.to_string(),
kind,
detail: detail.into(),
}
}
/// core 的错误 → DTO 大类 + 原文(**不翻译**,翻译在 app 层)。
fn classify(error: &ModError) -> (ModFailureKind, String) {
match error {
ModError::UnsafeName { detail, .. } => (ModFailureKind::UnsafeName, detail.clone()),
ModError::ModsDirUnusable { detail, .. } => {
(ModFailureKind::ModsDirUnusable, detail.clone())
}
ModError::NotFound { .. } => (ModFailureKind::NotFound, error.to_string()),
ModError::NotARegularFile { detail, .. } => {
(ModFailureKind::NotARegularFile, detail.clone())
}
ModError::Collision { .. } => (ModFailureKind::Collision, error.to_string()),
ModError::Rename { detail, .. } => (ModFailureKind::Rename, detail.clone()),
}
}
#[cfg(test)]
mod tests;
+192
View File
@@ -0,0 +1,192 @@
//! `host::mods` 的用例(从 `host/mods.rs` 拆出:那边 457 行越了 400 行硬线)。
//!
//! 三条:**真盘端到端**(派生运行目录 → core 改名 → 快照回来)、跳过原因映射、错误分类。
//! 端到端那条是这个模块存在的主要理由——启停的正确性只有"真的建一个实例根再改一次盘"能验。
use super::*;
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
use unlucky_core::addon::mods::ModSkip;
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);
}
}
/// 一个可用的便携根:`mods/`(给定文件)+ `profiles.json` + `custom` 档位的实例设置。
///
/// 走 `custom` 是**故意**的:它是 D20 里唯一「运行目录由实例自己指」的档位,于是用例不必造
/// `versions/<id>/` 也能验「派生出来的目录就是启停的目标目录」。
fn fixture(tag: &str, files: &[(&str, &[u8])]) -> (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.join("mods")).expect("建 mods");
for (name, body) in files {
std::fs::write(run.join("mods").join(name), body).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: "isolated".to_string(),
version_id: VERSION.to_string(),
version_present: true,
launch: LaunchArgsView::default(),
}
}
/// **端到端**(真盘 + 真派生):列目录 → 启停 → 再列,顺带验运行中被挡。
///
/// 打通「派生运行目录 → core 改盘 → 快照回来」整条链。抓得住:派生用错目录、启停后没重扫、
/// `.disabled` 判据与 core 不一致。抓不住:Windows 上的占用/权限失败(那要靠真跑)。
#[test]
fn list_and_toggle_round_trip_on_a_real_root() {
let (_root, env, run) = fixture(
"mods-e2e",
&[
("sodium.jar", b"aa"),
("jei.jar.disabled", b"bbb"),
("old.jar.old", b"cccc"),
],
);
let listed = list(&env, ID, false).expect("列目录");
assert!(listed.exists && listed.known && !listed.running);
assert_eq!((listed.enabled_count(), listed.disabled_count()), (2, 1));
assert!(listed.entries.iter().any(|entry| entry.old));
let report = toggle(&env, ID, "sodium.jar", false, false);
assert!(report.toggled.expect("要报告改了什么").renamed);
assert!(run.join("mods/sodium.jar.disabled").is_file());
assert_eq!(report.list.expect("新快照").enabled_count(), 1);
// 再启用回去(`.disabled` 剥掉,字节不动)
let report = toggle(&env, ID, "sodium.jar.disabled", true, false);
assert!(report.toggled.expect("报告").renamed);
assert_eq!(report.list.expect("快照").enabled_count(), 2);
assert_eq!(
std::fs::read(run.join("mods/sodium.jar")).expect("读回"),
b"aa"
);
// 游戏在跑:host 直接挡(不碰盘、不改名)
let blocked = toggle(&env, ID, "sodium.jar", false, true);
assert_eq!(
blocked.list.expect_err("运行中必须拒").kind,
ModFailureKind::Running
);
assert!(blocked.toggled.is_none());
assert!(run.join("mods/sodium.jar").is_file(), "盘被动了");
assert!(!run.join("mods/sodium.jar.disabled").exists());
}
/// 跳过原因一一对应,且 runtime/known 标记原样过桥。
///
/// 抓得住:`NotAFile` 归到 `SymbolicLink`("为什么没列出来"撒谎)、`running` 被写死。
#[test]
fn the_snapshot_keeps_skip_reasons_and_flags() {
let scan = ModScan {
dir: PathBuf::from("m"),
exists: true,
entries: Vec::new(),
skipped: vec![ModSkip {
file: "x".to_string(),
reason: SkipReason::SymbolicLink,
}],
};
let listed = view(&instance(), &scan, true);
assert!(listed.running && listed.known && listed.exists);
let kinds = [
(SkipReason::SymbolicLink, ModSkipKind::SymbolicLink),
(SkipReason::NotAFile, ModSkipKind::NotAFile),
(SkipReason::UnsafeName, ModSkipKind::UnsafeName),
(SkipReason::Unreadable, ModSkipKind::Unreadable),
];
for (reason, expected) in kinds {
let mut scan = scan.clone();
scan.skipped[0].reason = reason;
assert_eq!(view(&instance(), &scan, false).skipped[0].reason, expected);
}
}
/// 错误分类:每个 core 变体都落到自己的大类(没有「未知 → 一律 IO」的兜底),原因不丢。
#[test]
fn every_core_error_keeps_its_own_kind() {
let dir = PathBuf::from("m");
let cases = [
(
ModError::UnsafeName {
file: "a/b".into(),
detail: "含分隔符".into(),
},
ModFailureKind::UnsafeName,
),
(
ModError::NotFound {
file: "x.jar".into(),
dir: dir.clone(),
},
ModFailureKind::NotFound,
),
(
ModError::Collision {
file: "x.jar".into(),
target: "x.jar.disabled".into(),
dir,
},
ModFailureKind::Collision,
),
(
ModError::Rename {
file: "x.jar".into(),
target: "x.jar.disabled".into(),
detail: "被占用".into(),
},
ModFailureKind::Rename,
),
];
for (error, expected) in cases {
let (kind, detail) = classify(&error);
assert_eq!(kind, expected, "{error}");
assert!(!detail.is_empty(), "{error} 的分类把原因吃掉了");
}
}
+9 -17
View File
@@ -45,9 +45,12 @@ mod accounts;
// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线)
mod agent;
mod live;
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
mod mods;
mod tiles;
pub use live::MAX_LIVE_LINES;
pub use mods::ModsState;
pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
@@ -136,6 +139,8 @@ pub struct AppState {
pub pack: PackState,
/// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态)
pub adopt: AdoptState,
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
pub mods: ModsState,
}
impl Default for AppState {
@@ -182,6 +187,7 @@ impl AppState {
content: ContentState::default(),
pack: PackState::default(),
adopt: AdoptState::default(),
mods: ModsState::default(),
}
}
@@ -306,27 +312,13 @@ impl AppState {
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
CoreEvent::ContentDetail { project, result } => {
self.apply_content_detail(&project, *result);
self.apply_content_detail(&project, *result)
}
// M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记)
CoreEvent::Mods(result) => self.mods.apply(*result),
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
/// 运行中的实例(实例页顶部迷你状态条)。
pub fn running(&self) -> Vec<&InstanceSummary> {
self.instances
+188
View File
@@ -0,0 +1,188 @@
//! 本地 Mod 的快照落地与「请求在飞」标记(M224 · MODTOGGLE)。
//!
//! 从 `state.rs` 拆出(那个文件正好贴着 400 行硬线),形状与 [`super::accounts`] / [`super::live`]
//! 一致:**另一个 `impl AppState` 块**,路径与可见性都不变,`apply_core` 只有一行投递。
//!
//! # 为什么快照住在这里而不是页面
//!
//! 「列表画的永远是磁盘上的真相」是本模块唯一要保证的事,所以:
//!
//! - 快照走 [`crate::dto::CoreEvent::Mods`] 的 `Ok` 一支,**整体替换**(不做增量合并)——host 每次
//! 都是重新扫一遍目录才发的,合并只会两处真相;
//! - 失败走同一个事件的 `Err` 一支,记下一条**具名原因**,页面按 i18n key 现求文案
//! (presenter 不拼句子,M161 纪律);
//! - [`ModsState::pending`] 是**页面交互态**(哪个文件的请求在飞),只用来把那个开关先置灰
//! ——**不做乐观翻面**:`view` 里的 `enabled` 只跟磁盘快照走。
//!
//! # 迟到结果
//!
//! 启停是「后台线程 + 快照回来」:用户很可能点完就切到别的实例。所以 `view.instance` 与
//! [`ModsState::bound`] 不一致的结果**直接丢掉**(与内容库按 request id 判迟到同一条纪律)——
//! 否则 A 的列表会被 B 的启停结果顶掉。
use std::collections::BTreeSet;
use crate::dto::{ModFailureView, ModListView};
use super::AppState;
/// 本地 Mod 的视图态。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModsState {
/// 当前详情页的实例 id(`None` = 不在详情页)。
pub bound: Option<String>,
/// 最近一份快照(`None` = 还没请求过)。
pub view: Option<ModListView>,
/// 最近一条失败(具名原因;成功一次就清掉)。
pub failed: Option<ModFailureView>,
/// 正在飞的启停请求(磁盘文件名,含后缀)。
pub pending: BTreeSet<String>,
}
impl ModsState {
/// 切到另一个实例(换实例必须清空: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;
self.pending.clear();
}
/// 一条结果落地(`Ok` = 快照整体替换,`Err` = 记一条具名失败;迟到/串台的丢掉)。
pub fn apply(&mut self, result: Result<ModListView, ModFailureView>) {
match result {
Ok(view) => self.changed(view),
Err(failure) => self.failed(failure),
}
}
/// 列表快照落地(整体替换;迟到/串台的丢掉)。
pub fn changed(&mut self, view: ModListView) {
if self.bound.as_deref() != Some(view.instance.as_str()) {
return;
}
// 这一批结果回来了 ⇒ 那个文件的"在飞"标记可以撤(不管成没成,另一条事件会说)。
self.pending.clear();
self.failed = None;
self.view = Some(view);
}
/// 失败落地(只记**当前绑定实例**的;列表那一份保留——用户还能看见旧列表,只是多一条红字)。
pub fn failed(&mut self, failure: ModFailureView) {
if self.bound.as_deref() != Some(failure.instance.as_str()) {
return;
}
self.pending.clear();
self.failed = Some(failure);
}
/// 标一个文件"请求在飞"(UI 把那个开关置灰,不做乐观翻面)。
pub fn mark_pending(&mut self, file: &str) {
self.pending.insert(file.to_string());
}
/// 某个文件的请求是否在飞。
#[must_use]
pub fn is_pending(&self, file: &str) -> bool {
self.pending.contains(file)
}
/// 当前绑定实例的列表是否已经有一份快照。
#[must_use]
pub fn has_view(&self) -> bool {
self.view.is_some()
}
}
impl AppState {
/// 进/出详情页时对齐绑定(`refresh_view` 调;与 `LaunchForm::sync` 同一处生命周期)。
pub fn bind_mods(&mut self, instance: Option<&str>) {
self.mods.bind(instance);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{ModEntryView, ModFailureKind};
fn view(instance: &str) -> ModListView {
ModListView {
instance: instance.to_string(),
dir: "C:/mc/mods".to_string(),
exists: true,
entries: vec![ModEntryView {
file: "x.jar".to_string(),
name: "x.jar".to_string(),
enabled: true,
bytes: 1,
old: false,
}],
skipped: Vec::new(),
running: false,
known: true,
}
}
fn failure(instance: &str) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: "x.jar".to_string(),
kind: ModFailureKind::Collision,
detail: "exists".to_string(),
}
}
/// 换实例必须清空(A 的列表不许画在 B 的详情页上)。
#[test]
fn binding_another_instance_drops_the_old_view() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
assert!(mods.has_view());
mods.bind(Some("b"));
assert!(!mods.has_view(), "换实例后还留着 A 的列表");
assert_eq!(mods.bound.as_deref(), Some("b"));
// 同一个实例再 bind 一次不该清(`refresh_view` 每帧都调)。
mods.changed(view("b"));
mods.bind(Some("b"));
assert!(mods.has_view());
}
/// 迟到/串台的结果被丢掉;成功一次清掉旧失败。
#[test]
fn late_results_are_dropped() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("b"));
assert!(!mods.has_view(), "B 的列表画到了 A 上");
mods.failed(failure("a"));
assert!(mods.failed.is_some());
mods.changed(view("a"));
assert!(mods.failed.is_none(), "成功后旧失败没清掉");
// 不在详情页(`bound = None`)时任何结果都不落地。
let mut idle = ModsState::default();
idle.changed(view("a"));
assert!(!idle.has_view());
}
/// 在飞标记:置位后能查,快照回来后自然清空。
#[test]
fn pending_tracks_in_flight_toggles() {
let mut mods = ModsState::default();
mods.bind(Some("a"));
mods.changed(view("a"));
mods.mark_pending("x.jar");
assert!(mods.is_pending("x.jar"));
assert!(!mods.is_pending("y.jar"));
mods.changed(view("a"));
assert!(!mods.is_pending("x.jar"), "快照回来后还挂着在飞标记");
}
}
+23
View File
@@ -2,9 +2,14 @@
//!
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
//!
//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是
//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。
use crate::dto::InstanceSummary;
use super::AppState;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
@@ -25,3 +30,21 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
.map(|index| (index / columns, index % columns))
.collect()
}
impl AppState {
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
}