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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 克隆即可)。
|
||||
|
||||
@@ -32,6 +32,7 @@ mod launch_args;
|
||||
mod loader_versions;
|
||||
mod logs;
|
||||
mod meter;
|
||||
mod mods;
|
||||
mod opener;
|
||||
mod settings;
|
||||
mod update;
|
||||
|
||||
@@ -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;
|
||||
@@ -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} 的分类把原因吃掉了");
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,12 @@ mod accounts;
|
||||
// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线)
|
||||
mod agent;
|
||||
mod live;
|
||||
// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记)
|
||||
mod mods;
|
||||
mod tiles;
|
||||
|
||||
pub use live::MAX_LIVE_LINES;
|
||||
pub use mods::ModsState;
|
||||
pub use tiles::{grid_positions, TileCell};
|
||||
|
||||
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
|
||||
@@ -136,6 +139,8 @@ pub struct AppState {
|
||||
pub pack: PackState,
|
||||
/// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态)
|
||||
pub adopt: AdoptState,
|
||||
/// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停
|
||||
pub mods: ModsState,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -182,6 +187,7 @@ impl AppState {
|
||||
content: ContentState::default(),
|
||||
pack: PackState::default(),
|
||||
adopt: AdoptState::default(),
|
||||
mods: ModsState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,27 +312,13 @@ impl AppState {
|
||||
CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure),
|
||||
// M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递)
|
||||
CoreEvent::ContentDetail { project, result } => {
|
||||
self.apply_content_detail(&project, *result);
|
||||
self.apply_content_detail(&project, *result)
|
||||
}
|
||||
// M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记)
|
||||
CoreEvent::Mods(result) => self.mods.apply(*result),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||
let query = self.instance_query.trim().to_lowercase();
|
||||
let filtered: Vec<&InstanceSummary> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
|
||||
.collect();
|
||||
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
|
||||
filtered
|
||||
.into_iter()
|
||||
.zip(positions)
|
||||
.map(|(instance, (row, col))| TileCell { instance, row, col })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 运行中的实例(实例页顶部迷你状态条)。
|
||||
pub fn running(&self) -> Vec<&InstanceSummary> {
|
||||
self.instances
|
||||
|
||||
@@ -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"), "快照回来后还挂着在飞标记");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
//!
|
||||
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出,
|
||||
//! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。
|
||||
//!
|
||||
//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是
|
||||
//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。
|
||||
|
||||
use crate::dto::InstanceSummary;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TileCell<'a> {
|
||||
@@ -25,3 +30,21 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
|
||||
.map(|index| (index / columns, index % columns))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
|
||||
pub fn tiles(&self) -> Vec<TileCell<'_>> {
|
||||
let query = self.instance_query.trim().to_lowercase();
|
||||
let filtered: Vec<&InstanceSummary> = self
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
|
||||
.collect();
|
||||
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
|
||||
filtered
|
||||
.into_iter()
|
||||
.zip(positions)
|
||||
.map(|(instance, (row, col))| TileCell { instance, row, col })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user