From 59aaca05d2bb3b72f918fe87cd33593b6e79b1a6 Mon Sep 17 00:00:00 2001 From: mx6436 Date: Fri, 2 Oct 2026 11:54:41 +0800 Subject: [PATCH 1/3] feat(linux): use project-specific XDG data directory Co-Authored-By: GPT-6.1 Sol Generated-By: pi 0.87.1 (https://pi.dev) --- src-tauri/src/commands/file_ops.rs | 3 +- src-tauri/src/commands/utils.rs | 122 ++++++++++++++++++++++++++++- src/services/interfaceLoader.ts | 4 +- src/services/updateService.ts | 4 +- src/stores/types.ts | 2 +- src/utils/paths.ts | 3 +- 6 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index cc1f0e2b..139da2b9 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -326,7 +326,8 @@ pub fn get_exe_dir() -> Result { /// 获取应用数据目录路径 /// - macOS: ~/Library/Application Support/MXU/ -/// - Windows/Linux: exe 所在目录 +/// - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} +/// - Windows: exe 所在目录 #[tauri::command] pub fn get_data_dir() -> Result { let data_dir = get_app_data_dir()?; diff --git a/src-tauri/src/commands/utils.rs b/src-tauri/src/commands/utils.rs index a8f1ef81..70eb49ec 100644 --- a/src-tauri/src/commands/utils.rs +++ b/src-tauri/src/commands/utils.rs @@ -234,7 +234,8 @@ pub fn emit_config_changed(app: &AppHandle, client_id: &str, revision: &str) { /// 获取应用数据目录 /// - macOS: ~/Library/Application Support/MXU/ -/// - Windows/Linux: exe 所在目录(保持便携式部署) +/// - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} +/// - Windows: exe 所在目录(保持便携式部署) pub fn get_app_data_dir() -> Result { #[cfg(target_os = "macos")] { @@ -246,13 +247,63 @@ pub fn get_app_data_dir() -> Result { Ok(path) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + let interface_path = get_exe_directory()?.join("interface.json"); + let content = match std::fs::read_to_string(&interface_path) { + Ok(content) => Some(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("读取 interface.json 失败: {}", e)), + }; + let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let home = std::env::var_os("HOME").map(PathBuf::from); + linux_data_dir(content.as_deref(), xdg_data_home.as_deref(), home.as_deref()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - // Windows/Linux 保持便携式,使用 exe 所在目录 get_exe_directory() } } +#[cfg(target_os = "linux")] +fn linux_data_dir( + interface_content: Option<&str>, + xdg_data_home: Option<&std::path::Path>, + home: Option<&std::path::Path>, +) -> Result { + use std::path::{Component, Path}; + + let interface = interface_content + .map(super::app_config::parse_jsonc) + .transpose() + .map_err(|e| format!("解析 interface.json 失败: {}", e))?; + let project_name = interface + .as_ref() + .and_then(|value| value.get("name")) + .and_then(|value| value.as_str()) + .unwrap_or("MXU"); + // 项目名必须是单个目录名,避免绝对路径或 .. 逃逸数据目录。 + let mut components = Path::new(project_name).components(); + if project_name.is_empty() + || project_name.contains('/') + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + return Err("interface.json 的项目名不是有效的目录名".to_string()); + } + + let base = match xdg_data_home.filter(|path| path.is_absolute()) { + Some(path) => path.to_path_buf(), + None => home + .filter(|path| path.is_absolute()) + .ok_or_else(|| "无法获取有效的 HOME 环境变量".to_string())? + .join(".local") + .join("share"), + }; + Ok(base.join(project_name)) +} + /// 规范化路径:移除冗余的 `.`、处理 `..`、统一分隔符 /// 使用 Path::components() 解析,不需要路径实际存在 pub fn normalize_path(path: &str) -> PathBuf { @@ -466,3 +517,68 @@ pub fn build_launch_command( cmd } + +#[cfg(all(test, target_os = "linux"))] +mod linux_data_dir_tests { + use super::linux_data_dir; + use std::path::{Path, PathBuf}; + + const INTERFACE: &str = r#"{"name": "MaaEnd"}"#; + + #[test] + fn linux_data_dir_uses_xdg_home() { + assert_eq!( + linux_data_dir(Some(INTERFACE), Some(Path::new("/data")), None).unwrap(), + PathBuf::from("/data/MaaEnd") + ); + } + + #[test] + fn linux_data_dir_defaults_to_local_share() { + for xdg in [None, Some(Path::new("")), Some(Path::new("relative"))] { + assert_eq!( + linux_data_dir(Some(INTERFACE), xdg, Some(Path::new("/home/user"))).unwrap(), + PathBuf::from("/home/user/.local/share/MaaEnd") + ); + } + } + + #[test] + fn linux_data_dir_supports_jsonc() { + assert_eq!( + linux_data_dir( + Some("{ // project\n\"name\": \"MaaEnd\" }"), + Some(Path::new("/data")), + None, + ) + .unwrap(), + PathBuf::from("/data/MaaEnd") + ); + } + + #[test] + fn linux_data_dir_defaults_to_mxu_without_project_name() { + for content in [None, Some("{}")] { + assert_eq!( + linux_data_dir(content, Some(Path::new("/data")), None).unwrap(), + PathBuf::from("/data/MXU") + ); + } + } + + #[test] + fn linux_data_dir_rejects_invalid_project_names() { + for name in ["", ".", "..", "../MaaEnd", "/tmp/MaaEnd", "a/b", "MaaEnd/"] { + let content = serde_json::json!({ "name": name }).to_string(); + assert!(linux_data_dir(Some(&content), Some(Path::new("/data")), None).is_err()); + } + } + + #[test] + fn linux_data_dir_requires_valid_home_without_xdg() { + for home in [None, Some(Path::new("")), Some(Path::new("relative"))] { + assert!(linux_data_dir(Some(INTERFACE), None, home).is_err()); + } + assert!(linux_data_dir(Some("invalid json"), Some(Path::new("/data")), None).is_err()); + } +} diff --git a/src/services/interfaceLoader.ts b/src/services/interfaceLoader.ts index e9064df7..e9753ee2 100644 --- a/src/services/interfaceLoader.ts +++ b/src/services/interfaceLoader.ts @@ -39,7 +39,7 @@ export interface LoadResult { interface: ProjectInterface; translations: Record>; basePath: string; - dataPath: string; // 数据目录(macOS: ~/Library/Application Support/MXU/,其他平台同 basePath) + dataPath: string; // 数据目录(Linux: $XDG_DATA_HOME/{项目名},macOS: ~/Library/Application Support/MXU/,Windows 同 basePath) webServerPort?: number; // 后端 Web 服务器实际监听端口(浏览器模式下用于 WS 直连) backendOS?: string; // 后端真实 OS(用于控制器过滤、更新匹配等;Tauri/HTTP 路径填充,dev 预览为空) backendArch?: string; // 后端真实架构 @@ -442,7 +442,7 @@ export async function autoLoadInterface(): Promise { const basePath = relativeBasePath ? `${exeDir}/${relativeBasePath}` : exeDir; log.info('basePath (绝对路径):', basePath); - // 获取数据目录(macOS 使用 Application Support,其他平台同 basePath) + // 获取数据目录(Linux 使用 XDG,macOS 使用 Application Support,Windows 同 basePath) const dataPath = await getDataDir(); log.info('dataPath (数据目录):', dataPath); diff --git a/src/services/updateService.ts b/src/services/updateService.ts index e60ffb6f..56a7c227 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -942,8 +942,8 @@ export async function checkAndPrepareDownload( /** * 获取更新包保存路径 - * @param dataPath 数据目录(macOS: ~/Library/Application - * Support/MXU/,其他平台: exe 目录) + * 使用应用数据目录(Linux: $XDG_DATA_HOME/{项目名}, + * macOS: ~/Library/Application Support/MXU/,Windows: exe 目录)。 * @param filename 文件名(可选)。如果不提供,使用默认名称。 * 注意:实际保存路径可能由 Rust 下载时从 302 重定向或 * Content-Disposition 检测后覆盖 diff --git a/src/stores/types.ts b/src/stores/types.ts index 7e565201..dab1387a 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -148,7 +148,7 @@ export interface AppState { projectInterface: ProjectInterface | null; interfaceTranslations: Record>; basePath: string; // exe 所在目录(资源路径) - dataPath: string; // 数据目录(macOS: ~/Library/Application Support/MXU/,其他平台同 basePath) + dataPath: string; // 数据目录(Linux: $XDG_DATA_HOME/{项目名},macOS: ~/Library/Application Support/MXU/,Windows 同 basePath) setProjectInterface: (pi: ProjectInterface) => void; setInterfaceTranslations: (lang: string, translations: Record) => void; setBasePath: (path: string) => void; diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 1cfd701e..f60f2a51 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -19,7 +19,8 @@ let cachedDataPath: string | null = null; /** * 获取应用数据目录 * - macOS: ~/Library/Application Support/MXU/ - * - Windows/Linux: exe 所在目录 + * - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} + * - Windows: exe 所在目录 * * 结果会被缓存,多次调用不会重复请求 */ From 85327f015b568e81355757e6ed62de86febf9424 Mon Sep 17 00:00:00 2001 From: mx6436 Date: Thu, 29 Oct 2026 21:34:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(linux):=20=E5=B0=86=20XDG=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89?= =?UTF-8?q?=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 数据目录改动以 `xdg-data-dir` feature 提供,默认构建仍保持 exe 目录的便携式布局,避免升级后配置/日志/缓存路径变更。 - Cargo.toml 声明 xdg-data-dir(非默认) - get_app_data_dir 按 all(target_os = "linux", feature = "xdg-data-dir") 分派 - linux_data_dir 及其 6 个单测随 feature 条件编译 - 文档见 docs/rust-features.md --- AGENTS.md | 7 ++++--- docs/rust-features.md | 18 ++++++++++++++++++ src-tauri/Cargo.toml | 5 +++++ src-tauri/src/commands/file_ops.rs | 4 ++-- src-tauri/src/commands/utils.rs | 21 ++++++++++++++------- src/services/interfaceLoader.ts | 4 ++-- src/services/updateService.ts | 4 ++-- src/stores/types.ts | 2 +- src/utils/paths.ts | 5 +++-- 9 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 docs/rust-features.md diff --git a/AGENTS.md b/AGENTS.md index 2d8d10ab..0408b81f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,10 @@ AI 使用信息: 以下手册提供特定开发场景的详细指引,**按需读取**对应文档即可: -| 场景 | 文档 | 何时阅读 | -| ----------------- | ---------------------------------------------------- | ------------------------------------------------------------- | -| 新增 MXU 特殊任务 | [docs/add-special-task.md](docs/add-special-task.md) | 需要添加基于 Custom Action 的内置功能任务(如延迟、通知等)时 | +| 场景 | 文档 | 何时阅读 | +| ---------------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | +| 新增 MXU 特殊任务 | [docs/add-special-task.md](docs/add-special-task.md) | 需要添加基于 Custom Action 的内置功能任务(如延迟、通知等)时 | +| Rust feature(可选构建行为) | [docs/rust-features.md](docs/rust-features.md) | 需要启用或新增 Cargo feature(如 `xdg-data-dir`)时 | ## 5. 相关资源 diff --git a/docs/rust-features.md b/docs/rust-features.md new file mode 100644 index 00000000..157b93a2 --- /dev/null +++ b/docs/rust-features.md @@ -0,0 +1,18 @@ +# Rust feature + +后端 (`src-tauri`) 用 Cargo feature 承载可选行为,默认构建保持既有行为。目前只有一个: + +## `xdg-data-dir` + +Linux 数据目录改为 `$XDG_DATA_HOME/{项目名}`(未设置时 `~/.local/share/{项目名}`)。默认关闭,此时与 macOS/Windows 一样按 exe 所在目录存放,旧版 Linux 数据(`config/`、`debug/`、`cache/`)因此无需迁移。 + +```bash +pnpm tauri build --features xdg-data-dir +cd src-tauri && cargo test --features xdg-data-dir +``` + +- 项目名取 exe 目录下 `interface.json` 的 `name`(JSONC,缺失时 `MXU`),必须是单段目录名,否则报错。 +- `XDG_DATA_HOME` 非绝对路径时被忽略;`HOME` 也无效时报错。 +- 开启后配置、日志、缓存、更新包下载目录都会改变,切换构建方式后需重启客户端。 + +实现见 `src-tauri/src/commands/utils.rs` 的 `get_app_data_dir` / `linux_data_dir`,单测随 feature 条件编译。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71821c2b..24750059 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,11 @@ edition = "2021" name = "mxu_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +# Linux: 数据目录遵循 XDG 规范($XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名}) +# 默认关闭以保持 exe 目录的便携式部署;开启后与旧版本数据目录不兼容。 +xdg-data-dir = [] + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 139da2b9..15abd25d 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -326,8 +326,8 @@ pub fn get_exe_dir() -> Result { /// 获取应用数据目录路径 /// - macOS: ~/Library/Application Support/MXU/ -/// - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} -/// - Windows: exe 所在目录 +/// - Linux(`xdg-data-dir` feature): $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} +/// - 其他: exe 所在目录 #[tauri::command] pub fn get_data_dir() -> Result { let data_dir = get_app_data_dir()?; diff --git a/src-tauri/src/commands/utils.rs b/src-tauri/src/commands/utils.rs index 70eb49ec..9e57b617 100644 --- a/src-tauri/src/commands/utils.rs +++ b/src-tauri/src/commands/utils.rs @@ -234,8 +234,8 @@ pub fn emit_config_changed(app: &AppHandle, client_id: &str, revision: &str) { /// 获取应用数据目录 /// - macOS: ~/Library/Application Support/MXU/ -/// - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} -/// - Windows: exe 所在目录(保持便携式部署) +/// - Linux(`xdg-data-dir` feature): $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} +/// - 其他: exe 所在目录(保持便携式部署) pub fn get_app_data_dir() -> Result { #[cfg(target_os = "macos")] { @@ -247,7 +247,7 @@ pub fn get_app_data_dir() -> Result { Ok(path) } - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", feature = "xdg-data-dir"))] { let interface_path = get_exe_directory()?.join("interface.json"); let content = match std::fs::read_to_string(&interface_path) { @@ -257,16 +257,23 @@ pub fn get_app_data_dir() -> Result { }; let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); let home = std::env::var_os("HOME").map(PathBuf::from); - linux_data_dir(content.as_deref(), xdg_data_home.as_deref(), home.as_deref()) + linux_data_dir( + content.as_deref(), + xdg_data_home.as_deref(), + home.as_deref(), + ) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + all(target_os = "linux", feature = "xdg-data-dir") + )))] { get_exe_directory() } } -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", feature = "xdg-data-dir"))] fn linux_data_dir( interface_content: Option<&str>, xdg_data_home: Option<&std::path::Path>, @@ -518,7 +525,7 @@ pub fn build_launch_command( cmd } -#[cfg(all(test, target_os = "linux"))] +#[cfg(all(test, target_os = "linux", feature = "xdg-data-dir"))] mod linux_data_dir_tests { use super::linux_data_dir; use std::path::{Path, PathBuf}; diff --git a/src/services/interfaceLoader.ts b/src/services/interfaceLoader.ts index e9753ee2..58055652 100644 --- a/src/services/interfaceLoader.ts +++ b/src/services/interfaceLoader.ts @@ -39,7 +39,7 @@ export interface LoadResult { interface: ProjectInterface; translations: Record>; basePath: string; - dataPath: string; // 数据目录(Linux: $XDG_DATA_HOME/{项目名},macOS: ~/Library/Application Support/MXU/,Windows 同 basePath) + dataPath: string; // 数据目录(macOS: Application Support;Linux 启用 xdg-data-dir feature: $XDG_DATA_HOME/{项目名};否则同 basePath) webServerPort?: number; // 后端 Web 服务器实际监听端口(浏览器模式下用于 WS 直连) backendOS?: string; // 后端真实 OS(用于控制器过滤、更新匹配等;Tauri/HTTP 路径填充,dev 预览为空) backendArch?: string; // 后端真实架构 @@ -442,7 +442,7 @@ export async function autoLoadInterface(): Promise { const basePath = relativeBasePath ? `${exeDir}/${relativeBasePath}` : exeDir; log.info('basePath (绝对路径):', basePath); - // 获取数据目录(Linux 使用 XDG,macOS 使用 Application Support,Windows 同 basePath) + // 获取数据目录(macOS 使用 Application Support,Linux 启用 xdg-data-dir feature 时使用 XDG,否则同 basePath) const dataPath = await getDataDir(); log.info('dataPath (数据目录):', dataPath); diff --git a/src/services/updateService.ts b/src/services/updateService.ts index 56a7c227..3949b7a9 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -942,8 +942,8 @@ export async function checkAndPrepareDownload( /** * 获取更新包保存路径 - * 使用应用数据目录(Linux: $XDG_DATA_HOME/{项目名}, - * macOS: ~/Library/Application Support/MXU/,Windows: exe 目录)。 + * 使用应用数据目录(macOS: ~/Library/Application Support/MXU/, + * Linux 启用 xdg-data-dir feature: $XDG_DATA_HOME/{项目名},否则为 exe 目录)。 * @param filename 文件名(可选)。如果不提供,使用默认名称。 * 注意:实际保存路径可能由 Rust 下载时从 302 重定向或 * Content-Disposition 检测后覆盖 diff --git a/src/stores/types.ts b/src/stores/types.ts index dab1387a..e450b42f 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -148,7 +148,7 @@ export interface AppState { projectInterface: ProjectInterface | null; interfaceTranslations: Record>; basePath: string; // exe 所在目录(资源路径) - dataPath: string; // 数据目录(Linux: $XDG_DATA_HOME/{项目名},macOS: ~/Library/Application Support/MXU/,Windows 同 basePath) + dataPath: string; // 数据目录(macOS: Application Support;Linux 启用 xdg-data-dir feature: $XDG_DATA_HOME/{项目名};否则同 basePath) setProjectInterface: (pi: ProjectInterface) => void; setInterfaceTranslations: (lang: string, translations: Record) => void; setBasePath: (path: string) => void; diff --git a/src/utils/paths.ts b/src/utils/paths.ts index f60f2a51..e0738fd2 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -17,9 +17,10 @@ export const isTauri = () => { let cachedDataPath: string | null = null; /** - * 获取应用数据目录 + * 获取应用数据目录(由后端 `get_data_dir` 决定) * - macOS: ~/Library/Application Support/MXU/ - * - Linux: $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名} + * - Linux: 构建时启用 `xdg-data-dir` feature 则为 $XDG_DATA_HOME/{项目名}, + * 默认 ~/.local/share/{项目名};否则为 exe 所在目录 * - Windows: exe 所在目录 * * 结果会被缓存,多次调用不会重复请求 From 9e06ae65fef7c28b76a0ef7a500718b5451d1ed5 Mon Sep 17 00:00:00 2001 From: mx6436 Date: Thu, 5 Nov 2026 19:25:55 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(update):=20=E9=85=8D=E7=BD=AE=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=E6=9B=B4=E6=96=B0=E5=8C=85=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新前备份从 exe 目录读取配置,开启 xdg-data-dir 后会跳过备份或备份 错误版本;改为使用 dataPath。更新包归档也移到 dataPath/cache/old, 与下载目录同分区,避免跨文件系统 rename 失败后残留在缓存中。 启动清理同时处理 exe 与 data 的 cache/old;程序旧文件与回滚备份仍 保留在 exe 目录。 验证:cargo test --features xdg-data-dir(79 项)、cargo check、 tsc、Prettier 与前端 92 项测试通过。 --- src-tauri/src/commands/download.rs | 6 ++-- src-tauri/src/commands/update.rs | 25 ++++++++++----- src-tauri/src/lib.rs | 43 +++++++++++++++++--------- src/components/InstallConfirmModal.tsx | 15 ++++++++- src/services/configService.ts | 6 ++-- src/services/updateService.ts | 27 ++++++++-------- 6 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index 6a5e4e12..44f4b645 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -13,7 +13,7 @@ use super::types::GitHubRelease; use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; use super::types::{DownloadProgressEvent, DownloadResult}; -use super::update::move_to_old_folder; +use super::update::archive_to_data_old; use super::utils::build_user_agent; /// 进度上报任务的守卫,在函数任意返回路径上都能确保发送停止信号 @@ -434,9 +434,9 @@ pub async fn download_file( }, ); - // 将可能存在的旧文件移动到 old 文件夹 + // 将可能存在的旧文件归档到数据目录的 old 文件夹 if actual_save_path_obj.exists() { - let _ = move_to_old_folder(actual_save_path_obj); + let _ = archive_to_data_old(actual_save_path_obj); } // 重命名临时文件(使用异步版本避免阻塞 runtime 线程) diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 395a6297..6dd255fb 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -47,7 +47,7 @@ fn ensure_update_path(path: &std::path::Path) -> Result<(), String> { Ok(()) } -/// cache/old 目录锁:全量更新期间旧文件作为回滚备份放在 cache/old, +/// 安装目录 cache/old 目录锁:全量更新期间旧文件作为回滚备份放在那里, /// 启动时的异步清理必须与之互斥,避免备份在回滚前被删掉 pub static OLD_DIR_LOCK: Mutex<()> = Mutex::new(()); @@ -256,15 +256,18 @@ pub fn cleanup_dir_contents(dir: &std::path::Path) -> (usize, usize) { (deleted, failed) } -/// 将文件或目录移动到 cache/old 文件夹,处理重名冲突 -/// 供前端调用,统一文件移动逻辑 +/// 将更新包归档到数据目录下的 cache/old 文件夹 #[tauri::command] -pub fn move_file_to_old(file_path: String) -> Result<(), String> { - let path = std::path::Path::new(&file_path); - move_to_old_folder(path) +pub fn move_update_package_to_old(file_path: String) -> Result<(), String> { + archive_to_data_old(Path::new(&file_path)) } -/// 获取 cache/old 目录路径(启动清理也使用此路径,保证与移入旧文件的位置一致) +/// 将数据目录中的文件归档到同一文件系统的 cache/old +pub fn archive_to_data_old(source: &Path) -> Result<(), String> { + move_to_old_folder_in_dir(source, &get_data_old_dir()?).map(|_| ()) +} + +/// 获取安装目录 cache/old 路径(程序旧文件与回滚备份) pub fn get_old_dir() -> Result { old_dir_for_executable(&get_exe_directory()?, get_app_data_dir) } @@ -285,6 +288,11 @@ fn old_dir_for_executable( Ok(old_dir) } +/// 获取数据目录 cache/old 路径(更新包归档) +pub fn get_data_old_dir() -> Result { + Ok(get_app_data_dir()?.join("cache").join("old")) +} + /// 清空 cache/old 目录,返回 (成功数, 失败数)。 /// 删不掉的文件(如仍被旧进程占用)会保留,后续移动遇到重名时自动加后缀 fn clear_old_folder(old_dir: &Path) -> (usize, usize) { @@ -302,11 +310,12 @@ pub fn move_to_old_folder(source: &std::path::Path) -> Result<(), String> { move_to_old_folder_dest(source).map(|_| ()) } -/// 将文件或目录移动到 cache/old,返回移动后的路径(源不存在时返回 None) +/// 将文件或目录移动到安装目录的 cache/old,返回移动后的路径(源不存在时返回 None) fn move_to_old_folder_dest(source: &Path) -> Result, String> { move_to_old_folder_in_dir(source, &get_old_dir()?) } +/// 将文件或目录移动到指定 cache/old,返回移动后的路径(源不存在时返回 None) fn move_to_old_folder_in_dir(source: &Path, old_dir: &Path) -> Result, String> { ensure_update_path(source)?; // 只有确认不存在才跳过;其他错误(如权限不足)不能当作不存在,否则回滚时无从还原 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 402baccd..5b149268 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -180,29 +180,44 @@ pub fn run() { } } - // 启动时异步清理 cache/old 目录(更新残留的旧文件),不阻塞应用启动 - // 路径与更新时移入旧文件的位置一致(macOS 上数据目录与程序目录不同) - if let Ok(old_dir) = commands::update::get_old_dir() { - if old_dir.exists() { - std::thread::spawn(move || { - // 与全量更新互斥:更新期间 cache/old 中是回滚备份 - let _guard = commands::update::OLD_DIR_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); + // 启动时异步清理 cache/old 目录(安装目录旧文件与数据目录的更新包归档), + // 不阻塞应用启动 + let mut old_dirs: Vec<_> = [ + commands::update::get_old_dir(), + commands::update::get_data_old_dir(), + ] + .into_iter() + .flatten() + .filter(|dir| dir.exists()) + .collect(); + old_dirs.sort(); + old_dirs.dedup(); + if !old_dirs.is_empty() { + std::thread::spawn(move || { + // 与全量更新互斥:更新期间安装目录 cache/old 中是回滚备份 + let _guard = commands::update::OLD_DIR_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + for old_dir in old_dirs { let (deleted, failed) = commands::cleanup_dir_contents(&old_dir); if deleted > 0 || failed > 0 { if failed == 0 { - log::info!("Cleaned up cache/old: {} items deleted", deleted); + log::info!( + "Cleaned up {}: {} items deleted", + old_dir.display(), + deleted + ); } else { log::warn!( - "Cleaned up cache/old: {} deleted, {} failed", + "Cleaned up {}: {} deleted, {} failed", + old_dir.display(), deleted, failed ); } } - }); - } + } + }); } // 启动时自动加载 MaaFramework DLL @@ -318,7 +333,7 @@ pub fn run() { commands::update::apply_full_update, commands::update::cleanup_extract_dir, commands::update::fallback_update, - commands::update::move_file_to_old, + commands::update::move_update_package_to_old, commands::update::cleanup_update_artifacts, commands::update::current_exe_exists, // 下载命令 diff --git a/src/components/InstallConfirmModal.tsx b/src/components/InstallConfirmModal.tsx index fd965072..8aa39565 100644 --- a/src/components/InstallConfirmModal.tsx +++ b/src/components/InstallConfirmModal.tsx @@ -41,6 +41,7 @@ export function InstallConfirmModal() { updateInfo, projectInterface, basePath, + dataPath, downloadSavePath, showInstallConfirmModal, installStatus, @@ -76,6 +77,7 @@ export function InstallConfirmModal() { const success = await installUpdate({ zipPath: downloadSavePath, targetDir: basePath, + dataPath, newVersion: updateInfo.versionName, projectName, onProgress: (stage, detail) => { @@ -100,7 +102,16 @@ export function InstallConfirmModal() { setInstallStatus('failed'); setInstallError(formatInstallError(error, t)); } - }, [downloadSavePath, basePath, updateInfo, projectName, setInstallStatus, setInstallError, t]); + }, [ + downloadSavePath, + basePath, + dataPath, + updateInfo, + projectName, + setInstallStatus, + setInstallError, + t, + ]); // 重启应用(直接重启,不再确认) const handleRestart = useCallback(async () => { @@ -181,6 +192,7 @@ export function InstallConfirmModal() { const success = await installUpdate({ zipPath: downloadSavePath, targetDir: basePath, + dataPath, newVersion: updateInfo.versionName, projectName, onProgress: (stage, detail) => { @@ -219,6 +231,7 @@ export function InstallConfirmModal() { installStatus, downloadSavePath, basePath, + dataPath, updateInfo, projectName, setInstallStatus, diff --git a/src/services/configService.ts b/src/services/configService.ts index 76fbbe53..5e82ecf8 100644 --- a/src/services/configService.ts +++ b/src/services/configService.ts @@ -416,16 +416,16 @@ async function pruneBackups(projectName?: string): Promise { /** * 在更新前备份配置文件到 cache/config_backup/。 * - * 更新是高风险操作,这里不受滚动备份的 1 天间隔限制,每次都写; + * 配置位于数据目录,更新是高风险操作,这里不受滚动备份的 1 天间隔限制,每次都写; * 多出来的同日条目由按份数裁剪消化。 */ export async function backupConfigBeforeUpdate( - basePath: string, + dataPath: string, projectName?: string, ): Promise { if (!isTauri()) return; - const configPath = getConfigPathSync(basePath, projectName); + const configPath = getConfigPathSync(dataPath, projectName); try { const { exists, readTextFile } = await import('@tauri-apps/plugin-fs'); diff --git a/src/services/updateService.ts b/src/services/updateService.ts index 3949b7a9..03414462 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -37,14 +37,14 @@ export function hasInstallFailedInSession(): boolean { } /** - * 将文件移动到 cache/old 文件夹(调用 Rust 端统一实现) + * 将更新包归档到数据目录的 cache/old 文件夹(调用 Rust 端统一实现) */ -async function moveToOldFolder(filePath: string): Promise { +async function moveUpdatePackageToOld(filePath: string): Promise { try { - await invoke('move_file_to_old', { filePath }); - log.info(`已移动到 cache/old: ${filePath}`); + await invoke('move_update_package_to_old', { filePath }); + log.info(`更新包已归档到数据目录 cache/old: ${filePath}`); } catch (error) { - log.warn(`移动文件到 cache/old 失败: ${filePath}`, error); + log.warn(`归档更新包失败: ${filePath}`, error); } } @@ -970,6 +970,7 @@ interface ChangesJson { export interface InstallUpdateOptions { zipPath: string; // 下载的更新包路径 targetDir: string; // 目标安装目录 + dataPath: string; // 数据目录(配置备份来源) newVersion: string; // 新版本号(用于兜底时创建文件夹) projectName?: string; // 项目名称(用于备份配置文件) onProgress?: (stage: string, detail?: string) => void; @@ -1063,7 +1064,7 @@ export async function installUpdate(options: InstallUpdateOptions): Promise ${targetDir}`); @@ -1082,7 +1083,7 @@ export async function installUpdate(options: InstallUpdateOptions): Promise {}); - // 清理下载的 zip 文件 - await moveToOldFolder(zipPath); + // 归档下载的 zip 文件 + await moveUpdatePackageToOld(zipPath); // 抛出特殊错误,告知用户可以使用兜底文件夹 throw new FallbackUpdateError(