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
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
270 lines
9.3 KiB
Rust
270 lines
9.3 KiB
Rust
//! 本地 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;
|