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:
1 parent
c921b9c185
commit
8c67e4eca6
13 files changed
+889
-448
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user