Files
Unlucky-Launcher/crates/core/src/addon/mods/toggle.rs
T
tower-worker a0f61d8bb5 feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)
三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。
r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。

core(crates/core/src/addon/mods/)
- suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名),
  收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant`
  改为委派/再导出,签名与行为不变。
- list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤,
  符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。
- toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做
  copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。

presenter
- dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。
- host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置;
  游戏在跑时 host 侧直接挡。
- state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。

app-iced(实例详情页)
- MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。
- 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。
- MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。
- i18n:19 条中英文案(`catalog/instances.rs` + 金表)。

rebase 冲突解法(三个文件,全是"两侧同段新增",都保留):
- `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的
  InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。
- `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归
  `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs`
  (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。

r1 四条文档疵:
1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么;
2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods`
   + 文字说明 Ok 落快照 / Err 落具名失败;
3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空";
4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 →
   改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。

顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条
`clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
2026-11-10 21:09:37 +08:00

167 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 启停一个 mod:**在 `mods/` 里改名**(`.disabled` 后缀就是禁用位)。
//!
//! # 为什么是改名而不是写配置文件
//!
//! 游戏本身不读任何"禁用列表"——它只加载 `mods/*.jar`。`.disabled` 后缀让文件**不再匹配**
//! 加载器的 glob,于是"禁用"是**文件系统状态**:换启动器、换机器、手动 `git checkout` 之后
//! 依然成立。这也是 Modrinth 官方导出表达禁用位的唯一方式(见
//! [`crate::addon::pack::manifest::PackFile::disabled`])。
//!
//! # 五条守卫(都不猜、都不覆盖)
//!
//! | 守卫 | 理由 |
//! |---|---|
//! | 只收**裸文件名**(过七条路径规则且不含 `/`) | 启停不接受相对路径,否则 UI 能指挥落点 |
//! | `mods/` 是符号链接就拒 | 改名会落到实例目录之外 |
//! | 目标必须是**普通文件**(非目录 / 非链接) | 改一个目录或链接不是"启停一个 mod" |
//! | 目标名已存在就拒([`ModError::Collision`]) | 覆盖掉的是**另一个 mod**,用户没同意 |
//! | rename 不做跨盘兜底(不 copy+delete) | copy 半截失败会留下两个文件,语义比报错更差 |
//!
//! # 幂等
//!
//! 「已经启用还要启用」不是错误:返回 `renamed = false` 的成功结果(UI 的开关重复点不会
//! 变成红字)。这一条由 [`super::suffix::renamed_for`] 的 `None` 表达。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::list::mods_dir;
use super::suffix::{is_disabled_path, renamed_for};
/// 一次启停的结果(`from == to` 且 `renamed == false` ⇒ 本来就是那个状态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModToggle {
/// 原文件名的磁盘名(含后缀)。
pub from: String,
/// 改名后的磁盘名(没改名时与 `from` 相同)。
pub to: String,
/// 这一步之后的启用状态。
pub enabled: bool,
/// 是否真的在磁盘上改了名。
pub renamed: bool,
}
/// 把 `mods/` 下的某个文件切到 `enabled` 状态(改名)。
///
/// # Errors
///
/// 见模块文档的五条守卫:[`ModError::UnsafeName`] / [`ModError::ModsDirUnusable`] /
/// [`ModError::NotFound`] / [`ModError::NotARegularFile`] / [`ModError::Collision`] /
/// [`ModError::Rename`]。
pub fn set_enabled(run_dir: &Path, file: &str, enabled: bool) -> Result<ModToggle, ModError> {
let name = bare_name(file)?;
let dir = mods_dir(run_dir);
usable_dir(&dir)?;
let from_path = dir.join(&name);
let meta = std::fs::symlink_metadata(&from_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ModError::NotFound {
file: name.clone(),
dir: dir.clone(),
}
} else {
ModError::NotARegularFile {
file: name.clone(),
detail: error.to_string(),
}
}
})?;
if meta.file_type().is_symlink() || !meta.is_file() {
return Err(ModError::NotARegularFile {
file: name.clone(),
detail: "不是普通文件(符号链接 / 目录 / 设备都不动)".to_string(),
});
}
let Some(target) = renamed_for(&name, enabled) else {
// 已经是目标状态:幂等返回,不产生多余 rename(Windows 上同大小写改名还可能失败)。
return Ok(ModToggle {
enabled: !is_disabled_path(&name),
from: name.clone(),
to: name,
renamed: false,
});
};
let to_path = dir.join(&target);
if std::fs::symlink_metadata(&to_path).is_ok() {
return Err(ModError::Collision {
file: name,
target,
dir,
});
}
std::fs::rename(&from_path, &to_path).map_err(|error| ModError::Rename {
file: name.clone(),
target: target.clone(),
detail: error.to_string(),
})?;
Ok(ModToggle {
from: name,
to: target,
enabled,
renamed: true,
})
}
/// 文件名必须是**裸文件名**:先过整合包侧的七条路径规则,再要求没有 `/`。
fn bare_name(file: &str) -> Result<String, ModError> {
let trimmed = file.trim();
let normalized = crate::addon::pack::path::normalize_file(trimmed).map_err(|error| {
ModError::UnsafeName {
file: file.to_string(),
detail: error.to_string(),
}
})?;
if normalized.contains('/') || normalized != trimmed {
return Err(ModError::UnsafeName {
file: file.to_string(),
detail: "只接受 mods/ 下的裸文件名(不含路径分隔符、不做归一)".to_string(),
});
}
Ok(normalized)
}
/// `mods/` 必须存在、是目录、且**不是符号链接**。
fn usable_dir(dir: &Path) -> Result<(), ModError> {
let meta = std::fs::symlink_metadata(dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: if error.kind() == std::io::ErrorKind::NotFound {
"不存在(这个实例还没有 mods 目录)".to_string()
} else {
error.to_string()
},
})?;
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "不是目录".to_string(),
});
}
Ok(())
}
/// 建 `mods/` 目录(**只在装 mod 时用**;列目录/启停不建)。
///
/// # Errors
///
/// 建不出来 / 建出来是符号链接以外的怪形状时给 [`ModError::ModsDirUnusable`]。
pub fn ensure_dir(run_dir: &Path) -> Result<PathBuf, ModError> {
let dir = mods_dir(run_dir);
std::fs::create_dir_all(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
usable_dir(&dir)?;
Ok(dir)
}