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
40 files changed
+2762 -91

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); pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
mod boot_probe; mod boot_probe;
// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响)
mod construct; mod construct;
mod mods_shot;
mod refresh; mod refresh;
mod theme; mod theme;
@@ -180,6 +182,11 @@ pub struct App {
/// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」, /// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」,
/// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。 /// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。
pub(super) launch_ends_seen: u64, pub(super) launch_ends_seen: u64,
/// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。
///
/// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、
/// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。
pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
/// ///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
@@ -139,6 +139,7 @@ impl App {
adopt_form: super::super::adopt::Form::default(), adopt_form: super::super::adopt::Form::default(),
install_cancelled_seen: 0, install_cancelled_seen: 0,
launch_ends_seen: 0, launch_ends_seen: 0,
mods_failed_seen: None,
dialog_card: None, dialog_card: None,
dialog_model: None, dialog_model: None,
content: pages::content::PageState::default(), 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 起):只有真的改了状态的事情才置位, //! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 //! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::dto::{AppCommand, ModsCommand};
use super::mods_shot;
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::view; 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` 的分类参数): // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category; let category = self.view.download.category;
@@ -110,4 +120,58 @@ impl App {
None => self.launch_form.bound = None, 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)。 //! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task; use iced::Task;
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch}; use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand};
use crate::pages::InstanceMessage; use crate::pages::InstanceMessage;
@@ -19,17 +19,29 @@ use super::super::message::Message;
use super::super::state::App; use super::super::state::App;
impl App { impl App {
/// 启动参数区的一条消息。 /// 实例页详情列的一条消息(启动参数表单 + MODS 区)。
pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> { pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task<Message> {
let Some(instance) = self.selected_instance_id() else { let Some(instance) = self.selected_instance_id() else {
return Task::none(); return Task::none();
}; };
if !self.launch_form.bound_to(&instance) {
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
return Task::none();
}
match message { 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) => { InstanceMessage::Jvm(text) => {
self.launch_form.jvm = text; self.launch_form.jvm = text;
self.launch_form.dirty = true; self.launch_form.dirty = true;
@@ -137,6 +149,34 @@ mod tests {
assert!(app.launch_form.dirty, "恢复之后还得点保存才落盘"); 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] #[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() { fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
+2
View File
@@ -255,6 +255,8 @@ pub(super) fn model(
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()), content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang), logs: logs_model(state).with_status(lang),
// M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段)
mods: instances::mods_of(state, catalog),
// M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它) // M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它)
update: update_of(&state.update), update: update_of(&state.update),
// M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按 // M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按
+10 -1
View File
@@ -23,8 +23,9 @@ use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished, InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
LaunchStage, LoaderKind, LogOutcome, 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`])。 /// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String { 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(), _ => 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)
}
@@ -85,8 +85,8 @@ pub(super) fn version_menu_height(items: usize) -> f32 {
/// head + chips,错误行不可达; /// head + chips,错误行不可达;
/// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是 /// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是
/// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。 /// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。
/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒ /// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒
/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 /// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。
pub(crate) fn section<'a>( pub(crate) fn section<'a>(
ui: &'a Ui, ui: &'a Ui,
catalog: Catalog, catalog: Catalog,
+17 -2
View File
@@ -49,6 +49,8 @@ mod data;
mod demo; mod demo;
mod detail; mod detail;
mod launch_args; mod launch_args;
// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关)
mod mods;
mod state; mod state;
mod text; mod text;
@@ -58,6 +60,15 @@ mod tests;
#[cfg(test)] #[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED}; pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(crate) use demo::{demo, demo_form}; 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 use state::{LaunchForm, Message as InstanceMessage};
pub(super) use text::Texts; pub(super) use text::Texts;
@@ -131,12 +142,16 @@ pub fn view<'a>(
metrics, metrics,
list_column(ui, block, forced, texts), list_column(ui, block, forced, texts),
match &block.detail { 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), None => detail::detail_empty(ui, texts),
}, },
), ),
_ => match &block.detail { _ => 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)), None => frame::body(metrics, detail::detail_empty(ui, texts)),
}, },
}; };
@@ -33,6 +33,7 @@ pub(super) fn detail_column<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
detail: &'a Detail, detail: &'a Detail,
form: &'a LaunchForm, form: &'a LaunchForm,
mods: &'a super::Mods,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -45,6 +46,10 @@ pub(super) fn detail_column<'a>(
.width(Length::Fill); .width(Length::Fill);
body = push_text(ui, metrics, body, detail); 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 内,卡片式)。 // M157:启动参数区在正文段之后、动作行之前(详情列 424px 内,卡片式)。
body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); body = body.push(super::launch_args::section(ui, &detail.launch, form, texts));
@@ -62,6 +67,7 @@ pub(super) fn narrow_detail<'a>(
metrics: FrameMetrics, metrics: FrameMetrics,
detail: &'a Detail, detail: &'a Detail,
form: &'a LaunchForm, form: &'a LaunchForm,
mods: &'a super::Mods,
forced: Option<Interaction>, forced: Option<Interaction>,
texts: Texts, texts: Texts,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@@ -84,6 +90,8 @@ pub(super) fn narrow_detail<'a>(
.width(Length::Fill); .width(Length::Fill);
body = push_text(ui, metrics, body, detail); 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(super::launch_args::section(ui, &detail.launch, form, texts));
body = body.push(frame::text_role( 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, Save,
/// 恢复跟随全局(清表单,仍需保存)。 /// 恢复跟随全局(清表单,仍需保存)。
Reset, Reset,
/// 启停一个 mod(M224):`file` 是**磁盘文件名**(含后缀),`enabled` 是期望状态。
///
/// 页面只发意图,落盘由 host 在后台线程做;回程是一份新快照(不做乐观回显)。
SetModEnabled {
/// 磁盘文件名
file: String,
/// 期望的启用状态
enabled: bool,
},
} }
#[cfg(test)] #[cfg(test)]
@@ -28,10 +28,15 @@ use super::super::Texts;
/// ///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"]; const ALLOWED: &[&str] = &[
"Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB",
// M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个
// 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。
"mods",
];
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 6] = [ const PAGE_SOURCES: [(&str, &str); 7] = [
("instances.rs", include_str!("../../instances.rs")), ("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")), ("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")), ("instances/demo.rs", include_str!("../demo.rs")),
@@ -44,6 +49,8 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
"app/view/instances.rs", "app/view/instances.rs",
include_str!("../../../app/view/instances.rs"), include_str!("../../../app/view/instances.rs"),
), ),
// M224:MODS 区(本地 mod 列表 + 启停开关)
("instances/mods.rs", include_str!("../mods.rs")),
]; ];
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
@@ -61,7 +68,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
const ALLOWED_LITERALS: [&str; 24] = [ const ALLOWED_LITERALS: [&str; 28] = [
"{date} {time} UTC", "{date} {time} UTC",
"isolated", "isolated",
"shared", "shared",
@@ -97,6 +104,12 @@ const ALLOWED_LITERALS: [&str; 24] = [
"ULL_LAUNCH_ARGS_FIXTURE", "ULL_LAUNCH_ARGS_FIXTURE",
// M161:示例「被门槛挡下」的取值(夹具)。 // M161:示例「被门槛挡下」的取值(夹具)。
"gated", "gated",
// M224:MODS 区四个 key 的**占位符名**(`{enabled}` / `{disabled}` / `{count}` / `{file}`),
// 不是文案——`Catalog::render` 按名字取值,换成别的词就渲染不出来了。
"enabled",
"disabled",
"count",
"file",
]; ];
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
@@ -26,6 +26,7 @@
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。 //! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key}; use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ModFailureKind;
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。 /// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -151,6 +152,66 @@ impl Texts {
self.text(Key::InstancesLaunchReset) 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 { fn text(self, key: Key) -> &'static str {
self.0.text(key) self.0.text(key)
} }
+1 -1
View File
@@ -128,7 +128,7 @@ pub(crate) mod settings;
mod tests; mod tests;
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
pub(crate) use instances::demo_form as demo_launch_form; 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 layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message, UpdateAction}; pub use message::{Action, Message, UpdateAction};
+6
View File
@@ -140,6 +140,11 @@ pub struct Model {
/// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是 /// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是
/// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。 /// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。
pub lang: Lang, 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` 时页面里的控件按该态画。 /// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
/// ///
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
@@ -169,6 +174,7 @@ impl Model {
// 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份, // 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份,
// 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。 // 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。
settings: Vec::new(), settings: Vec::new(),
mods: super::instances::Mods::default(),
lang: Lang::En, lang: Lang::En,
forced: None, forced: None,
} }
+4 -3
View File
@@ -181,9 +181,10 @@ fn relative_name(root: &Path, path: &Path) -> String {
} }
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。 /// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
///
/// M224 收敛:判据只有 [`crate::addon::mods::suffix`] 一处(启停也要用它,见
/// `DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。这里保留函数名与签名,行为逐字不变。
#[must_use] #[must_use]
pub fn is_disabled_path(path: &str) -> bool { pub fn is_disabled_path(path: &str) -> bool {
let lowered = path.trim_end().to_ascii_lowercase(); crate::addon::mods::is_disabled_path(path)
let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered);
without_old.ends_with(".disabled")
} }
+7 -35
View File
@@ -6,45 +6,17 @@
use super::super::report::{keys, ImportIssue}; use super::super::report::{keys, ImportIssue};
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。 /// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] ///
pub enum SuffixVariant { /// M224 收敛:类型本体只有 [`crate::addon::mods::suffix`] 一处,这里原样再导出,于是
/// `mods/x.jar`。 /// `paths::SuffixVariant` 与启停用的是**同一个类型**(零行为变化,调用点不用改)。
Plain, pub use crate::addon::mods::SuffixVariant;
/// `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] #[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant { pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase(); crate::addon::mods::suffix_variant(path)
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,
}
} }
/// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。 /// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。
+10 -1
View File
@@ -25,11 +25,16 @@
//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。 //! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。
//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。 //! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。
//! //!
//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。 //! - [`mods`](P2 · M224):**本地 Mod 列表与启停**——`.disabled` / `.old` 后缀的唯一判据
//! (导出扫描与导入计划两侧委派到这里)、`<实例运行目录>/mods/` 的单根列表、启停 = 在
//! `mods/` 里改名(五条守卫、幂等、不覆盖)。
//!
//! 规划中:CurseForge 客户端(§5)。
pub mod export; pub mod export;
pub mod import; pub mod import;
pub mod loader; pub mod loader;
pub mod mods;
pub mod online; pub mod online;
pub mod pack; pub mod pack;
@@ -47,6 +52,10 @@ pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport, LoaderSource, LoaderSupport,
}; };
pub use mods::{
ModEntry, ModError, ModScan, ModSkip, ModToggle, SkipReason, SuffixVariant as ModSuffixVariant,
MODS_DIR,
};
pub use online::{ pub use online::{
download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher, download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher,
EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage, 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", "实例名"), ("AdoptInstanceName", "Instance name", "实例名"),
("AdoptDoneTitle", "Imported", "已导入"), ("AdoptDoneTitle", "Imported", "已导入"),
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), ("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`)。 /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
+22
View File
@@ -132,6 +132,28 @@ macro_rules! instances_entries {
AdoptInstanceName => ("Instance name", "实例名"), AdoptInstanceName => ("Instance name", "实例名"),
AdoptDoneTitle => ("Imported", "已导入"), AdoptDoneTitle => ("Imported", "已导入"),
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"), 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, LogLine,
LogOutcome, LogOutcome,
LogSession, LogSession,
ModFailureView,
ModListView,
ModsCommand,
NoticeView, NoticeView,
PackCommand, PackCommand,
PackEvent, PackEvent,
@@ -45,6 +48,9 @@ use crate::dto::{
}; };
use crate::toast::Toast; use crate::toast::Toast;
// M224:`AppCommand` 的构造器(从本文件搬出去守 400 行硬线,纯搬移)
mod commands;
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。 /// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveLogBatch { pub struct LiveLogBatch {
@@ -160,6 +166,8 @@ pub enum CoreEvent {
/// ///
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。 /// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
Update(Box<UpdateView>), Update(Box<UpdateView>),
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
Mods(Box<Result<ModListView, ModFailureView>>),
} }
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -289,24 +297,8 @@ pub enum AppCommand {
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check` /// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
/// 读到的是同一个值。 /// 读到的是同一个值。
SetUpdateAutoCheck(bool), SetUpdateAutoCheck(bool),
} /// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
Mods(Box<ModsCommand>),
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,
}
}
} }
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。 /// 后端通道: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; mod notices;
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面) // M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
mod pack; mod pack;
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
mod mods;
mod settings; mod settings;
// M159: 自更新(检查结论 / 候选 / 就位状态) // M159: 自更新(检查结论 / 候选 / 就位状态)
mod update; mod update;
@@ -63,6 +65,11 @@ pub use instances::{
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; 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}; pub use notices::{NoticeLevel, NoticeView};
// M113: import/export // M113: import/export
pub use pack::{ 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::accounts::{self, LoginJob};
use super::{ 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 { impl CoreHost {
@@ -278,6 +278,17 @@ impl CoreHost {
self.toast(ToastKind::Warn, reason); 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 未落地前用脚本回复(不接真实模型) // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other); self.chat.dispatch(other);
+24 -1
View File
@@ -5,7 +5,7 @@
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent}; use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
use crate::toast::ToastKind; use crate::toast::ToastKind;
use super::{addon, logs, CoreHost, HostTask, LiveLaunch}; use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
impl CoreHost { impl CoreHost {
/// 后台消息 → 事件。 /// 后台消息 → 事件。
@@ -210,6 +210,29 @@ impl CoreHost {
self.update.adopt(*report); self.update.adopt(*report);
self.publish_update(); 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)), HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
} }
} }
+2
View File
@@ -153,6 +153,8 @@ pub enum HostTask {
Sessions(Vec<LogSession>), Sessions(Vec<LogSession>),
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`) /// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
Update(Box<crate::host::update::Report>), Update(Box<crate::host::update::Report>),
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
Mods(Box<super::mods::Report>),
} }
/// 后台线程用的发送端(`Sender` 克隆即可)。 /// 后台线程用的发送端(`Sender` 克隆即可)。
+1
View File
@@ -32,6 +32,7 @@ mod launch_args;
mod loader_versions; mod loader_versions;
mod logs; mod logs;
mod meter; mod meter;
mod mods;
mod opener; mod opener;
mod settings; mod settings;
mod update; 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 行硬线) // M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线)
mod agent; mod agent;
mod live; mod live;
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
mod mods;
mod tiles; mod tiles;
pub use live::MAX_LIVE_LINES; pub use live::MAX_LIVE_LINES;
pub use mods::ModsState;
pub use tiles::{grid_positions, TileCell}; pub use tiles::{grid_positions, TileCell};
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。 /// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
@@ -136,6 +139,8 @@ pub struct AppState {
pub pack: PackState, pub pack: PackState,
/// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态) /// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态)
pub adopt: AdoptState, pub adopt: AdoptState,
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
pub mods: ModsState,
} }
impl Default for AppState { impl Default for AppState {
@@ -182,6 +187,7 @@ impl AppState {
content: ContentState::default(), content: ContentState::default(),
pack: PackState::default(), pack: PackState::default(),
adopt: AdoptState::default(), adopt: AdoptState::default(),
mods: ModsState::default(),
} }
} }
@@ -306,27 +312,13 @@ impl AppState {
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
CoreEvent::ContentDetail { project, result } => { 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> { pub fn running(&self) -> Vec<&InstanceSummary> {
self.instances 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` 再导出, //! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。 //! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
//!
//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是
//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。
use crate::dto::InstanceSummary; use crate::dto::InstanceSummary;
use super::AppState;
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。 /// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> { 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)) .map(|index| (index / columns, index % columns))
.collect() .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()
}
}