Files
Unlucky-Launcher/crates/presenter/src/host/mods.rs
T
AskaEth a5d1dec495
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00

270 lines
9.3 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 列表与启停的宿主实现(M224 · MODTOGGLE)。
//!
//! ## 三条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_set_enabled`] 走 `job::spawn`,UI 只收快照 |
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 游戏在跑就不改名 | `running` 标记在 host 这一侧挡:Windows 上进程正持有 jar,rename 会失败,与其让用户读一句 OS 报错不如直说 |
//!
//! ## 为什么不复用启动路径的 `run_dir()`
//!
//! `host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例设置(`observe_mode`)。列 mod /
//! 启停**不该因为用户点开详情页就写盘**,所以这里只取派生结果、不落任何设置。
//!
//! ## 失败口径
//!
//! 一律走 [`crate::dto::ModFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 i18n key
//! 现求(M161 的纪律,`dto::events` 的 `LaunchFinished` 同款)。
use std::path::PathBuf;
use unlucky_core::addon::mods::{ModError, ModScan, SkipReason};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use crate::dto::{
locate, InstanceId, InstanceSummary, ModEntryView, ModFailureKind, ModFailureView, ModListView,
ModSkipKind, ModSkipView, ModsCommand,
};
use super::env::HostEnv;
use super::job::{spawn, JobSender};
/// 后台线程回报的一次结果(`HostTask::Mods` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
/// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。
pub instance: InstanceId,
/// 列表结果:成功 = 整份快照;失败 = 具名原因。
pub list: Result<ModListView, ModFailureView>,
/// 这一拍是启停操作的结果时带上「改了什么」(host 只记日志;开关跟快照翻)。
pub toggled: Option<Toggled>,
}
/// 一次成功的启停(`renamed == false` ⇒ 本来就是那个状态,什么也没动)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toggled {
/// 原磁盘名。
pub from: String,
/// 新磁盘名。
pub to: String,
/// 现在的启用状态。
pub enabled: bool,
/// 是否真的改了名。
pub renamed: bool,
}
/// 后台列一个实例的 `mods/`。
pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId, running: bool) {
spawn("mods-list", move || {
let report = Report {
instance: id.clone(),
list: list(&env, &id, running),
toggled: None,
};
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 后台启停一个文件,然后把**新快照**一起带回来(app 不必再请求一次)。
pub fn spawn_set_enabled(
env: HostEnv,
tx: JobSender,
id: InstanceId,
file: String,
enabled: bool,
running: bool,
) {
spawn("mods-toggle", move || {
let report = toggle(&env, &id, &file, enabled, running);
let _ = tx.send(super::HostTask::Mods(Box::new(report)));
});
}
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: ModsCommand, running: bool) {
match command {
ModsCommand::List(id) => spawn_list(env, tx, id, running),
ModsCommand::SetEnabled { id, file, enabled } => {
spawn_set_enabled(env, tx, id, file, enabled, running)
}
}
}
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
fn target(
env: &HostEnv,
id: &str,
file: &str,
) -> Result<(InstanceSummary, PathBuf), ModFailureView> {
let instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else {
return Err(failure(
id,
file,
ModFailureKind::InstanceUnavailable,
format!("实例 {id} 不在快照里(可能刚被删除)"),
));
};
let dir = run_dir(env, instance).map_err(|detail| {
failure(
&instance.id,
file,
ModFailureKind::InstanceUnavailable,
detail,
)
})?;
Ok((instance.clone(), dir))
}
/// 列目录(后台线程里调)。
fn list(env: &HostEnv, id: &str, running: bool) -> Result<ModListView, ModFailureView> {
let (instance, dir) = target(env, id, "")?;
let scan = unlucky_core::addon::mods::scan(&dir).map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance.id, "", kind, detail)
})?;
Ok(view(&instance, &scan, running))
}
/// 启停一个文件 + 回来时带新快照。
fn toggle(env: &HostEnv, id: &str, file: &str, enabled: bool, running: bool) -> Report {
let (instance, dir) = match target(env, id, file) {
Ok(found) => found,
Err(view) => {
return Report {
instance: id.to_string(),
list: Err(view),
toggled: None,
};
}
};
let instance_id = instance.id.clone();
if running {
return Report {
instance: instance_id,
list: Err(failure(
id,
file,
ModFailureKind::Running,
"游戏进程正在跑(Windows 会拒绝改名)",
)),
toggled: None,
};
}
let toggled = match unlucky_core::addon::mods::set_enabled(&dir, file, enabled) {
Ok(result) => Toggled {
from: result.from,
to: result.to,
enabled: result.enabled,
renamed: result.renamed,
},
Err(error) => {
let (kind, detail) = classify(&error);
return Report {
instance: instance_id,
list: Err(failure(id, file, kind, detail)),
toggled: None,
};
}
};
// 改名之后**重新扫一遍**再发:权威状态来自磁盘,app 不做乐观回显(与启动/停止同一条纪律)。
let list = unlucky_core::addon::mods::scan(&dir)
.map(|scan| view(&instance, &scan, running))
.map_err(|error| {
let (kind, detail) = classify(&error);
failure(&instance_id, file, kind, detail)
});
Report {
instance: instance_id,
list,
toggled: Some(toggled),
}
}
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
let global = super::game::global_mode(env);
let config = read_instance_config(&env.layout, &instance.id)
.map_err(|error| format!("读实例设置失败:{error}"))?;
let derivation = derive_game_dir(
&env.game,
&instance.version_id,
global,
config.as_ref(),
None,
)
.map_err(|error| format!("派生运行目录失败:{error}"))?;
Ok(derivation.game_dir)
}
/// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。
fn view(instance: &InstanceSummary, scan: &ModScan, running: bool) -> ModListView {
ModListView {
instance: instance.id.clone(),
dir: scan.dir.display().to_string(),
exists: scan.exists,
entries: scan
.entries
.iter()
.map(|entry| ModEntryView {
file: entry.file.clone(),
name: entry.name.clone(),
enabled: entry.enabled,
bytes: entry.bytes,
old: entry.variant.is_old(),
})
.collect(),
skipped: scan
.skipped
.iter()
.map(|skip| ModSkipView {
file: skip.file.clone(),
reason: match skip.reason {
SkipReason::SymbolicLink => ModSkipKind::SymbolicLink,
SkipReason::NotAFile => ModSkipKind::NotAFile,
SkipReason::UnsafeName => ModSkipKind::UnsafeName,
SkipReason::Unreadable => ModSkipKind::Unreadable,
},
})
.collect(),
running,
known: true,
}
}
fn failure(
instance: &str,
file: &str,
kind: ModFailureKind,
detail: impl Into<String>,
) -> ModFailureView {
ModFailureView {
instance: instance.to_string(),
file: file.to_string(),
kind,
detail: detail.into(),
}
}
/// core 的错误 → DTO 大类 + 原文(**不翻译**,翻译在 app 层)。
fn classify(error: &ModError) -> (ModFailureKind, String) {
match error {
ModError::UnsafeName { detail, .. } => (ModFailureKind::UnsafeName, detail.clone()),
ModError::ModsDirUnusable { detail, .. } => {
(ModFailureKind::ModsDirUnusable, detail.clone())
}
ModError::NotFound { .. } => (ModFailureKind::NotFound, error.to_string()),
ModError::NotARegularFile { detail, .. } => {
(ModFailureKind::NotARegularFile, detail.clone())
}
ModError::Collision { .. } => (ModFailureKind::Collision, error.to_string()),
ModError::Rename { detail, .. } => (ModFailureKind::Rename, detail.clone()),
}
}
#[cfg(test)]
mod tests;