//! 实例存档 / 截图的宿主实现(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写路径)。 //! //! ## 四条纪律 //! //! | 纪律 | 落点 | //! |---|---| //! | 磁盘只在后台线程碰 | [`spawn_list`] / [`spawn_open_folder`] / [`spawn_rename`] / [`spawn_delete`] / [`spawn_backup`] 全走 `job::spawn`,UI 只收快照 | //! | 单根语义(D20,M274 起按**实例自己的根**) | 运行目录来自 `game::isolation::derive_game_dir`——**与启动同一个答案**,不另派生;`super::game::game_for` 先定根,再把那个根喂给派生 | //! | 读路径只读 | [`list`] 一次写盘都没有;`saves/` 不存在也不替玩家建(core 的 `saves::scan` 同理) | //! | 写路径自己重校边界 | 三个写操作**不在 host 这边信任自己**:core 的 `saves::write` 每次重新校验 `run_dir` 与目标路径(越界 / 符号链接 / 撞名都在那里拒) | //! | 游戏在跑就不写 | [`dispatch`] 的 `running` 为真时三条写命令**不 spawn、不碰盘**,直接回 [`crate::dto::SavesFailureKind::GameRunning`] | //! //! ## 三个写操作的回程形状 //! //! | 操作 | 成功 | 失败 | //! |---|---|---| //! | 重命名 | 新快照 + [`SavesOped::Renamed`] | 具名 [`SavesFailureKind`](撞名 / 名字不可用 / 越界 / 链接 / IO) | //! | 删除 | 新快照 + [`SavesOped::Deleted`] | 同上 | //! | 备份 | 新快照 + [`SavesOped::BackedUp`](落点路径 / 字节数 / 跳过的链接数) | 同上 + `TooLarge` | //! //! **写成功后一律重新列一遍**(`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()` 顺带把「这次用哪个档位」写进实例 //! 设置(`observe_mode`)。**列存档不该因为用户点开详情页就写盘**,所以这里只取派生结果。 //! //! ## 「打开文件夹」为什么由 host 决定打开哪个目录 //! //! `saves/` 可能还不存在(新实例),而用户点「打开文件夹」想看到的是**能放存档的地方**。 //! 于是退化成:`saves/` → `screenshots/` → 运行目录,取第一个存在的。这个判断是"磁盘上到底 //! 有什么"的事实,放在 host 这一侧;UI 只发一条 [`crate::dto::SavesCommand::OpenFolder`]。 //! //! 真正拉起文件管理器的是 [`unlucky_core::platform::opener::open_dir`](`AGENTS.md` §6: //! 平台差异只许收敛在 `core::platform`,本文件一个 `cfg` 都没有,也没有一次裸 spawn)。 //! //! ## 备份落点为什么是启动器自己的 `exports/` //! //! 本仓没有 `rfd` 这类原生文件选择框依赖(`core::launch::crash_bundle::default_target_dir` 的 //! 文档里记着同一条理由),所以「用户选定目录」先退化成:`/exports/saves/`。 //! 命令带一个可选的 `dest`,将来接了选择框、或 CLI 想指定别处,都不用改 core。 //! 成功的回执里带**完整路径 + 字节数**,用户不用猜落点。 //! //! ## 失败口径 //! //! 一律走 [`crate::dto::SavesFailureView`](结构 + OS 原文),**不拼中文**:文案由 app 层按 //! i18n key 现求(M161 的纪律)。读路径三类失败见 [`crate::dto::SavesFailureKind`], //! 写路径八类见同一个枚举的 `Write*` 那一族——每一种在 UI 上都有一句话。 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}; use unlucky_core::platform::opener; use crate::dto::{ locate, InstanceId, InstanceSummary, SavesArea, SavesCommand, SavesFailureKind, SavesFailureOp, SavesFailureView, SavesListView, SavesOped, SavesSkipKind, SavesSkipView, ScreenshotPreview, ScreenshotView, WorldView, }; use super::env::HostEnv; use super::job::{spawn, JobSender}; /// 截图的缩略图:后台解码 + 进程级 memo(D54 第 3/4/5 条,见模块文档) pub mod thumbs; mod write; /// 后台线程回报的一次结果(`HostTask::Saves` 的载荷)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct Report { /// 实例 id(迟到结果靠它判「这条还是不是当前实例的」)。 pub instance: InstanceId, /// 列表结果:成功 = 整份快照;失败 = 具名原因。 pub list: Result, } /// 后台列一个实例的 `saves/` 与 `screenshots/`。 pub fn spawn_list(env: HostEnv, tx: JobSender, id: InstanceId) { spawn("saves-list", move || { let report = Report { instance: id.clone(), list: list(&env, &id), }; let _ = tx.send(super::HostTask::Saves(Box::new(report))); }); } /// 后台用系统文件管理器打开存档目录,**并把刷新后的快照一起带回来**。 /// /// 带快照回来不是顺手:用户常在文件管理器里删掉旧世界再切回来,页面这一拍就能跟着更新, /// 不用等下一次进详情页。 pub fn spawn_open_folder(env: HostEnv, tx: JobSender, id: InstanceId) { spawn("saves-open", move || { let report = Report { instance: id.clone(), list: open_folder(&env, &id), }; let _ = tx.send(super::HostTask::Saves(Box::new(report))); }); } /// 一条指令属于哪个实例(host 侧算「游戏在不在跑」要用它)。 /// /// 五条命令都带实例 id,所以这里不返回 `Option`:少一条分支处理会让闸门在那条命令上 /// **静默失效**,而这正是 M247 要修的东西。 #[must_use] pub fn instance_of(command: &SavesCommand) -> &str { match command { SavesCommand::List(id) | SavesCommand::OpenFolder { id } | SavesCommand::RenameWorld { id, .. } | SavesCommand::DeleteWorld { id, .. } | SavesCommand::BackupWorld { id, .. } => id, } } /// 这条命令是不是**改盘**的写操作(读路径与打开文件夹不是)。 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, } } /// 一条指令 → 后台线程(`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), } } /// 写操作被「游戏在跑」挡下时的那条失败(**结构化,带世界名与操作**,文案由 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 } /// 列目录(后台线程里调)。 fn list(env: &HostEnv, id: &str) -> Result { let (instance, run_dir) = target(env, id)?; snapshot(&instance, &run_dir, None) } /// 重新扫一遍目录 → 快照(可挂一条写操作的回执)。 /// /// 写成功后**一律**走这里:界面画的是磁盘上的真相,不是"我以为我改成了什么"。 pub(super) fn snapshot( instance: &InstanceSummary, run_dir: &std::path::Path, last_op: Option, ) -> Result { let scan = saves::scan(run_dir).map_err(|error| unusable(&instance.id, &error))?; Ok(view(&instance.id, &scan, last_op)) } /// 打开存档目录 → 再列一遍(失败只说"打不开",原文进括号)。 fn open_folder(env: &HostEnv, id: &str) -> Result { let (instance, run_dir) = target(env, id)?; let scan = saves::scan(&run_dir).map_err(|error| unusable(&instance.id, &error))?; let target = open_target(&scan, &run_dir); opener::open_dir(&target) .map_err(|detail| failure(&instance.id, SavesFailureKind::OpenFailed, detail))?; Ok(view(&instance.id, &scan, None)) } /// 打开哪一个目录:`saves/` → `screenshots/` → 运行目录(取第一个存在的)。 fn open_target(scan: &SavesScan, run_dir: &std::path::Path) -> PathBuf { if scan.exists { return scan.dir.clone(); } if scan.screenshots_exists { return scan.screenshots_dir.clone(); } run_dir.to_path_buf() } /// 实例 + 它的运行目录(找不到实例 / 派生不出目录都给具名失败)。 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( id, SavesFailureKind::InstanceUnavailable, format!("实例 {id} 不在快照里(可能刚被删除)"), )); }; let dir = run_dir(env, instance) .map_err(|detail| failure(&instance.id, SavesFailureKind::InstanceUnavailable, detail))?; Ok((instance.clone(), dir)) } /// 实例的运行目录(D20 覆盖链 + 自动发现的基底档,与启动同一份派生;**不写盘**)。 pub(super) fn run_dir(env: &HostEnv, instance: &InstanceSummary) -> Result { // **这个实例自己那个根**(M274):共用目录的运行目录是这个根的根目录; // 拿 `env.game` 去派生,官方根里的实例会读到 ULL 根下的存档 / 模组(用户的原话:串了)。 let game = super::game::game_for(env, &instance.id); let base = super::game::base_mode(env, &game, &instance.id, &instance.version_id); let config = read_instance_config(&env.layout, &instance.id) .map_err(|error| format!("读实例设置失败:{error}"))?; let derivation = derive_game_dir(&game, &instance.version_id, base, config.as_ref(), None) .map_err(|error| format!("派生运行目录失败:{error}"))?; Ok(derivation.game_dir) } /// 快照映射:core 的扫描结果 → DTO(路径串在 host 这一侧才成形)。 /// /// **截图带缩略图**(M251):像素在这里(后台线程里)解好、随快照一起交出去,UI 只画。 /// D54 明写「不许在渲染回调里解码」——这条纪律的落点就是「`view` 里 attach,`view()` 只在 /// 后台线程被调」。解码失败/超限/超预算的每一张都带自己的状态,不留白(D54 第 6 条)。 fn view(instance: &str, scan: &SavesScan, last_op: Option) -> SavesListView { let mut screenshots: Vec = scan .screenshots .iter() .map(|shot| ScreenshotView { file: shot.file.clone(), bytes: shot.bytes, modified_ms: shot.modified_ms, // 先摆占位,再让 thumbs 按 mtime 倒序挑前 60 张去解(顺序与上限都在那一个模块里)。 preview: ScreenshotPreview::NotGenerated, }) .collect(); thumbs::attach(&scan.screenshots_dir, &mut screenshots); SavesListView { instance: instance.to_string(), dir: scan.dir.display().to_string(), exists: scan.exists, worlds: scan .worlds .iter() .map(|world| WorldView { dir: world.dir.clone(), icon: world.icon, modified_ms: world.modified_ms, }) .collect(), screenshots_dir: scan.screenshots_dir.display().to_string(), screenshots_exists: scan.screenshots_exists, screenshots, skipped: scan .skipped .iter() .map(|skip| SavesSkipView { area: match skip.area { saves::Area::Saves => SavesArea::Saves, saves::Area::Screenshots => SavesArea::Screenshots, }, file: skip.file.clone(), reason: match skip.reason { saves::SkipKind::SymbolicLink => SavesSkipKind::SymbolicLink, saves::SkipKind::NotADirectoryExpected => SavesSkipKind::NotADirectory, saves::SkipKind::NotAFile => SavesSkipKind::NotAFile, saves::SkipKind::UnsafeName => SavesSkipKind::UnsafeName, saves::SkipKind::Unreadable => SavesSkipKind::Unreadable, saves::SkipKind::NotAnImage => SavesSkipKind::NotAnImage, }, }) .collect(), last_op, } } pub(super) fn failure( instance: &str, kind: SavesFailureKind, detail: impl Into, ) -> SavesFailureView { SavesFailureView { instance: instance.to_string(), file: String::new(), op: None, kind, detail: detail.into(), } } /// core 的错误 → 「目录不可用」+ 原文(**不翻译**,翻译在 app 层)。 fn unusable(instance: &str, error: &SavesError) -> SavesFailureView { failure( instance, SavesFailureKind::DirUnusable, format!("{}:{}", error.dir.display(), error.detail), ) } #[cfg(test)] mod tests;