Files
Unlucky-Launcher/crates/presenter/src/dto/mods.rs
T
tower-worker a0f61d8bb5 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` 只缩进修掉,文字未动。
2026-11-10 21:09:37 +08:00

239 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 本地 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());
}
}