From e8dbf2019adff5ec61087cbc5a1d543d6bb3dee2 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Sun, 20 Sep 2026 09:58:25 +0800 Subject: [PATCH 01/27] =?UTF-8?q?style:=20cargo=20fmt=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=EF=BC=8C=E4=BF=AE=E5=A4=8D=20docker?= =?UTF-8?q?::tests=20=E6=A8=A1=E5=9D=97=E5=B5=8C=E5=A5=97=E5=91=BD?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/mod.rs 将 tests 模块改为 #[cfg(test)] 内部模块声明 - 消除 clippy module_inception 警告,为 CI 门禁做准备 --- src-tauri/build.rs | 2 +- src-tauri/src/commands/backup.rs | 30 +- src-tauri/src/commands/docker.rs | 25 +- src-tauri/src/commands/env_config.rs | 1189 ++++++++++++----- src-tauri/src/commands/mirror.rs | 31 +- src-tauri/src/commands/mod.rs | 4 +- src-tauri/src/commands/workspace.rs | 61 +- src-tauri/src/docker/manager.rs | 122 +- src-tauri/src/docker/mod.rs | 3 +- src-tauri/src/docker/tests.rs | 16 +- src-tauri/src/engine/backup_engine.rs | 87 +- src-tauri/src/engine/backup_manifest.rs | 17 +- src-tauri/src/engine/config_generator.rs | 334 +++-- src-tauri/src/engine/env_parser.rs | 20 +- src-tauri/src/engine/mirror_config.rs | 173 +-- src-tauri/src/engine/mirror_config_manager.rs | 128 +- src-tauri/src/engine/mirror_manager.rs | 44 +- src-tauri/src/engine/mod.rs | 20 +- src-tauri/src/engine/restore_engine.rs | 81 +- src-tauri/src/engine/user_override_manager.rs | 77 +- src-tauri/src/engine/version_manifest.rs | 20 +- src-tauri/src/engine/workspace_manager.rs | 24 +- src-tauri/src/lib.rs | 138 +- src-tauri/src/logging.rs | 20 +- src-tauri/src/main.rs | 2 +- .../integration/backup_restore_integration.rs | 51 +- .../integration/docker_manager_integration.rs | 5 +- .../workspace_commands_integration.rs | 129 +- 28 files changed, 1832 insertions(+), 1021 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 311900d..261851f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build(); + tauri_build::build(); } diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index ea1be90..df231fc 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -52,25 +52,14 @@ pub fn verify_backup(zip_path: String) -> Result { /// 执行环境恢复 #[tauri::command] -pub async fn execute_restore( - zip_path: String, - app_handle: tauri::AppHandle, -) -> Result<(), String> { +pub async fn execute_restore(zip_path: String, app_handle: tauri::AppHandle) -> Result<(), String> { let project_root = get_project_root()?; - let result = RestoreEngine::restore( - &zip_path, - &project_root, - Some(&app_handle), - ) - .await?; + let result = RestoreEngine::restore(&zip_path, &project_root, Some(&app_handle)).await?; if result.success { Ok(()) } else { - Err(format!( - "恢复完成但存在错误:\n{}", - result.errors.join("\n") - )) + Err(format!("恢复完成但存在错误:\n{}", result.errors.join("\n"))) } } @@ -84,13 +73,18 @@ pub fn select_project_folder() -> Result, String> { /// 将绝对路径转换为相对于项目根目录的路径 #[tauri::command] -pub fn convert_to_relative_path(absolute_path: String, is_directory: bool) -> Result { +pub fn convert_to_relative_path( + absolute_path: String, + is_directory: bool, +) -> Result { let project_root = get_project_root()?; let abs_path = std::path::PathBuf::from(&absolute_path); - + // 使用 pathdiff 计算相对路径,它会自动处理跨平台差异(如 Windows 盘符) match pathdiff::diff_paths(&abs_path, &project_root) { - Some(relative) if relative.as_os_str().is_empty() || relative == std::path::PathBuf::from(".") => { + Some(relative) + if relative.as_os_str().is_empty() || relative == std::path::PathBuf::from(".") => + { Err("不能选择项目根目录本身,请选择其子文件或子文件夹".to_string()) } Some(relative) => { @@ -102,7 +96,7 @@ pub fn convert_to_relative_path(absolute_path: String, is_directory: bool) -> Re project_root.display() )); } - + // 统一转换为正斜杠 let normalized = rel_str.replace('\\', "/"); if is_directory { diff --git a/src-tauri/src/commands/docker.rs b/src-tauri/src/commands/docker.rs index f331598..3cc127e 100644 --- a/src-tauri/src/commands/docker.rs +++ b/src-tauri/src/commands/docker.rs @@ -10,7 +10,10 @@ pub async fn check_docker() -> Result<(), String> { pub async fn list_containers() -> Result, String> { check_docker().await?; let manager = DockerManager::new().map_err(|e| e.to_string())?; - manager.list_ps_containers().await.map_err(|e| e.to_string()) + manager + .list_ps_containers() + .await + .map_err(|e| e.to_string()) } /// 获取所有运行中的容器(用于端口冲突检测) @@ -18,26 +21,38 @@ pub async fn list_containers() -> Result, String> { pub async fn list_all_running_containers() -> Result, String> { check_docker().await?; let manager = DockerManager::new().map_err(|e| e.to_string())?; - manager.list_all_running_containers().await.map_err(|e| e.to_string()) + manager + .list_all_running_containers() + .await + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn start_container(name: String) -> Result<(), String> { check_docker().await?; let manager = DockerManager::new().map_err(|e| e.to_string())?; - manager.start_container(&name).await.map_err(|e| e.to_string()) + manager + .start_container(&name) + .await + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn stop_container(name: String) -> Result<(), String> { check_docker().await?; let manager = DockerManager::new().map_err(|e| e.to_string())?; - manager.stop_container(&name).await.map_err(|e| e.to_string()) + manager + .stop_container(&name) + .await + .map_err(|e| e.to_string()) } #[tauri::command] pub async fn restart_container(name: String) -> Result<(), String> { check_docker().await?; let manager = DockerManager::new().map_err(|e| e.to_string())?; - manager.restart_container(&name).await.map_err(|e| e.to_string()) + manager + .restart_container(&name) + .await + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/env_config.rs b/src-tauri/src/commands/env_config.rs index 073cad2..89f433d 100644 --- a/src-tauri/src/commands/env_config.rs +++ b/src-tauri/src/commands/env_config.rs @@ -1,6 +1,6 @@ use crate::docker::manager::DockerManager; use crate::engine::config_generator::{ConfigGenerator, EnvConfig}; -use crate::engine::version_manifest::{VersionManifest, ServiceType as VmServiceType}; +use crate::engine::version_manifest::{ServiceType as VmServiceType, VersionManifest}; use super::get_project_root; @@ -8,34 +8,44 @@ use super::get_project_root; /// /// 使用 `docker compose logs --tail 100` 获取最近日志, /// 通过 skip_lines 跳过已处理的行,只返回新增内容。 -fn get_compose_logs(project_root: &std::path::Path, skip_lines: usize) -> Result<(Vec, usize), String> { +fn get_compose_logs( + project_root: &std::path::Path, + skip_lines: usize, +) -> Result<(Vec, usize), String> { use std::process::Command; - + let mut logs_cmd = Command::new("docker"); - logs_cmd.args(["compose", "logs", "--tail", "100"]) + logs_cmd + .args(["compose", "logs", "--tail", "100"]) .current_dir(project_root); - + #[cfg(windows)] { use std::os::windows::process::CommandExt; logs_cmd.creation_flags(0x08000200); } - - let output = logs_cmd.output().map_err(|e| format!("执行 docker compose logs 失败: {e}"))?; - + + let output = logs_cmd + .output() + .map_err(|e| format!("执行 docker compose logs 失败: {e}"))?; + if !output.status.success() { - return Err(format!("docker compose logs 退出码: {:?}", output.status.code())); + return Err(format!( + "docker compose logs 退出码: {:?}", + output.status.code() + )); } - + let stdout = String::from_utf8_lossy(&output.stdout); let all_lines: Vec<&str> = stdout.lines().collect(); let total_count = all_lines.len(); - - let new_lines: Vec = all_lines.iter() + + let new_lines: Vec = all_lines + .iter() .skip(skip_lines) .map(|s| s.to_string()) .collect(); - + Ok((new_lines, total_count)) } @@ -48,40 +58,40 @@ fn parse_docker_progress(line: &str) -> Option { return Some(format!("正在拉取镜像: {}", service)); } } - + // 下载进度: php Pulled if line.contains("Pulled") { if let Some(service) = extract_service_name(line, "Pulled") { return Some(format!("✅ 镜像拉取完成: {}", service)); } } - + // 构建镜像: Building php if line.contains("Building") { if let Some(service) = extract_service_name(line, "Building") { return Some(format!("🔨 正在构建镜像: {}", service)); } } - + // 创建容器: Creating ps-php-1 ... done if line.contains("Creating") { if let Some(service) = extract_service_name(line, "Creating") { return Some(format!("📦 正在创建容器: {}", service)); } } - + // 启动容器: Starting ps-php-1 ... done if line.contains("Starting") { if let Some(service) = extract_service_name(line, "Starting") { return Some(format!("🚀 正在启动服务: {}", service)); } } - + // 容器已存在: Container ps-php-1 is running if line.contains("is running") || line.contains("Up to date") { return Some("⚡ 服务已在运行".to_string()); } - + None } @@ -115,25 +125,25 @@ pub fn load_existing_config() -> Result, String> { let project_root = get_project_root()?; let env_path = project_root.join(".env"); let compose_path = project_root.join("docker-compose.yml"); - + // 如果两个文件都不存在,返回 None if !env_path.exists() || !compose_path.exists() { return Ok(None); } - + // 读取 .env 文件 - let env_content = std::fs::read_to_string(&env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))?; + let env_content = + std::fs::read_to_string(&env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))?; let env_file = crate::engine::env_parser::EnvFile::parse(&env_content) .map_err(|e| format!("解析 .env 文件失败: {e}"))?; let env_map = env_file.to_map(); - + // 解析服务配置 let mut services: Vec = Vec::new(); - + // 创建 VersionManifest 用于 env prefix 反查 let manifest = VersionManifest::new(); - + // 解析 PHP 服务(支持多版本) // 查找所有 PHPxx_VERSION 格式的键 for key in env_map.keys() { @@ -141,26 +151,29 @@ pub fn load_existing_config() -> Result, String> { // 提取前缀,如 PHP82_VERSION → PHP82 let prefix = &key[..key.len() - 8]; // 去掉 "_VERSION" let ver_part = &key[3..key.len() - 8]; // 去掉 "PHP" 和 "_VERSION" - + if ver_part.is_empty() { continue; } - + // 使用 manifest 反查 ID,如 "PHP82" → "php82" - let version = manifest.find_entry_by_env_prefix(&VmServiceType::Php, prefix) + let version = manifest + .find_entry_by_env_prefix(&VmServiceType::Php, prefix) .map(|(id, _)| id.clone()) .unwrap_or_else(|| prefix.to_lowercase()); - + let port_key = format!("PHP{ver_part}_HOST_PORT"); let ext_key = format!("PHP{ver_part}_EXTENSIONS"); - - let host_port = env_map.get(&port_key) + + let host_port = env_map + .get(&port_key) .and_then(|p| p.parse::().ok()) .unwrap_or(9000); - - let extensions = env_map.get(&ext_key) + + let extensions = env_map + .get(&ext_key) .map(|exts| exts.split(',').map(|s| s.trim().to_string()).collect()); - + services.push(crate::engine::config_generator::ServiceEntry { service_type: crate::engine::config_generator::ServiceType::PHP, version, @@ -169,61 +182,70 @@ pub fn load_existing_config() -> Result, String> { }); } } - + // 解析 MySQL 服务(支持多版本) let mut mysql_index = 0; for key in env_map.keys() { - if key.ends_with("_VERSION") && key.starts_with("MYSQL") && !key.contains("ROOT") && !key.contains("USER") && !key.contains("PASSWORD") { + if key.ends_with("_VERSION") + && key.starts_with("MYSQL") + && !key.contains("ROOT") + && !key.contains("USER") + && !key.contains("PASSWORD") + { let prefix = &key[..key.len() - 8]; let index_part = &key[5..key.len() - 8]; - + if index_part.is_empty() { continue; } - + let idx = index_part.parse::().unwrap_or(mysql_index); - - let version = manifest.find_entry_by_env_prefix(&VmServiceType::Mysql, prefix) + + let version = manifest + .find_entry_by_env_prefix(&VmServiceType::Mysql, prefix) .map(|(id, _)| id.clone()) .unwrap_or_else(|| prefix.to_lowercase()); - + let port_key = format!("MYSQL{index_part}_HOST_PORT"); - - let host_port = env_map.get(&port_key) + + let host_port = env_map + .get(&port_key) .and_then(|p| p.parse::().ok()) .unwrap_or(3306 + idx as u16); - + services.push(crate::engine::config_generator::ServiceEntry { service_type: crate::engine::config_generator::ServiceType::MySQL, version, host_port, extensions: None, }); - + mysql_index += 1; } } - + // 解析 Redis 服务(支持多版本) for key in env_map.keys() { if key.ends_with("_VERSION") && key.starts_with("REDIS") { let prefix = &key[..key.len() - 8]; let index_part = &key[5..key.len() - 8]; - + if index_part.is_empty() { continue; } - - let version = manifest.find_entry_by_env_prefix(&VmServiceType::Redis, prefix) + + let version = manifest + .find_entry_by_env_prefix(&VmServiceType::Redis, prefix) .map(|(id, _)| id.clone()) .unwrap_or_else(|| prefix.to_lowercase()); - + let port_key = format!("REDIS{index_part}_HOST_PORT"); - - let host_port = env_map.get(&port_key) + + let host_port = env_map + .get(&port_key) .and_then(|p| p.parse::().ok()) .unwrap_or(6379); - + services.push(crate::engine::config_generator::ServiceEntry { service_type: crate::engine::config_generator::ServiceType::Redis, version, @@ -232,27 +254,29 @@ pub fn load_existing_config() -> Result, String> { }); } } - + // 解析 Nginx 服务(支持多版本) for key in env_map.keys() { if key.ends_with("_VERSION") && key.starts_with("NGINX") { let prefix = &key[..key.len() - 8]; let index_part = &key[6..key.len() - 8]; // NGINX 是 5 个字母 + 1 = 6 - + if index_part.is_empty() { continue; } - - let version = manifest.find_entry_by_env_prefix(&VmServiceType::Nginx, prefix) + + let version = manifest + .find_entry_by_env_prefix(&VmServiceType::Nginx, prefix) .map(|(id, _)| id.clone()) .unwrap_or_else(|| prefix.to_lowercase()); - + let port_key = format!("NGINX{index_part}_HTTP_HOST_PORT"); - - let host_port = env_map.get(&port_key) + + let host_port = env_map + .get(&port_key) .and_then(|p| p.parse::().ok()) .unwrap_or(80); - + services.push(crate::engine::config_generator::ServiceEntry { service_type: crate::engine::config_generator::ServiceType::Nginx, version, @@ -261,20 +285,22 @@ pub fn load_existing_config() -> Result, String> { }); } } - + // 如果没有解析到任何服务,返回 None if services.is_empty() { return Ok(None); } - - let source_dir = env_map.get("SOURCE_DIR") + + let source_dir = env_map + .get("SOURCE_DIR") .cloned() .unwrap_or_else(|| "./www".to_string()); - let timezone = env_map.get("TZ") + let timezone = env_map + .get("TZ") .cloned() .unwrap_or_else(|| "Asia/Shanghai".to_string()); let mysql_root_password = env_map.get("MYSQL_ROOT_PASSWORD").cloned(); - + Ok(Some(EnvConfig { services, source_dir, @@ -303,67 +329,140 @@ pub fn preview_compose(config: EnvConfig) -> Result { pub fn check_config_files_exist() -> Result, String> { let project_root = get_project_root()?; let mut existing_files = Vec::new(); - + // 检查关键文件 let files_to_check = [ (".env", "环境配置文件"), ("docker-compose.yml", "Docker Compose 配置"), ]; - + for (filename, description) in &files_to_check { let file_path = project_root.join(filename); if file_path.exists() { existing_files.push(format!("{filename} ({description})")); } } - + // 检查 services 目录 let services_dir = project_root.join("services"); if services_dir.exists() { existing_files.push("services/ (服务配置目录)".to_string()); } - + Ok(existing_files) } /// 应用配置(写入 .env、docker-compose.yml、创建目录) #[tauri::command] -pub async fn apply_env_config(config: EnvConfig, enable_backup: bool, app_handle: tauri::AppHandle) -> Result, String> { - use tauri::Emitter; +pub async fn apply_env_config( + config: EnvConfig, + enable_backup: bool, + app_handle: tauri::AppHandle, +) -> Result, String> { use crate::ui_log; - - ui_log!(app_handle, info, "commands::apply_env_config", "📝 开始应用配置..."); - + use tauri::Emitter; + + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "📝 开始应用配置..." + ); + let project_root = get_project_root()?; - ui_log!(app_handle, info, "commands::apply_env_config", "📁 项目根目录: {:?}", project_root); - + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "📁 项目根目录: {:?}", + project_root + ); + // 检查用户覆盖配置 let overrides_path = project_root.join(".user_version_overrides.json"); if overrides_path.exists() { - ui_log!(app_handle, info, "commands::apply_env_config", "✅ 检测到用户版本覆盖配置"); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "✅ 检测到用户版本覆盖配置" + ); } else { - ui_log!(app_handle, info, "commands::apply_env_config", "ℹ️ 未找到用户覆盖配置,使用默认配置"); - } - - ui_log!(app_handle, info, "commands::apply_env_config", "🔧 验证配置..."); - ui_log!(app_handle, info, "commands::apply_env_config", "📄 生成 .env 文件..."); - ui_log!(app_handle, info, "commands::apply_env_config", "🐳 生成 docker-compose.yml..."); - ui_log!(app_handle, info, "commands::apply_env_config", "📂 创建服务目录结构..."); - + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "ℹ️ 未找到用户覆盖配置,使用默认配置" + ); + } + + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "🔧 验证配置..." + ); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "📄 生成 .env 文件..." + ); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "🐳 生成 docker-compose.yml..." + ); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "📂 创建服务目录结构..." + ); + match ConfigGenerator::apply(&config, &project_root, enable_backup).await { Ok(backed_up_files) => { if !backed_up_files.is_empty() { - ui_log!(app_handle, info, "commands::apply_env_config", "💾 已备份 {} 个文件/目录", backed_up_files.len()); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "💾 已备份 {} 个文件/目录", + backed_up_files.len() + ); for file in &backed_up_files { - ui_log!(app_handle, info, "commands::apply_env_config", " - {}", file); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + " - {}", + file + ); } } - ui_log!(app_handle, info, "commands::apply_env_config", "✅ 配置应用成功!"); - ui_log!(app_handle, info, "commands::apply_env_config", "💡 提示:请重启容器使新配置生效"); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "✅ 配置应用成功!" + ); + ui_log!( + app_handle, + info, + "commands::apply_env_config", + "💡 提示:请重启容器使新配置生效" + ); Ok(backed_up_files) } Err(e) => { - ui_log!(app_handle, error, "commands::apply_env_config", "❌ 配置应用失败: {}", e); + ui_log!( + app_handle, + error, + "commands::apply_env_config", + "❌ 配置应用失败: {}", + e + ); Err(e) } } @@ -372,199 +471,372 @@ pub async fn apply_env_config(config: EnvConfig, enable_backup: bool, app_handle /// 一键启动环境(docker compose up -d) #[tauri::command] pub async fn start_environment(app_handle: tauri::AppHandle) -> Result { + use crate::ui_log; use std::process::Command; use tauri::Emitter; - use crate::ui_log; - - ui_log!(app_handle, info, "commands::start_environment", "🚀 开始启动环境..."); - + + ui_log!( + app_handle, + info, + "commands::start_environment", + "🚀 开始启动环境..." + ); + let project_root = get_project_root()?; - ui_log!(app_handle, info, "commands::start_environment", "📁 项目根目录: {:?}", project_root); - + ui_log!( + app_handle, + info, + "commands::start_environment", + "📁 项目根目录: {:?}", + project_root + ); + let compose_file = project_root.join("docker-compose.yml"); - + if !compose_file.exists() { - ui_log!(app_handle, info, "commands::start_environment", "❌ docker-compose.yml 文件不存在"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ docker-compose.yml 文件不存在" + ); return Err("docker-compose.yml 文件不存在,请先应用配置".to_string()); } - - ui_log!(app_handle, info, "commands::start_environment", "✅ docker-compose.yml 存在"); - + + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ docker-compose.yml 存在" + ); + // 第一步:清理旧容器(避免名称冲突) - ui_log!(app_handle, info, "commands::start_environment", "🧹 清理旧容器..."); + ui_log!( + app_handle, + info, + "commands::start_environment", + "🧹 清理旧容器..." + ); let mut down_cmd = Command::new("docker"); - down_cmd.args(["compose", "down", "--remove-orphans"]) + down_cmd + .args(["compose", "down", "--remove-orphans"]) .current_dir(&project_root); - + #[cfg(windows)] { use std::os::windows::process::CommandExt; // CREATE_NEW_PROCESS_GROUP (0x00000200) | CREATE_NO_WINDOW (0x08000000) down_cmd.creation_flags(0x08000200); } - + let down_output = down_cmd.output().map_err(|e| { - let err_msg = format!("清理旧容器失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "⚠️ {}", err_msg); + let err_msg = format!("清理旧容器失败: {e}"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "⚠️ {}", err_msg - })?; - + ); + err_msg + })?; + if !down_output.status.success() { let stderr = String::from_utf8_lossy(&down_output.stderr); - ui_log!(app_handle, warn, "commands::start_environment", "清理警告: {}", stderr.lines().next().unwrap_or("")); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "清理警告: {}", + stderr.lines().next().unwrap_or("") + ); } else { - ui_log!(app_handle, info, "commands::start_environment", "✅ 旧容器已清理"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 旧容器已清理" + ); } - + // 等待 ps- 前缀的容器完全停止(最多等待 10 秒) - ui_log!(app_handle, info, "commands::start_environment", "⏳ 等待容器完全停止..."); + ui_log!( + app_handle, + info, + "commands::start_environment", + "⏳ 等待容器完全停止..." + ); let manager = DockerManager::new().map_err(|e| { let err_msg = format!("创建 Docker 管理器失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "❌ {}", err_msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ {}", + err_msg + ); err_msg })?; - + for attempt in 1..=10 { let ps_containers = manager.list_ps_containers().await.map_err(|e| { let err_msg = format!("检查容器状态失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "❌ {}", err_msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ {}", + err_msg + ); err_msg })?; - + // 过滤出仍在运行的 ps- 容器 - let running_ps_containers: Vec<_> = ps_containers.iter() - .filter(|c| c.state.to_lowercase().contains("running") || - c.state.to_lowercase().contains("up")) + let running_ps_containers: Vec<_> = ps_containers + .iter() + .filter(|c| { + c.state.to_lowercase().contains("running") || c.state.to_lowercase().contains("up") + }) .collect(); - + if running_ps_containers.is_empty() { - ui_log!(app_handle, info, "commands::start_environment", "✅ 所有 ps- 容器已完全停止"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 所有 ps- 容器已完全停止" + ); break; } - + if attempt == 10 { - ui_log!(app_handle, warn, "commands::start_environment", "等待超时,仍有 {} 个容器未停止", running_ps_containers.len()); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "等待超时,仍有 {} 个容器未停止", + running_ps_containers.len() + ); for container in &running_ps_containers { - ui_log!(app_handle, info, "commands::start_environment", " - {} ({})", container.name, container.state); + ui_log!( + app_handle, + info, + "commands::start_environment", + " - {} ({})", + container.name, + container.state + ); } } else { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } ui_log!(app_handle, info, "commands::start_environment", ""); - + // 第二步:端口冲突检测 - ui_log!(app_handle, info, "commands::start_environment", "🔍 检查端口冲突..."); - + ui_log!( + app_handle, + info, + "commands::start_environment", + "🔍 检查端口冲突..." + ); + // 获取所有运行中的容器 let manager = DockerManager::new().map_err(|e| { let err_msg = format!("创建 Docker 管理器失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "❌ {}", err_msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ {}", + err_msg + ); err_msg })?; - + let all_containers = manager.list_all_running_containers().await.map_err(|e| { let err_msg = format!("获取容器列表失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "❌ {}", err_msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ {}", + err_msg + ); err_msg })?; - + // 加载配置并检查端口 let config_result = load_existing_config()?; if let Some(config) = config_result { let mut conflicts: Vec<(String, u16, String)> = Vec::new(); - + for service in &config.services { let port = service.host_port; - let service_name = format!("{:?}{}", service.service_type, service.version.replace('.', "")); - + let service_name = format!( + "{:?}{}", + service.service_type, + service.version.replace('.', "") + ); + // 检查是否有容器占用了这个端口 for container in &all_containers { if container.ports.contains(&(port as i32)) { - conflicts.push(( - container.name.clone(), - port, - service_name.clone(), - )); + conflicts.push((container.name.clone(), port, service_name.clone())); break; } } } - + if !conflicts.is_empty() { - ui_log!(app_handle, info, "commands::start_environment", "❌ 检测到端口冲突!"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ 检测到端口冲突!" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - + for (container_name, port, service_name) in &conflicts { - ui_log!(app_handle, info, "commands::start_environment", " ❌ 端口 {} ({}) 被容器 {} 占用", port, service_name, container_name); + ui_log!( + app_handle, + info, + "commands::start_environment", + " ❌ 端口 {} ({}) 被容器 {} 占用", + port, + service_name, + container_name + ); } - + ui_log!(app_handle, info, "commands::start_environment", ""); - ui_log!(app_handle, info, "commands::start_environment", "💡 解决方案:"); - ui_log!(app_handle, info, "commands::start_environment", " • 停止冲突容器: docker stop <容器名>"); - ui_log!(app_handle, info, "commands::start_environment", " • 或删除冲突容器: docker rm <容器名>"); - ui_log!(app_handle, info, "commands::start_environment", " • 或在环境配置中修改为其他端口"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "💡 解决方案:" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " • 停止冲突容器: docker stop <容器名>" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " • 或删除冲突容器: docker rm <容器名>" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " • 或在环境配置中修改为其他端口" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - ui_log!(app_handle, info, "commands::start_environment", "⚠️ 请在前端解决冲突后重新启动"); - + ui_log!( + app_handle, + info, + "commands::start_environment", + "⚠️ 请在前端解决冲突后重新启动" + ); + // 返回错误,终止后续流程 - let conflict_details: Vec = conflicts.iter() + let conflict_details: Vec = conflicts + .iter() .map(|(name, port, service)| format!("端口 {port} ({service}) 被容器 {name} 占用")) .collect(); - + return Err(format!("PORT_CONFLICT:{}", conflict_details.join("; "))); } else { - ui_log!(app_handle, info, "commands::start_environment", "✅ 没有检测到端口冲突"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 没有检测到端口冲突" + ); } } else { - ui_log!(app_handle, info, "commands::start_environment", "⚠️ 未找到配置文件,跳过端口检查"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "⚠️ 未找到配置文件,跳过端口检查" + ); } ui_log!(app_handle, info, "commands::start_environment", ""); - + // 第二步:后台启动容器(spawn 模式,实时读取 build/pull 进度) - ui_log!(app_handle, info, "commands::start_environment", "🔧 执行: docker compose up -d"); - ui_log!(app_handle, info, "commands::start_environment", "⏳ 首次启动可能需要几分钟(下载镜像、安装扩展)..."); + ui_log!( + app_handle, + info, + "commands::start_environment", + "🔧 执行: docker compose up -d" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + "⏳ 首次启动可能需要几分钟(下载镜像、安装扩展)..." + ); ui_log!(app_handle, info, "commands::start_environment", ""); - + let mut compose_cmd = Command::new("docker"); - compose_cmd.args(["compose", "up", "-d"]) + compose_cmd + .args(["compose", "up", "-d"]) .current_dir(&project_root) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - + #[cfg(windows)] { use std::os::windows::process::CommandExt; compose_cmd.creation_flags(0x08000200); } - + // 使用 spawn 而非 output,这样 build/pull 过程中可以实时读取 stderr 进度 let mut child = compose_cmd.spawn().map_err(|e| { let err_msg = format!("执行 docker compose 失败: {e}"); - ui_log!(app_handle, info, "commands::start_environment", "❌ {}", err_msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ {}", + err_msg + ); err_msg })?; - + let stdout_opt = child.stdout.take(); let stderr_opt = child.stderr.take(); let stderr_lines_shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - + // stdout 线程:读取容器创建信息 let app_stdout = app_handle.clone(); - let stdout_thread = stdout_opt.map(|stdout| std::thread::spawn(move || { + let stdout_thread = stdout_opt.map(|stdout| { + std::thread::spawn(move || { use std::io::{BufRead, BufReader}; let reader = BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { if !line.is_empty() { - ui_log!(&app_stdout, info, "commands::start_environment", " {}", line); + ui_log!( + &app_stdout, + info, + "commands::start_environment", + " {}", + line + ); } } - })); - + }) + }); + // stderr 线程:读取 build/pull 进度(Docker Compose 的进度信息输出到 stderr) let app_stderr = app_handle.clone(); let stderr_lines_clone = stderr_lines_shared.clone(); - let stderr_thread = stderr_opt.map(|stderr| std::thread::spawn(move || { + let stderr_thread = stderr_opt.map(|stderr| { + std::thread::spawn(move || { use std::io::{BufRead, BufReader}; let reader = BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { @@ -572,102 +844,255 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "🔧 解决方案:" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 方案 1: 停止占用端口的容器" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " docker ps # 查看运行中的容器" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " docker stop <容器名>" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - ui_log!(app_handle, info, "commands::start_environment", " 方案 2: 修改 .env 文件中的端口配置"); - ui_log!(app_handle, info, "commands::start_environment", " 例如:MYSQL_PORT=3307 (改为其他端口)"); - ui_log!(app_handle, info, "commands::start_environment", " 然后重新应用配置"); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 方案 2: 修改 .env 文件中的端口配置" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 例如:MYSQL_PORT=3307 (改为其他端口)" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 然后重新应用配置" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - ui_log!(app_handle, info, "commands::start_environment", " 方案 3: 停止本地服务"); - ui_log!(app_handle, info, "commands::start_environment", " 检查是否有本地 MySQL/Nginx/Redis 在运行"); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 方案 3: 停止本地服务" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 检查是否有本地 MySQL/Nginx/Redis 在运行" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - + if let Some(line) = collected_stderr.lines().find(|l| l.contains("Bind for")) { - ui_log!(app_handle, info, "commands::start_environment", "📍 详细信息: {}", line.trim()); + ui_log!( + app_handle, + info, + "commands::start_environment", + "📍 详细信息: {}", + line.trim() + ); } } else { - ui_log!(app_handle, info, "commands::start_environment", "❌ Docker Compose 启动失败,退出码: {:?}", exit_code); + ui_log!( + app_handle, + info, + "commands::start_environment", + "❌ Docker Compose 启动失败,退出码: {:?}", + exit_code + ); ui_log!(app_handle, info, "commands::start_environment", ""); - ui_log!(app_handle, info, "commands::start_environment", "💡 建议检查:"); - ui_log!(app_handle, info, "commands::start_environment", " 1. Docker Desktop 是否正常运行"); - ui_log!(app_handle, info, "commands::start_environment", " 2. docker-compose.yml 文件格式是否正确"); - ui_log!(app_handle, info, "commands::start_environment", " 3. 镜像是否存在或网络是否正常"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "💡 建议检查:" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 1. Docker Desktop 是否正常运行" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 2. docker-compose.yml 文件格式是否正确" + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + " 3. 镜像是否存在或网络是否正常" + ); } - - let err_msg = format!("Docker Compose 启动失败: {}", - if is_port_conflict { "端口冲突" } else { "未知错误" }); + + let err_msg = format!( + "Docker Compose 启动失败: {}", + if is_port_conflict { + "端口冲突" + } else { + "未知错误" + } + ); return Err(err_msg); } - - ui_log!(app_handle, info, "commands::start_environment", "✅ 容器已在后台启动"); + + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 容器已在后台启动" + ); ui_log!(app_handle, info, "commands::start_environment", ""); - + // 第三步:轮询容器运行日志 + 检查就绪状态 - ui_log!(app_handle, info, "commands::start_environment", "📜 开始监控容器启动日志..."); - + ui_log!( + app_handle, + info, + "commands::start_environment", + "📜 开始监控容器启动日志..." + ); + let docker_manager = match DockerManager::new() { Ok(manager) => manager, Err(e) => { - ui_log!(app_handle, warn, "commands::start_environment", "⚠️ 无法创建 DockerManager: {}", e); - ui_log!(app_handle, info, "commands::start_environment", "⚠️ 跳过日志监控,直接返回"); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "⚠️ 无法创建 DockerManager: {}", + e + ); + ui_log!( + app_handle, + info, + "commands::start_environment", + "⚠️ 跳过日志监控,直接返回" + ); return Ok("环境启动成功(未检查容器状态)".to_string()); } }; - + let mut last_line_count: usize = 0; let mut consecutive_failures: u32 = 0; let max_consecutive_failures = 10; let start_time = std::time::Instant::now(); let max_wait = std::time::Duration::from_secs(300); - + loop { if start_time.elapsed() > max_wait { - ui_log!(app_handle, warn, "commands::start_environment", "⚠️ 等待容器就绪超时(5 分钟),跳过日志监控"); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "⚠️ 等待容器就绪超时(5 分钟),跳过日志监控" + ); break; } - + // 1. 获取最新的容器日志 match get_compose_logs(&project_root, last_line_count) { Ok((new_lines, total_count)) => { @@ -675,9 +1100,21 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result Result { consecutive_failures += 1; if consecutive_failures >= max_consecutive_failures { - ui_log!(app_handle, warn, "commands::start_environment", - "⚠️ 连续 {} 次获取日志失败,跳过日志监控: {}", - consecutive_failures, e); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "⚠️ 连续 {} 次获取日志失败,跳过日志监控: {}", + consecutive_failures, + e + ); break; } } } - + // 2. 检查所有容器是否就绪 match docker_manager.check_all_ps_containers_running().await { Ok(true) => { - ui_log!(app_handle, info, "commands::start_environment", "✅ 所有容器已就绪"); + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 所有容器已就绪" + ); if let Ok((final_lines, _)) = get_compose_logs(&project_root, last_line_count) { for line in &final_lines { let progress_msg = parse_docker_progress(line); if let Some(msg) = progress_msg { - ui_log!(app_handle, info, "commands::start_environment", "📦 {}", msg); + ui_log!( + app_handle, + info, + "commands::start_environment", + "📦 {}", + msg + ); } else { - ui_log!(app_handle, info, "commands::start_environment", " {}", line); + ui_log!( + app_handle, + info, + "commands::start_environment", + " {}", + line + ); } } } @@ -713,141 +1172,262 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result {} Err(e) => { - ui_log!(app_handle, warn, "commands::start_environment", "⚠️ 检查容器状态失败: {}", e); + ui_log!( + app_handle, + warn, + "commands::start_environment", + "⚠️ 检查容器状态失败: {}", + e + ); } } - + // 3. 每 2 秒检查一次 tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - - ui_log!(app_handle, info, "commands::start_environment", "✅ 环境启动成功!"); + + ui_log!( + app_handle, + info, + "commands::start_environment", + "✅ 环境启动成功!" + ); Ok("环境启动成功".to_string()) } /// 一键重启环境(docker compose restart) #[tauri::command] pub async fn restart_environment(app_handle: tauri::AppHandle) -> Result { + use crate::ui_log; use std::process::Command; use tauri::Emitter; - use crate::ui_log; - - ui_log!(app_handle, info, "commands::restart_environment", "🔄 开始重启环境..."); - + + ui_log!( + app_handle, + info, + "commands::restart_environment", + "🔄 开始重启环境..." + ); + let project_root = get_project_root()?; - ui_log!(app_handle, info, "commands::restart_environment", "📁 项目根目录: {:?}", project_root); - + ui_log!( + app_handle, + info, + "commands::restart_environment", + "📁 项目根目录: {:?}", + project_root + ); + let compose_file = project_root.join("docker-compose.yml"); - + if !compose_file.exists() { - ui_log!(app_handle, info, "commands::restart_environment", "❌ docker-compose.yml 文件不存在"); + ui_log!( + app_handle, + info, + "commands::restart_environment", + "❌ docker-compose.yml 文件不存在" + ); return Err("docker-compose.yml 文件不存在,请先应用配置".to_string()); } - - ui_log!(app_handle, info, "commands::restart_environment", "✅ docker-compose.yml 存在"); - + + ui_log!( + app_handle, + info, + "commands::restart_environment", + "✅ docker-compose.yml 存在" + ); + // 使用 docker compose restart 重启所有容器 - ui_log!(app_handle, info, "commands::restart_environment", "🔧 执行: docker compose restart"); - + ui_log!( + app_handle, + info, + "commands::restart_environment", + "🔧 执行: docker compose restart" + ); + let mut restart_cmd = Command::new("docker"); - restart_cmd.args(["compose", "restart"]) + restart_cmd + .args(["compose", "restart"]) .current_dir(&project_root); - + #[cfg(windows)] { use std::os::windows::process::CommandExt; // CREATE_NEW_PROCESS_GROUP (0x00000200) | CREATE_NO_WINDOW (0x08000000) restart_cmd.creation_flags(0x08000200); } - + let output = restart_cmd.output().map_err(|e| { - let err_msg = format!("执行 docker compose restart 失败: {e}"); - ui_log!(app_handle, info, "commands::restart_environment", "❌ {}", err_msg); + let err_msg = format!("执行 docker compose restart 失败: {e}"); + ui_log!( + app_handle, + info, + "commands::restart_environment", + "❌ {}", err_msg - })?; - + ); + err_msg + })?; + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - + if !output.status.success() { - ui_log!(app_handle, info, "commands::restart_environment", "❌ 重启失败"); - ui_log!(app_handle, info, "commands::restart_environment", "错误输出: {}", stderr); + ui_log!( + app_handle, + info, + "commands::restart_environment", + "❌ 重启失败" + ); + ui_log!( + app_handle, + info, + "commands::restart_environment", + "错误输出: {}", + stderr + ); return Err(format!("Docker Compose 重启失败: {stderr}")); } - + // 记录重启结果 if !stdout.is_empty() { for line in stdout.lines() { if !line.is_empty() { - ui_log!(app_handle, info, "commands::restart_environment", " {}", line); + ui_log!( + app_handle, + info, + "commands::restart_environment", + " {}", + line + ); } } } - - ui_log!(app_handle, info, "commands::restart_environment", "✅ 环境重启成功!"); + + ui_log!( + app_handle, + info, + "commands::restart_environment", + "✅ 环境重启成功!" + ); Ok("环境重启成功".to_string()) } /// 一键停止环境(docker compose stop) #[tauri::command] pub async fn stop_environment(app_handle: tauri::AppHandle) -> Result { + use crate::ui_log; use std::process::Command; use tauri::Emitter; - use crate::ui_log; - - ui_log!(app_handle, info, "commands::stop_environment", "🛑 开始停止环境..."); - + + ui_log!( + app_handle, + info, + "commands::stop_environment", + "🛑 开始停止环境..." + ); + let project_root = get_project_root()?; - ui_log!(app_handle, info, "commands::stop_environment", "📁 项目根目录: {:?}", project_root); - + ui_log!( + app_handle, + info, + "commands::stop_environment", + "📁 项目根目录: {:?}", + project_root + ); + let compose_file = project_root.join("docker-compose.yml"); - + if !compose_file.exists() { - ui_log!(app_handle, info, "commands::stop_environment", "❌ docker-compose.yml 文件不存在"); + ui_log!( + app_handle, + info, + "commands::stop_environment", + "❌ docker-compose.yml 文件不存在" + ); return Err("docker-compose.yml 文件不存在,请先应用配置".to_string()); } - - ui_log!(app_handle, info, "commands::stop_environment", "✅ docker-compose.yml 存在"); - + + ui_log!( + app_handle, + info, + "commands::stop_environment", + "✅ docker-compose.yml 存在" + ); + // 使用 docker compose stop 停止容器(保留容器,前端可显示"已停用"状态) - ui_log!(app_handle, info, "commands::stop_environment", "🔧 执行: docker compose stop"); - + ui_log!( + app_handle, + info, + "commands::stop_environment", + "🔧 执行: docker compose stop" + ); + let mut stop_cmd = Command::new("docker"); - stop_cmd.args(["compose", "stop"]) + stop_cmd + .args(["compose", "stop"]) .current_dir(&project_root); - + #[cfg(windows)] { use std::os::windows::process::CommandExt; // CREATE_NEW_PROCESS_GROUP (0x00000200) | CREATE_NO_WINDOW (0x08000000) stop_cmd.creation_flags(0x08000200); } - + let output = stop_cmd.output().map_err(|e| { - let err_msg = format!("执行 docker compose stop 失败: {e}"); - ui_log!(app_handle, info, "commands::stop_environment", "❌ {}", err_msg); + let err_msg = format!("执行 docker compose stop 失败: {e}"); + ui_log!( + app_handle, + info, + "commands::stop_environment", + "❌ {}", err_msg - })?; - + ); + err_msg + })?; + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - + if !output.status.success() { - ui_log!(app_handle, info, "commands::stop_environment", "❌ 停止失败"); - ui_log!(app_handle, info, "commands::stop_environment", "错误输出: {}", stderr); + ui_log!( + app_handle, + info, + "commands::stop_environment", + "❌ 停止失败" + ); + ui_log!( + app_handle, + info, + "commands::stop_environment", + "错误输出: {}", + stderr + ); return Err(format!("Docker Compose 停止失败: {stderr}")); } - + // 记录停止结果 if !stdout.is_empty() { for line in stdout.lines() { if !line.is_empty() { - ui_log!(app_handle, info, "commands::stop_environment", " {}", line); + ui_log!( + app_handle, + info, + "commands::stop_environment", + " {}", + line + ); } } } - - ui_log!(app_handle, info, "commands::stop_environment", "✅ 环境停止成功!"); + + ui_log!( + app_handle, + info, + "commands::stop_environment", + "✅ 环境停止成功!" + ); Ok("环境停止成功".to_string()) } @@ -861,7 +1441,7 @@ mod tests { // 创建临时目录 let temp_dir = std::env::temp_dir().join("php_stack_test_multi_redis"); fs::create_dir_all(&temp_dir).unwrap(); - + // 创建测试 .env 文件 let env_content = r#"SOURCE_DIR=./www TZ=Asia/Shanghai @@ -871,13 +1451,17 @@ REDIS72_VERSION=7.2-alpine REDIS72_HOST_PORT=6380 "#; fs::write(temp_dir.join(".env"), env_content).unwrap(); - + // 创建空的 docker-compose.yml - fs::write(temp_dir.join("docker-compose.yml"), "version: '3'\nservices: {}\n").unwrap(); - + fs::write( + temp_dir.join("docker-compose.yml"), + "version: '3'\nservices: {}\n", + ) + .unwrap(); + // 临时修改 project_root(这里无法直接测试,因为 get_project_root 是硬编码的) // 所以这个测试主要用于验证解析逻辑 - + // 清理 fs::remove_dir_all(&temp_dir).ok(); } @@ -887,7 +1471,7 @@ REDIS72_HOST_PORT=6380 fn test_load_existing_config_multi_nginx() { let temp_dir = std::env::temp_dir().join("php_stack_test_multi_nginx"); fs::create_dir_all(&temp_dir).unwrap(); - + let env_content = r#"SOURCE_DIR=./www TZ=Asia/Shanghai NGINX127_VERSION=1.27-alpine @@ -896,8 +1480,12 @@ NGINX125_VERSION=1.25-alpine NGINX125_HTTP_HOST_PORT=8080 "#; fs::write(temp_dir.join(".env"), env_content).unwrap(); - fs::write(temp_dir.join("docker-compose.yml"), "version: '3'\nservices: {}\n").unwrap(); - + fs::write( + temp_dir.join("docker-compose.yml"), + "version: '3'\nservices: {}\n", + ) + .unwrap(); + fs::remove_dir_all(&temp_dir).ok(); } @@ -906,7 +1494,7 @@ NGINX125_HTTP_HOST_PORT=8080 fn test_load_existing_config_mixed_services() { let temp_dir = std::env::temp_dir().join("php_stack_test_mixed"); fs::create_dir_all(&temp_dir).unwrap(); - + let env_content = r#"SOURCE_DIR=./www TZ=Asia/Shanghai PHP85_VERSION=8.5 @@ -920,9 +1508,12 @@ NGINX127_VERSION=1.27-alpine NGINX127_HTTP_HOST_PORT=80 "#; fs::write(temp_dir.join(".env"), env_content).unwrap(); - fs::write(temp_dir.join("docker-compose.yml"), "version: '3'\nservices: {}\n").unwrap(); - + fs::write( + temp_dir.join("docker-compose.yml"), + "version: '3'\nservices: {}\n", + ) + .unwrap(); + fs::remove_dir_all(&temp_dir).ok(); } - } diff --git a/src-tauri/src/commands/mirror.rs b/src-tauri/src/commands/mirror.rs index 3ffd551..7825562 100644 --- a/src-tauri/src/commands/mirror.rs +++ b/src-tauri/src/commands/mirror.rs @@ -1,5 +1,5 @@ +use crate::engine::mirror_config_manager::{MergedMirrorCategory, MirrorConfigManager}; use crate::engine::mirror_manager::{MirrorManager as UnifiedMirrorManager, MirrorPreset}; -use crate::engine::mirror_config_manager::{MirrorConfigManager, MergedMirrorCategory}; use super::get_project_root; @@ -32,10 +32,10 @@ pub async fn apply_mirror_preset(preset_name: String) -> Result<(), String> { pub fn update_single_mirror(category: String, source: String) -> Result<(), String> { let project_root = get_project_root()?; let env_path = project_root.join(".env"); - + // 标准化镜像源地址(去除尾部斜杠) let normalized_source = normalize_mirror_url(&source); - + UnifiedMirrorManager::update_single(&category, &normalized_source, &env_path) } @@ -51,8 +51,7 @@ pub fn get_mirror_status() -> Result { let project_root = get_project_root()?; let env_path = project_root.join(".env"); let status = UnifiedMirrorManager::get_current_status(&env_path)?; - serde_json::to_value(&status) - .map_err(|e| format!("序列化镜像源状态失败: {e}")) + serde_json::to_value(&status).map_err(|e| format!("序列化镜像源状态失败: {e}")) } /// 获取当前匹配的预设名称 @@ -74,10 +73,7 @@ pub fn get_merged_mirror_list() -> Result, String> { /// 保存用户选择的镜像源选项 #[tauri::command] -pub fn save_selected_mirror_option( - category_id: String, - option_id: String, -) -> Result<(), String> { +pub fn save_selected_mirror_option(category_id: String, option_id: String) -> Result<(), String> { let project_root = get_project_root()?; MirrorConfigManager::save_selected_option(&project_root, &category_id, &option_id) } @@ -90,21 +86,26 @@ pub fn save_user_mirror_category( description: Option, ) -> Result<(), String> { let project_root = get_project_root()?; - + // 标准化镜像源地址(去除尾部斜杠) let normalized_source = normalize_mirror_url(&source); - - MirrorConfigManager::save_user_category(&project_root, &category_id, &normalized_source, description) + + MirrorConfigManager::save_user_category( + &project_root, + &category_id, + &normalized_source, + description, + ) } /// 删除用户自定义的类别配置 #[tauri::command] pub fn remove_user_mirror_category(category_id: String) -> Result<(), String> { let project_root = get_project_root()?; - + // 1. 从用户配置中删除 MirrorConfigManager::remove_user_category(&project_root, &category_id)?; - + // 2. 同步更新 .env 文件,恢复为默认值 let env_path = project_root.join(".env"); let default_value = match category_id.as_str() { @@ -115,7 +116,7 @@ pub fn remove_user_mirror_category(category_id: String) -> Result<(), String> { "github_proxy" => "", _ => return Err(format!("未知的镜像源类别: {category_id}")), }; - + UnifiedMirrorManager::update_single(&category_id, default_value, &env_path) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fd6d41b..85d90d5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,14 +1,14 @@ +mod backup; mod docker; mod env_config; mod mirror; -mod backup; mod workspace; // Re-export all commands for lib.rs invoke_handler registration +pub use backup::*; pub use docker::*; pub use env_config::*; pub use mirror::*; -pub use backup::*; pub use workspace::*; use crate::engine::workspace_manager::WorkspaceManager; diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index 43fc76f..a1e07f1 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -1,5 +1,5 @@ -use crate::engine::version_manifest::{VersionManifest, ServiceType as VmServiceType}; use crate::engine::user_override_manager::{UserOverrideManager, UserVersionOverride}; +use crate::engine::version_manifest::{ServiceType as VmServiceType, VersionManifest}; use crate::engine::workspace_manager::WorkspaceManager; use super::get_project_root; @@ -9,11 +9,11 @@ use super::get_project_root; pub fn open_service_config(service_name: String) -> Result<(), String> { let project_root = get_project_root()?; let service_dir = project_root.join("services").join(&service_name); - + if !service_dir.exists() { return Err(format!("服务配置目录不存在: {}", service_dir.display())); } - + // 在 Windows 上使用 explorer 打开目录 #[cfg(target_os = "windows")] { @@ -22,7 +22,7 @@ pub fn open_service_config(service_name: String) -> Result<(), String> { .spawn() .map_err(|e| format!("无法打开目录: {e}"))?; } - + // 在 macOS 上使用 open 命令 #[cfg(target_os = "macos")] { @@ -31,7 +31,7 @@ pub fn open_service_config(service_name: String) -> Result<(), String> { .spawn() .map_err(|e| format!("无法打开目录: {}", e))?; } - + // 在 Linux 上使用 xdg-open 命令 #[cfg(target_os = "linux")] { @@ -40,13 +40,14 @@ pub fn open_service_config(service_name: String) -> Result<(), String> { .spawn() .map_err(|e| format!("无法打开目录: {}", e))?; } - + Ok(()) } /// 获取当前工作目录信息 #[tauri::command] -pub fn get_workspace_info() -> Result, String> { +pub fn get_workspace_info( +) -> Result, String> { WorkspaceManager::load_workspace() } @@ -64,12 +65,12 @@ pub fn set_workspace_path(path: String) -> Result<(), String> { #[tauri::command] pub fn get_version_mappings() -> Result { use std::collections::HashMap; - + let manifest = VersionManifest::new(); let project_root = get_project_root()?; let override_manager = UserOverrideManager::new(&project_root); let mut result = HashMap::new(); - + // 使用辅助函数处理每种服务类型 let service_types = [ ("php", VmServiceType::Php), @@ -77,18 +78,19 @@ pub fn get_version_mappings() -> Result { ("redis", VmServiceType::Redis), ("nginx", VmServiceType::Nginx), ]; - + for (key, service_type) in &service_types { let mut versions = Vec::new(); let entries = manifest.get_available_entries(service_type); - + for (id, entry) in entries { // 使用合并后的配置(用户覆盖优先) - let merged_entry = override_manager.get_merged_entry(service_type, id) + let merged_entry = override_manager + .get_merged_entry(service_type, id) .unwrap_or_else(|| entry.clone()); - + let has_user_override = override_manager.has_user_override(service_type, id); - + versions.push(serde_json::json!({ "id": id, "display_name": merged_entry.display_name, @@ -103,7 +105,7 @@ pub fn get_version_mappings() -> Result { } result.insert(key.to_string(), serde_json::Value::Array(versions)); } - + serde_json::to_value(result).map_err(|e| format!("序列化失败: {e}")) } @@ -118,7 +120,7 @@ pub fn validate_version(service_type: String, version: String) -> Result VmServiceType::Nginx, _ => return Err(format!("不支持的服务类型: {service_type}")), }; - + Ok(manifest.is_id_valid(&vm_service_type, &version)) } @@ -133,8 +135,10 @@ pub fn get_recommended_version(service_type: String) -> Result, S "nginx" => VmServiceType::Nginx, _ => return Err(format!("不支持的服务类型: {service_type}")), }; - - Ok(manifest.get_recommended_entry(&vm_service_type).map(|(id, _)| id.to_string())) + + Ok(manifest + .get_recommended_entry(&vm_service_type) + .map(|(id, _)| id.to_string())) } /// 保存用户自定义版本覆盖 @@ -147,7 +151,7 @@ pub fn save_user_override( ) -> Result<(), String> { let project_root = get_project_root()?; let mut manager = UserOverrideManager::new(&project_root); - + let vm_service_type = match service_type.as_str() { "php" => VmServiceType::Php, "mysql" => VmServiceType::Mysql, @@ -155,12 +159,12 @@ pub fn save_user_override( "nginx" => VmServiceType::Nginx, _ => return Err(format!("不支持的服务类型: {service_type}")), }; - + let override_config = UserVersionOverride { image_tag, description, }; - + manager.save_user_override(&project_root, vm_service_type, id, override_config) } @@ -169,7 +173,7 @@ pub fn save_user_override( pub fn remove_user_override(service_type: String, id: String) -> Result<(), String> { let project_root = get_project_root()?; let mut manager = UserOverrideManager::new(&project_root); - + let vm_service_type = match service_type.as_str() { "php" => VmServiceType::Php, "mysql" => VmServiceType::Mysql, @@ -177,7 +181,7 @@ pub fn remove_user_override(service_type: String, id: String) -> Result<(), Stri "nginx" => VmServiceType::Nginx, _ => return Err(format!("不支持的服务类型: {service_type}")), }; - + manager.remove_user_override(&project_root, &vm_service_type, &id) } @@ -186,7 +190,7 @@ pub fn remove_user_override(service_type: String, id: String) -> Result<(), Stri pub fn reset_all_overrides() -> Result<(), String> { let project_root = get_project_root()?; let mut manager = UserOverrideManager::new(&project_root); - + manager.reset_all_overrides(&project_root) } @@ -212,13 +216,12 @@ pub fn export_logs() -> Result { .ok_or("无法获取程序所在目录")? .to_path_buf() }; - + let log_path = log_dir.join("php-stack.log"); - + if !log_path.exists() { return Err("日志文件不存在,请先执行一些操作".to_string()); } - - std::fs::read_to_string(&log_path) - .map_err(|e| format!("读取日志失败: {e}")) + + std::fs::read_to_string(&log_path).map_err(|e| format!("读取日志失败: {e}")) } diff --git a/src-tauri/src/docker/manager.rs b/src-tauri/src/docker/manager.rs index a0609ec..41bff4c 100644 --- a/src-tauri/src/docker/manager.rs +++ b/src-tauri/src/docker/manager.rs @@ -1,8 +1,8 @@ -use bollard::Docker; use bollard::query_parameters::{ - ListContainersOptions, StartContainerOptions, StopContainerOptions, RestartContainerOptions, + ListContainersOptions, RestartContainerOptions, StartContainerOptions, StopContainerOptions, }; -use serde::{Serialize, Deserialize}; +use bollard::Docker; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Serialize, Deserialize)] @@ -35,7 +35,7 @@ impl DockerManager { pub async fn list_ps_containers(&self) -> Result, Box> { let mut filters = HashMap::new(); filters.insert("name".to_string(), vec!["ps-".to_string()]); - + let options = Some(ListContainersOptions { all: true, filters: Some(filters), @@ -43,80 +43,110 @@ impl DockerManager { }); let containers = self.docker.list_containers(options).await?; - - let ps_containers = containers.into_iter().map(|c| { - let name = c.names.clone().unwrap_or_default().first() - .map(|n| n.trim_start_matches('/').to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - PsContainer { - id: c.id.unwrap_or_default(), - name, - image: c.image.unwrap_or_default(), - status: c.status.unwrap_or_default(), - state: format!("{:?}", c.state), - ports: c.ports.unwrap_or_default().into_iter() - .filter_map(|p| p.public_port.map(|port| port as i32)) - .collect(), - } - }).collect(); + + let ps_containers = containers + .into_iter() + .map(|c| { + let name = c + .names + .clone() + .unwrap_or_default() + .first() + .map(|n| n.trim_start_matches('/').to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + PsContainer { + id: c.id.unwrap_or_default(), + name, + image: c.image.unwrap_or_default(), + status: c.status.unwrap_or_default(), + state: format!("{:?}", c.state), + ports: c + .ports + .unwrap_or_default() + .into_iter() + .filter_map(|p| p.public_port.map(|port| port as i32)) + .collect(), + } + }) + .collect(); Ok(ps_containers) } /// 获取所有运行中的容器(用于端口冲突检测) - pub async fn list_all_running_containers(&self) -> Result, Box> { + pub async fn list_all_running_containers( + &self, + ) -> Result, Box> { let options = Some(ListContainersOptions { all: false, // 只返回运行中的容器 ..Default::default() }); let containers = self.docker.list_containers(options).await?; - - let all_containers = containers.into_iter().map(|c| { - let name = c.names.clone().unwrap_or_default().first() - .map(|n| n.trim_start_matches('/').to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - PsContainer { - id: c.id.unwrap_or_default(), - name, - image: c.image.unwrap_or_default(), - status: c.status.unwrap_or_default(), - state: format!("{:?}", c.state), - ports: c.ports.unwrap_or_default().into_iter() - .filter_map(|p| p.public_port.map(|port| port as i32)) - .collect(), - } - }).collect(); + + let all_containers = containers + .into_iter() + .map(|c| { + let name = c + .names + .clone() + .unwrap_or_default() + .first() + .map(|n| n.trim_start_matches('/').to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + PsContainer { + id: c.id.unwrap_or_default(), + name, + image: c.image.unwrap_or_default(), + status: c.status.unwrap_or_default(), + state: format!("{:?}", c.state), + ports: c + .ports + .unwrap_or_default() + .into_iter() + .filter_map(|p| p.public_port.map(|port| port as i32)) + .collect(), + } + }) + .collect(); Ok(all_containers) } pub async fn start_container(&self, name: &str) -> Result<(), Box> { - self.docker.start_container(name, None::).await?; + self.docker + .start_container(name, None::) + .await?; Ok(()) } pub async fn stop_container(&self, name: &str) -> Result<(), Box> { - self.docker.stop_container(name, None::).await?; + self.docker + .stop_container(name, None::) + .await?; Ok(()) } pub async fn restart_container(&self, name: &str) -> Result<(), Box> { - self.docker.restart_container(name, None::).await?; + self.docker + .restart_container(name, None::) + .await?; Ok(()) } /// 检查所有 ps- 前缀的容器是否都处于 running 状态 pub async fn check_all_ps_containers_running(&self) -> Result { - let containers = self.list_ps_containers().await + let containers = self + .list_ps_containers() + .await .map_err(|e| format!("获取容器列表失败: {e}"))?; - + if containers.is_empty() { return Ok(false); } - + // 检查所有容器是否都是 running 状态 for container in &containers { // state 字段是 "Some(RUNNING)" 或 "Some(EXITED)" 等格式 @@ -124,7 +154,7 @@ impl DockerManager { return Ok(false); } } - + Ok(true) } } diff --git a/src-tauri/src/docker/mod.rs b/src-tauri/src/docker/mod.rs index 07301bd..1418046 100644 --- a/src-tauri/src/docker/mod.rs +++ b/src-tauri/src/docker/mod.rs @@ -1,2 +1,3 @@ pub mod manager; -pub mod tests; +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/docker/tests.rs b/src-tauri/src/docker/tests.rs index d538d79..7a97194 100644 --- a/src-tauri/src/docker/tests.rs +++ b/src-tauri/src/docker/tests.rs @@ -1,10 +1,10 @@ -#[cfg(test)] -mod tests { - use crate::docker::manager::DockerManager; +use crate::docker::manager::DockerManager; - #[tokio::test] - async fn test_docker_manager_init() { - let manager = DockerManager::new(); - assert!(manager.is_ok(), "DockerManager 应该能够初始化,即使 Docker 未运行(懒连接)"); - } +#[tokio::test] +async fn test_docker_manager_init() { + let manager = DockerManager::new(); + assert!( + manager.is_ok(), + "DockerManager 应该能够初始化,即使 Docker 未运行(懒连接)" + ); } diff --git a/src-tauri/src/engine/backup_engine.rs b/src-tauri/src/engine/backup_engine.rs index 5acdc77..e74422e 100644 --- a/src-tauri/src/engine/backup_engine.rs +++ b/src-tauri/src/engine/backup_engine.rs @@ -1,9 +1,9 @@ +use glob::glob; +use sha2::{Digest, Sha256}; use std::fs; -use std::io::{Write, Seek}; +use std::io::{Seek, Write}; use std::path::Path; use zip::write::FileOptions; -use sha2::{Sha256, Digest}; -use glob::glob; use super::backup_manifest::{BackupManifest, BackupOptions}; use crate::app_log; @@ -26,8 +26,7 @@ impl BackupEngine { project_root: &Path, app_handle: Option<&tauri::AppHandle>, ) -> Result<(), String> { - let file = fs::File::create(save_path) - .map_err(|e| format!("创建备份文件失败: {e}"))?; + let file = fs::File::create(save_path).map_err(|e| format!("创建备份文件失败: {e}"))?; let mut zip = zip::ZipWriter::new(file); let mut manifest = BackupManifest::new(); manifest.options = options.clone(); @@ -36,8 +35,7 @@ impl BackupEngine { Self::emit_progress(app_handle, "打包环境配置...", 10); let env_path = project_root.join(".env"); if env_path.exists() { - let content = fs::read(&env_path) - .map_err(|e| format!("读取 .env 失败: {e}"))?; + let content = fs::read(&env_path).map_err(|e| format!("读取 .env 失败: {e}"))?; Self::add_file_to_zip(&mut zip, ".env", &content, &mut manifest)?; } @@ -47,12 +45,7 @@ impl BackupEngine { if compose_path.exists() { let content = fs::read(&compose_path) .map_err(|e| format!("读取 docker-compose.yml 失败: {e}"))?; - Self::add_file_to_zip( - &mut zip, - "docker-compose.yml", - &content, - &mut manifest, - )?; + Self::add_file_to_zip(&mut zip, "docker-compose.yml", &content, &mut manifest)?; } // Step 3: Pack services/ configs (30%) @@ -64,7 +57,7 @@ impl BackupEngine { // Step 3.5: Pack user custom configuration files (35%) Self::emit_progress(app_handle, "打包用户自定义配置...", 35); - + // .user_mirror_config.json - User mirror source configuration let user_mirror_config_path = project_root.join(".user_mirror_config.json"); if user_mirror_config_path.exists() { @@ -77,7 +70,7 @@ impl BackupEngine { &mut manifest, )?; } - + // .user_version_overrides.json - User version override configuration let user_version_overrides_path = project_root.join(".user_version_overrides.json"); if user_version_overrides_path.exists() { @@ -97,22 +90,31 @@ impl BackupEngine { for pattern in &options.project_patterns { // 将相对路径模式转换为绝对路径模式 let mut normalized_pattern = pattern.clone(); - + // 如果模式以 /** 结尾,添加 /* 以匹配文件 // 例如:"www/AAA/**" -> "www/AAA/**/*" if normalized_pattern.ends_with("/**") { normalized_pattern.push_str("/*"); } - + let abs_pattern = if std::path::Path::new(&normalized_pattern).is_absolute() { normalized_pattern } else { - project_root.join(&normalized_pattern).to_string_lossy().replace('\\', "/") + project_root + .join(&normalized_pattern) + .to_string_lossy() + .replace('\\', "/") }; - + // 记录尝试的模式(用于调试) - app_log!(debug, "engine::backup", "尝试匹配模式: {} -> {}", pattern, abs_pattern); - + app_log!( + debug, + "engine::backup", + "尝试匹配模式: {} -> {}", + pattern, + abs_pattern + ); + match glob(&abs_pattern) { Ok(entries) => { let mut matched_count = 0; @@ -123,11 +125,17 @@ impl BackupEngine { match fs::read(&path) { Ok(content) => { // 计算相对于项目根目录的路径 - let relative_path = pathdiff::diff_paths(&path, project_root) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|| path.display().to_string()); + let relative_path = + pathdiff::diff_paths(&path, project_root) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| path.display().to_string()); let zip_path = format!("projects/{relative_path}"); - app_log!(debug, "engine::backup", "添加文件: {}", zip_path); + app_log!( + debug, + "engine::backup", + "添加文件: {}", + zip_path + ); Self::add_file_to_zip( &mut zip, &zip_path, @@ -154,7 +162,13 @@ impl BackupEngine { } } } - app_log!(info, "engine::backup", "模式 '{}' 匹配到 {} 个文件", pattern, matched_count); + app_log!( + info, + "engine::backup", + "模式 '{}' 匹配到 {} 个文件", + pattern, + matched_count + ); } Err(e) => { let error_msg = format!("Glob 模式错误 '{pattern}': {e}"); @@ -178,15 +192,16 @@ impl BackupEngine { // Step 8: Write manifest.json (95%) Self::emit_progress(app_handle, "生成备份清单...", 95); let manifest_json = manifest.serialize()?; - let zip_options = FileOptions::<()>::default() - .compression_method(zip::CompressionMethod::Deflated); + let zip_options = + FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated); zip.start_file("manifest.json", zip_options) .map_err(|e| format!("创建 manifest 条目失败: {e}"))?; zip.write_all(manifest_json.as_bytes()) .map_err(|e| format!("写入 manifest 失败: {e}"))?; // Finish ZIP - zip.finish().map_err(|e| format!("完成 ZIP 文件失败: {e}"))?; + zip.finish() + .map_err(|e| format!("完成 ZIP 文件失败: {e}"))?; Self::emit_progress(app_handle, "备份完成", 100); Ok(()) @@ -220,8 +235,8 @@ impl BackupEngine { content: &[u8], manifest: &mut BackupManifest, ) -> Result<(), String> { - let zip_options = FileOptions::<()>::default() - .compression_method(zip::CompressionMethod::Deflated); + let zip_options = + FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated); zip.start_file(zip_path, zip_options) .map_err(|e| format!("创建 ZIP 条目失败: {e}"))?; zip.write_all(content) @@ -242,9 +257,7 @@ impl BackupEngine { if !src_dir.exists() { return Ok(()); } - for entry in - fs::read_dir(src_dir).map_err(|e| format!("读取目录失败: {e}"))? - { + for entry in fs::read_dir(src_dir).map_err(|e| format!("读取目录失败: {e}"))? { let entry = entry.map_err(|e| format!("读取目录条目失败: {e}"))?; let path = entry.path(); let name = path.file_name().unwrap().to_string_lossy(); @@ -293,8 +306,7 @@ mod tests { let project_root = tmp_dir.path(); // Create .env and docker-compose.yml in the temp project root - fs::write(project_root.join(".env"), "PHP82_VERSION=8.2.27\n") - .expect("写入 .env 失败"); + fs::write(project_root.join(".env"), "PHP82_VERSION=8.2.27\n").expect("写入 .env 失败"); fs::write( project_root.join("docker-compose.yml"), "version: '3'\nservices:\n php:\n image: php:8.2\n", @@ -304,8 +316,7 @@ mod tests { // Create a services/ directory with a config file let services_dir = project_root.join("services/php82"); fs::create_dir_all(&services_dir).expect("创建 services 目录失败"); - fs::write(services_dir.join("php.ini"), "memory_limit=256M\n") - .expect("写入 php.ini 失败"); + fs::write(services_dir.join("php.ini"), "memory_limit=256M\n").expect("写入 php.ini 失败"); // Create user custom configuration files fs::write( diff --git a/src-tauri/src/engine/backup_manifest.rs b/src-tauri/src/engine/backup_manifest.rs index dd77d9f..8b8ba52 100644 --- a/src-tauri/src/engine/backup_manifest.rs +++ b/src-tauri/src/engine/backup_manifest.rs @@ -48,8 +48,7 @@ impl Default for BackupManifest { impl BackupManifest { /// 序列化为格式化的 JSON 字符串(缩进 2 空格) pub fn serialize(&self) -> Result { - serde_json::to_string_pretty(self) - .map_err(|e| format!("序列化 manifest 失败: {e}")) + serde_json::to_string_pretty(self).map_err(|e| format!("序列化 manifest 失败: {e}")) } /// 从 JSON 字符串反序列化,缺少必需字段时返回描述性错误 @@ -94,7 +93,6 @@ impl BackupManifest { } } - #[cfg(test)] mod tests { use super::*; @@ -105,14 +103,8 @@ mod tests { ports.insert(8080, 80); let mut files = HashMap::new(); - files.insert( - ".env".to_string(), - "abc123def456".to_string(), - ); - files.insert( - "docker-compose.yml".to_string(), - "789xyz000111".to_string(), - ); + files.insert(".env".to_string(), "abc123def456".to_string()); + files.insert("docker-compose.yml".to_string(), "789xyz000111".to_string()); BackupManifest { version: "1.0.0".to_string(), @@ -147,8 +139,7 @@ mod tests { fn test_serialize_deserialize_roundtrip() { let manifest = sample_manifest(); let json = manifest.serialize().expect("serialize should succeed"); - let deserialized = - BackupManifest::deserialize(&json).expect("deserialize should succeed"); + let deserialized = BackupManifest::deserialize(&json).expect("deserialize should succeed"); assert_eq!(manifest, deserialized); } diff --git a/src-tauri/src/engine/config_generator.rs b/src-tauri/src/engine/config_generator.rs index 88eb495..d3c525e 100644 --- a/src-tauri/src/engine/config_generator.rs +++ b/src-tauri/src/engine/config_generator.rs @@ -1,13 +1,13 @@ +use chrono::Local; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; use std::io::Write; -use chrono::Local; +use std::path::{Path, PathBuf}; use zip::write::FileOptions; use super::env_parser::EnvFile; -use super::version_manifest::{VersionManifest, ServiceType as VmServiceType}; -use super::user_override_manager::UserOverrideManager; use super::mirror_config_manager::UserMirrorConfig; +use super::user_override_manager::UserOverrideManager; +use super::version_manifest::{ServiceType as VmServiceType, VersionManifest}; use crate::app_log; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -31,20 +31,20 @@ pub struct EnvConfig { pub services: Vec, pub source_dir: String, pub timezone: String, - pub mysql_root_password: Option, // MySQL root密码(可选) + pub mysql_root_password: Option, // MySQL root密码(可选) } pub struct ConfigGenerator; /// 检测宿主机当前用户的 UID/GID -/// +/// /// - Linux: 精确检测当前用户 ID(唯一需要精确映射的平台) /// - Windows/macOS: 返回默认值 1000,依赖 Docker Desktop 的权限转换层 fn detect_host_uid_gid() -> Result<(u32, u32), String> { #[cfg(target_os = "linux")] { use std::process::Command; - + let uid_output = Command::new("id") .arg("-u") .output() @@ -53,7 +53,7 @@ fn detect_host_uid_gid() -> Result<(u32, u32), String> { .trim() .parse::() .map_err(|e| format!("Invalid UID: {}", e))?; - + let gid_output = Command::new("id") .arg("-g") .output() @@ -62,10 +62,10 @@ fn detect_host_uid_gid() -> Result<(u32, u32), String> { .trim() .parse::() .map_err(|e| format!("Invalid GID: {}", e))?; - + Ok((uid, gid)) } - + #[cfg(not(target_os = "linux"))] { // Windows/macOS 下 Docker Desktop 自动处理权限转换 @@ -130,7 +130,11 @@ impl ConfigGenerator { /// /// Note: `ServiceEntry.version` is now a manifest ID (e.g., "php82", "mysql84"). /// `project_root` is the user's workspace directory where `.user_version_overrides.json` resides. - pub fn generate_env(config: &EnvConfig, existing_env: Option<&EnvFile>, project_root: &Path) -> EnvFile { + pub fn generate_env( + config: &EnvConfig, + existing_env: Option<&EnvFile>, + project_root: &Path, + ) -> EnvFile { let mut env = if let Some(existing) = existing_env { existing.clone() } else { @@ -192,19 +196,13 @@ impl ConfigGenerator { match &service.service_type { ServiceType::PHP => { // Use entry.image_tag directly (e.g., "php:8.2-fpm") - env.set( - &format!("{env_prefix}_VERSION"), - &entry.image_tag, - ); + env.set(&format!("{env_prefix}_VERSION"), &entry.image_tag); env.set( &format!("{env_prefix}_HOST_PORT"), &service.host_port.to_string(), ); if let Some(exts) = &service.extensions { - env.set( - &format!("{env_prefix}_EXTENSIONS"), - &exts.join(","), - ); + env.set(&format!("{env_prefix}_EXTENSIONS"), &exts.join(",")); } env.set( &format!("{env_prefix}_PHP_CONF_FILE"), @@ -221,28 +219,61 @@ impl ConfigGenerator { } ServiceType::MySQL => { env.set(&format!("{env_prefix}_VERSION"), &entry.image_tag); - env.set(&format!("{env_prefix}_HOST_PORT"), &service.host_port.to_string()); - + env.set( + &format!("{env_prefix}_HOST_PORT"), + &service.host_port.to_string(), + ); + // 设置MySQL root密码(优先使用用户配置的密码) let root_password = config.mysql_root_password.as_deref().unwrap_or("root"); env.set("MYSQL_ROOT_PASSWORD", root_password); - - env.set(&format!("{env_prefix}_CONF_FILE"), &format!("./services/{service_dir}/mysql.cnf")); - env.set(&format!("{env_prefix}_DATA_DIR"), &format!("./data/{service_dir}")); - env.set(&format!("{env_prefix}_LOG_DIR"), &format!("./logs/{service_dir}")); + + env.set( + &format!("{env_prefix}_CONF_FILE"), + &format!("./services/{service_dir}/mysql.cnf"), + ); + env.set( + &format!("{env_prefix}_DATA_DIR"), + &format!("./data/{service_dir}"), + ); + env.set( + &format!("{env_prefix}_LOG_DIR"), + &format!("./logs/{service_dir}"), + ); } ServiceType::Redis => { env.set(&format!("{env_prefix}_VERSION"), &entry.image_tag); - env.set(&format!("{env_prefix}_HOST_PORT"), &service.host_port.to_string()); - env.set(&format!("{env_prefix}_CONF_FILE"), &format!("./services/{service_dir}/redis.conf")); - env.set(&format!("{env_prefix}_DATA_DIR"), &format!("./data/{service_dir}")); + env.set( + &format!("{env_prefix}_HOST_PORT"), + &service.host_port.to_string(), + ); + env.set( + &format!("{env_prefix}_CONF_FILE"), + &format!("./services/{service_dir}/redis.conf"), + ); + env.set( + &format!("{env_prefix}_DATA_DIR"), + &format!("./data/{service_dir}"), + ); } ServiceType::Nginx => { env.set(&format!("{env_prefix}_VERSION"), &entry.image_tag); - env.set(&format!("{env_prefix}_HTTP_HOST_PORT"), &service.host_port.to_string()); - env.set(&format!("{env_prefix}_BUILD_CONTEXT"), &format!("./services/{service_dir}")); - env.set(&format!("{env_prefix}_CONF_FILE"), &format!("./services/{service_dir}/nginx.conf")); - env.set(&format!("{env_prefix}_CONFD_DIR"), &format!("./services/{service_dir}/conf.d")); + env.set( + &format!("{env_prefix}_HTTP_HOST_PORT"), + &service.host_port.to_string(), + ); + env.set( + &format!("{env_prefix}_BUILD_CONTEXT"), + &format!("./services/{service_dir}"), + ); + env.set( + &format!("{env_prefix}_CONF_FILE"), + &format!("./services/{service_dir}/nginx.conf"), + ); + env.set( + &format!("{env_prefix}_CONFD_DIR"), + &format!("./services/{service_dir}/conf.d"), + ); env.set("NGINX_LOG_DIR", "./logs/nginx"); } } @@ -256,21 +287,21 @@ impl ConfigGenerator { env.set("APT_MIRROR", &apt_cat.source); } } - + // Composer Mirror if let Some(composer_cat) = user_mirror_config.get_category("composer") { if composer_cat.enabled && !composer_cat.source.is_empty() { env.set("COMPOSER_MIRROR", &composer_cat.source); } } - + // NPM Mirror if let Some(npm_cat) = user_mirror_config.get_category("npm") { if npm_cat.enabled && !npm_cat.source.is_empty() { env.set("NPM_MIRROR", &npm_cat.source); } } - + // GitHub Proxy if let Some(github_cat) = user_mirror_config.get_category("github_proxy") { if github_cat.enabled && !github_cat.source.is_empty() { @@ -323,13 +354,23 @@ impl ConfigGenerator { lines.push(format!(" context: ./services/{service_dir}")); lines.push(" args:".to_string()); // Pass the full image tag to Dockerfile's PHP_BASE_IMAGE ARG - lines.push(format!(" PHP_BASE_IMAGE: \"${{{env_prefix}_VERSION}}\"")); - lines.push(format!(" PHP_EXTENSIONS: \"${{{env_prefix}_EXTENSIONS}}\"")); + lines.push(format!( + " PHP_BASE_IMAGE: \"${{{env_prefix}_VERSION}}\"" + )); + lines.push(format!( + " PHP_EXTENSIONS: \"${{{env_prefix}_EXTENSIONS}}\"" + )); lines.push(" TZ: \"${TZ}\"".to_string()); // 镜像源配置(Debian APT 加速,适用于所有 PHP 版本) // 注意:所有 PHP Dockerfile 现已统一使用 Debian 基础镜像(与 version_manifest.json 一致) - lines.push(" DEBIAN_MIRROR_DOMAIN: \"${APT_MIRROR:-deb.debian.org}\"".to_string()); - lines.push(" COMPOSER_MIRROR: \"${COMPOSER_MIRROR:-https://packagist.org}\"".to_string()); + lines.push( + " DEBIAN_MIRROR_DOMAIN: \"${APT_MIRROR:-deb.debian.org}\"" + .to_string(), + ); + lines.push( + " COMPOSER_MIRROR: \"${COMPOSER_MIRROR:-https://packagist.org}\"" + .to_string(), + ); lines.push(" GITHUB_PROXY: \"${GITHUB_PROXY:-}\"".to_string()); // File permissions configuration lines.push(" PUID: \"${PUID:-1000}\"".to_string()); @@ -345,9 +386,7 @@ impl ConfigGenerator { lines.push(format!( " - ${{{env_prefix}_FPM_CONF_FILE}}:/usr/local/etc/php-fpm.d/www.conf" )); - lines.push(format!( - " - ${{{env_prefix}_LOG_DIR}}:/var/log/php" - )); + lines.push(format!(" - ${{{env_prefix}_LOG_DIR}}:/var/log/php")); lines.push(" restart: always".to_string()); lines.push(" networks:".to_string()); lines.push(" - php-stack-network".to_string()); @@ -389,11 +428,11 @@ impl ConfigGenerator { lines.push(format!( " - ${{{env_prefix}_CONF_FILE}}:/etc/redis.conf:ro" )); - lines.push(format!( - " - ${{{env_prefix}_DATA_DIR}}:/data/:rw" - )); + lines.push(format!(" - ${{{env_prefix}_DATA_DIR}}:/data/:rw")); lines.push(" restart: always".to_string()); - lines.push(" entrypoint: [\"redis-server\", \"/etc/redis.conf\"]".to_string()); + lines.push( + " entrypoint: [\"redis-server\", \"/etc/redis.conf\"]".to_string(), + ); lines.push(" networks:".to_string()); lines.push(" - php-stack-network".to_string()); lines.push(String::new()); @@ -404,7 +443,9 @@ impl ConfigGenerator { lines.push(format!(" context: ${{{env_prefix}_BUILD_CONTEXT}}")); lines.push(" args:".to_string()); // Pass the full image tag to Dockerfile's NGINX_BASE_IMAGE ARG - lines.push(format!(" NGINX_BASE_IMAGE: \"${{{env_prefix}_VERSION}}\"")); + lines.push(format!( + " NGINX_BASE_IMAGE: \"${{{env_prefix}_VERSION}}\"" + )); // File permissions configuration lines.push(" PUID: \"${PUID:-1000}\"".to_string()); lines.push(" PGID: \"${PGID:-1000}\"".to_string()); @@ -456,7 +497,11 @@ impl ConfigGenerator { candidates.push(src_tauri.join("services")); } // 3. 测试二进制布局:exe_dir 为 src-tauri/target//deps/ - if let Some(src_tauri) = exe_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) { + if let Some(src_tauri) = exe_dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + { candidates.push(src_tauri.join("services")); } } @@ -509,8 +554,7 @@ impl ConfigGenerator { // Create destination directory if needed if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {e}"))?; + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; } // Copy file (destination is guaranteed not to exist at this point) @@ -538,9 +582,9 @@ impl ConfigGenerator { }; // Check if the exact service_dir template exists in any candidate base dir - let exact_found = Self::template_base_candidates().iter().any(|base| { - base.join(service_dir).join(key_file).exists() - }); + let exact_found = Self::template_base_candidates() + .iter() + .any(|base| base.join(service_dir).join(key_file).exists()); if exact_found { return (service_dir.to_string(), false); } @@ -593,11 +637,15 @@ impl ConfigGenerator { .unwrap_or_else(|| id.clone()); // Resolve template directory: check if exact service_dir template exists, else fallback - let (template_dir, is_fallback) = Self::resolve_template_dir(&service.service_type, &service_dir_name); + let (template_dir, is_fallback) = + Self::resolve_template_dir(&service.service_type, &service_dir_name); if is_fallback { - app_log!(info, "engine::config_generator", + app_log!( + info, + "engine::config_generator", "模板目录 services/{} 不存在,使用 {} 作为模板源", - service_dir_name, template_dir + service_dir_name, + template_dir ); } @@ -665,8 +713,12 @@ impl ConfigGenerator { let service_dir = root.join(format!("services/{service_dir_name}")); std::fs::create_dir_all(&service_dir) .map_err(|e| format!("创建 services/{service_dir_name}/ 目录失败: {e}"))?; - std::fs::create_dir_all(root.join(format!("services/{service_dir_name}/conf.d"))) - .map_err(|e| format!("创建 services/{service_dir_name}/conf.d/ 目录失败: {e}"))?; + std::fs::create_dir_all( + root.join(format!("services/{service_dir_name}/conf.d")), + ) + .map_err(|e| { + format!("创建 services/{service_dir_name}/conf.d/ 目录失败: {e}") + })?; // Copy Dockerfile from template Self::copy_template_file( @@ -700,11 +752,11 @@ impl ConfigGenerator { /// Returns BackupState or error if pre-check fails fn precheck_backup(project_root: &Path) -> Result { let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string(); - + // List of files/directories to backup let items_to_backup = vec![".env", "docker-compose.yml", "services"]; let mut existing_items = Vec::new(); - + // Check which items exist for item in &items_to_backup { let path = project_root.join(item); @@ -712,22 +764,22 @@ impl ConfigGenerator { existing_items.push((*item).to_string()); } } - + if existing_items.is_empty() { return Ok(BackupState::NothingToBackup); } - + // Pre-check: verify backup zip file doesn't exist (avoid overwriting old backups) let backup_zip_name = format!("config_backup_{timestamp}.zip"); let backup_zip_path = project_root.join(&backup_zip_name); - + if backup_zip_path.exists() { return Err(format!("备份文件已存在,请删除后重试: {backup_zip_name}")); } - - Ok(BackupState::Ready { - timestamp, - items: existing_items + + Ok(BackupState::Ready { + timestamp, + items: existing_items, }) } @@ -739,22 +791,27 @@ impl ConfigGenerator { BackupState::Ready { timestamp, items } => { let backup_zip_name = format!("config_backup_{timestamp}.zip"); let backup_zip_path = project_root.join(&backup_zip_name); - - app_log!(info, "engine::config_generator", "开始创建配置备份: {}", backup_zip_name); - + + app_log!( + info, + "engine::config_generator", + "开始创建配置备份: {}", + backup_zip_name + ); + // Create ZIP file let file = std::fs::File::create(&backup_zip_path) .map_err(|e| format!("创建备份文件失败: {e}"))?; let mut zip = zip::ZipWriter::new(file); let zip_options = FileOptions::<()>::default() .compression_method(zip::CompressionMethod::Deflated); - + let mut backed_up_count = 0; - + // Add each item to the ZIP for item in &items { let item_path = project_root.join(item); - + if item_path.is_file() { // Add single file match std::fs::read(&item_path) { @@ -763,35 +820,61 @@ impl ConfigGenerator { .map_err(|e| format!("添加文件到ZIP失败: {e}"))?; zip.write_all(&content) .map_err(|e| format!("写入文件内容失败: {e}"))?; - app_log!(info, "engine::config_generator", "已添加到备份: {}", item); + app_log!( + info, + "engine::config_generator", + "已添加到备份: {}", + item + ); backed_up_count += 1; } Err(e) => { - app_log!(error, "engine::config_generator", "读取文件 {} 失败: {}", item, e); + app_log!( + error, + "engine::config_generator", + "读取文件 {} 失败: {}", + item, + e + ); // Continue with other files, don't fail entire backup } } } else if item_path.is_dir() { // Add directory recursively - match Self::add_dir_to_zip_recursive(&mut zip, &item_path, item, zip_options) { + match Self::add_dir_to_zip_recursive( + &mut zip, + &item_path, + item, + zip_options, + ) { Ok(count) => { - app_log!(info, "engine::config_generator", "已添加目录 {} ({} 个文件)", item, count); + app_log!( + info, + "engine::config_generator", + "已添加目录 {} ({} 个文件)", + item, + count + ); backed_up_count += count; } Err(e) => { - app_log!(error, "engine::config_generator", "添加目录 {} 失败: {}", item, e); + app_log!( + error, + "engine::config_generator", + "添加目录 {} 失败: {}", + item, + e + ); // Continue with other items } } } } - + // Add user custom configuration files (same as backup_engine.rs) - let user_config_files = vec![ - ".user_mirror_config.json", - ".user_version_overrides.json", - ]; - + let user_config_files = + vec![".user_mirror_config.json", ".user_version_overrides.json"]; + for config_file in &user_config_files { let config_path = project_root.join(config_file); if config_path.exists() { @@ -801,33 +884,54 @@ impl ConfigGenerator { .map_err(|e| format!("添加用户配置文件到ZIP失败: {e}"))?; zip.write_all(&content) .map_err(|e| format!("写入用户配置文件失败: {e}"))?; - app_log!(info, "engine::config_generator", "已添加用户配置: {}", config_file); + app_log!( + info, + "engine::config_generator", + "已添加用户配置: {}", + config_file + ); backed_up_count += 1; } Err(e) => { - app_log!(warn, "engine::config_generator", "读取用户配置文件 {} 失败: {}", config_file, e); + app_log!( + warn, + "engine::config_generator", + "读取用户配置文件 {} 失败: {}", + config_file, + e + ); // Continue with other config files } } } } - + // Finish ZIP file zip.finish().map_err(|e| format!("完成ZIP文件失败: {e}"))?; - + if backed_up_count == 0 { // No files were successfully added, delete the empty ZIP let _ = std::fs::remove_file(&backup_zip_path); - app_log!(warn, "engine::config_generator", "没有文件被成功备份,已删除空ZIP文件"); + app_log!( + warn, + "engine::config_generator", + "没有文件被成功备份,已删除空ZIP文件" + ); return Ok(vec![]); } - - app_log!(info, "engine::config_generator", "备份完成: {} (共 {} 个文件/目录项)", backup_zip_name, items.len()); + + app_log!( + info, + "engine::config_generator", + "备份完成: {} (共 {} 个文件/目录项)", + backup_zip_name, + items.len() + ); Ok(vec![backup_zip_name]) } } } - + /// Recursively add directory contents to ZIP fn add_dir_to_zip_recursive( zip: &mut zip::ZipWriter, @@ -836,13 +940,11 @@ impl ConfigGenerator { options: FileOptions<()>, ) -> Result { let mut file_count = 0; - - for entry in std::fs::read_dir(dir_path) - .map_err(|e| format!("读取目录失败: {e}"))? - { + + for entry in std::fs::read_dir(dir_path).map_err(|e| format!("读取目录失败: {e}"))? { let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; let path = entry.path(); - + if path.is_file() { if let Some(file_name) = path.file_name() { let zip_path = format!("{}/{}", zip_base_path, file_name.to_string_lossy()); @@ -855,19 +957,26 @@ impl ConfigGenerator { file_count += 1; } Err(e) => { - app_log!(warn, "engine::config_generator", "跳过文件 {:?}: {}", path, e); + app_log!( + warn, + "engine::config_generator", + "跳过文件 {:?}: {}", + path, + e + ); } } } } else if path.is_dir() { if let Some(dir_name) = path.file_name() { let sub_zip_path = format!("{}/{}", zip_base_path, dir_name.to_string_lossy()); - let sub_count = Self::add_dir_to_zip_recursive(zip, &path, &sub_zip_path, options)?; + let sub_count = + Self::add_dir_to_zip_recursive(zip, &path, &sub_zip_path, options)?; file_count += sub_count; } } } - + Ok(file_count) } @@ -876,10 +985,10 @@ impl ConfigGenerator { /// Contains: .env, docker-compose.yml, services/, .user_mirror_config.json, .user_version_overrides.json pub fn backup_existing_config(project_root: &Path) -> Result, String> { app_log!(info, "engine::config_generator", "开始预检查备份..."); - + // Phase 1: Pre-check let backup_state = Self::precheck_backup(project_root)?; - + // Phase 2: Execute with rollback app_log!(info, "engine::config_generator", "执行备份..."); Self::execute_backup(backup_state, project_root) @@ -887,7 +996,11 @@ impl ConfigGenerator { /// Apply config: write .env, docker-compose.yml, create directories. /// If enable_backup is true, backup existing config files before overwriting. - pub async fn apply(config: &EnvConfig, project_root: &Path, enable_backup: bool) -> Result, String> { + pub async fn apply( + config: &EnvConfig, + project_root: &Path, + enable_backup: bool, + ) -> Result, String> { // Validate first Self::validate(config)?; @@ -910,18 +1023,20 @@ impl ConfigGenerator { // Create directory structure Self::generate_service_dirs(config, project_root)?; - + // Generate .npmrc file in workspace path if NPM mirror is configured let npm_mirror = env_file.get("NPM_MIRROR").unwrap_or(""); if !npm_mirror.is_empty() && npm_mirror != "https://registry.npmjs.org" { // 从 workspace.json 获取工作区路径 - let workspace_path = if let Some(workspace_config) = crate::engine::workspace_manager::WorkspaceManager::load_workspace()? { + let workspace_path = if let Some(workspace_config) = + crate::engine::workspace_manager::WorkspaceManager::load_workspace()? + { PathBuf::from(workspace_config.workspace_path) } else { // 如果没有配置 workspace,使用项目根目录作为后备 project_root.to_path_buf() }; - + let npmrc_content = format!("registry={npm_mirror}\n"); let npmrc_path = workspace_path.join(".npmrc"); std::fs::write(&npmrc_path, npmrc_content) @@ -930,7 +1045,6 @@ impl ConfigGenerator { Ok(backed_up_files) } - } #[cfg(test)] @@ -1003,8 +1117,7 @@ mod tests { let dir = tempfile::tempdir().expect("创建临时目录失败"); let dest = dir.path().join("Dockerfile"); - ConfigGenerator::copy_template_file("php85/Dockerfile", &dest) - .expect("模板释放应成功"); + ConfigGenerator::copy_template_file("php85/Dockerfile", &dest).expect("模板释放应成功"); assert!(dest.exists(), "目标文件应被释放创建"); assert!(!std::fs::read_to_string(&dest).unwrap().is_empty()); @@ -1099,7 +1212,8 @@ mod tests { mysql_root_password: None, }; - let env = ConfigGenerator::generate_env(&config, Some(&existing_env), &std::env::temp_dir()); + let env = + ConfigGenerator::generate_env(&config, Some(&existing_env), &std::env::temp_dir()); let map = env.to_map(); // Custom variable preserved diff --git a/src-tauri/src/engine/env_parser.rs b/src-tauri/src/engine/env_parser.rs index a19c9f0..f1cc959 100644 --- a/src-tauri/src/engine/env_parser.rs +++ b/src-tauri/src/engine/env_parser.rs @@ -339,10 +339,7 @@ mod tests { } => { assert_eq!(key, "KEY"); assert_eq!(value, "value"); - assert_eq!( - inline_comment, - &Some("# this is a comment".to_string()) - ); + assert_eq!(inline_comment, &Some("# this is a comment".to_string())); } _ => panic!("Expected KeyValue"), } @@ -407,9 +404,7 @@ mod tests { assert_eq!(env.get("KEY"), Some("new")); // Inline comment should be preserved match &env.lines[0] { - EnvLine::KeyValue { - inline_comment, .. - } => { + EnvLine::KeyValue { inline_comment, .. } => { assert_eq!(inline_comment, &Some("# comment".to_string())); } _ => panic!("Expected KeyValue"), @@ -479,13 +474,12 @@ QUOTED_VALUE='hello world'"#; assert_eq!(map.get("QUOTED_VALUE"), Some(&"hello world".to_string())); // Verify inline comment on APT_MIRROR - let apt_line = env.lines.iter().find(|l| { - matches!(l, EnvLine::KeyValue { key, .. } if key == "APT_MIRROR") - }); + let apt_line = env + .lines + .iter() + .find(|l| matches!(l, EnvLine::KeyValue { key, .. } if key == "APT_MIRROR")); match apt_line { - Some(EnvLine::KeyValue { - inline_comment, .. - }) => { + Some(EnvLine::KeyValue { inline_comment, .. }) => { assert_eq!(inline_comment, &Some("# Aliyun mirror".to_string())); } _ => panic!("Expected APT_MIRROR KeyValue with inline comment"), diff --git a/src-tauri/src/engine/mirror_config.rs b/src-tauri/src/engine/mirror_config.rs index ecced7b..9166f27 100644 --- a/src-tauri/src/engine/mirror_config.rs +++ b/src-tauri/src/engine/mirror_config.rs @@ -9,7 +9,7 @@ use std::fs; use std::path::Path; /// 镜像源配置 -/// +/// /// 统一管理容器内依赖镜像源(APT/Composer/PyPI/NPM) /// 注意:Docker Daemon 的 registry-mirrors 由 docker/mirror.rs 管理 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,7 +19,7 @@ pub struct MirrorConfig { pub composer_mirror: MirrorSource, pub pypi_mirror: MirrorSource, pub npm_mirror: MirrorSource, - + // Docker 构建代理(可选) pub http_proxy: Option, pub https_proxy: Option, @@ -43,24 +43,16 @@ impl MirrorSource { pub fn get_url(&self, service: &str) -> String { match (self, service) { // Docker Registry - (MirrorSource::Ustc, "docker") => { - "https://docker.mirrors.ustc.edu.cn".to_string() - } - (MirrorSource::Tencent, "docker") => { - "https://mirror.ccs.tencentyun.com".to_string() - } - + (MirrorSource::Ustc, "docker") => "https://docker.mirrors.ustc.edu.cn".to_string(), + (MirrorSource::Tencent, "docker") => "https://mirror.ccs.tencentyun.com".to_string(), + // APT - (MirrorSource::Aliyun, "apt") => { - "http://mirrors.aliyun.com/debian/".to_string() - } + (MirrorSource::Aliyun, "apt") => "http://mirrors.aliyun.com/debian/".to_string(), (MirrorSource::Tsinghua, "apt") => { "https://mirrors.tuna.tsinghua.edu.cn/debian/".to_string() } - (MirrorSource::Ustc, "apt") => { - "https://mirrors.ustc.edu.cn/debian/".to_string() - } - + (MirrorSource::Ustc, "apt") => "https://mirrors.ustc.edu.cn/debian/".to_string(), + // Composer (MirrorSource::Aliyun, "composer") => { "https://mirrors.aliyun.com/composer/".to_string() @@ -68,27 +60,21 @@ impl MirrorSource { (MirrorSource::HuaweiCloud, "composer") => { "https://repo.huaweicloud.com/repository/php/".to_string() } - + // PyPI - (MirrorSource::Aliyun, "pypi") => { - "https://mirrors.aliyun.com/pypi/simple/".to_string() - } + (MirrorSource::Aliyun, "pypi") => "https://mirrors.aliyun.com/pypi/simple/".to_string(), (MirrorSource::Tsinghua, "pypi") => { "https://pypi.tuna.tsinghua.edu.cn/simple".to_string() } - + // NPM - (MirrorSource::Taobao, "npm") => { - "https://registry.npmmirror.com".to_string() - } - (MirrorSource::Tencent, "npm") => { - "https://mirrors.cloud.tencent.com/npm/".to_string() - } - + (MirrorSource::Taobao, "npm") => "https://registry.npmmirror.com".to_string(), + (MirrorSource::Tencent, "npm") => "https://mirrors.cloud.tencent.com/npm/".to_string(), + _ => String::new(), // Default or unsupported } } - + /// 转换为字符串标识 pub fn as_str(&self) -> &str { match self { @@ -101,7 +87,7 @@ impl MirrorSource { MirrorSource::Taobao => "taobao", } } - + /// 从字符串解析 pub fn from_key(s: &str) -> Self { match s { @@ -120,94 +106,113 @@ impl MirrorConfig { /// 从 .env 文件加载配置 pub fn load_from_env() -> Result { let env_path = Path::new(".env"); - + if !env_path.exists() { log::warn!("⚠️ .env 文件不存在,使用默认配置"); return Ok(Self::default()); } - - let env_content = fs::read_to_string(env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))?; - + + let env_content = + fs::read_to_string(env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))?; + let env_map = Self::parse_env_file(&env_content); - + Ok(Self { apt_mirror: MirrorSource::from_key( - env_map.get("APT_MIRROR").map(|s| s.as_str()).unwrap_or("default") + env_map + .get("APT_MIRROR") + .map(|s| s.as_str()) + .unwrap_or("default"), ), composer_mirror: MirrorSource::from_key( - env_map.get("COMPOSER_MIRROR").map(|s| s.as_str()).unwrap_or("default") + env_map + .get("COMPOSER_MIRROR") + .map(|s| s.as_str()) + .unwrap_or("default"), ), pypi_mirror: MirrorSource::from_key( - env_map.get("PYPI_MIRROR").map(|s| s.as_str()).unwrap_or("default") + env_map + .get("PYPI_MIRROR") + .map(|s| s.as_str()) + .unwrap_or("default"), ), npm_mirror: MirrorSource::from_key( - env_map.get("NPM_MIRROR").map(|s| s.as_str()).unwrap_or("default") + env_map + .get("NPM_MIRROR") + .map(|s| s.as_str()) + .unwrap_or("default"), ), http_proxy: env_map.get("HTTP_PROXY").cloned(), https_proxy: env_map.get("HTTPS_PROXY").cloned(), no_proxy: env_map.get("NO_PROXY").cloned(), }) } - + /// 解析 .env 文件内容 fn parse_env_file(content: &str) -> HashMap { let mut map = HashMap::new(); - + for line in content.lines() { let line = line.trim(); - + // 跳过注释和空行 if line.is_empty() || line.starts_with('#') { continue; } - + // 解析 KEY=VALUE if let Some((key, value)) = line.split_once('=') { let key = key.trim().to_string(); - let value = value.trim().trim_matches('"').trim_matches('\'').to_string(); + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); map.insert(key, value); } } - + map } - + /// 保存到 .env 文件 pub fn save_to_env(&self) -> Result<(), String> { let env_path = Path::new(".env"); let mut env_content = if env_path.exists() { - fs::read_to_string(env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))? + fs::read_to_string(env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))? } else { String::new() }; - + // 更新或添加配置项(仅容器内依赖镜像源) env_content = Self::update_env_value(&env_content, "APT_MIRROR", self.apt_mirror.as_str()); - env_content = Self::update_env_value(&env_content, "COMPOSER_MIRROR", self.composer_mirror.as_str()); - env_content = Self::update_env_value(&env_content, "PYPI_MIRROR", self.pypi_mirror.as_str()); + env_content = Self::update_env_value( + &env_content, + "COMPOSER_MIRROR", + self.composer_mirror.as_str(), + ); + env_content = + Self::update_env_value(&env_content, "PYPI_MIRROR", self.pypi_mirror.as_str()); env_content = Self::update_env_value(&env_content, "NPM_MIRROR", self.npm_mirror.as_str()); - + if let Some(proxy) = &self.http_proxy { env_content = Self::update_env_value(&env_content, "HTTP_PROXY", proxy); } if let Some(proxy) = &self.https_proxy { env_content = Self::update_env_value(&env_content, "HTTPS_PROXY", proxy); } - - fs::write(env_path, env_content) - .map_err(|e| format!("写入 .env 文件失败: {e}"))?; + + fs::write(env_path, env_content).map_err(|e| format!("写入 .env 文件失败: {e}"))?; log::info!("✅ 容器内镜像源配置已保存到 .env"); Ok(()) } - + /// 更新 .env 文件中的值 fn update_env_value(content: &str, key: &str, value: &str) -> String { let lines: Vec<&str> = content.lines().collect(); let mut new_lines: Vec = Vec::new(); let mut found = false; - + for line in lines { if line.trim().starts_with(&format!("{key}=")) { new_lines.push(format!("{key}={value}")); @@ -216,18 +221,18 @@ impl MirrorConfig { new_lines.push(line.to_string()); } } - + if !found { new_lines.push(format!("{key}={value}")); } - + new_lines.join("\n") } - + /// 生成 Docker 构建参数 pub fn to_build_args(&self) -> Vec { let mut args = Vec::new(); - + // 添加代理配置 if let Some(proxy) = &self.http_proxy { args.push(format!("HTTP_PROXY={proxy}")); @@ -238,14 +243,14 @@ impl MirrorConfig { if let Some(no_proxy) = &self.no_proxy { args.push(format!("NO_PROXY={no_proxy}")); } - + args } - + /// 生成 Dockerfile 中的镜像源配置片段 pub fn to_dockerfile_snippet(&self) -> String { let mut snippet = String::new(); - + // APT 镜像源 if self.apt_mirror != MirrorSource::Default { let apt_url = self.apt_mirror.get_url("apt"); @@ -256,10 +261,12 @@ RUN sed -i 's|deb.debian.org/debian|{}|g' /etc/apt/sources.list && \ ", apt_url.trim_end_matches('/'), - apt_url.replace("debian", "debian-security").trim_end_matches('/') + apt_url + .replace("debian", "debian-security") + .trim_end_matches('/') )); } - + // Composer 镜像源(先安装 Composer) if self.composer_mirror != MirrorSource::Default { let composer_url = self.composer_mirror.get_url("composer"); @@ -272,22 +279,22 @@ RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local composer_url.trim_end_matches('/') )); } - + // PyPI 镜像源(暂时禁用,PHP-FPM 镜像不包含 pip) // 如果未来需要,可以先安装 Python /* - if self.pypi_mirror != MirrorSource::Default { - let pypi_url = self.pypi_mirror.get_url("pypi"); - snippet.push_str(&format!( - r#"# 配置 PyPI 镜像源 -RUN pip config set global.index-url {} + if self.pypi_mirror != MirrorSource::Default { + let pypi_url = self.pypi_mirror.get_url("pypi"); + snippet.push_str(&format!( + r#"# 配置 PyPI 镜像源 + RUN pip config set global.index-url {} + + "#, + pypi_url.trim_end_matches('/') + )); + } + */ -"#, - pypi_url.trim_end_matches('/') - )); - } - */ - snippet } } @@ -309,23 +316,23 @@ impl Default for MirrorConfig { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_mirror_source_from_key() { assert_eq!(MirrorSource::from_key("aliyun"), MirrorSource::Aliyun); assert_eq!(MirrorSource::from_key("tsinghua"), MirrorSource::Tsinghua); assert_eq!(MirrorSource::from_key("invalid"), MirrorSource::Default); } - + #[test] fn test_mirror_source_get_url() { let url = MirrorSource::Aliyun.get_url("apt"); assert!(url.contains("aliyun.com")); - + let url = MirrorSource::Tsinghua.get_url("pypi"); assert!(url.contains("tsinghua.edu.cn")); } - + #[test] fn test_parse_env_file() { let content = r#" diff --git a/src-tauri/src/engine/mirror_config_manager.rs b/src-tauri/src/engine/mirror_config_manager.rs index db08295..2229231 100644 --- a/src-tauri/src/engine/mirror_config_manager.rs +++ b/src-tauri/src/engine/mirror_config_manager.rs @@ -36,51 +36,50 @@ impl UserMirrorConfig { /// 从文件加载用户配置 pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(".user_mirror_config.json"); - + if !config_path.exists() { return Ok(Self::default()); } - + let content = std::fs::read_to_string(&config_path) .map_err(|e| format!("读取用户镜像配置文件失败: {e}"))?; - - serde_json::from_str(&content) - .map_err(|e| format!("解析用户镜像配置文件失败: {e}")) + + serde_json::from_str(&content).map_err(|e| format!("解析用户镜像配置文件失败: {e}")) } - + /// 保存用户配置到文件 pub fn save(&self, project_root: &Path) -> Result<(), String> { let config_path = project_root.join(".user_mirror_config.json"); - + let content = serde_json::to_string_pretty(self) .map_err(|e| format!("序列化用户镜像配置失败: {e}"))?; - - std::fs::write(&config_path, content) - .map_err(|e| format!("写入用户镜像配置文件失败: {e}")) + + std::fs::write(&config_path, content).map_err(|e| format!("写入用户镜像配置文件失败: {e}")) } - + /// 检查某个类别是否有用户自定义配置 pub fn has_user_override(&self, category_id: &str) -> bool { - self.categories.get(category_id) + self.categories + .get(category_id) .map(|c| c.enabled) .unwrap_or(false) } - + /// 获取某个类别的自定义配置 pub fn get_category(&self, category_id: &str) -> Option<&UserMirrorCategory> { self.categories.get(category_id) } - + /// 设置某个类别的自定义配置 pub fn set_category(&mut self, category_id: String, category: UserMirrorCategory) { self.categories.insert(category_id, category); } - + /// 删除某个类别的自定义配置 pub fn remove_category(&mut self, category_id: &str) -> Option { self.categories.remove(category_id) } - + /// 清空所有自定义配置 pub fn clear_all(&mut self) { self.categories.clear(); @@ -101,9 +100,9 @@ pub struct MirrorSourceOption { pub struct MergedMirrorCategory { pub category_id: String, pub options: Vec, - pub selected_id: String, // 当前选中的选项 ID - pub current_value: String, // 当前值 - pub has_user_override: bool, // 是否有用户自定义 + pub selected_id: String, // 当前选中的选项 ID + pub current_value: String, // 当前值 + pub has_user_override: bool, // 是否有用户自定义 } pub struct MirrorConfigManager; @@ -112,10 +111,9 @@ impl MirrorConfigManager { /// 从嵌入的 JSON 数据加载默认镜像配置 pub fn load_default_config() -> Result { let json_data = include_str!("../../services/mirror_config.json"); - serde_json::from_str(json_data) - .map_err(|e| format!("解析 mirror_config.json 失败: {e}")) + serde_json::from_str(json_data).map_err(|e| format!("解析 mirror_config.json 失败: {e}")) } - + /// 获取所有类别 ID 列表 pub fn get_category_ids() -> Vec { vec![ @@ -126,43 +124,42 @@ impl MirrorConfigManager { "github_proxy".to_string(), ] } - + /// 获取合并后的镜像源列表(默认配置 + 用户自定义) - pub fn get_merged_mirror_list(project_root: &Path) -> Result, String> { + pub fn get_merged_mirror_list( + project_root: &Path, + ) -> Result, String> { let default_config = Self::load_default_config()?; let user_config = UserMirrorConfig::load(project_root)?; - + let category_ids = Self::get_category_ids(); let mut merged_list = Vec::new(); - + for category_id in category_ids { // 从默认配置中获取该类别的选项列表 let options_value = &default_config[&category_id]; let options_array = options_value .as_array() .ok_or(format!("mirror_config.json 中缺少类别: {category_id}"))?; - + // 解析选项列表 let options: Vec = options_array .iter() - .filter_map(|opt| { - serde_json::from_value(opt.clone()).ok() - }) + .filter_map(|opt| serde_json::from_value(opt.clone()).ok()) .collect(); - + if options.is_empty() { continue; } - + // 确定当前选中的选项 let has_user_override = user_config.has_user_override(&category_id); let (selected_id, current_value, final_options) = if has_user_override { // 用户有自定义配置 if let Some(user_cat) = user_config.get_category(&category_id) { // 查找匹配的选项 ID - let matched_option = options.iter() - .find(|opt| opt.value == user_cat.source); - + let matched_option = options.iter().find(|opt| opt.value == user_cat.source); + if let Some(opt) = matched_option { // 匹配到预设选项 (opt.id.clone(), user_cat.source.clone(), options.clone()) @@ -175,19 +172,27 @@ impl MirrorConfigManager { value: user_cat.source.clone(), description: user_cat.description.clone().unwrap_or_default(), }); - - ("custom".to_string(), user_cat.source.clone(), opts_with_custom) + + ( + "custom".to_string(), + user_cat.source.clone(), + opts_with_custom, + ) } } else { // 回退到第一个选项 - (options[0].id.clone(), options[0].value.clone(), options.clone()) + ( + options[0].id.clone(), + options[0].value.clone(), + options.clone(), + ) } } else { // 首次设置:没有用户自定义配置 // selected_id 返回空字符串,让前端显示所有选项为可点选状态 ("".to_string(), String::new(), options.clone()) }; - + merged_list.push(MergedMirrorCategory { category_id, options: final_options, @@ -196,10 +201,10 @@ impl MirrorConfigManager { has_user_override, }); } - + Ok(merged_list) } - + /// 保存用户选择的镜像源选项 pub fn save_selected_option( project_root: &Path, @@ -211,17 +216,18 @@ impl MirrorConfigManager { let options_array = options_value .as_array() .ok_or(format!("类别 {category_id} 不存在"))?; - + // 查找对应的选项值 - let selected_value = options_array.iter() + let selected_value = options_array + .iter() .find(|opt| opt.get("id").and_then(|v| v.as_str()) == Some(option_id)) .and_then(|opt| opt.get("value").and_then(|v| v.as_str())) .ok_or(format!("选项 {option_id} 不存在于类别 {category_id} 中"))?; - + // 保存为用户自定义配置 Self::save_user_category(project_root, category_id, selected_value, None) } - + /// 保存用户自定义的单个类别配置 pub fn save_user_category( project_root: &Path, @@ -230,24 +236,24 @@ impl MirrorConfigManager { description: Option, ) -> Result<(), String> { let mut user_config = UserMirrorConfig::load(project_root)?; - + let category = UserMirrorCategory { source: source.to_string(), enabled: true, description, }; - + user_config.set_category(category_id.to_string(), category); user_config.save(project_root) } - + /// 删除用户自定义的类别配置 pub fn remove_user_category(project_root: &Path, category_id: &str) -> Result<(), String> { let mut user_config = UserMirrorConfig::load(project_root)?; user_config.remove_category(category_id); user_config.save(project_root) } - + /// 重置所有用户自定义配置 pub fn reset_all_overrides(project_root: &Path) -> Result<(), String> { let mut user_config = UserMirrorConfig::load(project_root)?; @@ -261,18 +267,18 @@ mod tests { use super::*; use std::path::PathBuf; use tempfile::TempDir; - + fn create_test_project() -> (TempDir, PathBuf) { let dir = TempDir::new().expect("创建临时目录失败"); let path = dir.path().to_path_buf(); (dir, path) } - + #[test] fn test_load_default_config() { let config = MirrorConfigManager::load_default_config(); assert!(config.is_ok()); - + let config = config.unwrap(); assert!(config.get("docker_registry").is_some()); assert!(config.get("apt").is_some()); @@ -280,22 +286,22 @@ mod tests { assert!(config.get("npm").is_some()); assert!(config.get("github_proxy").is_some()); } - + #[test] fn test_user_config_save_and_load() { let (_dir, path) = create_test_project(); - + let mut user_config = UserMirrorConfig::default(); - + let category = UserMirrorCategory { source: "https://custom.mirror.com".to_string(), enabled: true, description: Some("Custom mirror".to_string()), }; user_config.set_category("npm".to_string(), category); - + user_config.save(&path).expect("保存配置失败"); - + let loaded = UserMirrorConfig::load(&path).expect("加载配置失败"); assert!(loaded.has_user_override("npm")); assert_eq!( @@ -303,17 +309,17 @@ mod tests { "https://custom.mirror.com" ); } - + #[test] fn test_get_merged_mirror_list() { let (_dir, path) = create_test_project(); - + let merged = MirrorConfigManager::get_merged_mirror_list(&path); assert!(merged.is_ok()); - + let merged = merged.unwrap(); assert!(!merged.is_empty()); - + // 检查是否包含所有类别 let category_ids: Vec<&String> = merged.iter().map(|m| &m.category_id).collect(); assert!(category_ids.contains(&&"docker_registry".to_string())); diff --git a/src-tauri/src/engine/mirror_manager.rs b/src-tauri/src/engine/mirror_manager.rs index 5e79dc9..c394d07 100644 --- a/src-tauri/src/engine/mirror_manager.rs +++ b/src-tauri/src/engine/mirror_manager.rs @@ -86,14 +86,13 @@ impl MirrorManager { .ok_or_else(|| format!("未找到预设方案: {preset_name}"))?; let content = if env_path.exists() { - std::fs::read_to_string(env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))? + std::fs::read_to_string(env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))? } else { String::new() }; - let mut env_file = EnvFile::parse(&content) - .map_err(|e| format!("解析 .env 文件失败: {e}"))?; + let mut env_file = + EnvFile::parse(&content).map_err(|e| format!("解析 .env 文件失败: {e}"))?; env_file.set("DOCKER_REGISTRY_MIRROR", &preset.docker_registry); env_file.set("APT_MIRROR", preset.apt.as_str()); @@ -101,8 +100,7 @@ impl MirrorManager { env_file.set("NPM_MIRROR", &preset.npm); let output = env_file.format(); - std::fs::write(env_path, output) - .map_err(|e| format!("写入 .env 文件失败: {e}"))?; + std::fs::write(env_path, output).map_err(|e| format!("写入 .env 文件失败: {e}"))?; Ok(()) } @@ -111,11 +109,7 @@ impl MirrorManager { /// /// category: "docker", "apt", "composer", "npm", "github" /// 只更新指定类别的键,其他类别保持不变。 - pub fn update_single( - category: &str, - value: &str, - env_path: &Path, - ) -> Result<(), String> { + pub fn update_single(category: &str, value: &str, env_path: &Path) -> Result<(), String> { let key = match category { "docker_registry" => "DOCKER_REGISTRY_MIRROR", "apt" => "APT_MIRROR", @@ -126,20 +120,18 @@ impl MirrorManager { }; let content = if env_path.exists() { - std::fs::read_to_string(env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))? + std::fs::read_to_string(env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))? } else { String::new() }; - let mut env_file = EnvFile::parse(&content) - .map_err(|e| format!("解析 .env 文件失败: {e}"))?; + let mut env_file = + EnvFile::parse(&content).map_err(|e| format!("解析 .env 文件失败: {e}"))?; env_file.set(key, value); let output = env_file.format(); - std::fs::write(env_path, output) - .map_err(|e| format!("写入 .env 文件失败: {e}"))?; + std::fs::write(env_path, output).map_err(|e| format!("写入 .env 文件失败: {e}"))?; Ok(()) } @@ -159,7 +151,9 @@ impl MirrorManager { .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; match client.head(url).send().await { - Ok(response) => Ok(response.status().is_success() || response.status().is_redirection()), + Ok(response) => { + Ok(response.status().is_success() || response.status().is_redirection()) + } Err(e) => { if e.is_timeout() || e.is_connect() { Ok(false) @@ -175,14 +169,12 @@ impl MirrorManager { /// 从 .env 文件中读取 4 个镜像源键的值。 pub fn get_current_status(env_path: &Path) -> Result { let content = if env_path.exists() { - std::fs::read_to_string(env_path) - .map_err(|e| format!("读取 .env 文件失败: {e}"))? + std::fs::read_to_string(env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))? } else { String::new() }; - let env_file = EnvFile::parse(&content) - .map_err(|e| format!("解析 .env 文件失败: {e}"))?; + let env_file = EnvFile::parse(&content).map_err(|e| format!("解析 .env 文件失败: {e}"))?; Ok(MirrorStatus { docker_registry: env_file @@ -222,7 +214,9 @@ impl MirrorManager { if status.docker_registry.is_empty() && (status.apt == "default" || status.apt.is_empty()) && (status.composer == "default" || status.composer.is_empty()) - && (status.npm == "default" || status.npm.is_empty() || status.npm == "https://registry.npmjs.org") + && (status.npm == "default" + || status.npm.is_empty() + || status.npm == "https://registry.npmjs.org") { return Ok("官方默认".to_string()); } @@ -282,7 +276,9 @@ mod tests { MirrorManager::apply_preset("阿里云全套", &env_path).expect("应用预设失败"); let content = fs::read_to_string(&env_path).expect("读取文件失败"); - assert!(content.contains("DOCKER_REGISTRY_MIRROR=https://registry.cn-hangzhou.aliyuncs.com")); + assert!( + content.contains("DOCKER_REGISTRY_MIRROR=https://registry.cn-hangzhou.aliyuncs.com") + ); assert!(content.contains("APT_MIRROR=aliyun")); assert!(content.contains("COMPOSER_MIRROR=aliyun")); assert!(content.contains("NPM_MIRROR=https://registry.npmmirror.com")); diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index 69a2d52..3ec82af 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -1,11 +1,11 @@ -pub mod mirror_config; // 镜像源配置(向后兼容) -pub mod env_parser; // Env 解析器/格式化器 -pub mod backup_manifest; // 备份清单数据模型与序列化 -pub mod config_generator; // 可视化配置生成器 -pub mod mirror_manager; // 统一镜像源管理器 -pub mod backup_engine; // 增强备份引擎 -pub mod restore_engine; // 恢复引擎 -pub mod version_manifest; // 版本清单管理器 -pub mod user_override_manager; // 用户版本覆盖管理器 +pub mod backup_engine; // 增强备份引擎 +pub mod backup_manifest; // 备份清单数据模型与序列化 +pub mod config_generator; // 可视化配置生成器 +pub mod env_parser; // Env 解析器/格式化器 +pub mod mirror_config; // 镜像源配置(向后兼容) pub mod mirror_config_manager; // 用户镜像源配置管理器 -pub mod workspace_manager; // 工作目录管理器 +pub mod mirror_manager; // 统一镜像源管理器 +pub mod restore_engine; // 恢复引擎 +pub mod user_override_manager; // 用户版本覆盖管理器 +pub mod version_manifest; // 版本清单管理器 +pub mod workspace_manager; // 工作目录管理器 diff --git a/src-tauri/src/engine/restore_engine.rs b/src-tauri/src/engine/restore_engine.rs index 804c144..705ede1 100644 --- a/src-tauri/src/engine/restore_engine.rs +++ b/src-tauri/src/engine/restore_engine.rs @@ -33,10 +33,9 @@ impl RestoreEngine { /// Parse backup ZIP and return preview info. /// Reads manifest.json from ZIP, detects port conflicts, counts files. pub fn preview(zip_path: &str) -> Result { - let file = std::fs::File::open(zip_path) - .map_err(|e| format!("打开备份文件失败: {e}"))?; - let mut archive = zip::ZipArchive::new(file) - .map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; + let file = std::fs::File::open(zip_path).map_err(|e| format!("打开备份文件失败: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; // Read manifest.json let manifest = Self::read_manifest_from_archive(&mut archive)?; @@ -61,10 +60,9 @@ impl RestoreEngine { /// For each file in manifest.files, read from ZIP and compute SHA256, /// compare with recorded hash. pub fn verify_integrity(zip_path: &str) -> Result { - let file = std::fs::File::open(zip_path) - .map_err(|e| format!("打开备份文件失败: {e}"))?; - let mut archive = zip::ZipArchive::new(file) - .map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; + let file = std::fs::File::open(zip_path).map_err(|e| format!("打开备份文件失败: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; let manifest = Self::read_manifest_from_archive(&mut archive)?; @@ -97,10 +95,9 @@ impl RestoreEngine { let mut restored_files: Vec = Vec::new(); let mut errors: Vec = Vec::new(); - let file = std::fs::File::open(zip_path) - .map_err(|e| format!("打开备份文件失败: {e}"))?; - let mut archive = zip::ZipArchive::new(file) - .map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; + let file = std::fs::File::open(zip_path).map_err(|e| format!("打开备份文件失败: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; // Step 1: Read manifest Self::emit_progress(app_handle, "解析备份包...", 5); @@ -115,7 +112,11 @@ impl RestoreEngine { // Step 3: Extract docker-compose.yml Self::emit_progress(app_handle, "恢复 Docker 配置...", 25); - match Self::extract_file_to_path(&mut archive, "docker-compose.yml", &project_root.join("docker-compose.yml")) { + match Self::extract_file_to_path( + &mut archive, + "docker-compose.yml", + &project_root.join("docker-compose.yml"), + ) { Ok(()) => restored_files.push("docker-compose.yml".to_string()), Err(e) => errors.push(format!("恢复 docker-compose.yml 失败: {e}")), } @@ -129,7 +130,7 @@ impl RestoreEngine { // Step 4.5: Restore user custom configuration files Self::emit_progress(app_handle, "恢复用户自定义配置...", 45); - + // .user_mirror_config.json - User mirror source configuration match Self::extract_file_to_path( &mut archive, @@ -141,7 +142,7 @@ impl RestoreEngine { // Not a critical error, file may not exist in backup } } - + // .user_version_overrides.json - User version override configuration match Self::extract_file_to_path( &mut archive, @@ -179,11 +180,7 @@ impl RestoreEngine { // Step 7: Extract database/ SQL files Self::emit_progress(app_handle, "恢复数据库文件...", 85); - match Self::extract_prefix( - &mut archive, - "database/", - &project_root.join("database"), - ) { + match Self::extract_prefix(&mut archive, "database/", &project_root.join("database")) { Ok(files) => restored_files.extend(files), Err(e) => errors.push(format!("恢复数据库文件失败: {e}")), } @@ -200,7 +197,6 @@ impl RestoreEngine { }) } - /// Read manifest.json from a ZIP archive. fn read_manifest_from_archive( archive: &mut zip::ZipArchive, @@ -233,8 +229,7 @@ impl RestoreEngine { .map_err(|e| format!("读取文件内容 '{zip_entry}' 失败: {e}"))?; if let Some(parent) = target_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {e}"))?; + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; } std::fs::write(target_path, &content) @@ -307,8 +302,7 @@ impl RestoreEngine { .map_err(|e| format!("读取 .env 内容失败: {e}"))?; let env_path = project_root.join(".env"); - std::fs::write(&env_path, content) - .map_err(|e| format!("写入 .env 失败: {e}"))?; + std::fs::write(&env_path, content).map_err(|e| format!("写入 .env 失败: {e}"))?; Ok(()) } @@ -328,7 +322,6 @@ impl RestoreEngine { } } - #[cfg(test)] mod tests { use super::*; @@ -367,7 +360,8 @@ mod tests { let php_ini_hash = BackupEngine::compute_sha256(php_ini_content); // Add user custom configuration files - let user_mirror_config_content = b"{\"apt\":{\"source\":\"http://mirrors.aliyun.com/debian/\",\"enabled\":true}}"; + let user_mirror_config_content = + b"{\"apt\":{\"source\":\"http://mirrors.aliyun.com/debian/\",\"enabled\":true}}"; zip.start_file(".user_mirror_config.json", zip_options) .unwrap(); zip.write_all(user_mirror_config_content).unwrap(); @@ -385,7 +379,10 @@ mod tests { files.insert("docker-compose.yml".to_string(), compose_hash); files.insert("services/php82/php.ini".to_string(), php_ini_hash); files.insert(".user_mirror_config.json".to_string(), user_mirror_hash); - files.insert(".user_version_overrides.json".to_string(), user_version_hash); + files.insert( + ".user_version_overrides.json".to_string(), + user_version_hash, + ); let mut ports = HashMap::new(); ports.insert(3306, 3306); @@ -466,10 +463,7 @@ mod tests { services: Vec::new(), options: BackupOptions { include_projects: true, - project_patterns: vec![ - "www/test/**".to_string(), - "www/readme.md".to_string(), - ], + project_patterns: vec!["www/test/**".to_string(), "www/readme.md".to_string()], include_logs: false, }, files, @@ -509,8 +503,7 @@ mod tests { let tmp_dir = tempfile::tempdir().expect("创建临时目录失败"); let zip_path = create_test_backup(tmp_dir.path()); - let result = - RestoreEngine::verify_integrity(&zip_path).expect("验证完整性失败"); + let result = RestoreEngine::verify_integrity(&zip_path).expect("验证完整性失败"); assert!(result, "Valid backup should pass integrity check"); } @@ -557,10 +550,7 @@ mod tests { let result = RestoreEngine::verify_integrity(backup_path.to_str().unwrap()) .expect("验证完整性调用失败"); - assert!( - !result, - "Tampered backup should fail integrity check" - ); + assert!(!result, "Tampered backup should fail integrity check"); } #[test] @@ -573,11 +563,7 @@ mod tests { fs::create_dir_all(&restore_dir).expect("创建恢复目录失败"); let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(RestoreEngine::restore( - &zip_path, - &restore_dir, - None, - )); + let result = rt.block_on(RestoreEngine::restore(&zip_path, &restore_dir, None)); let restore_result = result.expect("恢复操作失败"); assert!( @@ -608,8 +594,7 @@ mod tests { php_ini_path.exists(), "services/php82/php.ini should be restored" ); - let php_ini_content = - fs::read_to_string(&php_ini_path).expect("读取 php.ini 失败"); + let php_ini_content = fs::read_to_string(&php_ini_path).expect("读取 php.ini 失败"); assert!( php_ini_content.contains("memory_limit=256M"), "php.ini should contain memory_limit" @@ -664,11 +649,7 @@ mod tests { fs::create_dir_all(&restore_dir).expect("创建恢复目录失败"); let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(RestoreEngine::restore( - &zip_path, - &restore_dir, - None, - )); + let result = rt.block_on(RestoreEngine::restore(&zip_path, &restore_dir, None)); let restore_result = result.expect("恢复操作失败"); assert!( diff --git a/src-tauri/src/engine/user_override_manager.rs b/src-tauri/src/engine/user_override_manager.rs index dabdc0c..3800077 100644 --- a/src-tauri/src/engine/user_override_manager.rs +++ b/src-tauri/src/engine/user_override_manager.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; -use super::version_manifest::{VersionEntry, ServiceType, VersionManifest}; +use super::version_manifest::{ServiceType, VersionEntry, VersionManifest}; use crate::app_log; /// 用户自定义的版本覆盖配置 @@ -28,7 +28,7 @@ impl UserOverrideManager { pub fn new(project_root: &Path) -> Self { let default_manifest = VersionManifest::new(); let user_overrides = Self::load_user_overrides(project_root); - + Self { default_manifest, user_overrides, @@ -41,21 +41,32 @@ impl UserOverrideManager { ) -> HashMap> { // 使用 project_root 作为配置文件存放位置(与 .env 同级) let overrides_path = project_root.join(".user_version_overrides.json"); - + if !overrides_path.exists() { - app_log!(info, "engine::user_override", "未找到用户覆盖配置文件,使用默认配置"); + app_log!( + info, + "engine::user_override", + "未找到用户覆盖配置文件,使用默认配置" + ); return HashMap::new(); } - app_log!(info, "engine::user_override", "加载用户覆盖配置: {:?}", overrides_path); + app_log!( + info, + "engine::user_override", + "加载用户覆盖配置: {:?}", + overrides_path + ); match std::fs::read_to_string(&overrides_path) { Ok(content) => { - match serde_json::from_str::>>(&content) { + match serde_json::from_str::>>( + &content, + ) { Ok(raw) => { let mut result = HashMap::new(); let mut override_count = 0; - + for (service_key, versions) in raw { let service_type = match service_key.as_str() { "php" => ServiceType::Php, @@ -65,11 +76,21 @@ impl UserOverrideManager { _ => continue, }; override_count += versions.len(); - app_log!(info, "engine::user_override", "{service_key}: {} 个版本覆盖", versions.len()); + app_log!( + info, + "engine::user_override", + "{service_key}: {} 个版本覆盖", + versions.len() + ); result.insert(service_type, versions); } - - app_log!(info, "engine::user_override", "加载成功,共 {} 个服务类型,{override_count} 个版本覆盖", result.len()); + + app_log!( + info, + "engine::user_override", + "加载成功,共 {} 个服务类型,{override_count} 个版本覆盖", + result.len() + ); result } Err(e) => { @@ -87,22 +108,22 @@ impl UserOverrideManager { /// 获取合并后的版本条目(用户覆盖 > 默认配置) /// 用户覆盖仅替换 image_tag(和可选的 description),其他字段保持 manifest 默认值 - pub fn get_merged_entry( - &self, - service_type: &ServiceType, - id: &str, - ) -> Option { + pub fn get_merged_entry(&self, service_type: &ServiceType, id: &str) -> Option { // 1. 检查用户是否有覆盖配置 if let Some(user_override) = self .user_overrides .get(service_type) .and_then(|entries| entries.get(id)) { - app_log!(info, "engine::user_override", "{} {} 使用自定义标签: {}", + app_log!( + info, + "engine::user_override", + "{} {} 使用自定义标签: {}", format!("{service_type:?}").to_lowercase(), - id, - user_override.image_tag); - + id, + user_override.image_tag + ); + // 2. 获取默认配置作为基础 if let Some(default_info) = self.default_manifest.get_entry(service_type, id) { // 3. 返回合并后的配置(用户覆盖的 image_tag 优先) @@ -113,7 +134,10 @@ impl UserOverrideManager { default_port: default_info.default_port, show_port: default_info.show_port, eol: default_info.eol, - description: user_override.description.clone().or_else(|| default_info.description.clone()), + description: user_override + .description + .clone() + .or_else(|| default_info.description.clone()), }); } } @@ -141,8 +165,7 @@ impl UserOverrideManager { let json = serde_json::to_string_pretty(&self.user_overrides) .map_err(|e| format!("序列化失败: {e}"))?; - std::fs::write(&overrides_path, json) - .map_err(|e| format!("写入文件失败: {e}"))?; + std::fs::write(&overrides_path, json).map_err(|e| format!("写入文件失败: {e}"))?; Ok(()) } @@ -163,8 +186,7 @@ impl UserOverrideManager { let json = serde_json::to_string_pretty(&self.user_overrides) .map_err(|e| format!("序列化失败: {e}"))?; - std::fs::write(&overrides_path, json) - .map_err(|e| format!("写入文件失败: {e}"))?; + std::fs::write(&overrides_path, json).map_err(|e| format!("写入文件失败: {e}"))?; Ok(()) } @@ -172,12 +194,11 @@ impl UserOverrideManager { /// 重置所有用户覆盖(恢复到默认配置) pub fn reset_all_overrides(&mut self, project_root: &Path) -> Result<(), String> { self.user_overrides.clear(); - + // 删除配置文件(与 .env 同级目录) let overrides_path = project_root.join(".user_version_overrides.json"); if overrides_path.exists() { - std::fs::remove_file(&overrides_path) - .map_err(|e| format!("删除文件失败: {e}"))?; + std::fs::remove_file(&overrides_path).map_err(|e| format!("删除文件失败: {e}"))?; } Ok(()) @@ -208,7 +229,7 @@ mod tests { fn test_get_default_when_no_override() { let temp_dir = env::temp_dir(); let manager = UserOverrideManager::new(&temp_dir); - + let info = manager.get_merged_entry(&ServiceType::Mysql, "mysql80"); assert!(info.is_some()); let info = info.unwrap(); diff --git a/src-tauri/src/engine/version_manifest.rs b/src-tauri/src/engine/version_manifest.rs index e9de1e2..72f105f 100644 --- a/src-tauri/src/engine/version_manifest.rs +++ b/src-tauri/src/engine/version_manifest.rs @@ -79,13 +79,11 @@ impl VersionManifest { prefix: &str, ) -> Option<(&String, &VersionEntry)> { let prefix_lower = prefix.to_lowercase(); - self.versions - .get(service_type) - .and_then(|entries| { - entries - .iter() - .find(|(_, entry)| entry.service_dir == prefix_lower) - }) + self.versions.get(service_type).and_then(|entries| { + entries + .iter() + .find(|(_, entry)| entry.service_dir == prefix_lower) + }) } /// 获取指定服务的所有可用版本条目,按版本号降序排列 @@ -130,12 +128,12 @@ impl VersionManifest { self.get_entry(service_type, id) .filter(|entry| entry.eol) .and_then(|entry| { - entry.description.as_ref().map(|desc| { - format!("⚠️ {desc} - 建议使用更新版本") - }) + entry + .description + .as_ref() + .map(|desc| format!("⚠️ {desc} - 建议使用更新版本")) }) } - } /// 从 ID 中提取版本数字用于排序(如 "php82" → (8, 2), "nginx128" → (1, 28)) diff --git a/src-tauri/src/engine/workspace_manager.rs b/src-tauri/src/engine/workspace_manager.rs index 2827abc..03a5f50 100644 --- a/src-tauri/src/engine/workspace_manager.rs +++ b/src-tauri/src/engine/workspace_manager.rs @@ -14,12 +14,12 @@ pub struct WorkspaceManager; impl WorkspaceManager { /// 获取 workspace.json 的路径(位于可执行文件同级) fn get_config_path() -> Result { - let exe_path = std::env::current_exe() - .map_err(|e| format!("获取程序路径失败: {e}"))?; - + let exe_path = std::env::current_exe().map_err(|e| format!("获取程序路径失败: {e}"))?; + let config_dir = if cfg!(debug_assertions) { // 开发模式:workspace.json 放在项目根目录 (src-tauri 的父目录) - exe_path.parent() + exe_path + .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .and_then(|p| p.parent()) @@ -27,7 +27,8 @@ impl WorkspaceManager { .to_path_buf() } else { // 生产模式:放在 exe 同级目录 - exe_path.parent() + exe_path + .parent() .ok_or("无法获取程序所在目录")? .to_path_buf() }; @@ -44,9 +45,9 @@ impl WorkspaceManager { let content = fs::read_to_string(&config_path) .map_err(|e| format!("读取 workspace.json 失败: {e}"))?; - - let config: WorkspaceConfig = serde_json::from_str(&content) - .map_err(|e| format!("解析 workspace.json 失败: {e}"))?; + + let config: WorkspaceConfig = + serde_json::from_str(&content).map_err(|e| format!("解析 workspace.json 失败: {e}"))?; Ok(Some(config)) } @@ -59,11 +60,10 @@ impl WorkspaceManager { last_updated: Some(chrono::Local::now().to_rfc3339()), }; - let json = serde_json::to_string_pretty(&config) - .map_err(|e| format!("序列化配置失败: {e}"))?; + let json = + serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?; - fs::write(&config_path, json) - .map_err(|e| format!("写入 workspace.json 失败: {e}"))?; + fs::write(&config_path, json).map_err(|e| format!("写入 workspace.json 失败: {e}"))?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 929ceaf..42388fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ -pub mod docker; pub mod commands; +pub mod docker; pub mod engine; pub mod logging; #[macro_use] @@ -14,7 +14,13 @@ pub fn run() { // 开发模式:使用项目根目录 std::env::current_exe() .ok() - .and_then(|p| p.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.to_path_buf())) + .and_then(|p| { + p.parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + }) .unwrap_or_else(|| std::path::PathBuf::from(".")) } else { // 生产模式:使用可执行文件所在目录 @@ -23,73 +29,79 @@ pub fn run() { .and_then(|p| p.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| std::path::PathBuf::from(".")) }; - + // 初始化日志系统 if let Err(e) = logging::init_logging(&log_dir) { eprintln!("Failed to initialize logging: {e}"); } - - app_log!(info, "app", "PHP-Stack started, log file at: {:?}", log_dir.join("php-stack.log")); - + + app_log!( + info, + "app", + "PHP-Stack started, log file at: {:?}", + log_dir.join("php-stack.log") + ); + app.handle().plugin(tauri_plugin_dialog::init())?; - app.handle().plugin(tauri_plugin_clipboard_manager::init())?; + app.handle() + .plugin(tauri_plugin_clipboard_manager::init())?; app.handle().plugin(tauri_plugin_shell::init())?; Ok(()) }) - .invoke_handler(tauri::generate_handler![ - // Dashboard - commands::check_docker, - commands::list_containers, - commands::list_all_running_containers, - commands::start_container, - commands::stop_container, - commands::restart_container, - commands::open_service_config, - // 可视化配置生成 - commands::load_existing_config, - commands::validate_env_config, - commands::generate_env_config, - commands::preview_compose, - commands::check_config_files_exist, - commands::apply_env_config, - commands::start_environment, - commands::restart_environment, - commands::stop_environment, - // 统一镜像源管理 - commands::get_mirror_presets, - commands::apply_mirror_preset, - commands::update_single_mirror, - commands::test_mirror, - commands::get_mirror_status, - commands::get_current_mirror_preset, - // 增强镜像源管理 - commands::get_merged_mirror_list, - commands::save_selected_mirror_option, - commands::save_user_mirror_category, - commands::remove_user_mirror_category, - commands::reset_all_mirror_overrides, - // 备份 - commands::create_backup, - commands::select_project_folder, - commands::convert_to_relative_path, - // 恢复 - commands::preview_restore, - commands::verify_backup, - commands::execute_restore, - // 工作目录管理 - commands::get_workspace_info, - commands::set_workspace_path, - // 版本管理 - commands::get_version_mappings, - commands::validate_version, - commands::get_recommended_version, - // 用户版本覆盖 - commands::save_user_override, - commands::remove_user_override, - commands::reset_all_overrides, - // 日志导出 - commands::export_logs, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .invoke_handler(tauri::generate_handler![ + // Dashboard + commands::check_docker, + commands::list_containers, + commands::list_all_running_containers, + commands::start_container, + commands::stop_container, + commands::restart_container, + commands::open_service_config, + // 可视化配置生成 + commands::load_existing_config, + commands::validate_env_config, + commands::generate_env_config, + commands::preview_compose, + commands::check_config_files_exist, + commands::apply_env_config, + commands::start_environment, + commands::restart_environment, + commands::stop_environment, + // 统一镜像源管理 + commands::get_mirror_presets, + commands::apply_mirror_preset, + commands::update_single_mirror, + commands::test_mirror, + commands::get_mirror_status, + commands::get_current_mirror_preset, + // 增强镜像源管理 + commands::get_merged_mirror_list, + commands::save_selected_mirror_option, + commands::save_user_mirror_category, + commands::remove_user_mirror_category, + commands::reset_all_mirror_overrides, + // 备份 + commands::create_backup, + commands::select_project_folder, + commands::convert_to_relative_path, + // 恢复 + commands::preview_restore, + commands::verify_backup, + commands::execute_restore, + // 工作目录管理 + commands::get_workspace_info, + commands::set_workspace_path, + // 版本管理 + commands::get_version_mappings, + commands::validate_version, + commands::get_recommended_version, + // 用户版本覆盖 + commands::save_user_override, + commands::remove_user_override, + commands::reset_all_overrides, + // 日志导出 + commands::export_logs, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index 6142bf8..17b75fc 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -12,38 +12,38 @@ pub fn init_logging(app_data_dir: &PathBuf) -> Result<(), String> { // 确保目录存在 std::fs::create_dir_all(app_data_dir) .map_err(|e| format!("无法创建应用数据目录 {}: {}", app_data_dir.display(), e))?; - + let log_path = app_data_dir.join("php-stack.log"); - + // 每次启动时覆盖写入(truncate) let file = OpenOptions::new() .create(true) .write(true) - .truncate(true) // 关键:覆盖旧日志 + .truncate(true) // 关键:覆盖旧日志 .open(&log_path) .map_err(|e| format!("无法创建日志文件 {}: {}", log_path.display(), e))?; - + // 保存文件句柄到全局变量 *LOG_FILE.lock().unwrap() = Some(file); - + // 配置 tracing subscriber - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info,app=debug")); - + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,app=debug")); + // 自定义格式化器:添加时间前缀 let formatter = fmt::format() .with_target(false) .with_level(true) .with_timer(CustomTimer) .compact(); - + // 输出到控制台 tracing_subscriber::fmt() .with_env_filter(filter) .event_format(formatter) .with_writer(std::io::stdout) .init(); - + Ok(()) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ad5fe83..69c3a72 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/src-tauri/tests/integration/backup_restore_integration.rs b/src-tauri/tests/integration/backup_restore_integration.rs index 3e286db..c98f479 100644 --- a/src-tauri/tests/integration/backup_restore_integration.rs +++ b/src-tauri/tests/integration/backup_restore_integration.rs @@ -2,8 +2,8 @@ // These tests verify the end-to-end backup and restore workflow use app_lib::engine::backup_engine::BackupEngine; -use app_lib::engine::restore_engine::RestoreEngine; use app_lib::engine::backup_manifest::BackupOptions; +use app_lib::engine::restore_engine::RestoreEngine; use std::fs; use tempfile::TempDir; @@ -12,17 +12,17 @@ async fn test_backup_and_restore_workflow() { // Create a temporary directory for testing let temp_dir = TempDir::new().expect("Failed to create temp dir"); let workspace_path = temp_dir.path().to_path_buf(); - + // Create a sample .env file let env_content = "PHP_VERSION=8.2\nMYSQL_PORT=3306\n"; let env_path = workspace_path.join(".env"); fs::write(&env_path, env_content).expect("Failed to write .env file"); - + // Create a sample docker-compose.yml file let compose_content = "version: '3'\nservices:\n php:\n image: php:8.2\n"; let compose_path = workspace_path.join("docker-compose.yml"); fs::write(&compose_path, compose_content).expect("Failed to write docker-compose.yml"); - + // Test backup creation let backup_path = workspace_path.join("backup.zip"); let options = BackupOptions { @@ -30,37 +30,42 @@ async fn test_backup_and_restore_workflow() { project_patterns: vec![], include_logs: false, }; - + let backup_result = BackupEngine::create_backup( backup_path.to_str().unwrap(), options, &workspace_path, None, // No app handle in tests - ).await; - + ) + .await; + assert!(backup_result.is_ok(), "Backup should succeed"); - + // Verify backup file exists assert!(backup_path.exists(), "Backup file should exist"); - + // Test restore from backup let restore_workspace = temp_dir.path().join("restored"); fs::create_dir(&restore_workspace).expect("Failed to create restore dir"); - + let restore_result = RestoreEngine::restore( backup_path.to_str().unwrap(), &restore_workspace, None, // No app handle in tests - ).await; - + ) + .await; + assert!(restore_result.is_ok(), "Restore should succeed"); - + // Verify restored files exist let restored_env = restore_workspace.join(".env"); assert!(restored_env.exists(), "Restored .env should exist"); - + let restored_compose = restore_workspace.join("docker-compose.yml"); - assert!(restored_compose.exists(), "Restored docker-compose.yml should exist"); + assert!( + restored_compose.exists(), + "Restored docker-compose.yml should exist" + ); } #[tokio::test] @@ -69,26 +74,30 @@ async fn test_backup_with_database_export() { // For now, we'll just test the structure without actual DB export let temp_dir = TempDir::new().expect("Failed to create temp dir"); let workspace_path = temp_dir.path().to_path_buf(); - + // Create minimal environment for backup let env_content = "MYSQL_HOST_PORT=3306\n"; let env_path = workspace_path.join(".env"); fs::write(&env_path, env_content).expect("Failed to write .env file"); - + let backup_path = workspace_path.join("backup_with_db.zip"); let options = BackupOptions { include_projects: false, project_patterns: vec![], include_logs: false, }; - + let backup_result = BackupEngine::create_backup( backup_path.to_str().unwrap(), options, &workspace_path, None, - ).await; - + ) + .await; + // Even without a real database, the backup should succeed - assert!(backup_result.is_ok(), "Backup should succeed even without DB"); + assert!( + backup_result.is_ok(), + "Backup should succeed even without DB" + ); } diff --git a/src-tauri/tests/integration/docker_manager_integration.rs b/src-tauri/tests/integration/docker_manager_integration.rs index ae549ef..c02d168 100644 --- a/src-tauri/tests/integration/docker_manager_integration.rs +++ b/src-tauri/tests/integration/docker_manager_integration.rs @@ -38,10 +38,7 @@ async fn test_list_all_running_containers() { ); } - assert!( - !containers.is_empty(), - "宿主机应至少有一个运行中的容器" - ); + assert!(!containers.is_empty(), "宿主机应至少有一个运行中的容器"); } #[tokio::test] diff --git a/src-tauri/tests/integration/workspace_commands_integration.rs b/src-tauri/tests/integration/workspace_commands_integration.rs index 91b797b..e8fc99c 100644 --- a/src-tauri/tests/integration/workspace_commands_integration.rs +++ b/src-tauri/tests/integration/workspace_commands_integration.rs @@ -1,9 +1,9 @@ // Integration tests for workspace and version management commands // These tests verify the end-to-end workspace and version workflow -use app_lib::engine::workspace_manager::WorkspaceManager; -use app_lib::engine::version_manifest::VersionManifest; use app_lib::engine::user_override_manager::UserOverrideManager; +use app_lib::engine::version_manifest::VersionManifest; +use app_lib::engine::workspace_manager::WorkspaceManager; use std::fs; use tempfile::TempDir; @@ -31,7 +31,10 @@ fn test_workspace_manager_save_and_load() { fn test_workspace_manager_load_nonexistent() { // When no workspace.json exists, load should return None let load_result = WorkspaceManager::load_workspace(); - assert!(load_result.is_ok(), "Load workspace should succeed even if file doesn't exist"); + assert!( + load_result.is_ok(), + "Load workspace should succeed even if file doesn't exist" + ); } #[test] @@ -39,16 +42,20 @@ fn test_version_manifest_load() { let manifest = VersionManifest::new(); // Test that we can get entries for each service type - let php_entries = manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Php); + let php_entries = + manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Php); assert!(!php_entries.is_empty(), "Should have PHP versions"); - let mysql_entries = manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Mysql); + let mysql_entries = + manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Mysql); assert!(!mysql_entries.is_empty(), "Should have MySQL versions"); - let redis_entries = manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Redis); + let redis_entries = + manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Redis); assert!(!redis_entries.is_empty(), "Should have Redis versions"); - let nginx_entries = manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Nginx); + let nginx_entries = + manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Nginx); assert!(!nginx_entries.is_empty(), "Should have Nginx versions"); } @@ -57,13 +64,17 @@ fn test_version_manifest_validate_id() { let manifest = VersionManifest::new(); // Test valid IDs - let php_entries = manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Php); + let php_entries = + manifest.get_available_entries(&app_lib::engine::version_manifest::ServiceType::Php); if let Some((id, _)) = php_entries.first() { assert!(manifest.is_id_valid(&app_lib::engine::version_manifest::ServiceType::Php, id)); } // Test invalid ID - assert!(!manifest.is_id_valid(&app_lib::engine::version_manifest::ServiceType::Php, "nonexistent_version")); + assert!(!manifest.is_id_valid( + &app_lib::engine::version_manifest::ServiceType::Php, + "nonexistent_version" + )); } #[test] @@ -71,17 +82,33 @@ fn test_version_manifest_recommended() { let manifest = VersionManifest::new(); // Each service type should have a recommended version - let php_recommended = manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Php); - assert!(php_recommended.is_some(), "Should have recommended PHP version"); + let php_recommended = + manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Php); + assert!( + php_recommended.is_some(), + "Should have recommended PHP version" + ); - let mysql_recommended = manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Mysql); - assert!(mysql_recommended.is_some(), "Should have recommended MySQL version"); + let mysql_recommended = + manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Mysql); + assert!( + mysql_recommended.is_some(), + "Should have recommended MySQL version" + ); - let redis_recommended = manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Redis); - assert!(redis_recommended.is_some(), "Should have recommended Redis version"); + let redis_recommended = + manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Redis); + assert!( + redis_recommended.is_some(), + "Should have recommended Redis version" + ); - let nginx_recommended = manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Nginx); - assert!(nginx_recommended.is_some(), "Should have recommended Nginx version"); + let nginx_recommended = + manifest.get_recommended_entry(&app_lib::engine::version_manifest::ServiceType::Nginx); + assert!( + nginx_recommended.is_some(), + "Should have recommended Nginx version" + ); } #[test] @@ -94,7 +121,7 @@ fn test_user_override_manager_save_and_load() { // Initially, no overrides should exist let has_override = manager.has_user_override( &app_lib::engine::version_manifest::ServiceType::Php, - "php82" + "php82", ); assert!(!has_override, "Should not have override initially"); @@ -115,14 +142,14 @@ fn test_user_override_manager_save_and_load() { // Now should have override let has_override = manager.has_user_override( &app_lib::engine::version_manifest::ServiceType::Php, - "php82" + "php82", ); assert!(has_override, "Should have override after save"); // Get merged entry should return custom values let merged = manager.get_merged_entry( &app_lib::engine::version_manifest::ServiceType::Php, - "php82" + "php82", ); assert!(merged.is_some(), "Should get merged entry"); let merged = merged.unwrap(); @@ -143,12 +170,14 @@ fn test_user_override_manager_remove() { description: None, }; - manager.save_user_override( - &project_root, - app_lib::engine::version_manifest::ServiceType::Php, - "php82".to_string(), - override_config, - ).unwrap(); + manager + .save_user_override( + &project_root, + app_lib::engine::version_manifest::ServiceType::Php, + "php82".to_string(), + override_config, + ) + .unwrap(); // Remove the override let remove_result = manager.remove_user_override( @@ -161,7 +190,7 @@ fn test_user_override_manager_remove() { // Should not have override anymore let has_override = manager.has_user_override( &app_lib::engine::version_manifest::ServiceType::Php, - "php82" + "php82", ); assert!(!has_override, "Should not have override after remove"); } @@ -184,19 +213,23 @@ fn test_user_override_manager_reset_all() { description: None, }; - manager.save_user_override( - &project_root, - app_lib::engine::version_manifest::ServiceType::Php, - "php82".to_string(), - override1, - ).unwrap(); - - manager.save_user_override( - &project_root, - app_lib::engine::version_manifest::ServiceType::Mysql, - "mysql80".to_string(), - override2, - ).unwrap(); + manager + .save_user_override( + &project_root, + app_lib::engine::version_manifest::ServiceType::Php, + "php82".to_string(), + override1, + ) + .unwrap(); + + manager + .save_user_override( + &project_root, + app_lib::engine::version_manifest::ServiceType::Mysql, + "mysql80".to_string(), + override2, + ) + .unwrap(); // Reset all overrides let reset_result = manager.reset_all_overrides(&project_root); @@ -205,15 +238,21 @@ fn test_user_override_manager_reset_all() { // Should not have any overrides let has_php_override = manager.has_user_override( &app_lib::engine::version_manifest::ServiceType::Php, - "php82" + "php82", ); let has_mysql_override = manager.has_user_override( &app_lib::engine::version_manifest::ServiceType::Mysql, - "mysql80" + "mysql80", ); - assert!(!has_php_override, "Should not have PHP override after reset"); - assert!(!has_mysql_override, "Should not have MySQL override after reset"); + assert!( + !has_php_override, + "Should not have PHP override after reset" + ); + assert!( + !has_mysql_override, + "Should not have MySQL override after reset" + ); } #[test] @@ -267,4 +306,4 @@ MYSQL_ROOT_PASSWORD=secret123 let content2 = fs::read_to_string(&env_path).expect("Failed to read updated .env file"); let env3 = EnvFile::parse(&content2).expect("Failed to parse updated .env file"); assert_eq!(env3.get("MYSQL_ROOT_PASSWORD"), Some("new_secret")); -} \ No newline at end of file +} From 75220798d1303b0c87a6bfcafe7f0afab3f7375a Mon Sep 17 00:00:00 2001 From: jeeinn Date: Sun, 20 Sep 2026 11:24:18 +0800 Subject: [PATCH 02/27] =?UTF-8?q?fix(restore):=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E5=90=AB=E8=B7=AF=E5=BE=84=E9=81=8D=E5=8E=86=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E5=A4=87=E4=BB=BD=E5=8C=85=EF=BC=8C=E5=A0=B5=E4=BD=8F?= =?UTF-8?q?=20zip-slip=20=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 is_safe_entry_name 校验:拒绝绝对路径、父目录引用(..)、盘符/UNC 前缀 - restore() 在任何写盘操作前对全部条目做安全校验 - 回归测试:恶意 ZIP(../../escaped.txt)整体拒绝且不落盘 - 对应改进报告 R1(docs/IMPROVEMENT_REPORT.md) --- src-tauri/src/engine/restore_engine.rs | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src-tauri/src/engine/restore_engine.rs b/src-tauri/src/engine/restore_engine.rs index 705ede1..674ea30 100644 --- a/src-tauri/src/engine/restore_engine.rs +++ b/src-tauri/src/engine/restore_engine.rs @@ -29,6 +29,28 @@ pub struct RestoreResult { pub struct RestoreEngine; +/// 校验 ZIP 条目名是否安全(zip-slip 路径遍历防护) +/// +/// 拒绝三类条目: +/// - 绝对路径(`/etc/passwd`、Windows 盘符 `C:\...` 或 UNC `\\...`) +/// - 包含父目录引用(`../`) +/// - 根目录组件 +fn is_safe_entry_name(name: &str) -> bool { + let path = std::path::Path::new(name); + if path.is_absolute() { + return false; + } + for comp in path.components() { + match comp { + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => return false, + _ => {} + } + } + true +} + impl RestoreEngine { /// Parse backup ZIP and return preview info. /// Reads manifest.json from ZIP, detects port conflicts, counts files. @@ -99,6 +121,18 @@ impl RestoreEngine { let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("解析 ZIP 文件失败: {e}"))?; + // Step 0: 安全校验——拒绝包含路径遍历条目的备份包,在任何写盘操作之前拦截 + for i in 0..archive.len() { + let name = archive + .by_index(i) + .map_err(|e| format!("读取 ZIP 条目失败: {e}"))? + .name() + .to_string(); + if !is_safe_entry_name(&name) { + return Err(format!("备份包包含非法路径条目,已拒绝恢复: {name}")); + } + } + // Step 1: Read manifest Self::emit_progress(app_handle, "解析备份包...", 5); let manifest = Self::read_manifest_from_archive(&mut archive)?; @@ -332,6 +366,61 @@ mod tests { use std::io::Write; use zip::write::FileOptions; + /// Feature: restore-security, Property: 恶意条目名必须被拒绝 + #[test] + fn test_is_safe_entry_name_rejects_traversal() { + assert!(!is_safe_entry_name("../evil.txt")); + assert!(!is_safe_entry_name("a/../../evil.txt")); + assert!(!is_safe_entry_name("/etc/passwd")); + assert!(!is_safe_entry_name("C:\\Windows\\evil.txt")); + assert!(!is_safe_entry_name("\\\\server\\share\\evil.txt")); + } + + #[test] + fn test_is_safe_entry_name_accepts_normal_paths() { + assert!(is_safe_entry_name(".env")); + assert!(is_safe_entry_name("docker-compose.yml")); + assert!(is_safe_entry_name("services/php82/php.ini")); + assert!(is_safe_entry_name("projects/www/test/index.php")); + assert!(is_safe_entry_name("services/php82/sub/dir/conf.d/default.conf")); + } + + /// Feature: restore-security, Property: 含路径遍历条目的 ZIP 必须整体拒绝且不写盘 + #[test] + fn test_restore_rejects_zip_slip() { + let tmp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let restore_dir = tmp_dir.path().join("restored"); + fs::create_dir_all(&restore_dir).expect("创建恢复目录失败"); + + // 构造含 ../ 逃逸条目的恶意 ZIP + let malicious_zip = tmp_dir.path().join("malicious.zip"); + let file = fs::File::create(&malicious_zip).expect("创建恶意 ZIP 失败"); + let mut zip = zip::ZipWriter::new(file); + let zip_options = + FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated); + zip.start_file("../../escaped.txt", zip_options).unwrap(); + zip.write_all(b"pwned").unwrap(); + zip.finish().unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(RestoreEngine::restore( + malicious_zip.to_str().unwrap(), + &restore_dir, + None, + )); + + assert!(result.is_err(), "含路径遍历条目的备份包必须被拒绝"); + let err_msg = result.unwrap_err(); + assert!( + err_msg.contains("../../escaped.txt"), + "错误信息应指出非法条目,实际: {err_msg}" + ); + + // 逃逸目标(restore_dir 的上级)不应出现被写入的文件 + let escaped = tmp_dir.path().join("escaped.txt"); + assert!(!escaped.exists(), "逃逸文件不应被写入磁盘"); + } + /// Helper: create a test backup ZIP with manifest and some files. fn create_test_backup(dir: &Path) -> String { let backup_path = dir.join("test_backup.zip"); From 0e5dec49a83715bbdc4d422b85e93d9bcff82980 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 09:14:02 +0800 Subject: [PATCH 03/27] =?UTF-8?q?refactor(commands):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=8C=E6=97=A0=E6=95=88=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=BD=AC=E4=B8=BA=E7=9C=9F=E6=96=AD=E8=A8=80=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Nginx=20=E7=AB=AF=E5=8F=A3=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20off-by-one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 select_project_folder 空占位命令及其 invoke_handler 注册(前端从未调用) - load_existing_config 解析主体抽为纯函数 parse_env_to_services,3 个无断言空测试改为真断言 (多 Redis / 多 Nginx / 混合服务 + MYSQL_ROOT_PASSWORD 不误识别 + 未收录前缀回退) - config_generation_integration 占位测试替换为 5 个端到端断言(validate/env/compose/自定义变量保留) - 修复真实 bug:NGINX 版本号切片 [6..] 少一位,非默认端口的 Nginx 配置回读时被错误重置为 80 (新测试捕获;对应报告 A2 魔数切片风险的现实印证,整段重写留待后续批次) - 修复 clippy cmp_owned 警告 - 对应改进报告 A6、Q4(docs/IMPROVEMENT_REPORT.md) --- src-tauri/src/commands/backup.rs | 12 +- src-tauri/src/commands/env_config.rs | 246 ++++++++++-------- src-tauri/src/engine/restore_engine.rs | 4 +- src-tauri/src/lib.rs | 1 - .../config_generation_integration.rs | 109 +++++++- 5 files changed, 240 insertions(+), 132 deletions(-) diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index df231fc..c842e02 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -63,14 +63,6 @@ pub async fn execute_restore(zip_path: String, app_handle: tauri::AppHandle) -> } } -/// 选择项目文件夹并转换为相对路径 -#[tauri::command] -pub fn select_project_folder() -> Result, String> { - // 实际的文件选择逻辑应该在前端通过 @tauri-apps/plugin-dialog 实现 - // 这里仅作为占位符 - Ok(None) -} - /// 将绝对路径转换为相对于项目根目录的路径 #[tauri::command] pub fn convert_to_relative_path( @@ -82,9 +74,7 @@ pub fn convert_to_relative_path( // 使用 pathdiff 计算相对路径,它会自动处理跨平台差异(如 Windows 盘符) match pathdiff::diff_paths(&abs_path, &project_root) { - Some(relative) - if relative.as_os_str().is_empty() || relative == std::path::PathBuf::from(".") => - { + Some(relative) if relative.as_os_str().is_empty() || relative.as_os_str() == "." => { Err("不能选择项目根目录本身,请选择其子文件或子文件夹".to_string()) } Some(relative) => { diff --git a/src-tauri/src/commands/env_config.rs b/src-tauri/src/commands/env_config.rs index 89f433d..48381a2 100644 --- a/src-tauri/src/commands/env_config.rs +++ b/src-tauri/src/commands/env_config.rs @@ -119,30 +119,17 @@ pub fn validate_env_config(config: EnvConfig) -> Result<(), String> { ConfigGenerator::validate(&config) } -/// 读取现有配置文件并解析为 EnvConfig -#[tauri::command] -pub fn load_existing_config() -> Result, String> { - let project_root = get_project_root()?; - let env_path = project_root.join(".env"); - let compose_path = project_root.join("docker-compose.yml"); - - // 如果两个文件都不存在,返回 None - if !env_path.exists() || !compose_path.exists() { - return Ok(None); - } - - // 读取 .env 文件 - let env_content = - std::fs::read_to_string(&env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))?; - let env_file = crate::engine::env_parser::EnvFile::parse(&env_content) - .map_err(|e| format!("解析 .env 文件失败: {e}"))?; - let env_map = env_file.to_map(); +/// 从 .env 变量映射解析出服务配置列表(纯函数,便于单元测试) +/// +/// 按键名前缀(PHP82_VERSION / MYSQL80_VERSION / ...)识别多版本服务, +/// 并通过 VersionManifest 反查版本 ID;未收录的前缀回退为小写 ID。 +fn parse_env_to_services( + env_map: &std::collections::HashMap, + manifest: &VersionManifest, +) -> Vec { + use crate::engine::config_generator::{ServiceEntry, ServiceType}; - // 解析服务配置 - let mut services: Vec = Vec::new(); - - // 创建 VersionManifest 用于 env prefix 反查 - let manifest = VersionManifest::new(); + let mut services: Vec = Vec::new(); // 解析 PHP 服务(支持多版本) // 查找所有 PHPxx_VERSION 格式的键 @@ -174,8 +161,8 @@ pub fn load_existing_config() -> Result, String> { .get(&ext_key) .map(|exts| exts.split(',').map(|s| s.trim().to_string()).collect()); - services.push(crate::engine::config_generator::ServiceEntry { - service_type: crate::engine::config_generator::ServiceType::PHP, + services.push(ServiceEntry { + service_type: ServiceType::PHP, version, host_port, extensions, @@ -213,8 +200,8 @@ pub fn load_existing_config() -> Result, String> { .and_then(|p| p.parse::().ok()) .unwrap_or(3306 + idx as u16); - services.push(crate::engine::config_generator::ServiceEntry { - service_type: crate::engine::config_generator::ServiceType::MySQL, + services.push(ServiceEntry { + service_type: ServiceType::MySQL, version, host_port, extensions: None, @@ -246,8 +233,8 @@ pub fn load_existing_config() -> Result, String> { .and_then(|p| p.parse::().ok()) .unwrap_or(6379); - services.push(crate::engine::config_generator::ServiceEntry { - service_type: crate::engine::config_generator::ServiceType::Redis, + services.push(ServiceEntry { + service_type: ServiceType::Redis, version, host_port, extensions: None, @@ -259,7 +246,8 @@ pub fn load_existing_config() -> Result, String> { for key in env_map.keys() { if key.ends_with("_VERSION") && key.starts_with("NGINX") { let prefix = &key[..key.len() - 8]; - let index_part = &key[6..key.len() - 8]; // NGINX 是 5 个字母 + 1 = 6 + // NGINX 为 5 个字母(与 MYSQL/REDIS 一致),版本号从第 5 位开始 + let index_part = &key[5..key.len() - 8]; if index_part.is_empty() { continue; @@ -277,8 +265,8 @@ pub fn load_existing_config() -> Result, String> { .and_then(|p| p.parse::().ok()) .unwrap_or(80); - services.push(crate::engine::config_generator::ServiceEntry { - service_type: crate::engine::config_generator::ServiceType::Nginx, + services.push(ServiceEntry { + service_type: ServiceType::Nginx, version, host_port, extensions: None, @@ -286,6 +274,32 @@ pub fn load_existing_config() -> Result, String> { } } + services +} + +/// 读取现有配置文件并解析为 EnvConfig +#[tauri::command] +pub fn load_existing_config() -> Result, String> { + let project_root = get_project_root()?; + let env_path = project_root.join(".env"); + let compose_path = project_root.join("docker-compose.yml"); + + // 如果两个文件都不存在,返回 None + if !env_path.exists() || !compose_path.exists() { + return Ok(None); + } + + // 读取 .env 文件 + let env_content = + std::fs::read_to_string(&env_path).map_err(|e| format!("读取 .env 文件失败: {e}"))?; + let env_file = crate::engine::env_parser::EnvFile::parse(&env_content) + .map_err(|e| format!("解析 .env 文件失败: {e}"))?; + let env_map = env_file.to_map(); + + // 创建 VersionManifest 用于 env prefix 反查 + let manifest = VersionManifest::new(); + let services = parse_env_to_services(&env_map, &manifest); + // 如果没有解析到任何服务,返回 None if services.is_empty() { return Ok(None); @@ -1433,87 +1447,107 @@ pub async fn stop_environment(app_handle: tauri::AppHandle) -> Result std::collections::HashMap { + EnvFile::parse(content) + .expect("解析 .env 内容失败") + .to_map() + } - /// 测试 load_existing_config 解析多版本 Redis + /// 测试解析多版本 Redis #[test] - fn test_load_existing_config_multi_redis() { - // 创建临时目录 - let temp_dir = std::env::temp_dir().join("php_stack_test_multi_redis"); - fs::create_dir_all(&temp_dir).unwrap(); - - // 创建测试 .env 文件 - let env_content = r#"SOURCE_DIR=./www -TZ=Asia/Shanghai -REDIS62_VERSION=6.2-alpine-01 -REDIS62_HOST_PORT=6379 -REDIS72_VERSION=7.2-alpine -REDIS72_HOST_PORT=6380 -"#; - fs::write(temp_dir.join(".env"), env_content).unwrap(); - - // 创建空的 docker-compose.yml - fs::write( - temp_dir.join("docker-compose.yml"), - "version: '3'\nservices: {}\n", - ) - .unwrap(); - - // 临时修改 project_root(这里无法直接测试,因为 get_project_root 是硬编码的) - // 所以这个测试主要用于验证解析逻辑 - - // 清理 - fs::remove_dir_all(&temp_dir).ok(); + fn test_parse_env_to_services_multi_redis() { + let env_map = parse_env( + "SOURCE_DIR=./www\nTZ=Asia/Shanghai\nREDIS62_VERSION=6.2-alpine-01\nREDIS62_HOST_PORT=6379\nREDIS72_VERSION=7.2-alpine\nREDIS72_HOST_PORT=6380\n", + ); + let manifest = VersionManifest::new(); + let services = parse_env_to_services(&env_map, &manifest); + + let redis: Vec<_> = services + .iter() + .filter(|s| matches!(s.service_type, ServiceType::Redis)) + .collect(); + assert_eq!( + redis.len(), + 2, + "应解析出 2 个 Redis 服务,实际: {services:?}" + ); + + let ports: Vec = redis.iter().map(|s| s.host_port).collect(); + assert!(ports.contains(&6379), "REDIS62 端口应为 6379"); + assert!(ports.contains(&6380), "REDIS72 端口应为 6380"); } - /// 测试 load_existing_config 解析多版本 Nginx + /// 测试解析多版本 Nginx #[test] - fn test_load_existing_config_multi_nginx() { - let temp_dir = std::env::temp_dir().join("php_stack_test_multi_nginx"); - fs::create_dir_all(&temp_dir).unwrap(); - - let env_content = r#"SOURCE_DIR=./www -TZ=Asia/Shanghai -NGINX127_VERSION=1.27-alpine -NGINX127_HTTP_HOST_PORT=80 -NGINX125_VERSION=1.25-alpine -NGINX125_HTTP_HOST_PORT=8080 -"#; - fs::write(temp_dir.join(".env"), env_content).unwrap(); - fs::write( - temp_dir.join("docker-compose.yml"), - "version: '3'\nservices: {}\n", - ) - .unwrap(); - - fs::remove_dir_all(&temp_dir).ok(); + fn test_parse_env_to_services_multi_nginx() { + let env_map = parse_env( + "SOURCE_DIR=./www\nTZ=Asia/Shanghai\nNGINX127_VERSION=1.27-alpine\nNGINX127_HTTP_HOST_PORT=80\nNGINX125_VERSION=1.25-alpine\nNGINX125_HTTP_HOST_PORT=8080\n", + ); + let manifest = VersionManifest::new(); + let services = parse_env_to_services(&env_map, &manifest); + + let nginx: Vec<_> = services + .iter() + .filter(|s| matches!(s.service_type, ServiceType::Nginx)) + .collect(); + assert_eq!( + nginx.len(), + 2, + "应解析出 2 个 Nginx 服务,实际: {services:?}" + ); + + let ports: Vec = nginx.iter().map(|s| s.host_port).collect(); + assert!(ports.contains(&80), "NGINX127 端口应为 80"); + assert!(ports.contains(&8080), "NGINX125 端口应为 8080"); + + // manifest 收录的版本应反查出 ID,未收录的回退为小写前缀 + let ids: Vec<&str> = nginx.iter().map(|s| s.version.as_str()).collect(); + assert!(ids.contains(&"nginx127"), "NGINX127 应反查为 ID nginx127"); + assert!(ids.contains(&"nginx125"), "NGINX125 应反查为 ID nginx125"); } - /// 测试 load_existing_config 解析混合服务 + /// 测试解析混合服务(含 manifest 未收录的 PHP85 回退路径) #[test] - fn test_load_existing_config_mixed_services() { - let temp_dir = std::env::temp_dir().join("php_stack_test_mixed"); - fs::create_dir_all(&temp_dir).unwrap(); - - let env_content = r#"SOURCE_DIR=./www -TZ=Asia/Shanghai -PHP85_VERSION=8.5 -PHP85_HOST_PORT=9000 -PHP85_EXTENSIONS=mysqli,mbstring -MYSQL84_VERSION=8.4 -MYSQL84_HOST_PORT=3306 -REDIS62_VERSION=6.2-alpine-01 -REDIS62_HOST_PORT=6379 -NGINX127_VERSION=1.27-alpine -NGINX127_HTTP_HOST_PORT=80 -"#; - fs::write(temp_dir.join(".env"), env_content).unwrap(); - fs::write( - temp_dir.join("docker-compose.yml"), - "version: '3'\nservices: {}\n", - ) - .unwrap(); - - fs::remove_dir_all(&temp_dir).ok(); + fn test_parse_env_to_services_mixed_services() { + let env_map = parse_env( + "SOURCE_DIR=./www\nTZ=Asia/Shanghai\nPHP85_VERSION=8.5\nPHP85_HOST_PORT=9000\nPHP85_EXTENSIONS=mysqli,mbstring\nMYSQL84_VERSION=8.4\nMYSQL84_HOST_PORT=3306\nREDIS62_VERSION=6.2-alpine-01\nREDIS62_HOST_PORT=6379\nNGINX127_VERSION=1.27-alpine\nNGINX127_HTTP_HOST_PORT=80\n", + ); + let manifest = VersionManifest::new(); + let services = parse_env_to_services(&env_map, &manifest); + + assert_eq!(services.len(), 4, "应解析出 4 个服务,实际: {services:?}"); + + let php = services + .iter() + .find(|s| matches!(s.service_type, ServiceType::PHP)) + .expect("应包含 PHP 服务"); + assert_eq!(php.version, "php85", "未收录的 PHP85 应回退为小写前缀"); + assert_eq!(php.host_port, 9000); + let php_exts = php.extensions.as_ref().expect("PHP 服务应有扩展列表"); + assert_eq!( + php_exts, + &["mysqli".to_string(), "mbstring".to_string()], + "扩展列表应按逗号拆分" + ); + + let mysql = services + .iter() + .find(|s| matches!(s.service_type, ServiceType::MySQL)) + .expect("应包含 MySQL 服务"); + assert_eq!(mysql.version, "mysql84"); + assert_eq!(mysql.host_port, 3306); + + // MYSQL_ROOT_PASSWORD 不应被误识别为 MySQL 服务 + let with_root = parse_env("MYSQL_ROOT_PASSWORD=secret\n"); + let no_services = parse_env_to_services(&with_root, &VersionManifest::new()); + assert!( + no_services.is_empty(), + "仅 ROOT_PASSWORD 时不应解析出服务,实际: {no_services:?}" + ); } } diff --git a/src-tauri/src/engine/restore_engine.rs b/src-tauri/src/engine/restore_engine.rs index 674ea30..8b83584 100644 --- a/src-tauri/src/engine/restore_engine.rs +++ b/src-tauri/src/engine/restore_engine.rs @@ -382,7 +382,9 @@ mod tests { assert!(is_safe_entry_name("docker-compose.yml")); assert!(is_safe_entry_name("services/php82/php.ini")); assert!(is_safe_entry_name("projects/www/test/index.php")); - assert!(is_safe_entry_name("services/php82/sub/dir/conf.d/default.conf")); + assert!(is_safe_entry_name( + "services/php82/sub/dir/conf.d/default.conf" + )); } /// Feature: restore-security, Property: 含路径遍历条目的 ZIP 必须整体拒绝且不写盘 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42388fb..426c020 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,7 +82,6 @@ pub fn run() { commands::reset_all_mirror_overrides, // 备份 commands::create_backup, - commands::select_project_folder, commands::convert_to_relative_path, // 恢复 commands::preview_restore, diff --git a/src-tauri/tests/integration/config_generation_integration.rs b/src-tauri/tests/integration/config_generation_integration.rs index 288a330..315a8df 100644 --- a/src-tauri/tests/integration/config_generation_integration.rs +++ b/src-tauri/tests/integration/config_generation_integration.rs @@ -1,15 +1,98 @@ -// Integration tests for configuration generation -// These tests verify the end-to-end config generation workflow -// Note: Most config generation logic is covered by unit tests in config_generator.rs -// Integration tests would require full Tauri app context - -#[cfg(test)] -mod integration_tests_placeholder { - #[test] - fn test_integration_tests_setup() { - // Placeholder test to verify integration test setup works - // Actual config generation is tested in unit tests - // This test ensures the integration test module compiles and runs correctly - assert!(true, "Integration test setup is working"); +//! 配置生成端到端集成测试:EnvConfig → validate → .env 内容 → compose 内容 +//! +//! 解析细节由 config_generator.rs 的单元测试覆盖,本文件验证完整链路。 + +use app_lib::engine::config_generator::{ConfigGenerator, EnvConfig, ServiceEntry, ServiceType}; + +fn sample_config() -> EnvConfig { + EnvConfig { + services: vec![ + ServiceEntry { + service_type: ServiceType::PHP, + version: "php82".to_string(), + host_port: 9000, + extensions: Some(vec!["pdo_mysql".to_string(), "mysqli".to_string()]), + }, + ServiceEntry { + service_type: ServiceType::MySQL, + version: "mysql80".to_string(), + host_port: 3306, + extensions: None, + }, + ], + source_dir: "./www".to_string(), + timezone: "Asia/Shanghai".to_string(), + mysql_root_password: None, } } + +#[test] +fn test_validate_accepts_conflict_free_config() { + ConfigGenerator::validate(&sample_config()).expect("无冲突配置应通过验证"); +} + +#[test] +fn test_validate_rejects_port_conflict() { + let mut config = sample_config(); + config.services[1].host_port = 9000; // 与 PHP 服务同端口 + let result = ConfigGenerator::validate(&config); + assert!(result.is_err(), "同端口配置应验证失败"); + assert!( + result.unwrap_err().contains("9000"), + "错误信息应包含冲突端口" + ); +} + +#[test] +fn test_generate_env_contains_expected_keys() { + let tmp = tempfile::tempdir().expect("创建临时目录失败"); + let env = ConfigGenerator::generate_env(&sample_config(), None, tmp.path()); + let formatted = env.format(); + + assert!(formatted.contains("SOURCE_DIR=./www"), "应包含 SOURCE_DIR"); + assert!(formatted.contains("TZ=Asia/Shanghai"), "应包含 TZ"); + assert!( + formatted.contains("PHP82_VERSION=php:8.2-fpm"), + "PHP82_VERSION 应来自 manifest 的 image_tag,实际:\n{formatted}" + ); + assert!( + formatted.contains("PHP82_EXTENSIONS=pdo_mysql,mysqli"), + "PHP82_EXTENSIONS 应包含所选扩展" + ); + assert!( + formatted.contains("MYSQL80_HOST_PORT=3306"), + "MYSQL80_HOST_PORT 应为 3306" + ); + assert!( + formatted.contains("MYSQL_ROOT_PASSWORD=root"), + "未指定密码时应使用默认 root" + ); +} + +#[test] +fn test_generate_compose_contains_services() { + let compose = ConfigGenerator::generate_compose(&sample_config()); + + assert!( + compose.contains("php82"), + "compose 应包含 php82 服务配置,实际:\n{compose}" + ); + assert!( + compose.contains("mysql80"), + "compose 应包含 mysql80 服务配置" + ); +} + +#[test] +fn test_generate_env_preserves_user_variables() { + let tmp = tempfile::tempdir().expect("创建临时目录失败"); + let existing = app_lib::engine::env_parser::EnvFile::parse("MY_CUSTOM_VAR=hello\n").unwrap(); + + let env = ConfigGenerator::generate_env(&sample_config(), Some(&existing), tmp.path()); + let formatted = env.format(); + + assert!( + formatted.contains("MY_CUSTOM_VAR=hello"), + "用户自定义变量应被保留,实际:\n{formatted}" + ); +} From 73166af754e64b83208438a6603aad6de8d3384a Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 09:14:24 +0800 Subject: [PATCH 04/27] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20push/PR=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ubuntu 单 job:cargo fmt --check / clippy -D warnings / cargo test / vitest / vue-tsc+vite build - 多平台打包仍由 release.yml(tag 触发)覆盖 - 对应改进报告 Q1(docs/IMPROVEMENT_REPORT.md) --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c73cc17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Test & Build + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev + + - name: Rust format check + working-directory: src-tauri + run: cargo fmt --check + + - name: Rust clippy + working-directory: src-tauri + run: cargo clippy --all-targets -- -D warnings + + - name: Rust tests + working-directory: src-tauri + run: cargo test + + - name: Install frontend dependencies + run: npm ci + + - name: Frontend tests + run: npm run test:run + + - name: Frontend type-check & build + run: npm run build From 199a5a15227b58d35772ead831ca34dd4d395205 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 09:14:35 +0800 Subject: [PATCH 05/27] =?UTF-8?q?chore:=20=E8=A1=A5=E5=85=85=20MIT=20LICEN?= =?UTF-8?q?SE=20=E6=96=87=E4=BB=B6=EF=BC=8C=E5=A3=B0=E6=98=8E=20Cargo=20li?= =?UTF-8?q?cense=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 中的 MIT 链接此前指向不存在的文件 - 对应改进报告 Q3(docs/IMPROVEMENT_REPORT.md) --- LICENSE | 21 +++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..093a9b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jeeinn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f91335..78059ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "app" version = "0.3.1" description = "PHP-Stack - PHP Development Environment Manager" authors = ["jeeinn"] -license = "" +license = "MIT" repository = "" edition = "2021" rust-version = "1.77.2" From d3adcbf8a5423947e012b592df59a2b16406149c Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 09:21:55 +0800 Subject: [PATCH 06/27] =?UTF-8?q?fix(env-config):=20=E5=A4=9A=20PHP=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=9A=84=E8=87=AA=E5=AE=9A=E4=B9=89=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=BE=93=E5=85=A5=E6=94=B9=E4=B8=BA=E6=8C=89=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=8B=AC=E7=AB=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - customExtInput 由单一字符串改为 Record,与扩展面板状态同模式 - 新增/删除 PHP 服务时同步维护输入状态索引 - 回归测试:两个 PHP 服务的自定义扩展输入互不串扰(已验证旧代码上该测试失败) - 对应改进报告 U1(docs/IMPROVEMENT_REPORT.md) --- src/components/EnvConfigPage.vue | 30 +++++++++---- .../__tests__/EnvConfigPage.spec.ts | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/components/EnvConfigPage.vue b/src/components/EnvConfigPage.vue index 28832b6..3a46dea 100644 --- a/src/components/EnvConfigPage.vue +++ b/src/components/EnvConfigPage.vue @@ -29,7 +29,7 @@ const commonExtensions = [ 'imagick', 'exif', 'pcntl', 'sockets' ]; -const customExtInput = ref(''); +const customExtInput = ref>({}); // 为每个 PHP 服务维护独立的自定义扩展输入 // 为每个 PHP 服务维护独立的扩展面板展开状态 const phpExtensionsPanelState = ref>({}); @@ -409,8 +409,9 @@ function addPhpVersion() { host_port: 9000 + phpServices.value.length, extensions: ['pdo_mysql', 'mysqli', 'mbstring', 'curl'], }); - // 初始化新添加的 PHP 服务的扩展面板状态为关闭 + // 初始化新添加的 PHP 服务的扩展面板状态与自定义扩展输入 phpExtensionsPanelState.value[newIndex] = false; + customExtInput.value[newIndex] = ''; } function removePhpVersion(index: number) { @@ -418,6 +419,7 @@ function removePhpVersion(index: number) { phpServices.value.splice(index, 1); // 清理已删除服务的状态 delete phpExtensionsPanelState.value[index]; + delete customExtInput.value[index]; // 重新索引后续服务的状态 const newState: Record = {}; Object.keys(phpExtensionsPanelState.value).forEach(key => { @@ -429,6 +431,16 @@ function removePhpVersion(index: number) { } }); phpExtensionsPanelState.value = newState; + const newInputs: Record = {}; + Object.keys(customExtInput.value).forEach(key => { + const numKey = Number(key); + if (numKey > index) { + newInputs[numKey - 1] = customExtInput.value[numKey]; + } else { + newInputs[numKey] = customExtInput.value[numKey]; + } + }); + customExtInput.value = newInputs; } // Add MySQL version @@ -494,16 +506,16 @@ function toggleExtension(phpIndex: number, ext: string) { function syncCustomExtensions(phpIndex: number) { const service = phpServices.value[phpIndex]; if (!service.extensions) service.extensions = []; - + // 获取当前已选的预设扩展 const presetExts = service.extensions.filter(e => commonExtensions.includes(e)); - - // 解析用户输入的自定义扩展 - const customExts = customExtInput.value + + // 解析该服务自己的自定义扩展输入 + const customExts = (customExtInput.value[phpIndex] || '') .split(/[,\s]+/) .map(s => s.trim()) .filter(s => s.length > 0 && !commonExtensions.includes(s)); - + // 合并并去重 service.extensions = [...new Set([...presetExts, ...customExts])]; } @@ -858,8 +870,8 @@ const goToMirrorSettings = () => {
- { expect(text).toContain('MySQL 8.0') expect(text).toContain('mysql:8.0') }) + + it('custom extension inputs are independent across multiple PHP services', async () => { + const wrapper = mount(EnvConfigPage) + await flushPromises() + + // 在 PHP 分区内点击"添加版本",得到两个 PHP 服务卡片 + const sections = wrapper.findAll('section') + const phpSection = sections.find(s => s.text().includes('PHP 服务'))! + const addBtn = phpSection.findAll('button').find(b => b.text().includes('添加版本'))! + await addBtn.trigger('click') + + // 展开两个服务的扩展面板(默认收起) + const panelToggles = phpSection + .findAll('button') + .filter(b => b.text().includes('预设扩展库')) + expect(panelToggles.length).toBe(2) + for (const toggle of panelToggles) { + await toggle.trigger('click') + } + + // 找到两个"自定义扩展"输入框(placeholder 定位) + const customInputs = phpSection.findAll('input[placeholder="例如: grpc protobuf"]') + expect(customInputs.length).toBe(2) + + // 给第一个服务的输入框填写内容 + await customInputs[0].setValue('xdebug') + expect((customInputs[0].element as HTMLInputElement).value).toBe('xdebug') + + // 给第二个服务的输入框填写不同内容(修复前共享同一个 ref,会覆盖第一个输入框) + await customInputs[1].setValue('swoole') + + const v1 = (customInputs[0].element as HTMLInputElement).value + const v2 = (customInputs[1].element as HTMLInputElement).value + expect(v1).toBe('xdebug') + expect(v2).toBe('swoole') + expect(v1).not.toBe(v2) + + // blur 触发各自的合并逻辑,输入框内容保持独立 + await customInputs[0].trigger('blur') + await customInputs[1].trigger('blur') + expect((customInputs[0].element as HTMLInputElement).value).toBe('xdebug') + expect((customInputs[1].element as HTMLInputElement).value).toBe('swoole') + }) }) From 14029f79123c763f41b29bf0e3753cc250a54d48 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 09:22:08 +0800 Subject: [PATCH 07/27] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=20AGENTS.md=20?= =?UTF-8?q?=E5=A4=B1=E5=AE=9E=E6=8F=8F=E8=BF=B0=EF=BC=8CCHANGELOG=20?= =?UTF-8?q?=E8=A1=A5=E8=AE=B0=209=20=E6=9C=88=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md:移除 proptest 属性测试描述(代码中 0 使用,依赖一并移除); 恢复端口冲突检测/自动分配、后端日志英文化标记为待完善; 集成测试数量、恢复引擎特性(zip-slip 防护)同步实际实现 - 新增测试规范:禁止 assert!(true) 占位,解析逻辑先抽纯函数再测 - CHANGELOG Unreleased 补记 2026-09 的修复与改进 - 对应改进报告 Q2(docs/IMPROVEMENT_REPORT.md) --- AGENTS.md | 28 ++++++++++++++++++---------- CHANGELOG.md | 16 ++++++++++++++++ src-tauri/Cargo.toml | 1 - 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4eb1e2c..c7bab8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,24 +70,24 @@ ### 4. 环境恢复(RestorePage) - **需求覆盖**: 需求 4.1-4.10 -- **实现状态**: ✅ 完成 +- **实现状态**: ✅ 完成(端口冲突检测除外,见「待完善功能」) - **核心功能**: - 备份包预览(manifest 解析、文件统计) - SHA256 完整性验证 - - 端口冲突检测与自动分配 + - 路径遍历防护(zip-slip,恶意备份包整体拒绝) - 配置文件、项目文件、SQL 文件还原(SQL 导入执行待完善) - 进度通知与错误汇总 ### 5. 基础设施模块 - **env_parser.rs**: .env 文件可靠读写,保留注释和空行(Property 9, 10) - **backup_manifest.rs**: Manifest 序列化/反序列化(Property 11, 12) -- **测试覆盖**: 后端单元测试 + 3 个集成测试(backup_restore、config_generation、workspace_commands)+ 前端 8 个组件测试(Vitest),含属性测试(proptest) +- **测试覆盖**: 后端单元测试 + 4 个集成测试(backup_restore、config_generation、workspace_commands、docker_manager)+ 前端组件测试(Vitest) ## ✅ v0.2.0 / v0.3.0 新增功能 ### 1. 前端国际化(i18n) - 中/英双语支持,运行时动态切换(vue-i18n) -- 后端日志英文化 +- ⚠️ 后端用户可见日志(ui_log!)当前为中文,i18n 化待完善 ### 2. 主题预设系统 - 自动 / 明亮 / 暗黑三种模式,全组件适配 @@ -115,9 +115,9 @@ 3. **测试**: - **单元测试**:放在源文件内的 `#[cfg(test)] mod tests { ... }` 模块中 - **集成测试**:放在 `src-tauri/tests/integration/` 目录下 - - 纯函数模块(env_parser、backup_manifest、config_generator)使用 `proptest` 进行属性测试 - - 标签格式:`// Feature: env-config-and-backup, Property N: {property_text}` - - 运行测试:`cargo test` + - 解析/生成类纯函数应抽为可直接测试的函数,测试必须包含真断言(禁止 `assert!(true)` 占位) + - 安全或回归类测试建议标注:`// Feature: {feature}, Property: {描述}` + - 运行测试:`cargo test`;CI 同时执行 `cargo fmt --check` 与 `cargo clippy --all-targets -- -D warnings` 4. **模块注册**: - 新增引擎模块需在 `engine/mod.rs` 中声明 `pub mod xxx;` - 新增命令子模块需在 `commands/mod.rs` 中声明并 re-export @@ -183,8 +183,8 @@ - 特性: - 备份预览(manifest 解析) - SHA256 完整性验证 - - 端口冲突检测与自动分配 - - 配置文件、项目文件还原(数据库 SQL 导入待完善) + - 路径遍历防护(zip-slip) + - 配置文件、项目文件还原(端口冲突检测、数据库 SQL 导入待完善) ### 7. 备份清单(v0.1.0 新增) - 位置:`src-tauri/src/engine/backup_manifest.rs` @@ -302,7 +302,7 @@ 3. **权限校验**: 若新增了 Tauri 插件调用,请务必更新 `src-tauri/capabilities/default.json`。 4. **TDD 流程**: - 优先编写单元测试 - - 纯函数模块使用 proptest 进行属性测试 + - 解析/生成类逻辑先抽纯函数再测 - 运行 `cargo test` 确保所有测试通过 5. **类型同步**: 修改 Rust 数据结构后,同步更新 `src/types/` 中的 TypeScript 类型定义。 @@ -339,6 +339,10 @@ ### 待完善功能 +#### 恢复端口冲突检测(中优先级) +- **当前状态**:恢复直接使用备份包中的端口,不检测宿主机端口冲突(早期文档宣称已实现,实际从未落地) +- **待完善**:恢复预览时读取 manifest.services 的端口映射,与当前运行容器/本机占用比对,冲突时提示改端口 + #### 数据库备份/恢复(低优先级) - **目标**:完善备份/恢复引擎中的数据库导出与导入 - **当前状态**:备份不导出数据库(`BackupOptions` 无 `include_database`),恢复仅提取 SQL 文件到本地、不执行导入 @@ -348,6 +352,10 @@ - 事务性恢复(失败时回滚) - **优先级**: 低(当前版本可使用手动方式备份/恢复数据库) +#### 后端日志 i18n(低优先级) +- **当前状态**:`ui_log!` 输出的用户可见日志为中文 + emoji,英文界面下日志面板仍显示中文 +- **建议策略**:结构化字段(模块、级别)保持英文,面向用户的固定短语如需双语,由前端按 key 翻译;不做完整后端 i18n + ### 开发建议 1. **稳定优先**: v0.3.1 重点是稳定性和用户体验优化 2. **Bug 修复**: 优先处理用户反馈的问题和边界情况 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0156438..7a86a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,24 @@ ### 待发布功能 - 虚拟主机管理(Nginx 站点配置) +### 🐛 修复 +- 一键停止改用 `docker compose stop`,保留容器并展示已停用状态 +- 统一服务模板解析路径,释放后不覆盖用户修改 +- 容器状态 running 文案中文化 +- 恢复引擎拒绝含路径遍历条目的备份包,堵住 zip-slip 漏洞 +- 修复 Nginx 多版本配置回读时非默认端口被错误重置为 80 的问题 +- 修复环境配置页多 PHP 服务时自定义扩展输入互相串扰的问题 + +### 🔧 改进 +- 环境配置页 .env 解析主体抽为纯函数并以真断言测试覆盖 +- 配置生成集成测试替换为端到端断言(validate / .env / compose / 自定义变量保留) +- 清理死代码:未注册使用的占位命令、无效占位测试、未使用的 proptest 依赖 +- 新增 CI 测试流水线(fmt / clippy / cargo test / 前端测试 / 构建) +- 补充 MIT LICENSE 文件,声明 Cargo `license` 字段 + ### 📁 文档 - 合并 `doc/` 与 `docs/` 为统一的 `docs/` 目录,更新 README、AGENTS、文档索引中的路径引用 +- 修订 AGENTS.md 与实际实现不符的描述(proptest 属性测试、恢复端口冲突检测、后端日志英文化),未实现项移入「待完善功能」 --- diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 78059ea..ea92711 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,7 +41,6 @@ sha2 = "0.10" pathdiff = "0.2" [dev-dependencies] -proptest = "1" tempfile = "3" [[test]] From 59c567d3cbf5914e25a37b20e5f25aed1f08aa1d Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 14:49:45 +0800 Subject: [PATCH 08/27] =?UTF-8?q?fix(scripts):=20=E7=A7=BB=E9=99=A4=20sheb?= =?UTF-8?q?ang=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20SyntaxError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-php-dockerfile.mjs / sync-version-manifest.mjs 首行的 #!/usr/bin/env node 在 vitest 解析 ESM 时不会被剥离,导致 scripts/__tests__ 下两个 spec 报 "Invalid or unexpected token" 而无法加载(2 个 suite 全红)。 脚本统一通过 `node scripts/...` 调用(package.json 已封装为 npm scripts), shebang 无实际作用,移除不影响可执行性。 验证:vitest 15 文件 / 176 测试全通过;node scripts/sync-php-dockerfile.mjs --check 正常 --- scripts/sync-php-dockerfile.mjs | 5 ++++- scripts/sync-version-manifest.mjs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/sync-php-dockerfile.mjs b/scripts/sync-php-dockerfile.mjs index c5598c0..e713dec 100644 --- a/scripts/sync-php-dockerfile.mjs +++ b/scripts/sync-php-dockerfile.mjs @@ -1,7 +1,10 @@ -#!/usr/bin/env node /** * 同步各 PHP 版本目录下的 Dockerfile。 * + * 注意:本文件不含 shebang。vitest 解析 ESM 时不会剥离 `#!` 行, + * 会导致 scripts/__tests__ 下的单元测试报 "Invalid or unexpected token"。 + * 统一通过 `node scripts/sync-php-dockerfile.mjs` 调用(package.json 已封装为 npm scripts)。 + * * 背景:services/phpXX/Dockerfile 共 8 份,内容仅两行不同(版本注释与 * ARG PHP_BASE_IMAGE 的默认值),其余完全一致。由于 config_generator.rs * 的 resolve_template_dir() 要求每个 service_dir 都真实存在 Dockerfile diff --git a/scripts/sync-version-manifest.mjs b/scripts/sync-version-manifest.mjs index ba637fa..642b36a 100644 --- a/scripts/sync-version-manifest.mjs +++ b/scripts/sync-version-manifest.mjs @@ -1,8 +1,11 @@ -#!/usr/bin/env node /** * 从上游(docker-library/official-images + endoflife.date)拉取版本元数据, * 与 src-tauri/services/version_manifest.json 对比,生成可审计的差异报告。 * + * 注意:本文件不含 shebang。vitest 解析 ESM 时不会剥离 `#!` 行, + * 会导致 scripts/__tests__ 下的单元测试报 "Invalid or unexpected token"。 + * 统一通过 `node scripts/sync-version-manifest.mjs` 调用(package.json 已封装为 npm scripts)。 + * * 背景:version_manifest.json 之前是手工维护的,结果严重滞后(项目 PHP 还在 * 8.5,但上游已有 8.6-RC;MySQL 跳过了整条 9.x)。本脚本在仓库侧 / CI 跑, * 把"哪些版本算 stable"的决策外包给 endoflife.date(权威 EOL 数据源), From 9645872031ffcf27775e445eb79babc8a1eb3fab Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:00:56 +0800 Subject: [PATCH 09/27] =?UTF-8?q?feat(restore):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=89=8D=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=8C=85=EF=BC=88R2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复逐文件覆盖 .env / docker-compose.yml / services/,中途失败会留下 半恢复状态且无法回退——对一个迁移工具来说是最伤信任的故障模式。 改动: - restore_engine.rs: RestoreResult 新增 rollback_path 字段(Option,缺省不序列化) - commands/backup.rs: 新增 create_rollback_bundle(),在 restore 前把现有配置 打包为 工作区/.restore_rollback_<时间戳>.zip(仅配置文件,不含项目与日志) - 首次恢复(无 .env/compose/services)时跳过,避免产生无意义的空包 - 回滚包生成失败不阻断恢复,只记日志——它是兜底手段而非前置条件 - 致命错误返回时把回滚包路径一并附上,用户至少能回到恢复前 配套测试(commands/backup.rs):空工作区跳过生成 / 生成的是合法非空 ZIP 验证:cargo test 全绿(单元测试 95 → 97) --- src-tauri/src/commands/backup.rs | 120 +++++++++++++++++++++++-- src-tauri/src/engine/restore_engine.rs | 9 ++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index c842e02..1f860a4 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -1,6 +1,7 @@ +use crate::app_log; use crate::engine::backup_engine::BackupEngine; use crate::engine::backup_manifest::BackupOptions; -use crate::engine::restore_engine::{RestoreEngine, RestorePreview}; +use crate::engine::restore_engine::{RestoreEngine, RestorePreview, RestoreResult}; use super::get_project_root; @@ -51,15 +52,77 @@ pub fn verify_backup(zip_path: String) -> Result { } /// 执行环境恢复 +/// +/// 始终返回 `RestoreResult`(U2):成功与「部分成功」都把文件清单和错误明细 +/// 交回前端渲染,只有致命错误(备份包打不开等)才走 `Err`。 #[tauri::command] -pub async fn execute_restore(zip_path: String, app_handle: tauri::AppHandle) -> Result<(), String> { +pub async fn execute_restore( + zip_path: String, + app_handle: tauri::AppHandle, +) -> Result { let project_root = get_project_root()?; - let result = RestoreEngine::restore(&zip_path, &project_root, Some(&app_handle)).await?; - if result.success { - Ok(()) - } else { - Err(format!("恢复完成但存在错误:\n{}", result.errors.join("\n"))) + // R2: 恢复是逐文件覆盖的破坏性操作,开始前先打一份回滚包 + let rollback_path = create_rollback_bundle(&project_root).await; + + let mut result = match RestoreEngine::restore(&zip_path, &project_root, Some(&app_handle)).await + { + Ok(r) => r, + Err(e) => { + // 致命错误:把回滚包位置一并告知,用户至少能回到恢复前的状态 + return Err(match &rollback_path { + Some(p) => format!("{e}\n\n已生成恢复前回滚包: {p}"), + None => e, + }); + } + }; + + result.rollback_path = rollback_path; + Ok(result) +} + +/// 恢复前把即将被覆盖的配置文件打包成回滚包(R2) +/// +/// 写入项目根目录 `.restore_rollback_<时间戳>.zip`,只打包配置文件 +/// (不含项目文件与日志),保证体积小、回滚快。 +/// 失败不阻断恢复——回滚包是兜底手段,没有它也允许用户继续。 +async fn create_rollback_bundle(project_root: &std::path::Path) -> Option { + // 首次恢复场景:没有任何现有配置,不存在覆盖风险,跳过 + let has_existing = project_root.join(".env").exists() + || project_root.join("docker-compose.yml").exists() + || project_root.join("services").exists(); + if !has_existing { + return None; + } + + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let save_path = project_root.join(format!(".restore_rollback_{timestamp}.zip")); + let options = BackupOptions { + include_projects: false, + project_patterns: Vec::new(), + include_logs: false, + }; + + match BackupEngine::create_backup(&save_path.to_string_lossy(), options, project_root, None) + .await + { + Ok(()) => { + app_log!( + info, + "commands::execute_restore", + "已生成恢复前回滚包: {}", + save_path.display() + ); + Some(save_path.to_string_lossy().to_string()) + } + Err(e) => { + app_log!( + warn, + "commands::execute_restore", + "生成回滚包失败(不阻断恢复): {e}" + ); + None + } } } @@ -98,3 +161,46 @@ pub fn convert_to_relative_path( None => Err("无法计算相对路径,请确保文件位于项目目录内".to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// `create_rollback_bundle` 是 async,但内部只做文件 IO, + /// 用当前线程 runtime block 住即可。 + fn block_on(fut: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("创建 tokio runtime") + .block_on(fut) + } + + #[test] + fn test_create_rollback_bundle_skips_empty_workspace() { + let dir = tempfile::tempdir().unwrap(); + let result = block_on(create_rollback_bundle(dir.path())); + assert!(result.is_none(), "没有任何现有配置时不应生成回滚包"); + } + + #[test] + fn test_create_rollback_bundle_creates_valid_zip() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".env"), "PHP82_VERSION=8.2\n").unwrap(); + + let result = block_on(create_rollback_bundle(dir.path())); + let path = result.expect("存在 .env 时应生成回滚包"); + + assert!(std::path::Path::new(&path).exists(), "回滚包文件应存在"); + assert!( + path.contains(".restore_rollback_"), + "回滚包应带可识别的前缀,实际: {path}" + ); + + // 回滚包必须是合法 ZIP——否则用户真要回滚时才发现打不开 + let file = fs::File::open(&path).expect("回滚包应可读"); + let archive = zip::ZipArchive::new(file).expect("回滚包应是合法 ZIP"); + assert!(archive.len() > 0, "回滚包不应为空"); + } +} diff --git a/src-tauri/src/engine/restore_engine.rs b/src-tauri/src/engine/restore_engine.rs index 8b83584..adc9aa7 100644 --- a/src-tauri/src/engine/restore_engine.rs +++ b/src-tauri/src/engine/restore_engine.rs @@ -25,6 +25,13 @@ pub struct RestoreResult { pub success: bool, pub restored_files: Vec, pub errors: Vec, + /// 恢复开始前自动生成的回滚包路径(R2)。 + /// + /// 恢复是逐文件覆盖现有配置的破坏性操作,中途失败会留下半恢复状态。 + /// 该字段指向恢复前的自动备份,用户可用它一键回退。 + /// 由调用方 `commands::execute_restore` 在创建回滚包后填入。 + #[serde(skip_serializing_if = "Option::is_none")] + pub rollback_path: Option, } pub struct RestoreEngine; @@ -228,6 +235,8 @@ impl RestoreEngine { success: errors.is_empty(), restored_files, errors, + // 由调用方(commands::execute_restore)在创建回滚包后填入 + rollback_path: None, }) } From e7361d1e1ec9e1e26f61b21b358dd12e52f74509 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:01:11 +0800 Subject: [PATCH 10/27] =?UTF-8?q?feat(ui):=20=E6=81=A2=E5=A4=8D=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B1=95=E7=A4=BA=E6=96=87=E4=BB=B6=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E9=94=99=E8=AF=AF=E6=98=8E=E7=BB=86=EF=BC=88U2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 execute_restore 原本把多行 errors 用 \n 拼进 Err,前端弹一个 3 秒 toast——用户既看不全也来不及看,而 restored_files 明细后端算好了却没展示。 改动: - 后端:execute_restore 改为始终返回 RestoreResult,只有致命错误才走 Err - 前端:RestoreResult 类型 + restoreResult 状态;第 4 步渲染结果卡片 (已恢复文件列表 / 逐条错误明细 / 回滚包路径),成功与部分成功都展示 - 部分失败(success=false)不再静默当成功处理:停在原步骤 + warning toast - 中英文案补齐 restore.result.* / restore.partialSuccess.* / toast.partialSuccess 配套测试(RestorePage.spec.ts): - 成功时渲染文件列表与回滚包路径,不出现错误区块 - 部分失败时逐条渲染 errors,仍给出回滚包路径 验证:vitest 178 通过(新增 2) --- src/components/RestorePage.vue | 55 ++++++++++-- src/components/__tests__/RestorePage.spec.ts | 95 +++++++++++++++++++- src/i18n/locales/en.json | 14 ++- src/i18n/locales/zh-CN.json | 14 ++- src/types/env-config.ts | 12 +++ 5 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/components/RestorePage.vue b/src/components/RestorePage.vue index fe52f30..465156f 100644 --- a/src/components/RestorePage.vue +++ b/src/components/RestorePage.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'; import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; import { listen } from '@tauri-apps/api/event'; -import type { RestorePreview, RestoreProgress } from '../types/env-config'; +import type { RestorePreview, RestoreProgress, RestoreResult } from '../types/env-config'; import { showToast } from '../composables/useToast'; import { showConfirm } from '../composables/useConfirmDialog'; @@ -18,6 +18,8 @@ const verified = ref(null); const restoring = ref(false); const loading = ref(false); const progress = ref(null); +// U2: 恢复结果明细(已恢复文件 / 错误列表 / 回滚包路径) +const restoreResult = ref(null); const currentStep = ref('select'); const completedSteps = ref>(new Set()); @@ -146,15 +148,27 @@ async function handleRestore() { restoring.value = true; progress.value = { step: t('common.loading'), percentage: 0 }; + restoreResult.value = null; + try { - await invoke('execute_restore', { + // U2: 后端始终返回 RestoreResult,部分失败也能拿到逐条明细 + const result = await invoke('execute_restore', { zipPath: zipPath.value, }); - showToast(t('restore.toast.success'), 'success'); - progress.value = { step: '✅', percentage: 100 }; - markStepCompleted('restore'); + restoreResult.value = result; + + if (result.success) { + showToast(t('restore.toast.success'), 'success'); + progress.value = { step: '✅', percentage: 100 }; + markStepCompleted('restore'); + } else { + // 部分失败:不静默吞掉,明细留在页面上供用户逐条查看 + showToast(t('restore.toast.partialSuccess'), 'warning'); + progress.value = null; + } } catch (e) { showToast(e as string, 'error'); + progress.value = null; } finally { restoring.value = false; } @@ -345,6 +359,37 @@ function formatTimestamp(ts: string): string {

{{ $t('restore.success.title') }}

{{ $t('restore.success.description') }}

+ + +
+
+

{{ $t('restore.partialSuccess.title') }}

+

{{ $t('restore.partialSuccess.description') }}

+
+ +
+

+ {{ $t('restore.result.restoredFiles') }} + ({{ $t('restore.result.filesCount', { count: restoreResult.restored_files.length }) }}) +

+
    +
  • {{ file }}
  • +
+
+ +
+

{{ $t('restore.result.errors') }}

+
    +
  • {{ err }}
  • +
+
+ +
+

{{ $t('restore.result.rollbackTitle') }}

+

{{ $t('restore.result.rollbackHint') }}

+ {{ restoreResult.rollback_path }} +
+
diff --git a/src/components/__tests__/RestorePage.spec.ts b/src/components/__tests__/RestorePage.spec.ts index 16e1319..14870a9 100644 --- a/src/components/__tests__/RestorePage.spec.ts +++ b/src/components/__tests__/RestorePage.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { mount } from '@vue/test-utils' +import { mount, flushPromises } from '@vue/test-utils' import RestorePage from '../RestorePage.vue' +import { invoke } from '@tauri-apps/api/core' // Mock @tauri-apps/api/core vi.mock('@tauri-apps/api/core', () => ({ @@ -91,4 +92,96 @@ describe('RestorePage', () => { const stepIndicators = wrapper.findAll('.rounded-full') expect(stepIndicators.length).toBeGreaterThanOrEqual(4) }) +}) + +// ─── U2 / R2:恢复结果明细展示 ────────────────────────────────────── +// 以前 execute_restore 只回 Ok/Err,前端弹一句 toast 就结束, +// 用户既看不全 restored_files 也来不及看 errors。现在改为始终返回结果, +// 这两条测试把「明细真的渲染出来了」锁住。 + +const PREVIEW_PAYLOAD = { + manifest: { + timestamp: '2026-04-30T10:00:00Z', + app_version: '0.3.1', + os_info: 'Windows 11', + services: [{ name: 'ps-nginx', image: 'nginx', version: '1.27' }], + files: { '.env': {}, 'docker-compose.yml': {} }, + errors: [], + }, +} + +/** 走完 select → preview → verify,停在第 4 步,可执行恢复 */ +async function prepareRestoreStep(wrapper: ReturnType) { + const vm = wrapper.vm as any + await vm.selectFile() + await vm.handlePreview() + await vm.handleVerify() + vm.goToStep('restore') + await flushPromises() +} + +function mockInvoke(executeRestoreResult: unknown) { + vi.mocked(invoke).mockImplementation(async (command: string) => { + if (command === 'preview_restore') return PREVIEW_PAYLOAD + if (command === 'verify_backup') return true + if (command === 'execute_restore') return executeRestoreResult + return null + }) +} + +describe('RestorePage — 恢复结果明细', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('成功时展示已恢复文件列表与回滚包路径', async () => { + mockInvoke({ + success: true, + restored_files: ['.env', 'docker-compose.yml', 'services/php82/php.ini'], + errors: [], + rollback_path: '/ws/.restore_rollback_20260921_120000.zip', + }) + + const wrapper = mount(RestorePage) + await prepareRestoreStep(wrapper) + await (wrapper.vm as any).handleRestore() + await flushPromises() + + const files = wrapper.find('[data-testid="restored-files"]') + expect(files.exists()).toBe(true) + expect(files.text()).toContain('.env') + expect(files.text()).toContain('services/php82/php.ini') + + // R2:回滚包路径必须可见,否则用户不知道去哪撤销 + const rollback = wrapper.find('[data-testid="rollback-path"]') + expect(rollback.exists()).toBe(true) + expect(rollback.text()).toContain('.restore_rollback_') + + // 成功时不应出现错误区块 + expect(wrapper.find('[data-testid="restore-errors"]').exists()).toBe(false) + }) + + it('部分失败时逐条展示错误明细,不折叠成一句话 toast', async () => { + mockInvoke({ + success: false, + restored_files: ['.env'], + errors: ['恢复 services/ 失败: 权限不足', '恢复 database/ 失败: 条目损坏'], + rollback_path: '/ws/.restore_rollback_20260921_120001.zip', + }) + + const wrapper = mount(RestorePage) + await prepareRestoreStep(wrapper) + await (wrapper.vm as any).handleRestore() + await flushPromises() + + const errors = wrapper.find('[data-testid="restore-errors"]') + expect(errors.exists()).toBe(true) + expect(errors.text()).toContain('恢复 services/ 失败: 权限不足') + expect(errors.text()).toContain('恢复 database/ 失败: 条目损坏') + + // 部分失败同样要给出回滚包,这是用户唯一的退路 + expect(wrapper.find('[data-testid="rollback-path"]').text()).toContain( + '.restore_rollback_', + ) + }) }) \ No newline at end of file diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ac99b90..7e13071 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -548,6 +548,17 @@ "title": "Environment Restored!", "description": "You can restart Docker containers to apply the new configuration" }, + "partialSuccess": { + "title": "Restore finished with errors", + "description": "Files that could not be restored are listed below. Use the rollback bundle to undo this restore if needed." + }, + "result": { + "restoredFiles": "Restored files", + "filesCount": "{count} files", + "errors": "Errors", + "rollbackTitle": "Pre-restore rollback bundle", + "rollbackHint": "Your previous configuration was backed up automatically. To undo, restore from this bundle:" + }, "progress": { "title": "📊 Restore Progress" }, @@ -560,7 +571,8 @@ "previewDone": "Preview complete, check backup contents", "verifyPassed": "Verification passed, ready to restore", "verifyFailed": "Verification failed, backup may be corrupted", - "success": "Environment restored successfully!" + "success": "Environment restored successfully!", + "partialSuccess": "⚠️ Restore finished with errors, see details below" } }, "workspace": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3a049e2..ac01f2a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -548,6 +548,17 @@ "title": "环境恢复成功!", "description": "您可以重新启动 Docker 容器以应用新的配置" }, + "partialSuccess": { + "title": "恢复完成,但有部分失败", + "description": "下方列出了未能恢复的文件。如需撤销本次恢复,可使用回滚包还原。" + }, + "result": { + "restoredFiles": "已恢复文件", + "filesCount": "共 {count} 个文件", + "errors": "错误明细", + "rollbackTitle": "恢复前回滚包", + "rollbackHint": "恢复开始前已自动备份当前配置,如需撤销请用这个备份包重新恢复:" + }, "progress": { "title": "📊 恢复进度" }, @@ -560,7 +571,8 @@ "previewDone": "预览完成,请查看备份内容", "verifyPassed": "校验通过,可以开始恢复", "verifyFailed": "校验失败,备份文件可能已损坏", - "success": "环境恢复成功!" + "success": "环境恢复成功!", + "partialSuccess": "⚠️ 恢复完成但存在错误,请查看下方明细" } }, "workspace": { diff --git a/src/types/env-config.ts b/src/types/env-config.ts index d184bd8..0af8535 100644 --- a/src/types/env-config.ts +++ b/src/types/env-config.ts @@ -111,6 +111,18 @@ export interface RestoreProgress { percentage: number; } +/// 后端 `RestoreResult` 序列化形态 +/// +/// 恢复命令始终返回该结构(U2):success=false 表示「部分失败」而非整体失败, +/// 此时 errors 里有逐条明细,前端需完整展示而不是只弹一句 toast。 +export interface RestoreResult { + success: boolean; + restored_files: string[]; + errors: string[]; + /// 恢复前自动生成的回滚包绝对路径(R2)。无回滚包时后端省略该字段。 + rollback_path?: string | null; +} + // Docker 容器端口冲突信息 export interface ContainerPortConflict { port: number; From 6bdb3c6fa316395923dfb392119f4ad6b68b50f3 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:09:06 +0800 Subject: [PATCH 11/27] =?UTF-8?q?refactor(env-config):=20Docker=20?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E8=B0=83=E7=94=A8=E7=BB=9F=E4=B8=80=E8=B5=B0?= =?UTF-8?q?=20spawn=5Fblocking=EF=BC=88R3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_environment 在 async 命令里直接跑同步子进程调用,会占住 Tauri 的 async 执行线程,同线程上的其它命令(容器列表轮询、镜像探测等)一起被拖慢。 改动: - 新增 run_blocking_command():把 FnOnce() -> Result 的同步闭包 丢进 tokio 阻塞线程池,与 commands/backup.rs 的既有做法对齐 - docker compose down 的 Command::output() 改走该助手 - docker compose up 的 Child::wait() 改走该助手 - get_compose_logs 改为 async(内部同样 spawn_blocking)——它在启动流程中 最长 5 分钟内每 2 秒被调用一次,是最持续的线程占用来源 - 顺手修掉上一提交引入的 clippy 警告(archive.len() > 0 → !is_empty()) 未做:commands/backup.rs 里 create_backup 的 spawn_blocking + block_on 双层 嵌套未简化——那是为了绕开 BackupEngine 返回非 Send future 的约束, 改成单层 tokio::spawn 会直接编译失败,收益不抵风险。 配套测试:run_blocking_command 的成功/失败结果原样透传 验证:cargo test 98 通过,cargo clippy 零警告,cargo fmt --check 通过 --- src-tauri/src/commands/backup.rs | 2 +- src-tauri/src/commands/env_config.rs | 148 +++++++++++++++++---------- 2 files changed, 97 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index 1f860a4..87f2af3 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -201,6 +201,6 @@ mod tests { // 回滚包必须是合法 ZIP——否则用户真要回滚时才发现打不开 let file = fs::File::open(&path).expect("回滚包应可读"); let archive = zip::ZipArchive::new(file).expect("回滚包应是合法 ZIP"); - assert!(archive.len() > 0, "回滚包不应为空"); + assert!(!archive.is_empty(), "回滚包不应为空"); } } diff --git a/src-tauri/src/commands/env_config.rs b/src-tauri/src/commands/env_config.rs index 6dfc94a..8c1fe9a 100644 --- a/src-tauri/src/commands/env_config.rs +++ b/src-tauri/src/commands/env_config.rs @@ -13,49 +13,72 @@ pub struct PullImageResult { pub error: Option, } -/// 获取 Docker Compose 容器的最新日志(非阻塞) +/// 把同步子进程调用挪到阻塞线程池执行(R3) +/// +/// Tauri 命令跑在 async 执行线程上,同步的 `Command::output()` / `Child::wait()` +/// 会把整条线程占住,同线程上的其它命令(容器列表轮询、镜像探测等) +/// 会被一起拖慢。这里收敛成统一助手,与 `commands/backup.rs` 的既有做法对齐。 +async fn run_blocking_command(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| format!("阻塞任务执行失败: {e}"))? +} + +/// 获取 Docker Compose 容器的最新日志 /// /// 使用 `docker compose logs --tail 100` 获取最近日志, /// 通过 skip_lines 跳过已处理的行,只返回新增内容。 -fn get_compose_logs( +/// +/// 内部是同步子进程调用,已包进 `run_blocking_command`——本函数在启动流程中 +/// 最长 5 分钟内每 2 秒被调用一次,直接在 async 线程上跑会持续占用该线程。 +async fn get_compose_logs( project_root: &std::path::Path, skip_lines: usize, ) -> Result<(Vec, usize), String> { - use std::process::Command; + let project_root = project_root.to_path_buf(); - let mut logs_cmd = Command::new("docker"); - logs_cmd - .args(["compose", "logs", "--tail", "100"]) - .current_dir(project_root); + run_blocking_command(move || { + use std::process::Command; - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - logs_cmd.creation_flags(0x08000200); - } + let mut logs_cmd = Command::new("docker"); + logs_cmd + .args(["compose", "logs", "--tail", "100"]) + .current_dir(&project_root); - let output = logs_cmd - .output() - .map_err(|e| format!("执行 docker compose logs 失败: {e}"))?; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + logs_cmd.creation_flags(0x08000200); + } - if !output.status.success() { - return Err(format!( - "docker compose logs 退出码: {:?}", - output.status.code() - )); - } + let output = logs_cmd + .output() + .map_err(|e| format!("执行 docker compose logs 失败: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let all_lines: Vec<&str> = stdout.lines().collect(); - let total_count = all_lines.len(); + if !output.status.success() { + return Err(format!( + "docker compose logs 退出码: {:?}", + output.status.code() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let all_lines: Vec<&str> = stdout.lines().collect(); + let total_count = all_lines.len(); - let new_lines: Vec = all_lines - .iter() - .skip(skip_lines) - .map(|s| s.to_string()) - .collect(); + let new_lines: Vec = all_lines + .iter() + .skip(skip_lines) + .map(|s| s.to_string()) + .collect(); - Ok((new_lines, total_count)) + Ok((new_lines, total_count)) + }) + .await } /// 解析 Docker Compose 输出,提取关键进度信息 @@ -677,16 +700,16 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result Result Result { if !new_lines.is_empty() { for line in &new_lines { @@ -1294,7 +1317,8 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result Result = rt.block_on(run_blocking_command(|| Ok(42))); + assert_eq!(ok.expect("成功分支应返回值"), 42); + + let err: Result = + rt.block_on(run_blocking_command(|| Err("boom".to_string()))); + assert_eq!(err.expect_err("失败分支应返回错误"), "boom"); + } + fn parse_env(content: &str) -> std::collections::HashMap { EnvFile::parse(content) .expect("解析 .env 内容失败") From 9670b999a41098a713da196a52c6f69fcf0d9e36 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:10:10 +0800 Subject: [PATCH 12/27] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E8=AF=AF?= =?UTF-8?q?=E5=85=A5=E7=89=88=E6=9C=AC=E5=BA=93=E7=9A=84=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20merge-paths.tmp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该文件是另一个会话产生的临时产物(34 行文件路径列表),在合并 commit 的 git add -A 中被一并带入。文件本身保留在磁盘上,仅从版本库移除。 --- merge-paths.tmp | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 merge-paths.tmp diff --git a/merge-paths.tmp b/merge-paths.tmp deleted file mode 100644 index ca21e56..0000000 --- a/merge-paths.tmp +++ /dev/null @@ -1,33 +0,0 @@ -.gitignore -docs/README.md -docs/architecture/SERVICE_TEMPLATE_SOURCING.md -docs/guides/ADDING_SERVICE_VERSION.md -docs/guides/SYNC_VERSION_MANIFEST.md -package.json -scripts/__tests__/sync-php-dockerfile.spec.mjs -scripts/__tests__/sync-version-manifest.spec.mjs -scripts/sync-php-dockerfile.mjs -scripts/sync-version-manifest.mjs -src-tauri/services/php56/Dockerfile -src-tauri/services/php74/Dockerfile -src-tauri/services/redis62/redis.conf -src-tauri/services/redis70/redis.conf -src-tauri/services/redis72/redis.conf -src-tauri/services/redis82/redis.conf -src-tauri/services/version_manifest.json -src-tauri/src/commands/env_config.rs -src-tauri/src/engine/config_extractor.rs -src-tauri/src/engine/config_generator.rs -src-tauri/src/engine/mod.rs -src-tauri/src/engine/version_manifest.rs -src-tauri/src/lib.rs -src/components/EnvConfigPage.vue -src/components/ImagePullConfirmModal.vue -src/components/VersionHelpModal.vue -src/components/__tests__/EnvConfigPage.applyFlow.spec.ts -src/components/__tests__/ImagePullConfirmModal.spec.ts -src/components/__tests__/VersionHelpModal.spec.ts -src/i18n/locales/en.json -src/i18n/locales/zh-CN.json -src/test/setup.ts -src/types/env-config.ts From 8d5ed462ffc6736f21317442157240e6da99dc73 Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:10:10 +0800 Subject: [PATCH 13/27] =?UTF-8?q?docs:=20CHANGELOG=20=E8=A1=A5=E8=AE=B0?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=89=B9=E5=89=8D=203=20=E9=A1=B9=E4=B8=8E?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应 docs/IMPROVEMENT_REPORT.md 第二批: - 第 1 项 R2 恢复前自动备份(回滚包) - 第 2 项 U2 恢复结果展示明细 - 第 3 项 R3 Docker 阻塞调用 spawn_blocking 另补记合并后修复的 scripts 单元测试加载失败问题。 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a86a05..aa17799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 恢复引擎拒绝含路径遍历条目的备份包,堵住 zip-slip 漏洞 - 修复 Nginx 多版本配置回读时非默认端口被错误重置为 80 的问题 - 修复环境配置页多 PHP 服务时自定义扩展输入互相串扰的问题 +- 修复 `scripts/` 下两个同步脚本的单元测试因 shebang 被 vitest 报 SyntaxError、整个 suite 无法加载的问题 ### 🔧 改进 - 环境配置页 .env 解析主体抽为纯函数并以真断言测试覆盖 @@ -24,6 +25,9 @@ - 清理死代码:未注册使用的占位命令、无效占位测试、未使用的 proptest 依赖 - 新增 CI 测试流水线(fmt / clippy / cargo test / 前端测试 / 构建) - 补充 MIT LICENSE 文件,声明 Cargo `license` 字段 +- 恢复前自动生成回滚包(`.restore_rollback_<时间戳>.zip`),恢复出错可据此回退 +- 恢复结果由「成功/失败一句话」改为返回完整明细,前端展示已恢复文件列表、逐条错误与回滚包路径 +- 环境启动流程的 Docker 同步调用(`compose down`、`up` 等待、日志轮询)统一走 `spawn_blocking`,不再阻塞 async 执行线程 ### 📁 文档 - 合并 `doc/` 与 `docs/` 为统一的 `docs/` 目录,更新 README、AGENTS、文档索引中的路径引用 From 160f071f7ea2efa50cee2bd1fc571623f2389fce Mon Sep 17 00:00:00 2001 From: jeeinn Date: Mon, 21 Sep 2026 15:34:09 +0800 Subject: [PATCH 14/27] =?UTF-8?q?refactor(contract):=20=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9E=9A=E4=B8=BE=E5=8C=96=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20Debug=20=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=EF=BC=88R4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端原本用 format!("{:?}", c.state) 把 bollard 枚举序列化成 "Some(RUNNING)", 前端再靠 includes('running') 猜回来,后端自己也写注释承认这个格式问题。 Docker 或 bollard 升级即碎,且 "up"/"running" 混判。 改动: - docker/manager.rs: 新增 ContainerState 枚举(serde rename_all=lowercase), 含 From> 与 Display;EMPTY/None 归 Unknown - is_running() 只认 running —— restarting 尚未提供服务,不再算运行中 - PsContainer.state 由 String 改为 ContainerState - 两个 list 方法逐字重复的映射闭包收敛为 to_ps_container(),此前必须双处同步 - check_all_ps_containers_running 与 start_environment 的字符串匹配改为 is_running() - 前端新增 src/types/docker.ts 承载契约(ContainerState / isContainerRunning / parseContainerState 兜底),App.vue、portChecker.ts 共用,删除两处重复的 isRunning() 字符串猜测 - 顺带清掉 App.vue 两个 @ts-ignore 的死 computed 与 Container 接口的包装类型 String(A6 遗留项) 配套测试: - Rust: 枚举序列化/往返/bollard 映射/is_running/Display 共 5 个 - 前端: docker.spec.ts 4 个(含历史 "Some(RUNNING)" 降级不崩) 验证:cargo test --lib 103 通过;vitest 相关 6 通过;vue-tsc 零错误 --- src-tauri/src/commands/env_config.rs | 4 +- src-tauri/src/docker/manager.rs | 224 ++++++++++++++++++++------- src/App.vue | 40 +---- src/types/__tests__/docker.spec.ts | 52 +++++++ src/types/docker.ts | 44 ++++++ src/utils/portChecker.ts | 21 +-- 6 files changed, 272 insertions(+), 113 deletions(-) create mode 100644 src/types/__tests__/docker.spec.ts create mode 100644 src/types/docker.ts diff --git a/src-tauri/src/commands/env_config.rs b/src-tauri/src/commands/env_config.rs index 8c1fe9a..f6927a5 100644 --- a/src-tauri/src/commands/env_config.rs +++ b/src-tauri/src/commands/env_config.rs @@ -765,9 +765,7 @@ pub async fn start_environment(app_handle: tauri::AppHandle) -> Result = ps_containers .iter() - .filter(|c| { - c.state.to_lowercase().contains("running") || c.state.to_lowercase().contains("up") - }) + .filter(|c| c.state.is_running()) .collect(); if running_ps_containers.is_empty() { diff --git a/src-tauri/src/docker/manager.rs b/src-tauri/src/docker/manager.rs index 41bff4c..5d09af2 100644 --- a/src-tauri/src/docker/manager.rs +++ b/src-tauri/src/docker/manager.rs @@ -1,9 +1,70 @@ +use bollard::models::ContainerSummaryStateEnum; use bollard::query_parameters::{ ListContainersOptions, RestartContainerOptions, StartContainerOptions, StopContainerOptions, }; use bollard::Docker; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; + +/// 容器运行状态。 +/// +/// 前后端契约:序列化为小写字符串(`running` / `exited` / ...)。 +/// 此前后端用 `format!("{:?}", c.state)` 产出 `"Some(RUNNING)"`,前端靠 +/// `includes('running')` 猜测解析——Docker 或 bollard 升级即碎。枚举化后 +/// 契约由 serde 保证,两端都不再做字符串猜测。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContainerState { + Created, + Running, + Paused, + Restarting, + Exited, + Removing, + Dead, + /// Docker 未返回状态(字段缺失或为空)。保留兜底变体,避免反序列化失败。 + Unknown, +} + +impl ContainerState { + /// 仅 `running` 视为运行中;`restarting` 尚未真正提供服务,不算。 + pub fn is_running(&self) -> bool { + matches!(self, ContainerState::Running) + } +} + +impl From> for ContainerState { + fn from(state: Option) -> Self { + match state { + Some(ContainerSummaryStateEnum::CREATED) => ContainerState::Created, + Some(ContainerSummaryStateEnum::RUNNING) => ContainerState::Running, + Some(ContainerSummaryStateEnum::PAUSED) => ContainerState::Paused, + Some(ContainerSummaryStateEnum::RESTARTING) => ContainerState::Restarting, + Some(ContainerSummaryStateEnum::EXITED) => ContainerState::Exited, + Some(ContainerSummaryStateEnum::REMOVING) => ContainerState::Removing, + Some(ContainerSummaryStateEnum::DEAD) => ContainerState::Dead, + // 空字符串变体与字段缺失都归为 Unknown + Some(ContainerSummaryStateEnum::EMPTY) | None => ContainerState::Unknown, + } + } +} + +impl fmt::Display for ContainerState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ContainerState::Created => "created", + ContainerState::Running => "running", + ContainerState::Paused => "paused", + ContainerState::Restarting => "restarting", + ContainerState::Exited => "exited", + ContainerState::Removing => "removing", + ContainerState::Dead => "dead", + ContainerState::Unknown => "unknown", + }; + f.write_str(s) + } +} #[derive(Debug, Serialize, Deserialize)] pub struct PsContainer { @@ -11,10 +72,38 @@ pub struct PsContainer { pub name: String, pub image: String, pub status: String, - pub state: String, + pub state: ContainerState, pub ports: Vec, } +/// 把 bollard 的容器摘要映射为前端契约结构。 +/// +/// `list_ps_containers` 与 `list_all_running_containers` 此前各抄了一遍, +/// 两处必须同步修改才能保持一致——收敛为单一实现。 +fn to_ps_container(c: bollard::models::ContainerSummary) -> PsContainer { + let name = c + .names + .clone() + .unwrap_or_default() + .first() + .map(|n| n.trim_start_matches('/').to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + PsContainer { + id: c.id.unwrap_or_default(), + name, + image: c.image.unwrap_or_default(), + status: c.status.unwrap_or_default(), + state: ContainerState::from(c.state), + ports: c + .ports + .unwrap_or_default() + .into_iter() + .filter_map(|p| p.public_port.map(|port| port as i32)) + .collect(), + } +} + pub struct DockerManager { docker: Docker, } @@ -44,34 +133,7 @@ impl DockerManager { let containers = self.docker.list_containers(options).await?; - let ps_containers = containers - .into_iter() - .map(|c| { - let name = c - .names - .clone() - .unwrap_or_default() - .first() - .map(|n| n.trim_start_matches('/').to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - PsContainer { - id: c.id.unwrap_or_default(), - name, - image: c.image.unwrap_or_default(), - status: c.status.unwrap_or_default(), - state: format!("{:?}", c.state), - ports: c - .ports - .unwrap_or_default() - .into_iter() - .filter_map(|p| p.public_port.map(|port| port as i32)) - .collect(), - } - }) - .collect(); - - Ok(ps_containers) + Ok(containers.into_iter().map(to_ps_container).collect()) } /// 获取所有运行中的容器(用于端口冲突检测) @@ -85,34 +147,7 @@ impl DockerManager { let containers = self.docker.list_containers(options).await?; - let all_containers = containers - .into_iter() - .map(|c| { - let name = c - .names - .clone() - .unwrap_or_default() - .first() - .map(|n| n.trim_start_matches('/').to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - PsContainer { - id: c.id.unwrap_or_default(), - name, - image: c.image.unwrap_or_default(), - status: c.status.unwrap_or_default(), - state: format!("{:?}", c.state), - ports: c - .ports - .unwrap_or_default() - .into_iter() - .filter_map(|p| p.public_port.map(|port| port as i32)) - .collect(), - } - }) - .collect(); - - Ok(all_containers) + Ok(containers.into_iter().map(to_ps_container).collect()) } pub async fn start_container(&self, name: &str) -> Result<(), Box> { @@ -149,8 +184,7 @@ impl DockerManager { // 检查所有容器是否都是 running 状态 for container in &containers { - // state 字段是 "Some(RUNNING)" 或 "Some(EXITED)" 等格式 - if !container.state.contains("RUNNING") { + if !container.state.is_running() { return Ok(false); } } @@ -158,3 +192,75 @@ impl DockerManager { Ok(true) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_state_serializes_lowercase() { + assert_eq!( + serde_json::to_string(&ContainerState::Running).unwrap(), + "\"running\"" + ); + assert_eq!( + serde_json::to_string(&ContainerState::Exited).unwrap(), + "\"exited\"" + ); + assert_eq!( + serde_json::to_string(&ContainerState::Unknown).unwrap(), + "\"unknown\"" + ); + } + + #[test] + fn test_container_state_roundtrip() { + for state in [ + ContainerState::Created, + ContainerState::Running, + ContainerState::Paused, + ContainerState::Restarting, + ContainerState::Exited, + ContainerState::Removing, + ContainerState::Dead, + ContainerState::Unknown, + ] { + let json = serde_json::to_string(&state).unwrap(); + let back: ContainerState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state, "往返失败: {json}"); + } + } + + #[test] + fn test_container_state_from_bollard_enum() { + assert_eq!( + ContainerState::from(Some(ContainerSummaryStateEnum::RUNNING)), + ContainerState::Running + ); + assert_eq!( + ContainerState::from(Some(ContainerSummaryStateEnum::EXITED)), + ContainerState::Exited + ); + // 空字符串变体与字段缺失都不能让反序列化崩掉 + assert_eq!( + ContainerState::from(Some(ContainerSummaryStateEnum::EMPTY)), + ContainerState::Unknown + ); + assert_eq!(ContainerState::from(None), ContainerState::Unknown); + } + + #[test] + fn test_is_running_only_for_running() { + assert!(ContainerState::Running.is_running()); + // restarting 尚未真正提供服务,不当作运行中 + assert!(!ContainerState::Restarting.is_running()); + assert!(!ContainerState::Exited.is_running()); + assert!(!ContainerState::Unknown.is_running()); + } + + #[test] + fn test_container_state_display() { + assert_eq!(ContainerState::Running.to_string(), "running"); + assert_eq!(ContainerState::Dead.to_string(), "dead"); + } +} diff --git a/src/App.vue b/src/App.vue index ca82080..7c627da 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,20 +13,13 @@ import ConfirmDialog from './components/ConfirmDialog.vue'; import WorkspaceInitDialog from './components/WorkspaceInitDialog.vue'; import { getLogs, addLog, showToast } from './composables/useToast'; import { showConfirm } from './composables/useConfirmDialog'; +import type { Container } from './types/docker'; +import { isContainerRunning } from './types/docker'; const { t } = useI18n(); const appVersion = ref('v0.0.0'); // 应用版本号 -interface Container { - id: String; - name: String; - image: String; - status: String; - state: String; - ports: number[]; -} - const containers = ref([]); const loading = ref(false); const starting = ref(false); // 启动环境时的加载状态 @@ -45,19 +38,7 @@ const hasEnvFile = ref(false); // .env 文件是否存在 // 判断是否有运行中的 ps- 容器 const hasRunningContainers = computed(() => { - return containers.value.some(c => isRunning(String(c.state))); -}); - -// 判断是否有任何 ps- 容器(不管状态) -// @ts-ignore - 用于后续功能扩展,暂时未使用 -const hasAnyContainers = computed(() => { - return containers.value.length > 0; -}); - -// 判断是否有任何停止的 ps- 容器 -// @ts-ignore - 用于后续功能扩展,暂时未使用 -const hasStoppedContainers = computed(() => { - return containers.value.some(c => !isRunning(String(c.state))); + return containers.value.some(c => isContainerRunning(c.state)); }); // 判断是否可以启动(没有任何容器或所有容器都已停止,且存在 .env 文件) @@ -75,13 +56,6 @@ const canStop = computed(() => { return hasRunningContainers.value; }); -// 判断容器是否运行中(兼容多种格式) -const isRunning = (state: string): boolean => { - // 后端返回的格式:"Some(RUNNING)" 或 "Some(Exceeded)" 等 - const normalized = state.toLowerCase(); - return normalized.includes('running'); -}; - const checkDocker = async () => { try { await invoke('check_docker'); @@ -565,11 +539,11 @@ async function copyLogs() {
{{ String(c.image).split(':')[0] }} - - {{ isRunning(String(c.state)) ? $t('dashboard.container.running') : $t('dashboard.container.stopped') }} + + {{ isContainerRunning(c.state) ? $t('dashboard.container.running') : $t('dashboard.container.stopped') }}
{{ String(c.name) }}
@@ -580,7 +554,7 @@ async function copyLogs() {