fix(presenter,app-iced): 「游戏在跑」挡在落 core 之前(M247 r2)

塔裁决:三个写操作在实例启动/运行时必须拒,判据不能只靠界面灰按钮 ——
Windows 上是 os error 32 兜底,**Linux 上删一个正在跑的世界会成功**,那是在
用户玩到一半时把存档删掉。

- host/dispatch.rs:`AppCommand::Saves` 与 `AppCommand::Mods` 同款,先查
  `CoreHost::in_flight(&id)` 再把 `running` 传下去
- host/saves.rs::dispatch 增 `running`:为真时三个写操作**不 spawn、不碰盘**,
  直接回一条 `SavesFailureKind::GameRunning` 的结构化失败;读路径与「打开文件夹」不受影响
- dto:新增 `GameRunning` 类别(不折进 WriteFailed —— 那不是"写失败",是"我们主动没写")
- i18n:「游戏正在运行(先退出再操作)」中英成对 + 金表同步
- 用例:在飞时三条写命令盘上指纹不变/世界还在/备份落点没建,且回 GameRunning;
  反向用例钉住闸门没有把写操作一并关死;UI 侧断言三种动词的主句都不出现「写盘失败」且不画重试
- 拆分(400 行):host/saves.rs → host/saves/write.rs;两处测试各拆出 write 子模块
- SavesState 模块文档补「生命周期」表:draft / pending / confirm 谁写谁清、何时必须清,
  并把 confirm「取走即清」(连点两下不删两个)写成安全不变量
- 出图:ULL_SAVES_FIXTURE=running 一张(失败那句话的样子)
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent c921b9c185
commit 8c67e4eca6
13 files changed
+889 -448

No files matched your search

+85 -142
View File
@@ -8,6 +8,7 @@
//! | 单根语义(D20) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生、不做双根合并 |
//! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) |
//! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) |
//! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] |
//!
//! ## 三个写操作的回程形状
//!
@@ -20,6 +21,22 @@
//! **写成功后一律重新列一遍**(`snapshot`):界面画的永远是磁盘上的真相,不是"我以为我改成了什么"。
//! 回执只是"这次动的是哪一条",与快照一起回去,所以 UI 不会出现「列表变了但不知道谁变的」。
//!
//! ## 「游戏在跑」为什么挡在 host 这一侧(M247)
//!
//! 判据是 `CoreHost::in_flight(&id)`(与 `AppCommand::Mods` 同一个),由 `host::dispatch` 传进来。
//! 为真时 [`dispatch`] **不 spawn 线程**、直接发一条 [`SavesFailureKind::GameRunning`] 的失败回去:
//!
//! | 平台 | 不挡的后果 |
//! |---|---|
//! | Windows | 进程占着世界目录 ⇒ rename/删除撞共享冲突 ⇒ 用户读一句 `os error 32` |
//! | **Linux** | **删一个正在跑的世界会成功** —— 用户在玩到一半时丢了存档 |
//!
//! 所以这不是"说得更清楚",是**安全性**。也不能只靠界面灰按钮:判据留在 host,UI 那一侧只是
//! 尽力而为的提示(本版没加"灰按钮"的形态,理由是快照里没有 `running` 字段——加它要动列表 DTO,
//! 而这一版已经有那条明确的失败句子兜住)。
//!
//! 读路径([`SavesCommand::List`])与「打开文件夹」**不受影响**:它们不改盘。
//! ## 为什么不复用启动路径的 `run_dir()`
//!
//! 与 [`super::mods`] 同一条理由:`host::launch` 的 `run_dir()` 顺带把「这次用哪个档位」写进实例
@@ -51,7 +68,7 @@ use std::path::PathBuf;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use unlucky_core::game::saves::{self, SavesError, SavesScan, SavesWriteError, SavesWriteKind};
use unlucky_core::game::saves::{self, SavesError, SavesScan};
use unlucky_core::platform::opener;
use crate::dto::{
@@ -63,6 +80,8 @@ use crate::dto::{
use super::env::HostEnv;
use super::job::{spawn, JobSender};
mod write;
/// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
@@ -97,54 +116,68 @@ pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) {
});
}
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand) {
/// 一条指令属于哪个实例(host 侧算「游戏在不在跑」要用它)。
///
/// 五条命令都带实例 id,所以这里不返回 `Option`:少一条分支处理会让闸门在那条命令上
/// **静默失效**,而这正是 M247 要修的东西。
#[must_use]
pub fn instance_of(command: &SavesCommand) -> &str {
match command {
SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
SavesCommand::RenameWorld { id, dir, to } => spawn_rename(env, tx, id, dir, to),
SavesCommand::DeleteWorld { id, dir } => spawn_delete(env, tx, id, dir),
SavesCommand::BackupWorld { id, dir, dest } => spawn_backup(env, tx, id, dir, dest),
SavesCommand::List(id)
| SavesCommand::OpenFolder { id }
| SavesCommand::RenameWorld { id, .. }
| SavesCommand::DeleteWorld { id, .. }
| SavesCommand::BackupWorld { id, .. } => id,
}
}
/// 后台重命名一个世界,**并把刷新后的快照与回执一起带回来**。
pub fn spawn_rename(env: HostEnv, tx: JobSender, id: InstanceId, dir: String, to: String) {
spawn("saves-rename", move || {
let report = Report {
instance: id.clone(),
list: rename(&env, &id, &dir, &to),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
/// 这条命令是不是**改盘**的写操作(读路径与打开文件夹不是)。
fn write_target(command: &SavesCommand) -> Option<(&str, SavesFailureOp)> {
match command {
SavesCommand::RenameWorld { dir, .. } => Some((dir, SavesFailureOp::Rename)),
SavesCommand::DeleteWorld { dir, .. } => Some((dir, SavesFailureOp::Delete)),
SavesCommand::BackupWorld { dir, .. } => Some((dir, SavesFailureOp::Backup)),
SavesCommand::List(_) | SavesCommand::OpenFolder { .. } => None,
}
}
/// 后台删除一个世界(**UI 已经二次确认过**;host 侧照样重新过边界校验)。
pub fn spawn_delete(env: HostEnv, tx: JobSender, id: InstanceId, dir: String) {
spawn("saves-delete", move || {
let report = Report {
instance: id.clone(),
list: delete(&env, &id, &dir),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
/// 一条指令 → 后台线程(`CoreHost::dispatch` 的唯一入口)。
///
/// `running` = 这个实例正在启动/运行中(`CoreHost::in_flight`)。为真时**三个写操作不落 core**:
/// 直接回一条 [`SavesFailureKind::GameRunning`] 的具名失败,一个字节都不碰盘。
///
/// 为什么这一层必须挡:Windows 上进程占着世界目录,rename/删除会撞共享冲突(难看但至少失败);
/// **Linux 上删一个正在跑的世界会成功**——那是在用户玩到一半时把存档删掉。所以判据不能只靠
/// 界面灰按钮(那也是同一条纪律:`ModsCommand::SetEnabled` 同样挡在 host 这一侧)。
///
/// 不 spawn 线程:没有磁盘工作要排队,直接发一条结果回去更诚实(`tx` 是同步通道)。
pub fn dispatch(env: HostEnv, tx: JobSender, command: SavesCommand, running: bool) {
if running {
if let Some((dir, op)) = write_target(&command) {
let id = instance_of(&command);
let report = Report {
instance: id.to_string(),
list: Err(blocked(id, dir, op)),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
return;
}
}
match command {
SavesCommand::List(id) => spawn_list(env, tx, id),
SavesCommand::OpenFolder { id } => spawn_open_folder(env, tx, id),
SavesCommand::RenameWorld { id, dir, to } => write::spawn_rename(env, tx, id, dir, to),
SavesCommand::DeleteWorld { id, dir } => write::spawn_delete(env, tx, id, dir),
SavesCommand::BackupWorld { id, dir, dest } => write::spawn_backup(env, tx, id, dir, dest),
}
}
/// 后台把一个世界备份成 zip(落点见 [`export_dir`]),并带回新快照。
pub fn spawn_backup(
env: HostEnv,
tx: JobSender,
id: InstanceId,
dir: String,
dest: Option<String>,
) {
spawn("saves-backup", move || {
let report = Report {
instance: id.clone(),
list: backup(&env, &id, &dir, dest.as_deref()),
};
let _ = tx.send(super::HostTask::Saves(Box::new(report)));
});
/// 写操作被「游戏在跑」挡下时的那条失败(**结构化,带世界名与操作**,文案由 app 层现求)。
fn blocked(instance: &str, dir: &str, op: SavesFailureOp) -> SavesFailureView {
let mut failure = failure(instance, SavesFailureKind::GameRunning, String::new());
failure.file = dir.to_string();
failure.op = Some(op);
failure
}
/// 列目录(后台线程里调)。
@@ -156,7 +189,7 @@ fn list(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
/// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。
///
/// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。
fn snapshot(
pub(super) fn snapshot(
instance: &InstanceSummary,
run_dir: &std::path::Path,
last_op: Option<SavesOped>,
@@ -165,74 +198,6 @@ fn snapshot(
Ok(view(&instance.id, &scan, last_op))
}
/// 重命名 → 再列一遍(回执里记「谁改成了什么」)。
fn rename(
env: &HostEnv,
id: &str,
from: &str,
to: &str,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::rename_world(&run_dir, from, to)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Rename, from, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Renamed {
from: from.to_string(),
to: to.to_string(),
}),
)
}
/// 删除 → 再列一遍(回执里记被删掉的那个名字)。
fn delete(env: &HostEnv, id: &str, dir: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
saves::delete_world(&run_dir, dir)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Delete, dir, &error))?;
snapshot(
&instance,
&run_dir,
Some(SavesOped::Deleted {
dir: dir.to_string(),
}),
)
}
/// 备份 → 再列一遍(回执里记落点路径 / 字节数 / 被跳过的链接数)。
fn backup(
env: &HostEnv,
id: &str,
dir: &str,
dest: Option<&str>,
) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
let dest = dest.map_or_else(|| export_dir(env), PathBuf::from);
let done = saves::backup_world(&run_dir, dir, &dest)
.map_err(|error| write_failure(&instance.id, SavesFailureOp::Backup, dir, &error))?;
let file = done
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
snapshot(
&instance,
&run_dir,
Some(SavesOped::BackedUp {
dir: dir.to_string(),
file,
path: done.path.display().to_string(),
bytes: done.bytes,
skipped: done.skipped,
}),
)
}
/// 备份的默认落点:`<data>/exports/saves/`(见模块文档「备份落点」一节)。
fn export_dir(env: &HostEnv) -> PathBuf {
env.layout.data().join("exports").join("saves")
}
/// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。
fn open_folder(env: &HostEnv, id: &str) -> Result<SavesListView, SavesFailureView> {
let (instance, run_dir) = target(env, id)?;
@@ -255,7 +220,10 @@ fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf {
}
/// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。
fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
pub(super) fn target(
env: &HostEnv,
id: &str,
) -> Result<(InstanceSummary, PathBuf), SavesFailureView> {
let instances = super::game::instances(env);
let Some(instance) = locate(&instances, id) else {
return Err(failure(
@@ -270,7 +238,7 @@ fn target(env: &HostEnv, id: &str) -> Result<(InstanceSummary, PathBuf), SavesFa
}
/// 实例的运行目录(D20 覆盖链,与启动同一份派生;**不写盘**)。
fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
pub(super) 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}"))?;
@@ -334,7 +302,11 @@ fn view(instance: &str, scan: &SavesScan, last_op: Option<SavesOped>) -> SavesLi
}
}
fn failure(instance: &str, kind: SavesFailureKind, detail: impl Into<String>) -> SavesFailureView {
pub(super) fn failure(
instance: &str,
kind: SavesFailureKind,
detail: impl Into<String>,
) -> SavesFailureView {
SavesFailureView {
instance: instance.to_string(),
file: String::new(),
@@ -353,34 +325,5 @@ fn unusable(instance: &str, error: &SavesError) -> SavesFailureView {
)
}
/// core 的写失败 → 具名类别 + 「路径:原文」(**不翻译**,翻译在 app 层)。
///
/// 八个类别一一对应,没有一个落到「未知」兜底里:兜底会变成静默(用户只看到"失败了")。
fn write_failure(
instance: &str,
op: SavesFailureOp,
file: &str,
error: &SavesWriteError,
) -> SavesFailureView {
let kind = match error.kind {
SavesWriteKind::UnsafeName => SavesFailureKind::WriteUnsafeName,
SavesWriteKind::SymbolicLink => SavesFailureKind::WriteSymlink,
SavesWriteKind::NotFound => SavesFailureKind::WriteMissing,
SavesWriteKind::NotADirectory => SavesFailureKind::WriteNotADirectory,
SavesWriteKind::Conflict => SavesFailureKind::WriteConflict,
SavesWriteKind::Boundary => SavesFailureKind::WriteBoundary,
SavesWriteKind::TooLarge => SavesFailureKind::WriteTooLarge,
SavesWriteKind::Io => SavesFailureKind::WriteFailed,
};
let mut failure = failure(
instance,
kind,
format!("{}:{}", error.path.display(), error.detail),
);
failure.file = file.to_string();
failure.op = Some(op);
failure
}
#[cfg(test)]
mod tests;