Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 相关资源

Expand Down
18 changes: 18 additions & 0 deletions docs/rust-features.md
Original file line number Diff line number Diff line change
@@ -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 条件编译。
5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [] }

Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/commands/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// 进度上报任务的守卫,在函数任意返回路径上都能确保发送停止信号
Expand Down Expand Up @@ -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 线程)
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/commands/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,8 @@ pub fn get_exe_dir() -> Result<String, String> {

/// 获取应用数据目录路径
/// - macOS: ~/Library/Application Support/MXU/
/// - Windows/Linux: exe 所在目录
/// - Linux(`xdg-data-dir` feature): $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名}
/// - 其他: exe 所在目录
#[tauri::command]
pub fn get_data_dir() -> Result<String, String> {
let data_dir = get_app_data_dir()?;
Expand Down
25 changes: 17 additions & 8 deletions src-tauri/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());

Expand Down Expand Up @@ -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<PathBuf, String> {
old_dir_for_executable(&get_exe_directory()?, get_app_data_dir)
}
Expand All @@ -285,6 +288,11 @@ fn old_dir_for_executable(
Ok(old_dir)
}

/// 获取数据目录 cache/old 路径(更新包归档)
pub fn get_data_old_dir() -> Result<PathBuf, String> {
Ok(get_app_data_dir()?.join("cache").join("old"))
}

/// 清空 cache/old 目录,返回 (成功数, 失败数)。
/// 删不掉的文件(如仍被旧进程占用)会保留,后续移动遇到重名时自动加后缀
fn clear_old_folder(old_dir: &Path) -> (usize, usize) {
Expand All @@ -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<Option<PathBuf>, 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<Option<PathBuf>, String> {
ensure_update_path(source)?;
// 只有确认不存在才跳过;其他错误(如权限不足)不能当作不存在,否则回滚时无从还原
Expand Down
129 changes: 126 additions & 3 deletions src-tauri/src/commands/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-dir` feature): $XDG_DATA_HOME/{项目名},默认 ~/.local/share/{项目名}
/// - 其他: exe 所在目录(保持便携式部署)
pub fn get_app_data_dir() -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
{
Expand All @@ -246,13 +247,70 @@ pub fn get_app_data_dir() -> Result<PathBuf, String> {
Ok(path)
}

#[cfg(not(target_os = "macos"))]
#[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) {
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);
Comment thread
mx6436 marked this conversation as resolved.
Resolved
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",
all(target_os = "linux", feature = "xdg-data-dir")
)))]
{
// Windows/Linux 保持便携式,使用 exe 所在目录
get_exe_directory()
}
}

#[cfg(all(target_os = "linux", feature = "xdg-data-dir"))]
fn linux_data_dir(
interface_content: Option<&str>,
xdg_data_home: Option<&std::path::Path>,
home: Option<&std::path::Path>,
) -> Result<PathBuf, String> {
use std::path::{Component, Path};

let interface = interface_content
.map(super::app_config::parse_jsonc)
Comment thread
mx6436 marked this conversation as resolved.
Resolved
.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))
Comment thread
mx6436 marked this conversation as resolved.
Resolved
Comment thread
mx6436 marked this conversation as resolved.
Resolved
Comment thread
mx6436 marked this conversation as resolved.
Resolved
}
Comment thread
mx6436 marked this conversation as resolved.
Resolved
Comment thread
mx6436 marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved

/// 规范化路径:移除冗余的 `.`、处理 `..`、统一分隔符
/// 使用 Path::components() 解析,不需要路径实际存在
pub fn normalize_path(path: &str) -> PathBuf {
Expand Down Expand Up @@ -466,3 +524,68 @@ pub fn build_launch_command(

cmd
}

#[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};

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());
}
}
43 changes: 29 additions & 14 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
// 下载命令
Expand Down
Loading
Loading