diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 649aeb0..e3073b8 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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); mod boot_probe; +// M224: 出图取证用的 MODS 夹具(`ULL_MODS_FIXTURE`;产品路径零影响) mod construct; +mod mods_shot; mod refresh; mod theme; @@ -180,6 +182,11 @@ pub struct App { /// 已经提示过的启动收尾次数(M161):`AppState::launch_ends` 是「收尾过几场」, /// 这里是「其中几场已经弹过 Toast」。两者不等 ⇒ 弹一次(与 `install_cancelled_seen` 同构)。 pub(super) launch_ends_seen: u64, + /// 已经提示过的启停失败(M224):`(实例, 文件, 大类)`。同一条失败重复到达不重弹。 + /// + /// 为什么不用计数器(与上面两条不同):失败是**并发可交错**的(A 的文件失败、B 的列表成功、 + /// A 又失败),计数差说明不了"哪一条没提示过",三元组才是幂等键。 + pub(super) mods_failed_seen: Option<(String, String, unlucky_presenter::dto::ModFailureKind)>, /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 /// /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 1dc5802..311104f 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -139,6 +139,7 @@ impl App { adopt_form: super::super::adopt::Form::default(), install_cancelled_seen: 0, launch_ends_seen: 0, + mods_failed_seen: None, dialog_card: None, dialog_model: None, content: pages::content::PageState::default(), diff --git a/crates/app-iced/src/app/state/mods_shot.rs b/crates/app-iced/src/app/state/mods_shot.rs new file mode 100644 index 0000000..8b9c096 --- /dev/null +++ b/crates/app-iced/src/app/state/mods_shot.rs @@ -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, + } +} diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index 859c84a..e21d4b8 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -4,6 +4,9 @@ //! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位, //! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 +use unlucky_presenter::dto::{AppCommand, ModsCommand}; + +use super::mods_shot; use unlucky_presenter::toast::ToastKind; 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` 的分类参数): // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 let category = self.view.download.category; @@ -110,4 +120,58 @@ impl App { 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); + } } diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index 10fec0c..0d7dbec 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -11,7 +11,7 @@ //! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。 use iced::Task; -use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch}; +use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand}; use crate::pages::InstanceMessage; @@ -19,17 +19,29 @@ use super::super::message::Message; use super::super::state::App; impl App { - /// 启动参数区的一条消息。 + /// 实例页详情列的一条消息(启动参数表单 + MODS 区)。 pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task { let Some(instance) = self.selected_instance_id() else { return Task::none(); }; - if !self.launch_form.bound_to(&instance) { - // 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。 - return Task::none(); - } 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) => { self.launch_form.jvm = text; self.launch_form.dirty = true; @@ -137,6 +149,34 @@ mod tests { 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] fn edits_are_ignored_when_the_form_is_bound_elsewhere() { diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 44104e5..e3ee9bb 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -255,6 +255,8 @@ pub(super) fn model( // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` content: pages::content::Content::from_snapshot(state, content, lang.catalog()), logs: logs_model(state).with_status(lang), + // M224:详情页 MODS 区的列表 / 启停(presenter 的视图态 → 展示字段) + mods: instances::mods_of(state, catalog), // M159:更新快照 → 页面视图(设置页「更新」区 + 顶部提示条都读它) update: update_of(&state.update), // M201:设置组快照(`SettingsChanged` 的回流结果)→ 页面视图。设置页的下载组从这里按 diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index fd5c16b..3d734a3 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -23,8 +23,9 @@ use unlucky_presenter::dto::{ InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished, 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`])。 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(), } } + +/// 本地 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) +} diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs index 8ad1f00..41b8c64 100644 --- a/crates/app-iced/src/pages/download/options/view.rs +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -85,8 +85,8 @@ pub(super) fn version_menu_height(items: usize) -> f32 { /// head + chips,错误行不可达; /// - chips 档(可用宽 < 600,`of` 对 `Options`/`Install` 无条件 Shared):`problem` 可以是 /// `Some`,错误行**会进滚动区**、可能被滚走;`show_versions()` 为假时钉死区只剩实例名。 -/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒ -/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 +/// D30 八审实测:滚动区可见高 ≈96–104px,内容(head 40 + chips 32 + 间距 4)≈76px ⇒ +/// 1180×760 下 head(墨迹 462–501)与六枚 chips(506–537,32px 四角闭合)完整在首屏。 pub(crate) fn section<'a>( ui: &'a Ui, catalog: Catalog, diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 203e120..7f28ac1 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -49,6 +49,8 @@ mod data; mod demo; mod detail; mod launch_args; +// M224: 详情页的 MODS 区(本地 mod 列表 + 启停开关) +mod mods; mod state; mod text; @@ -58,6 +60,15 @@ mod tests; #[cfg(test)] pub(crate) use data::{DEMO, NOTE, SELECTED}; 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(super) use text::Texts; @@ -131,12 +142,16 @@ pub fn view<'a>( metrics, list_column(ui, block, forced, texts), 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), }, ), _ => 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)), }, }; diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index cd5599b..31e99f2 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -33,6 +33,7 @@ pub(super) fn detail_column<'a>( metrics: FrameMetrics, detail: &'a Detail, form: &'a LaunchForm, + mods: &'a super::Mods, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -45,6 +46,10 @@ pub(super) fn detail_column<'a>( .width(Length::Fill); 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 内,卡片式)。 body = body.push(super::launch_args::section(ui, &detail.launch, form, texts)); @@ -62,6 +67,7 @@ pub(super) fn narrow_detail<'a>( metrics: FrameMetrics, detail: &'a Detail, form: &'a LaunchForm, + mods: &'a super::Mods, forced: Option, texts: Texts, ) -> Element<'a, Message> { @@ -84,6 +90,8 @@ pub(super) fn narrow_detail<'a>( .width(Length::Fill); 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(frame::text_role( diff --git a/crates/app-iced/src/pages/instances/mods.rs b/crates/app-iced/src/pages/instances/mods.rs new file mode 100644 index 0000000..d3fd731 --- /dev/null +++ b/crates/app-iced/src/pages/instances/mods.rs @@ -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, + /// 正在读盘(详情页首帧:请求已发出、快照还没回来)。 + pub loading: bool, + /// 游戏在该实例上运行(开关只读)。 + pub running: bool, + /// 实例有 `mods/` 目录吗(`false` 且没有错误 ⇒ 还没装过模组)。 + pub exists: bool, + /// 读目录失败的人读原因(app 层按 i18n 拼好)。 + pub error: Option, + /// 列表。 + pub items: Vec, + /// 跳过条目数(符号链接 / 目录 / 读不出)。 + 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, + 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, +) -> 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), + ) +} diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index 1343e31..bd9528b 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -154,6 +154,15 @@ pub enum Message { Save, /// 恢复跟随全局(清表单,仍需保存)。 Reset, + /// 启停一个 mod(M224):`file` 是**磁盘文件名**(含后缀),`enabled` 是期望状态。 + /// + /// 页面只发意图,落盘由 host 在后台线程做;回程是一份新快照(不做乐观回显)。 + SetModEnabled { + /// 磁盘文件名 + file: String, + /// 期望的启用状态 + enabled: bool, + }, } #[cfg(test)] diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 8af92b5..9e732b5 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -28,10 +28,15 @@ use super::super::Texts; /// /// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的; /// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。 -const ALLOWED: &[&str] = &["Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB"]; +const ALLOWED: &[&str] = &[ + "Java", "pid", "JVM", "OpenGL", "Vulkan", "MiB", + // M224:`mods` 是**目录名**(文件系统事实)。中文界面上写成「模组目录」会让用户找不到那个 + // 文件夹——`mods/` 就是他要打开的那个东西,所以两套语言都原样写它。 + "mods", +]; /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 -const PAGE_SOURCES: [(&str, &str); 6] = [ +const PAGE_SOURCES: [(&str, &str); 7] = [ ("instances.rs", include_str!("../../instances.rs")), ("instances/detail.rs", include_str!("../detail.rs")), ("instances/demo.rs", include_str!("../demo.rs")), @@ -44,6 +49,8 @@ const PAGE_SOURCES: [(&str, &str); 6] = [ "app/view/instances.rs", include_str!("../../../app/view/instances.rs"), ), + // M224:MODS 区(本地 mod 列表 + 启停开关) + ("instances/mods.rs", include_str!("../mods.rs")), ]; /// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 @@ -61,7 +68,7 @@ const PAGE_SOURCES: [(&str, &str); 6] = [ /// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | /// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | /// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | -const ALLOWED_LITERALS: [&str; 24] = [ +const ALLOWED_LITERALS: [&str; 28] = [ "{date} {time} UTC", "isolated", "shared", @@ -97,6 +104,12 @@ const ALLOWED_LITERALS: [&str; 24] = [ "ULL_LAUNCH_ARGS_FIXTURE", // M161:示例「被门槛挡下」的取值(夹具)。 "gated", + // M224:MODS 区四个 key 的**占位符名**(`{enabled}` / `{disabled}` / `{count}` / `{file}`), + // 不是文案——`Catalog::render` 按名字取值,换成别的词就渲染不出来了。 + "enabled", + "disabled", + "count", + "file", ]; /// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index 69874cb..f048f82 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -26,6 +26,7 @@ //! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。 use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::ModFailureKind; /// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -151,6 +152,66 @@ impl Texts { 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 { self.0.text(key) } diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index bba5cb1..991dcec 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -128,7 +128,7 @@ pub(crate) mod settings; mod tests; pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader}; 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 logs::{Confidence, Finding, Level, Line, Logs, Outcome}; pub use message::{Action, Message, UpdateAction}; diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 157c870..c7c7c3c 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -140,6 +140,11 @@ pub struct Model { /// 即「用户档位 / 系统 locale → 生效语言」这条链的结论。**不是**基线值:M95 以前这里是 /// `Lang::En` 占位,M106 之后中文系统上灌进来的是 `Lang::ZhCn`。 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` 时页面里的控件按该态画。 /// /// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的; @@ -169,6 +174,7 @@ impl Model { // 空快照 = 这一帧没有设置数据(harness / `pages_demo`;`demo_model` 也不伪造一份, // 见 `pages/settings/download.rs` 的「没有快照就画演示行」)。 settings: Vec::new(), + mods: super::instances::Mods::default(), lang: Lang::En, forced: None, } diff --git a/crates/core/src/addon/export/scan.rs b/crates/core/src/addon/export/scan.rs index 1b6e274..f13f331 100644 --- a/crates/core/src/addon/export/scan.rs +++ b/crates/core/src/addon/export/scan.rs @@ -181,9 +181,10 @@ fn relative_name(root: &Path, path: &Path) -> String { } /// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。 +/// +/// M224 收敛:判据只有 [`crate::addon::mods::suffix`] 一处(启停也要用它,见 +/// `DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。这里保留函数名与签名,行为逐字不变。 #[must_use] pub fn is_disabled_path(path: &str) -> bool { - let lowered = path.trim_end().to_ascii_lowercase(); - let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered); - without_old.ends_with(".disabled") + crate::addon::mods::is_disabled_path(path) } diff --git a/crates/core/src/addon/import/plan/paths.rs b/crates/core/src/addon/import/plan/paths.rs index 366596e..7e7e7e2 100644 --- a/crates/core/src/addon/import/plan/paths.rs +++ b/crates/core/src/addon/import/plan/paths.rs @@ -6,45 +6,17 @@ use super::super::report::{keys, ImportIssue}; /// 路径后缀变体(`.disabled` / `.old` 的四种组合)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -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) - } -} +/// +/// M224 收敛:类型本体只有 [`crate::addon::mods::suffix`] 一处,这里原样再导出,于是 +/// `paths::SuffixVariant` 与启停用的是**同一个类型**(零行为变化,调用点不用改)。 +pub use crate::addon::mods::SuffixVariant; /// 识别四种路径后缀变体。 +/// +/// 委派到唯一判据(见模块文档);保留函数名与签名。 #[must_use] pub fn suffix_variant(path: &str) -> SuffixVariant { - let lowered = path.trim_end().to_ascii_lowercase(); - 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, - } + crate::addon::mods::suffix_variant(path) } /// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。 diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index 08b09e9..b5789e9 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -25,11 +25,16 @@ //! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。 //! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。 //! -//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。 +//! - [`mods`](P2 · M224):**本地 Mod 列表与启停**——`.disabled` / `.old` 后缀的唯一判据 +//! (导出扫描与导入计划两侧委派到这里)、`<实例运行目录>/mods/` 的单根列表、启停 = 在 +//! `mods/` 里改名(五条守卫、幂等、不覆盖)。 +//! +//! 规划中:CurseForge 客户端(§5)。 pub mod export; pub mod import; pub mod loader; +pub mod mods; pub mod online; pub mod pack; @@ -47,6 +52,10 @@ pub use loader::{ LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSource, LoaderSupport, }; +pub use mods::{ + ModEntry, ModError, ModScan, ModSkip, ModToggle, SkipReason, SuffixVariant as ModSuffixVariant, + MODS_DIR, +}; pub use online::{ download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage, diff --git a/crates/core/src/addon/mods/error.rs b/crates/core/src/addon/mods/error.rs new file mode 100644 index 0000000..9fdaf62 --- /dev/null +++ b/crates/core/src/addon/mods/error.rs @@ -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` 目录本身不可用:是符号链接 / 不是目录 / 读不了。 + /// + /// 符号链接**不跟随**:`/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 {} diff --git a/crates/core/src/addon/mods/list.rs b/crates/core/src/addon/mods/list.rs new file mode 100644 index 0000000..9840d52 --- /dev/null +++ b/crates/core/src/addon/mods/list.rs @@ -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, + /// 跳过的条目与原因。 + pub skipped: Vec, +} + +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 { + 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::, _>>() + .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, + }) +} diff --git a/crates/core/src/addon/mods/mod.rs b/crates/core/src/addon/mods/mod.rs new file mode 100644 index 0000000..9fc67b1 --- /dev/null +++ b/crates/core/src/addon/mods/mod.rs @@ -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`] 的结果), +//! 本模块只碰 `/mods/`: +//! +//! - 不自己去猜游戏根,也不做「隔离侧 `versions//mods` + 共享侧 `.minecraft/mods` 合并」 +//! 这种双根视图——派生是启动与列表**同一个**答案的唯一来源; +//! - 只往下取一层:`mods/` 下的子目录不递归(游戏也不递归); +//! - `mods/` 本身是符号链接时**拒绝**(不跟随),否则改名会落到实例目录之外。 +//! +//! # 模块 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`suffix`] | `.disabled` / `.old` 的唯一判据与改名目标名(含四态枚举) | +//! | [`list`] | 列 `/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; diff --git a/crates/core/src/addon/mods/suffix.rs b/crates/core/src/addon/mods/suffix.rs new file mode 100644 index 0000000..b24052f --- /dev/null +++ b/crates/core/src/addon/mods/suffix.rs @@ -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 { + 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 { + 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 { + 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"); + } +} diff --git a/crates/core/src/addon/mods/tests.rs b/crates/core/src/addon/mods/tests.rs new file mode 100644 index 0000000..41c186f --- /dev/null +++ b/crates/core/src/addon/mods/tests.rs @@ -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 + } +} diff --git a/crates/core/src/addon/mods/toggle.rs b/crates/core/src/addon/mods/toggle.rs new file mode 100644 index 0000000..9e44241 --- /dev/null +++ b/crates/core/src/addon/mods/toggle.rs @@ -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 { + 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 { + 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 { + 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) +} diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3da5ed8..4816cfe 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -87,6 +87,24 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AdoptInstanceName", "Instance name", "实例名"), ("AdoptDoneTitle", "Imported", "已导入"), ("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`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 2bd1aab..679b1d9 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -132,6 +132,28 @@ macro_rules! instances_entries { AdoptInstanceName => ("Instance name", "实例名"), AdoptDoneTitle => ("Imported", "已导入"), 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", "改名失败"), } } }; } diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index ed85fe8..9ce4922 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -33,6 +33,9 @@ use crate::dto::{ LogLine, LogOutcome, LogSession, + ModFailureView, + ModListView, + ModsCommand, NoticeView, PackCommand, PackEvent, @@ -45,6 +48,9 @@ use crate::dto::{ }; use crate::toast::Toast; +// M224:`AppCommand` 的构造器(从本文件搬出去守 400 行硬线,纯搬移) +mod commands; + /// 启动会话的一段实时日志(host 尾随 JSONL 后按批次发)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LiveLogBatch { @@ -160,6 +166,8 @@ pub enum CoreEvent { /// /// 与 `SettingsChanged` 同一条形态:快照整体替换,UI 不做增量合并。 Update(Box), + /// 本地 Mod 列表 / 启停(M224):`Ok` = 整份快照;`Err` = 具名原因 + 原文(文案由 app 现求)。 + Mods(Box>), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, @@ -289,24 +297,8 @@ pub enum AppCommand { /// `cache/update/prefs.json` 迁过来的 schema 键)——`ull config get update.auto_check` /// 读到的是同一个值。 SetUpdateAutoCheck(bool), -} - -impl AppCommand { - /// 构造「切换隔离档位」指令(磁贴/详情页共用)。 - pub fn set_isolation(id: &str, mode: &str) -> Self { - Self::SetIsolation { - id: id.to_string(), - mode: mode.to_string(), - } - } - - /// 构造「写实例启动参数」指令(详情页「启动参数」区)。 - pub fn set_launch_args(id: &str, patch: LaunchArgsPatch) -> Self { - Self::SetInstanceLaunchArgs { - id: id.to_string(), - patch, - } - } + /// 本地 Mod 列表 / 启停(M224;子枚举与语义见 [`ModsCommand`] 的文档)。 + Mods(Box), } /// 后端通道:core/agent host 实现它;[`crate::mock::MockBackend`] 验证通道语义。 diff --git a/crates/presenter/src/dto/events/commands.rs b/crates/presenter/src/dto/events/commands.rs new file mode 100644 index 0000000..66f913a --- /dev/null +++ b/crates/presenter/src/dto/events/commands.rs @@ -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, + } + } +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 5a5e8c9..0408ed5 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -30,6 +30,8 @@ mod logs; mod notices; // M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面) mod pack; +// M224: 本地 Mod 列表与启停(实例详情页的 MODS 区) +mod mods; mod settings; // M159: 自更新(检查结论 / 候选 / 就位状态) mod update; @@ -63,6 +65,11 @@ pub use instances::{ pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; +// M224: 本地 Mod 列表与启停 +pub use mods::{ + locate, ModEntryView, ModFailureKind, ModFailureView, ModListView, ModSkipKind, ModSkipView, + ModsCommand, +}; pub use notices::{NoticeLevel, NoticeView}; // M113: import/export pub use pack::{ diff --git a/crates/presenter/src/dto/mods.rs b/crates/presenter/src/dto/mods.rs new file mode 100644 index 0000000..0c8e0f6 --- /dev/null +++ b/crates/presenter/src/dto/mods.rs @@ -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, + /// 跳过条目。 + pub skipped: Vec, + /// 这一份快照采样的时刻,游戏是否在该实例上运行(运行中开关只读)。 + 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::(&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()); + } +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index e747a62..328d93f 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -9,7 +9,7 @@ use crate::toast::ToastKind; use super::accounts::{self, LoginJob}; use super::{ - addon, game, install, java, launch, loader_versions, logs, settings, versions, CoreHost, + addon, game, install, java, launch, loader_versions, logs, mods, settings, versions, CoreHost, }; impl CoreHost { @@ -278,6 +278,17 @@ impl CoreHost { self.toast(ToastKind::Warn, reason); } }, + // M224:本地 Mod 列表 / 启停(磁盘在后台线程碰;这里只算"游戏在不在跑") + AppCommand::Mods(command) => { + let id = match &*command { + crate::dto::ModsCommand::List(id) => id.clone(), + crate::dto::ModsCommand::SetEnabled { id, .. } => id.clone(), + }; + // 运行中挡在 host 这一侧(不只是 UI 灰掉):Windows 上进程正持有 jar, + // rename 必然失败,与其让用户读一句 OS 报错,不如直说。 + let running = self.in_flight(&id); + mods::dispatch(self.env.clone(), self.tx.clone(), *command, running); + } // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { self.chat.dispatch(other); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index b6522db..b23c9cd 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -5,7 +5,7 @@ use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent}; use crate::toast::ToastKind; -use super::{addon, logs, CoreHost, HostTask, LiveLaunch}; +use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch}; impl CoreHost { /// 后台消息 → 事件。 @@ -210,6 +210,29 @@ impl CoreHost { self.update.adopt(*report); self.publish_update(); } + // M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。 + // 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。 + HostTask::Mods(report) => { + let mods::Report { + instance, + list, + toggled, + } = *report; + if let Some(toggled) = toggled { + // 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。 + eprintln!( + "[mods] {instance}: {} → {} ({})", + toggled.from, + toggled.to, + if toggled.enabled { + "enabled" + } else { + "disabled" + } + ); + } + self.send(CoreEvent::Mods(Box::new(list))); + } HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), } } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index fe02ba7..1b3500e 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -153,6 +153,8 @@ pub enum HostTask { Sessions(Vec), /// 自更新(M159):检查/应用的回报(载荷已翻成 DTO,见 `host::update::Report`) Update(Box), + /// 本地 Mod 列表 / 启停(M224):列表快照或具名失败(见 `host::mods::Report`) + Mods(Box), } /// 后台线程用的发送端(`Sender` 克隆即可)。 diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 6c15375..0e5af2f 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -32,6 +32,7 @@ mod launch_args; mod loader_versions; mod logs; mod meter; +mod mods; mod opener; mod settings; mod update; diff --git a/crates/presenter/src/host/mods.rs b/crates/presenter/src/host/mods.rs new file mode 100644 index 0000000..8513472 --- /dev/null +++ b/crates/presenter/src/host/mods.rs @@ -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, + /// 这一拍是启停操作的结果时带上「改了什么」(host 只记日志;开关跟快照翻)。 + pub toggled: Option, +} + +/// 一次成功的启停(`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 { + 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 { + 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, +) -> 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; diff --git a/crates/presenter/src/host/mods/tests.rs b/crates/presenter/src/host/mods/tests.rs new file mode 100644 index 0000000..9d6c61e --- /dev/null +++ b/crates/presenter/src/host/mods/tests.rs @@ -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//` 也能验「派生出来的目录就是启停的目标目录」。 +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} 的分类把原因吃掉了"); + } +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index a59d9c0..13fbbb1 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -45,9 +45,12 @@ mod accounts; // M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线) mod agent; mod live; +// M224: 本地 Mod 列表与启停的视图态(状态落地 + 在飞标记) +mod mods; mod tiles; pub use live::MAX_LIVE_LINES; +pub use mods::ModsState; pub use tiles::{grid_positions, TileCell}; /// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。 @@ -136,6 +139,8 @@ pub struct AppState { pub pack: PackState, /// 导入已有版本(M223):扫描 / 预检 / 失败 / 完成(**只装事件态**,表单在页面本地态) pub adopt: AdoptState, + /// 本地 Mod(M224):当前实例的快照 / 失败 / 在飞启停 + pub mods: ModsState, } impl Default for AppState { @@ -182,6 +187,7 @@ impl AppState { content: ContentState::default(), pack: PackState::default(), adopt: AdoptState::default(), + mods: ModsState::default(), } } @@ -306,27 +312,13 @@ impl AppState { CoreEvent::ContentFailed(failure) => self.apply_content_failure(*failure), // M162:详情/版本列表(判断逻辑在 `state/content.rs`,这里只有一行投递) CoreEvent::ContentDetail { project, result } => { - self.apply_content_detail(&project, *result); + self.apply_content_detail(&project, *result) } + // M224:本地 Mod(落地判据在 `state/mods.rs`,含迟到丢弃与在飞标记) + CoreEvent::Mods(result) => self.mods.apply(*result), } } - /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 - pub fn tiles(&self) -> Vec> { - let query = self.instance_query.trim().to_lowercase(); - let filtered: Vec<&InstanceSummary> = self - .instances - .iter() - .filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query)) - .collect(); - let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns()); - filtered - .into_iter() - .zip(positions) - .map(|(instance, (row, col))| TileCell { instance, row, col }) - .collect() - } - /// 运行中的实例(实例页顶部迷你状态条)。 pub fn running(&self) -> Vec<&InstanceSummary> { self.instances diff --git a/crates/presenter/src/state/mods.rs b/crates/presenter/src/state/mods.rs new file mode 100644 index 0000000..c55cb67 --- /dev/null +++ b/crates/presenter/src/state/mods.rs @@ -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, + /// 最近一份快照(`None` = 还没请求过)。 + pub view: Option, + /// 最近一条失败(具名原因;成功一次就清掉)。 + pub failed: Option, + /// 正在飞的启停请求(磁盘文件名,含后缀)。 + pub pending: BTreeSet, +} + +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) { + 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"), "快照回来后还挂着在飞标记"); + } +} diff --git a/crates/presenter/src/state/tiles.rs b/crates/presenter/src/state/tiles.rs index da8988b..40f31ab 100644 --- a/crates/presenter/src/state/tiles.rs +++ b/crates/presenter/src/state/tiles.rs @@ -2,9 +2,14 @@ //! //! **纯搬移**:类型、函数、文档一字未改,只是换了文件;`state.rs` 用 `pub use` 再导出, //! 于是 `crate::state::{TileCell, grid_positions}` 这两个老路径继续可用。 +//! +//! M224 又把 [`AppState::tiles`] 从 `state.rs` 搬到这里(同一个 400 行原因):筛选 + 排版是 +//! 磁贴域自己的事,方法留在 `impl AppState` 的另一个块里,调用点 `state.tiles()` 不变。 use crate::dto::InstanceSummary; +use super::AppState; + /// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct TileCell<'a> { @@ -25,3 +30,21 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> { .map(|index| (index / columns, index % columns)) .collect() } + +impl AppState { + /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 + pub fn tiles(&self) -> Vec> { + 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() + } +}