feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)
三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。 r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。 core(crates/core/src/addon/mods/) - suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名), 收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant` 改为委派/再导出,签名与行为不变。 - list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤, 符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。 - toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做 copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。 presenter - dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。 - host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置; 游戏在跑时 host 侧直接挡。 - state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。 app-iced(实例详情页) - MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。 - 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。 - MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。 - i18n:19 条中英文案(`catalog/instances.rs` + 金表)。 rebase 冲突解法(三个文件,全是"两侧同段新增",都保留): - `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的 InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。 - `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归 `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs` (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。 r1 四条文档疵: 1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么; 2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods` + 文字说明 Ok 落快照 / Err 落具名失败; 3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空"; 4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 → 改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。 顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条 `clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
1 parent
71144dfca8
commit
a0f61d8bb5
40 files changed
+2762
-91
No files matched your search
@@ -33,6 +33,9 @@ use crate::dto::{
|
||||
LogLine,
|
||||
LogOutcome,
|
||||
LogSession,
|
||||
ModFailureView,
|
||||
ModListView,
|
||||
ModsCommand,
|
||||
NoticeView,
|
||||
PackCommand,
|
||||
PackEvent,
|
||||
@@ -45,6 +48,9 @@ use crate::dto::{
|
||||
};
|
||||
use crate::toast::Toast;
|
||||
|
||||
// M224:`AppCommand` 的构造器(从本文件搬出去守 400 行硬线,纯搬移)
|
||||
mod commands;
|
||||
|
||||
/// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LiveLogBatch {
|
||||
@@ -160,6 +166,8 @@ pub enum CoreEvent {
|
||||
///
|
||||
/// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。
|
||||
Update(Box<UpdateView>),
|
||||
/// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。
|
||||
Mods(Box<Result<ModListView, ModFailureView>>),
|
||||
}
|
||||
|
||||
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
|
||||
@@ -289,24 +297,8 @@ pub enum AppCommand {
|
||||
/// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check`
|
||||
/// 读到的是同一个值。
|
||||
SetUpdateAutoCheck(bool),
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
|
||||
pub fn set_isolation(id: &str, mode: &str) -> Self {
|
||||
Self::SetIsolation {
|
||||
id: id.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
|
||||
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
|
||||
Self::SetInstanceLaunchArgs {
|
||||
id: id.to_string(),
|
||||
patch,
|
||||
}
|
||||
}
|
||||
/// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。
|
||||
Mods(Box<ModsCommand>),
|
||||
}
|
||||
|
||||
/// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `AppCommand` 的**构造器**(从 `dto/events.rs` 搬出:那边 406 行越了 400 行硬线)。
|
||||
//!
|
||||
//! **纯搬移**:函数体、文档、可见性一字未改,只是换了文件;`impl AppCommand` 的另一个块,
|
||||
//! 于是调用点 `AppCommand::set_isolation(..)` / `AppCommand::set_launch_args(..)` 一个字都不用改。
|
||||
//! 这样 `events.rs` 只留两个枚举本体(`AppCommand` / `CoreEvent`),M224 才能在里面加
|
||||
//! `Mods` 变体而不越线。
|
||||
|
||||
use crate::dto::{AppCommand, LaunchArgsPatch};
|
||||
|
||||
impl AppCommand {
|
||||
/// 构造「切换隔离档位」指令(磁贴/详情页共用)。
|
||||
pub fn set_isolation(id: &str, mode: &str) -> Self {
|
||||
Self::SetIsolation {
|
||||
id: id.to_string(),
|
||||
mode: mode.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造「写实例启动参数」指令(详情页「启动参数」区)。
|
||||
pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self {
|
||||
Self::SetInstanceLaunchArgs {
|
||||
id: id.to_string(),
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod logs;
|
||||
mod notices;
|
||||
// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面)
|
||||
mod pack;
|
||||
// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区)
|
||||
mod mods;
|
||||
mod settings;
|
||||
// M159: 自更新(检查结论 / 候选 / 就位状态)
|
||||
mod update;
|
||||
@@ -63,6 +65,11 @@ pub use instances::{
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
|
||||
// M224: 本地 Mod 列表与启停
|
||||
pub use mods::{
|
||||
locate, ModEntryView, ModFailureKind, ModFailureView, ModListView, ModSkipKind, ModSkipView,
|
||||
ModsCommand,
|
||||
};
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user