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

+12 -1
View File
@@ -9,7 +9,7 @@ use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{
addon, game, install, java, launch, loader_versions, logs, settings, versions, CoreHost,
addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost,
};
impl CoreHost {
@@ -278,6 +278,17 @@ impl CoreHost {
self.toast(ToastKind::Warn, reason);
}
},
// M224:本地 Mod 列表 / 启停(磁盘在后台线程碰;这里只算"游戏在不在跑")
AppCommand::Mods(command) => {
let id = match &*command {
crate::dto::ModsCommand::List(id) => id.clone(),
crate::dto::ModsCommand::SetEnabled { id, .. } => id.clone(),
};
// 运行中挡在 host 这一侧(不只是 UI 灰掉):Windows 上进程正持有 jar,
// rename 必然失败,与其让用户读一句 OS 报错,不如直说。
let running = self.in_flight(&id);
mods::dispatch(self.env.clone(), self.tx.clone(), *command, running);
}
// AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型)
other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => {
self.chat.dispatch(other);
+24 -1
View File
@@ -5,7 +5,7 @@
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
use crate::toast::ToastKind;
use super::{addon, logs, CoreHost, HostTask, LiveLaunch};
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
impl CoreHost {
/// 后台消息 → 事件。
@@ -210,6 +210,29 @@ impl CoreHost {
self.update.adopt(*report);
self.publish_update();
}
// M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。
// 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。
HostTask::Mods(report) => {
let mods::Report {
instance,
list,
toggled,
} = *report;
if let Some(toggled) = toggled {
// 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。
eprintln!(
"[mods] {instance}: {} → {} ({})",
toggled.from,
toggled.to,
if toggled.enabled {
"enabled"
} else {
"disabled"
}
);
}
self.send(CoreEvent::Mods(Box::new(list)));
}
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
}
}
+2
View File
@@ -153,6 +153,8 @@ pub enum HostTask {
Sessions(Vec<LogSession>),
/// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`)
Update(Box<crate::host::update::Report>),
/// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`)
Mods(Box<super::mods::Report>),
}
/// 后台线程用的发送端(`Sender` 克隆即可)。
+1
View File
@@ -32,6 +32,7 @@ mod launch_args;
mod loader_versions;
mod logs;
mod meter;
mod mods;
mod opener;
mod settings;
mod update;
+269
View File
@@ -0,0 +1,269 @@
//! 本地 Mod 列表与启停的宿主实现(M224 · MODTOGGLE)。
//!
//! ## 三条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
//!
//! ## 为什么不复用启动路径的 `run_dir()`
//!
//! `host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例设置(`observe_mode`)。列 mod /
//! 启停**不该因为用户点开详情页就写盘**,所以这里只取派生结果、不落任何设置。
//!
//! ## 失败口径
//!
//! 一律走 [`crate::dto::ModFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 i18n key
//! 现求(M161 的纪律,`dto::events` 的 `LaunchFinished` 同款)。
use std::path::PathBuf;
use unlucky_core::addon::mods::{ModError, ModScan, SkipReason};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use crate::dto::{
locate, InstanceId, InstanceSummary, ModEntryView, ModFailureKind, ModFailureView, ModListView,
ModSkipKind, ModSkipView, ModsCommand,
};
use super::env::HostEnv;
use super::job::{spawn, JobSender};
/// 后台线程回报的一次结果(`HostTask::Mods` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
/// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。
pub instance: InstanceId,
/// 列表结果:成功 = 整份快照;失败 = 具名原因。
pub list: Result<ModListView, ModFailureView>,
/// 这一拍是启停操作的结果时带上「改了什么」(host 只记日志;开关跟快照翻)。
pub toggled: Option<Toggled>,
}
/// 一次成功的启停(`renamed == false` ⇒ 本来就是那个状态,什么也没动)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toggled {
/// 原磁盘名。
pub from: String,
/// 新磁盘名。
pub to: String,
/// 现在的启用状态。
pub enabled: bool,
/// 是否真的改了名。
pub renamed: bool,
}
/// 后台列一个实例的 `mods/`。
pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId, running: bool) {
spawn("mods-list", move || {
let report = Report {
instance: id.clone(),
list: list(&env, &id, running),
toggled: None,
};
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 后台启停一个文件,然后把**新快照**一起带回来(app 不必再请求一次)。
pub fn spawn_set_enabled(
env: HostEnv,
tx: JobSender,
id: InstanceId,
file: String,
enabled: bool,
running: bool,
) {
spawn("mods-toggle", move || {
let report = toggle(&env, &id, &file, enabled, running);
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: ModsCommand, running: bool) {
match command {
ModsCommand::List(id) => spawn_list(env, tx, id, running),
ModsCommand::SetEnabled { id, file, enabled } => {
spawn_set_enabled(env, tx, id, file, enabled, running)
}
}
}
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
fn target(
env: &HostEnv,
id: &str,
file: &str,
) -> Result<(InstanceSummary, PathBuf), ModFailureView> {
let instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else {
return Err(failure(
id,
file,
ModFailureKind::InstanceUnavailable,
format!("实例 {id} 不在快照里(可能刚被删除)"),
));
};
let dir = run_dir(env, instance).map_err(|detail| {
failure(
&instance.id,
file,
ModFailureKind::InstanceUnavailable,
detail,
)
})?;
Ok((instance.clone(), dir))
}
/// 列目录(后台线程里调)。
fn list(env: &HostEnv, id: &str, running: bool) -> Result<ModListView, ModFailureView> {
let (instance, dir) = target(env, id, "")?;
let scan = unlucky_core::addon::mods::scan(&dir).map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance.id, "", kind, detail)
})?;
Ok(view(&instance, &scan, running))
}
/// 启停一个文件 + 回来时带新快照。
fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) -> Report {
let (instance, dir) = match target(env, id, file) {
Ok(found) => found,
Err(view) => {
return Report {
instance: id.to_string(),
list: Err(view),
toggled: None,
};
}
};
let instance_id = instance.id.clone();
if running {
return Report {
instance: instance_id,
list: Err(failure(
id,
file,
ModFailureKind::Running,
"游戏进程正在跑(Windows 会拒绝改名)",
)),
toggled: None,
};
}
let toggled = match unlucky_core::addon::mods::set_enabled(&dir, file, enabled) {
Ok(result) => Toggled {
from: result.from,
to: result.to,
enabled: result.enabled,
renamed: result.renamed,
},
Err(error) => {
let (kind, detail) = classify(&error);
return Report {
instance: instance_id,
list: Err(failure(id, file, kind, detail)),
toggled: None,
};
}
};
// 改名之后**重新扫一遍**再发:权威状态来自磁盘,app 不做乐观回显(与启动/停止同一条纪律)。
let list = unlucky_core::addon::mods::scan(&dir)
.map(|scan| view(&instance, &scan, running))
.map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance_id, file, kind, detail)
});
Report {
instance: instance_id,
list,
toggled: Some(toggled),
}
}
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
fn view(instance: &InstanceSummary, scan: &ModScan, running: bool) -> ModListView {
ModListView {
instance: instance.id.clone(),
dir: scan.dir.display().to_string(),
exists: scan.exists,
entries: scan
.entries
.iter()
.map(|entry| ModEntryView {
file: entry.file.clone(),
name: entry.name.clone(),
enabled: entry.enabled,
bytes: entry.bytes,
old: entry.variant.is_old(),
})
.collect(),
skipped: scan
.skipped
.iter()
.map(|skip| ModSkipView {
file: skip.file.clone(),
reason: match skip.reason {
SkipReason::SymbolicLink => ModSkipKind::SymbolicLink,
SkipReason::NotAFile => ModSkipKind::NotAFile,
SkipReason::UnsafeName => ModSkipKind::UnsafeName,
SkipReason::Unreadable => ModSkipKind::Unreadable,
},
})
.collect(),
running,
known: true,
}
}
fn failure(
instance: &str,
file: &str,
kind: ModFailureKind,
detail: impl Into<String>,
) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: file.to_string(),
kind,
detail: detail.into(),
}
}
/// core 的错误 → DTO 大类 + 原文(**不翻译**,翻译在 app 层)。
fn classify(error: &ModError) -> (ModFailureKind, String) {
match error {
ModError::UnsafeName { detail, .. } => (ModFailureKind::UnsafeName, detail.clone()),
ModError::ModsDirUnusable { detail, .. } => {
(ModFailureKind::ModsDirUnusable, detail.clone())
}
ModError::NotFound { .. } => (ModFailureKind::NotFound, error.to_string()),
ModError::NotARegularFile { detail, .. } => {
(ModFailureKind::NotARegularFile, detail.clone())
}
ModError::Collision { .. } => (ModFailureKind::Collision, error.to_string()),
ModError::Rename { detail, .. } => (ModFailureKind::Rename, detail.clone()),
}
}
#[cfg(test)]
mod tests;
+192
View File
@@ -0,0 +1,192 @@
//! `host::mods` 的用例(从 `host/mods.rs` 拆出:那边 457 行越了 400 行硬线)。
//!
//! 三条:**真盘端到端**(派生运行目录 → core 改名 → 快照回来)、跳过原因映射、错误分类。
//! 端到端那条是这个模块存在的主要理由——启停的正确性只有"真的建一个实例根再改一次盘"能验。
use super::*;
use crate::dto::{InstanceRuntime, LaunchArgsView, LoaderInfo, LoaderKind};
use unlucky_core::addon::mods::ModSkip;
const ID: &str = "survival";
const VERSION: &str = "1.20.4-fabric-0.16.5";
/// 一次性临时根(与本 crate 其它 host 测试同款:不引第三方 tempfile)。
struct TempRoot(PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 一个可用的便携根:`mods/`(给定文件)+ `profiles.json` + `custom` 档位的实例设置。
///
/// 走 `custom` 是**故意**的:它是 D20 里唯一「运行目录由实例自己指」的档位,于是用例不必造
/// `versions/<id>/` 也能验「派生出来的目录就是启停的目标目录」。
fn fixture(tag: &str, files: &[(&str, &[u8])]) -> (TempRoot, HostEnv, PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
let root = TempRoot(path);
std::fs::create_dir_all(&root.0).expect("建临时目录");
let env = HostEnv::at_root(&root.0);
let run = root.0.join("run");
std::fs::create_dir_all(run.join("mods")).expect("建 mods");
for (name, body) in files {
std::fs::write(run.join("mods").join(name), body).expect("写文件");
}
std::fs::create_dir_all(env.layout.config()).expect("建 config");
let profile = format!(r#"{{"id":"{ID}","name":"生存","versionId":"{VERSION}"}}"#);
std::fs::write(
env.layout.profiles_config(),
format!(r#"{{"schemaVersion":1,"profiles":[{profile}]}}"#),
)
.expect("写 profiles");
let config = format!(
r#"{{"schemaVersion":1,"id":"{ID}","versionId":"{VERSION}","isolation":"custom","gameDir":{}}}"#,
serde_json::to_string(&run).expect("路径转 JSON")
);
let path = env.layout.instance_config(ID).expect("实例 id");
std::fs::create_dir_all(path.parent().expect("instances 目录")).expect("建 instances");
std::fs::write(path, config).expect("写实例设置");
(root, env, run)
}
fn instance() -> InstanceSummary {
InstanceSummary {
id: ID.to_string(),
name: "生存".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: VERSION.to_string(),
version_present: true,
launch: LaunchArgsView::default(),
}
}
/// **端到端**(真盘 + 真派生):列目录 → 启停 → 再列,顺带验运行中被挡。
///
/// 打通「派生运行目录 → core 改盘 → 快照回来」整条链。抓得住:派生用错目录、启停后没重扫、
/// `.disabled` 判据与 core 不一致。抓不住:Windows 上的占用/权限失败(那要靠真跑)。
#[test]
fn list_and_toggle_round_trip_on_a_real_root() {
let (_root, env, run) = fixture(
"mods-e2e",
&[
("sodium.jar", b"aa"),
("jei.jar.disabled", b"bbb"),
("old.jar.old", b"cccc"),
],
);
let listed = list(&env, ID, false).expect("列目录");
assert!(listed.exists && listed.known && !listed.running);
assert_eq!((listed.enabled_count(), listed.disabled_count()), (2, 1));
assert!(listed.entries.iter().any(|entry| entry.old));
let report = toggle(&env, ID, "sodium.jar", false, false);
assert!(report.toggled.expect("要报告改了什么").renamed);
assert!(run.join("mods/sodium.jar.disabled").is_file());
assert_eq!(report.list.expect("新快照").enabled_count(), 1);
// 再启用回去(`.disabled` 剥掉,字节不动)
let report = toggle(&env, ID, "sodium.jar.disabled", true, false);
assert!(report.toggled.expect("报告").renamed);
assert_eq!(report.list.expect("快照").enabled_count(), 2);
assert_eq!(
std::fs::read(run.join("mods/sodium.jar")).expect("读回"),
b"aa"
);
// 游戏在跑:host 直接挡(不碰盘、不改名)
let blocked = toggle(&env, ID, "sodium.jar", false, true);
assert_eq!(
blocked.list.expect_err("运行中必须拒").kind,
ModFailureKind::Running
);
assert!(blocked.toggled.is_none());
assert!(run.join("mods/sodium.jar").is_file(), "盘被动了");
assert!(!run.join("mods/sodium.jar.disabled").exists());
}
/// 跳过原因一一对应,且 runtime/known 标记原样过桥。
///
/// 抓得住:`NotAFile` 归到 `SymbolicLink`("为什么没列出来"撒谎)、`running` 被写死。
#[test]
fn the_snapshot_keeps_skip_reasons_and_flags() {
let scan = ModScan {
dir: PathBuf::from("m"),
exists: true,
entries: Vec::new(),
skipped: vec![ModSkip {
file: "x".to_string(),
reason: SkipReason::SymbolicLink,
}],
};
let listed = view(&instance(), &scan, true);
assert!(listed.running && listed.known && listed.exists);
let kinds = [
(SkipReason::SymbolicLink, ModSkipKind::SymbolicLink),
(SkipReason::NotAFile, ModSkipKind::NotAFile),
(SkipReason::UnsafeName, ModSkipKind::UnsafeName),
(SkipReason::Unreadable, ModSkipKind::Unreadable),
];
for (reason, expected) in kinds {
let mut scan = scan.clone();
scan.skipped[0].reason = reason;
assert_eq!(view(&instance(), &scan, false).skipped[0].reason, expected);
}
}
/// 错误分类:每个 core 变体都落到自己的大类(没有「未知 → 一律 IO」的兜底),原因不丢。
#[test]
fn every_core_error_keeps_its_own_kind() {
let dir = PathBuf::from("m");
let cases = [
(
ModError::UnsafeName {
file: "a/b".into(),
detail: "含分隔符".into(),
},
ModFailureKind::UnsafeName,
),
(
ModError::NotFound {
file: "x.jar".into(),
dir: dir.clone(),
},
ModFailureKind::NotFound,
),
(
ModError::Collision {
file: "x.jar".into(),
target: "x.jar.disabled".into(),
dir,
},
ModFailureKind::Collision,
),
(
ModError::Rename {
file: "x.jar".into(),
target: "x.jar.disabled".into(),
detail: "被占用".into(),
},
ModFailureKind::Rename,
),
];
for (error, expected) in cases {
let (kind, detail) = classify(&error);
assert_eq!(kind, expected, "{error}");
assert!(!detail.is_empty(), "{error} 的分类把原因吃掉了");
}
}