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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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` 的回流结果)→ 页面视图。设置页的下载组从这里按
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
@@ -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`):进报告,告诉用户"这个名字我们是认得的"。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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`)。
|
||||||
|
|||||||
@@ -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", "改名失败"),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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::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);
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` 克隆即可)。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 行硬线)
|
// 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
|
||||||
|
|||||||
@@ -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` 再导出,
|
//! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user