From 6624ee5b9020d0c8b13c8f68f484c56d0d0ce6fb Mon Sep 17 00:00:00 2001 From: "allen.zhou" Date: Sun, 23 Aug 2026 15:30:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=AF=E5=8A=A8=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codegraph/.gitignore | 5 + .gitignore | 11 +- CONTRIBUTING.md | 4 +- README.zh-CN.md | 2 + docs/development-setup.zh-CN.md | 301 ++++++++++++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/crates/runtime/Cargo.toml | 3 + .../services/environment/kernel/launcher.rs | 205 ++++++++++-- src-tauri/src/core/paths.rs | 27 ++ .../services/environment/kernel/downloader.rs | 14 + .../src/services/environment/kernel/mod.rs | 256 ++++++++++++--- .../environment/kernel/runtime_bridge.rs | 38 +++ .../src/services/environment/kernel/utils.rs | 53 ++- .../environment/launch_runtime/paths.rs | 14 +- 14 files changed, 852 insertions(+), 83 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 docs/development-setup.zh-CN.md diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..d20c0fe4 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.gitignore b/.gitignore index 935ecc39..0e9c97cd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,13 @@ dist-ssr *.sln *.sw? -# Generated files -src/core/plugin/loader/plugin-imports.generated.ts - -# tauri generated files +# Generated files +src/core/plugin/loader/plugin-imports.generated.ts + +# Local runtime data +/data/ + +# tauri generated files src-tauri/target src-tauri/crates/*/target/ src-tauri/crates/*/Cargo.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3d4fd4..12ce2c4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,8 @@ This repository is in an active open-source transition. We welcome bug reports, ## Development Setup +Windows 从零配置、桌面端启动和可选服务端联调请先阅读 [开发配置文档](./docs/development-setup.zh-CN.md)。 + ### Prerequisites - Node.js 20+ @@ -41,7 +43,7 @@ Many contributions do not require a local backend. Documentation updates, CI cha If your change touches server-backed flows, workspace resources, or API-dependent behavior, you may also need a self-hosted backend during development. -The current bootstrap command for the self-hosted server is documented in [README.md](./README.md). After the server is available, update your local `src-tauri/config.development.toml` so `base_url` points to your development server. +The desktop application currently handles its main business routes through the embedded SQLite business layer. Only features that explicitly use the standalone HTTP server need the server-side setup described in the development guide. ## Useful Commands diff --git a/README.zh-CN.md b/README.zh-CN.md index e31118d0..307df7b0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -62,6 +62,8 @@ Simprint 想走另一条路线:构建一个开放、可编排、可编程的 ## Quick Start +完整的 Windows 从零开发配置步骤见 [开发配置文档](./docs/development-setup.zh-CN.md)。 + ### Prerequisites - Node.js 20+ diff --git a/docs/development-setup.zh-CN.md b/docs/development-setup.zh-CN.md new file mode 100644 index 00000000..2bbe5828 --- /dev/null +++ b/docs/development-setup.zh-CN.md @@ -0,0 +1,301 @@ +# Simprint 本地开发配置 + +本文按 Windows + PowerShell 编写,目标是从一台没有开发环境的机器开始,完成 Simprint 桌面端的依赖安装和开发启动。 + +## 1. 先了解项目结构 + +这个仓库包含三套相互独立的工程: + +| 目录 | 技术栈 | 是否是桌面端启动的必需项 | +| --- | --- | --- | +| 根目录 | React、Vite、TypeScript、pnpm | 是 | +| `src-tauri` | Tauri 2、Rust、嵌入式 SQLite | 是 | +| `server` | Axum、Rust、PostgreSQL | 仅服务端联调时需要 | + +桌面端当前是 local-first 架构:业务请求会进入 `src-tauri` 内嵌的 SQLite 业务层,首次启动会自动创建本地数据库和迁移表结构。因此,普通桌面端开发不需要先启动 PostgreSQL、Redis 或远程 API。 + +## 2. 安装 Windows 前置环境 + +### 2.1 Node.js + +仓库的 CI 使用 Node.js 20。建议安装 Node.js 20 LTS,不建议使用与 CI 差异较大的版本作为开发基线。 + +安装后在 PowerShell 检查: + +```powershell +node --version +npm --version +``` + +### 2.2 pnpm 9 + +仓库 CI 固定使用 pnpm 9,且 [`pnpm-lock.yaml`](../pnpm-lock.yaml) 的锁文件版本为 `9.0`。安装 pnpm 9: + +```powershell +npm install --global pnpm@9 +pnpm --version +``` + +### 2.3 Rust stable MSVC + +安装 Rustup 后,确认使用 Windows MSVC 工具链: + +```powershell +rustup default stable-x86_64-pc-windows-msvc +rustc --version +cargo --version +rustup show +``` + +如果 `rustup show` 中没有 `stable-x86_64-pc-windows-msvc`,补装目标: + +```powershell +rustup target add x86_64-pc-windows-msvc +``` + +### 2.4 Visual Studio C++ 构建工具 + +通过 Visual Studio Installer 安装 Build Tools,并勾选: + +- Desktop development with C++ +- MSVC 编译工具 +- Windows 10/11 SDK + +缺少这些组件时,Rust 编译通常会出现 `link.exe`、Windows SDK 或 C++ 工具链错误。 + +### 2.5 WebView2 + +Tauri 2 使用 Microsoft Edge WebView2。Windows 11 通常已经安装;Windows 10 如果启动时提示缺少 WebView2,请安装 Microsoft WebView2 Runtime。 + +## 3. 获取代码并进入仓库 + +```powershell +git clone REPOSITORY_URL simprint +Set-Location D:\code\rust\simprint +``` + +后续命令默认都在 `D:\code\rust\simprint` 执行。路径可以替换为实际目录,但不要在仓库外执行根目录的 `pnpm install`。 + +## 4. 安装根目录前端依赖 + +使用锁文件安装,避免依赖解析结果漂移: + +```powershell +pnpm install --frozen-lockfile +``` + +成功后应存在 `node_modules` 目录。若提示锁文件需要更新,优先确认 pnpm 是 9.x,不要直接删除锁文件或使用 `--no-frozen-lockfile`。 + +## 5. 安装 Tauri CLI + +仓库的 [`src-tauri/Cargo.toml`](../src-tauri/Cargo.toml) 使用 Tauri 2,但根目录 `package.json` 没有声明 npm 版 Tauri CLI。项目 README 使用 Cargo 版命令,因此安装 Cargo CLI: + +```powershell +cargo install tauri-cli --version "^2.0.0" --locked +cargo tauri --version +``` + +安装过程可能较慢,因为 Cargo 需要编译 CLI 本身。`cargo tauri --version` 能输出版本,才继续下一步。 + +## 6. 创建桌面端开发配置 + +复制示例配置: + +```powershell +Copy-Item ` + .\src-tauri\config.example.toml ` + .\src-tauri\config.development.toml +``` + +仓库的开发启动约定要求从示例复制该文件;当前内容主要是固定版本 WebView 下载地址。它不包含远程 API 地址,也不需要填写 PostgreSQL 连接信息。 + +`config.development.toml` 已被 `.gitignore` 忽略,不要把真实环境地址、密钥或个人路径提交到仓库。 + +## 7. 启动桌面端 + +```powershell +cargo tauri dev --features development +``` + +启动过程会自动完成以下工作: + +1. 执行 `pnpm dev`,启动 Vite/Slotkit 前端开发服务。 +2. 编译 `src-tauri` 及两个本地 crate:`business`、`runtime`。 +3. 创建本地 SQLite 数据库 `simprint.db`。 +4. 执行内嵌数据库迁移,并初始化本地用户、工作区和浏览器内核目录。 +5. 打开 Tauri 桌面窗口。 + +第一次编译 Rust 依赖可能需要较长时间,后续启动会使用 Cargo 缓存。 + +### 只调试前端 + +不需要 Rust 或 Tauri 窗口时,可以只启动前端: + +```powershell +pnpm dev +``` + +但依赖 Tauri `invoke`、本地 SQLite、浏览器内核或系统窗口的功能,在纯 Vite 页面中不能完整工作。 + +## 8. 可选:启动服务端进行联调 + +只有需要验证独立 HTTP 服务、PostgreSQL 数据库或服务端路由时,才执行这一节。 + +### 8.1 准备 PostgreSQL + +Windows 上推荐使用 Docker Desktop,只启动 Compose 中的 PostgreSQL: + +```powershell +docker compose -f .\server\docker-compose.yml up -d postgres +``` + +Compose 会创建: + +- 数据库:`simprintdb` +- 用户:`simprint` +- 密码:`change-me` +- 地址:`127.0.0.1:5432` + +检查容器状态: + +```powershell +docker compose -f .\server\docker-compose.yml ps +``` + +### 8.2 配置并启动服务端 + +```powershell +Set-Location .\server +Copy-Item .\configs\config.local.example.toml .\configs\config.local.toml +cargo fetch +cargo run -- -f .\configs\config.local.toml +``` + +服务端默认监听: + +```text +http://127.0.0.1:40041 +``` + +启动时会自动执行 `server/migrations` 中的数据库迁移。 + +当前服务端本地配置说明: + +- PostgreSQL 是当前本地启动最关键的外部依赖。 +- `storage.public_base_url` 只是资源 URL 配置,使用扩展、头像或版本下载功能时需要替换为真实地址。 +- SMTP 是邮件功能的可选依赖。 +- README 中提到 Redis,但当前服务上下文使用内存缓存,源码没有对应的 Redis 连接配置;不要因为桌面端开发而额外安装 Redis。 + +停止服务端:在运行服务端的 PowerShell 窗口按 `Ctrl+C`。停止 PostgreSQL 容器: + +```powershell +docker compose -f .\server\docker-compose.yml stop postgres +``` + +## 9. 启动成功后的检查 + +完成桌面端启动后,至少确认: + +- 桌面窗口能够打开。 +- 没有出现 `cargo tauri`、`link.exe` 或 WebView2 初始化错误。 +- 应用数据目录中生成了 `simprint.db`。 +- 能进入主界面,不会持续显示数据库初始化失败。 + +代码检查命令从仓库根目录执行: + +```powershell +pnpm lint +pnpm format:check +pnpm rust:fmt:check +pnpm rust:check +``` + +服务端单独检查: + +```powershell +Push-Location .\server +cargo check +Pop-Location +``` + +## 10. 常见问题 + +### `cargo tauri` 不是有效命令 + +说明 Tauri CLI 没安装,执行: + +```powershell +cargo install tauri-cli --version "^2.0.0" --locked +``` + +然后重新打开 PowerShell,再检查: + +```powershell +cargo tauri --version +``` + +### `link.exe` 或 Windows SDK 找不到 + +重新打开 Visual Studio Installer,确认安装了 Desktop development with C++、MSVC 和 Windows SDK,并确认 Rust 使用 `stable-x86_64-pc-windows-msvc`。 + +### `pnpm install --frozen-lockfile` 失败 + +先检查版本: + +```powershell +node --version +pnpm --version +``` + +优先使用 Node.js 20 和 pnpm 9。不要先删除 `pnpm-lock.yaml`。 + +### 端口 `40041` 被占用 + +查看占用进程: + +```powershell +Get-NetTCPConnection -LocalPort 40041 -ErrorAction SilentlyContinue +``` + +关闭占用端口的程序,或修改 `server/configs/config.local.toml` 中的 `app.port`。 + +### 服务端提示 PostgreSQL 连接失败 + +确认容器正在运行: + +```powershell +docker compose -f .\server\docker-compose.yml ps postgres +``` + +并确认 `server/configs/config.local.toml` 中的连接串仍是: + +```text +postgres://simprint:change-me@127.0.0.1:5432/simprintdb +``` + +### 桌面端需要远程服务地址吗? + +当前桌面端的主要业务路由已经由 `src-tauri` 的本地 SQLite 业务层处理,最小开发启动不需要配置 `base_url`。只有明确开发独立服务端或尚未迁移到本地业务层的功能时,才需要按具体代码路径配置远程服务。 + +## 11. 推荐的日常启动顺序 + +普通桌面端开发: + +```powershell +Set-Location D:\code\rust\simprint +cargo tauri dev --features development +``` + +前端页面开发: + +```powershell +Set-Location D:\code\rust\simprint +pnpm dev +``` + +桌面端 + 独立服务端联调:先启动 PostgreSQL 和 `server`,再在另一个 PowerShell 窗口启动: + +```powershell +Set-Location D:\code\rust\simprint +cargo tauri dev --features development +``` diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 054a04ee..a4af3c8c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -108,6 +108,6 @@ unsafe_code = "warn" all = "warn" [features] -development = [] +development = ["runtime/development"] production = ["tauri/custom-protocol"] test = [] diff --git a/src-tauri/crates/runtime/Cargo.toml b/src-tauri/crates/runtime/Cargo.toml index 8412b6ab..f4ae6231 100644 --- a/src-tauri/crates/runtime/Cargo.toml +++ b/src-tauri/crates/runtime/Cargo.toml @@ -6,6 +6,9 @@ publish = false license = "AGPL-3.0-only" autobins = false +[features] +development = [] + [lib] name = "runtime" path = "src/lib.rs" diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs b/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs index 9536b210..7ac8d24d 100644 --- a/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs +++ b/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs @@ -14,6 +14,9 @@ use std::path::Path; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; + +#[cfg(feature = "development")] +const DEVELOPMENT_BROWSER_ARGS: [&str; 1] = ["--no-sandbox"]; use tokio::process::Child; const BROWSER_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); @@ -147,22 +150,25 @@ pub async fn launch_browser( } }; - let browser = match wait_for_browser_ready(&env_id, browser, &mut server_ready).await { - Ok(browser) => browser, - Err(error) => { - fail_launch( - &env_id, - "eventbus_handshake", - &error, - cdp_endpoint_manager.clone(), - job_manager.clone(), - status_manager.clone(), - events.clone(), - ) - .await; - return Err(error); - } - }; + let browser = + match wait_for_browser_ready(&env_id, browser, &mut server_ready, &request.user_data_dir) + .await + { + Ok(browser) => browser, + Err(error) => { + fail_launch( + &env_id, + "eventbus_handshake", + &error, + cdp_endpoint_manager.clone(), + job_manager.clone(), + status_manager.clone(), + events.clone(), + ) + .await; + return Err(error); + } + }; let (mut browser, browser_ws_url) = match wait_for_cdp_ready(&env_id, cdp_port, browser).await { Ok(ready) => ready, @@ -307,6 +313,7 @@ async fn wait_for_browser_ready( env_id: &str, mut browser: Child, server_ready: &mut tokio::sync::mpsc::Receiver>, + user_data_dir: &str, ) -> Result { let timeout = tokio::time::sleep(BROWSER_STARTUP_TIMEOUT); tokio::pin!(timeout); @@ -328,9 +335,12 @@ async fn wait_for_browser_ready( "failed waiting for browser process for environment {}: {}", env_id, error )))?; + let log_tail = read_browser_log_tail(user_data_dir); Err(RuntimeError::Internal(format!( - "browser process exited before handshake for environment {}: {}", - env_id, status + "browser process exited before handshake for environment {}: {}{}", + env_id, + status, + log_tail ))) } _ = &mut timeout => { @@ -355,6 +365,8 @@ async fn spawn_browser_process( extension_dirs: Option<&Vec>, job_manager: Arc, ) -> Result { + validate_browser_runtime_layout(exe_path)?; + let mut args = vec![ format!("--simprint-env-id={}", env_id), format!("--user-data-dir={}", user_data_dir), @@ -368,6 +380,9 @@ async fn spawn_browser_process( args.push("--v=1".to_string()); } + #[cfg(feature = "development")] + args.extend(DEVELOPMENT_BROWSER_ARGS.iter().map(|arg| (*arg).to_string())); + if let Some(id) = display_id { args.push(format!("--simprint-display-id={}", id)); } @@ -440,7 +455,9 @@ async fn spawn_browser_process( .map_err(|error| RuntimeError::Internal(error.to_string()))?; } - let mut child = command.spawn().map_err(|error| RuntimeError::Internal(error.to_string()))?; + let mut child = command + .spawn() + .map_err(|error| RuntimeError::Internal(format_process_spawn_error(exe_path, error)))?; let pid = child.id().ok_or_else(|| { RuntimeError::Internal(format!( "browser process for environment {} has no pid", @@ -464,6 +481,156 @@ async fn spawn_browser_process( Ok(child) } +fn validate_browser_runtime_layout(exe_path: &str) -> Result<()> { + let exe = Path::new(exe_path); + if !exe.is_file() { + return Err(RuntimeError::Internal(format!( + "浏览器内核路径无效:{}", + exe_path + ))); + } + + Ok(()) +} + +fn read_browser_log_tail(user_data_dir: &str) -> String { + let path = Path::new(user_data_dir).join("simprint-browser.log"); + let Ok(content) = std::fs::read_to_string(path) else { + return String::new(); + }; + let tail = content + .lines() + .rev() + .take(12) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + if tail.is_empty() { + String::new() + } else { + format!(";浏览器日志:{tail}") + } +} + +fn format_process_spawn_error(exe_path: &str, error: std::io::Error) -> String { + if let Some(message) = windows_policy_error_message(error.raw_os_error()) { + return format!("{message}:{exe_path}"); + } + + format!("启动浏览器内核失败 {}:{}", exe_path, error) +} + +fn windows_policy_error_message(code: Option) -> Option<&'static str> { + match code.map(|value| value as u32) { + Some(577) => Some("Windows 代码完整性拒绝了该内核或其 DLL(错误 577)"), + Some(1260) => Some("Windows 应用控制策略阻止了该内核(错误 1260)"), + Some(225) => Some("Windows Defender 阻止了该内核(错误 225)"), + _ => None, + } +} + +#[cfg(test)] +mod policy_tests { + use super::{ + read_browser_log_tail, validate_browser_runtime_layout, windows_policy_error_message, + }; + + #[cfg(feature = "development")] + #[test] + fn development_launch_disables_chromium_sandbox_for_restricted_windows_hosts() { + assert!(super::DEVELOPMENT_BROWSER_ARGS + .iter() + .any(|arg| arg == "--no-sandbox")); + } + + #[test] + fn maps_windows_policy_errors_to_actionable_messages() { + for (code, expected) in [ + (577, "代码完整性"), + (1260, "应用控制策略"), + (225, "Defender"), + ] { + assert!(windows_policy_error_message(Some(code)).unwrap().contains(expected)); + } + assert!(windows_policy_error_message(Some(2)).is_none()); + } + + #[test] + fn reads_only_the_tail_of_the_browser_log() { + let dir = std::env::temp_dir().join(format!( + "simprint-browser-log-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let lines = (1..=15).map(|line| format!("line-{line}")).collect::>(); + std::fs::write(dir.join("simprint-browser.log"), lines.join("\n")).unwrap(); + + let tail = read_browser_log_tail(dir.to_str().unwrap()); + + assert!(tail.starts_with(";浏览器日志:line-4 | line-5")); + assert!(tail.ends_with("line-15")); + assert!(!tail.contains("line-3")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_browser_log_does_not_add_noise() { + let dir = std::env::temp_dir().join(format!( + "simprint-browser-log-missing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + assert_eq!(read_browser_log_tail(dir.to_str().unwrap()), ""); + } + + #[test] + fn accepts_a_kernel_without_standalone_crashpad_handler() { + let dir = std::env::temp_dir().join(format!( + "simprint-kernel-layout-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let exe = dir.join("simprint.exe"); + std::fs::write(&exe, b"test").unwrap(); + + let result = validate_browser_runtime_layout(exe.to_str().unwrap()); + + assert!(result.is_ok()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn rejects_a_missing_browser_executable() { + let dir = std::env::temp_dir().join(format!( + "simprint-kernel-layout-missing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let exe = dir.join("simprint.exe"); + + let error = validate_browser_runtime_layout(exe.to_str().unwrap()).unwrap_err(); + + assert!(error.to_string().contains("浏览器内核路径无效")); + } +} + pub async fn stop_environment( env_uuid: String, cdp_endpoint_manager: Arc, diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index f0f5956d..f663b715 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -142,6 +142,8 @@ impl PathManager { pub fn get_profiles_dir(_app: &tauri::AppHandle) -> Result { let dir = if let Some(value) = Self::read_store_storage_string("profilesPath") { Self::resolve_override_path(&value, &Self::get_root_dir()?)? + } else if let Some(workspace_root) = Self::find_workspace_root() { + workspace_root.join("data").join("profiles") } else { Self::get_data_dir()?.join("profiles") }; @@ -219,6 +221,18 @@ impl PathManager { .unwrap_or_else(|| root_dir.join(default_child))) } + fn find_workspace_root() -> Option { + let current = std::env::current_dir().ok()?; + for candidate in current.ancestors() { + let package_manifest = candidate.join("package.json"); + let tauri_manifest = candidate.join("src-tauri").join("Cargo.toml"); + if package_manifest.is_file() && tauri_manifest.is_file() { + return Some(candidate.to_path_buf()); + } + } + None + } + fn resolve_override_path(value: &str, base_dir: &Path) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -359,3 +373,16 @@ impl BootstrapConfig { && self.kernels_dir.is_none() } } + +#[cfg(test)] +mod tests { + use super::PathManager; + + #[test] + fn finds_the_workspace_root_when_running_from_the_project() { + let root = + PathManager::find_workspace_root().expect("workspace root should be discoverable"); + assert!(root.join("package.json").is_file()); + assert!(root.join("src-tauri").join("Cargo.toml").is_file()); + } +} diff --git a/src-tauri/src/services/environment/kernel/downloader.rs b/src-tauri/src/services/environment/kernel/downloader.rs index 4d7e56ef..096cbb6d 100644 --- a/src-tauri/src/services/environment/kernel/downloader.rs +++ b/src-tauri/src/services/environment/kernel/downloader.rs @@ -95,6 +95,20 @@ pub async fn download_and_install_kernel( return Err("解压后未找到可执行文件".into()); } + super::utils::validate_kernel_package_layout(kernel_dir).map_err(|error| { + emit_status( + status_emitter.as_ref(), + env_uuid, + kernel_value, + EnvironmentStatus::Error, + Some("内核包不完整"), + None, + None, + None, + ); + error + })?; + // 校验核心 DLL 的 signature if let Some(ref sig) = kernel_detail.signature { verify_core_dll_signature( diff --git a/src-tauri/src/services/environment/kernel/mod.rs b/src-tauri/src/services/environment/kernel/mod.rs index 76417655..4875bc0f 100644 --- a/src-tauri/src/services/environment/kernel/mod.rs +++ b/src-tauri/src/services/environment/kernel/mod.rs @@ -4,7 +4,10 @@ use crate::core::error::Result; use crate::domain::environment::{EnvironmentStatus, KernelDetail}; +use std::fs; +use std::path::{Path, PathBuf}; use tauri::Manager; +use uuid::Uuid; pub mod downloader; pub mod extension; @@ -97,26 +100,32 @@ impl KernelService { if !kernel_dir.is_dir() { return Err(format!("内核安装路径不是目录: {}", kernel_dir.display()).into()); } - // 检查可执行文件是否存在 - if !exe_path.exists() { + // 检查内核包是否包含启动所需的完整运行时文件。 + if let Err(error) = utils::validate_kernel_package_layout(&kernel_dir) { crate::log_warn!( crate::core::logger::modules::KERNEL, - "内核目录存在但未找到可执行文件,保留目录并停止启动: {}", - kernel_dir.display() + "内核目录不完整,将下载当前包替换: {} - {}", + kernel_dir.display(), + error ); - utils::emit_status( - status_emitter.as_ref(), + let exe_path = download_and_replace_kernel( + &app, &env_uuid, &install_dir_name, - EnvironmentStatus::Error, - Some("内核目录不完整"), - None, - None, - None, - ); - return Err( - format!("内核目录不完整,已保留原目录: {}", kernel_dir.display()).into(), - ); + &base, + &kernel_dir, + &kernel_detail, + status_emitter, + ) + .await?; + record_ready_installation( + &app, + &kernel_id, + &exe_path, + kernel_detail.signature.as_deref().unwrap_or_default(), + ) + .await; + return Ok(exe_path.to_string_lossy().to_string()); } else { // 校验 signature let primary_signature = kernel_detail @@ -143,8 +152,11 @@ impl KernelService { &accepted_signatures, status_emitter.clone(), ) { - Ok(Some(verified_signature)) => { + Ok(Some(verified_signature)) + if is_current_kernel_signature(&verified_signature, primary_signature) => + { // 校验通过 + // 只有当前包的主签名匹配时才复用已有目录。 utils::emit_status( status_emitter.as_ref(), &env_uuid, @@ -159,27 +171,20 @@ impl KernelService { .await; return Ok(exe_path.to_string_lossy().to_string()); } + Ok(Some(verified_signature)) => { + crate::log_warn!( + crate::core::logger::modules::KERNEL, + "内核目录命中历史兼容签名,将下载当前包替换: {} - {}", + kernel_dir.display(), + verified_signature + ); + } Ok(None) => { crate::log_warn!( crate::core::logger::modules::KERNEL, "内核校验失败,保留目录并停止启动: {}", kernel_dir.display() ); - utils::emit_status( - status_emitter.as_ref(), - &env_uuid, - &install_dir_name, - EnvironmentStatus::Error, - Some("内核校验失败"), - None, - None, - None, - ); - return Err(format!( - "内核校验失败,已保留原目录: {}", - kernel_dir.display() - ) - .into()); } Err(e) => { crate::log_warn!( @@ -188,31 +193,36 @@ impl KernelService { kernel_dir.display(), e ); - utils::emit_status( - status_emitter.as_ref(), - &env_uuid, - &install_dir_name, - EnvironmentStatus::Error, - Some("内核校验出错"), - None, - None, - None, - ); - return Err(format!( - "内核校验出错,已保留原目录 {}: {e}", - kernel_dir.display() - ) - .into()); } } + + let exe_path = download_and_replace_kernel( + &app, + &env_uuid, + &install_dir_name, + &base, + &kernel_dir, + &kernel_detail, + status_emitter, + ) + .await?; + record_ready_installation( + &app, + &kernel_id, + &exe_path, + kernel_detail.signature.as_deref().unwrap_or_default(), + ) + .await; + return Ok(exe_path.to_string_lossy().to_string()); } } - // 只有目录不存在时才下载;现有安装绝不在替代包准备好之前删除。 - let exe_path = downloader::download_and_install_kernel( + // 目录不存在时也先下载到 staging,校验完整后再安装到正式目录。 + let exe_path = download_and_replace_kernel( &app, &env_uuid, &install_dir_name, + &base, &kernel_dir, &kernel_detail, status_emitter, @@ -321,3 +331,153 @@ impl KernelService { runtime_bridge::set_window_bounds(env_uuid, x, y, width, height).await } } + +async fn download_and_replace_kernel( + app: &tauri::AppHandle, + env_uuid: &Option, + install_dir_name: &str, + base: &Path, + kernel_dir: &Path, + kernel_detail: &KernelDetail, + status_emitter: Option, +) -> Result { + let staging_name = format!(".{install_dir_name}.staging-{}", Uuid::new_v4()); + let staging_dir = utils::resolve_kernel_install_dir(base, &staging_name)?; + downloader::download_and_install_kernel( + app, + env_uuid, + install_dir_name, + &staging_dir, + kernel_detail, + status_emitter, + ) + .await + .map_err(|error| kernel_prepare_error(error, &staging_dir, kernel_dir))?; + + let backup_dir = if kernel_dir.exists() { + let path = base.join(format!(".{install_dir_name}.backup-{}", Uuid::new_v4())); + if let Err(error) = fs::rename(kernel_dir, &path) { + let _ = fs::remove_dir_all(&staging_dir); + return Err(format!("替换旧内核前无法保留原目录: {error}").into()); + } + Some(path) + } else { + None + }; + + if let Err(error) = fs::rename(&staging_dir, kernel_dir) { + if let Some(backup_dir) = &backup_dir { + let _ = fs::rename(backup_dir, kernel_dir); + } + let _ = fs::remove_dir_all(&staging_dir); + return Err(format!("安装新内核失败,已恢复原目录: {error}").into()); + } + + if let Some(backup_dir) = backup_dir { + if let Err(error) = fs::remove_dir_all(&backup_dir) { + crate::log_warn!( + crate::core::logger::modules::KERNEL, + "新内核已安装,但清理旧目录失败: {} - {}", + backup_dir.display(), + error + ); + } + } + + Ok(kernel_dir.join(utils::exe_name())) +} + +fn kernel_prepare_error( + error: impl std::fmt::Display, + staging_dir: &Path, + kernel_dir: &Path, +) -> crate::core::error::Error { + let _ = fs::remove_dir_all(staging_dir); + let staging_path = staging_dir.to_string_lossy(); + let install_path = kernel_dir.to_string_lossy(); + let mut detail = error.to_string().replace(staging_path.as_ref(), install_path.as_ref()); + const ERROR_PREFIX: &str = "[080601] Kernel preparation failed: "; + const DETAIL_PREFIX: &str = "内核包准备失败:"; + loop { + if let Some(stripped) = detail.strip_prefix(ERROR_PREFIX) { + detail = stripped.to_string(); + } else if let Some(stripped) = detail.strip_prefix(DETAIL_PREFIX) { + detail = stripped.to_string(); + } else { + break; + } + } + crate::core::error::Error::KernelPrepareFailed(detail) +} + +fn is_current_kernel_signature(verified_signature: &str, primary_signature: &str) -> bool { + verified_signature.eq_ignore_ascii_case(primary_signature) +} + +#[cfg(test)] +mod tests { + use super::{is_current_kernel_signature, kernel_prepare_error}; + + #[test] + fn only_the_current_signature_reuses_an_existing_kernel() { + assert!(is_current_kernel_signature("ABC123", "abc123")); + assert!(!is_current_kernel_signature("old-compatible", "current")); + } + + #[test] + fn staging_failure_reports_install_path_and_removes_temporary_directory() { + let base = std::env::temp_dir().join(format!( + "simprint-kernel-staging-error-{}", + uuid::Uuid::new_v4() + )); + let staging_dir = base.join(".Chrome 144.staging-test"); + let kernel_dir = base.join("Chrome 144"); + std::fs::create_dir_all(&staging_dir).unwrap(); + + let error = kernel_prepare_error( + format!( + "内核包不完整:缺少 {}", + staging_dir.join("chrome_crashpad_handler.exe").display() + ), + &staging_dir, + &kernel_dir, + ) + .to_string(); + + assert!(!staging_dir.exists()); + assert!(error.contains(&kernel_dir.to_string_lossy().to_string())); + assert!(!error.contains(".Chrome 144.staging-test")); + assert_eq!( + error.matches("[080601] Kernel preparation failed:").count(), + 1 + ); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn staging_failure_does_not_duplicate_kernel_prepare_prefix() { + let base = std::env::temp_dir().join(format!( + "simprint-kernel-prefix-error-{}", + uuid::Uuid::new_v4() + )); + let staging_dir = base.join(".Chrome 144.staging-test"); + let kernel_dir = base.join("Chrome 144"); + std::fs::create_dir_all(&staging_dir).unwrap(); + + let error = kernel_prepare_error( + crate::core::error::Error::KernelPrepareFailed( + "内核包不完整:缺少 simprint.exe".to_string(), + ), + &staging_dir, + &kernel_dir, + ) + .to_string(); + + assert_eq!( + error.matches("[080601] Kernel preparation failed:").count(), + 1 + ); + assert!(!error.contains("内核包准备失败:")); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/src-tauri/src/services/environment/kernel/runtime_bridge.rs b/src-tauri/src/services/environment/kernel/runtime_bridge.rs index 60a2c701..740b9b3f 100644 --- a/src-tauri/src/services/environment/kernel/runtime_bridge.rs +++ b/src-tauri/src/services/environment/kernel/runtime_bridge.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::{fs, io::Write}; use futures::future::try_join_all; use tauri::AppHandle; @@ -374,6 +375,7 @@ async fn prepare_start_request( let user_data_dir = PathBuf::from(cache_path.trim()).join("browser").join("cache").join(&env_id); + ensure_browser_user_data_dir(&user_data_dir)?; if let Some(ctx) = AppContext::try_get() { ctx.env_status_manager @@ -499,6 +501,42 @@ async fn prepare_start_request( }) } +fn ensure_browser_user_data_dir(path: &std::path::Path) -> Result<()> { + fs::create_dir_all(path) + .map_err(|error| format!("无法创建浏览器用户数据目录 {}: {error}", path.display()))?; + + let probe_path = path.join(".simprint-write-probe"); + let probe_result = (|| -> std::io::Result<()> { + let mut file = fs::File::create(&probe_path)?; + file.write_all(b"simprint")?; + file.sync_all()?; + Ok(()) + })(); + let _ = fs::remove_file(&probe_path); + probe_result.map_err(|error| { + format!( + "浏览器用户数据目录不可写 {}: {error}。请检查目录权限或更换缓存路径", + path.display() + ) + .into() + }) +} + +#[cfg(test)] +mod tests { + use super::ensure_browser_user_data_dir; + + #[test] + fn creates_and_validates_browser_user_data_dir() { + let path = + std::env::temp_dir().join(format!("simprint-user-data-{}", uuid::Uuid::new_v4())); + ensure_browser_user_data_dir(&path).unwrap(); + assert!(path.is_dir()); + assert!(!path.join(".simprint-write-probe").exists()); + let _ = std::fs::remove_dir_all(path); + } +} + fn merge_local_extensions( app: &AppHandle, extensions: Option>, diff --git a/src-tauri/src/services/environment/kernel/utils.rs b/src-tauri/src/services/environment/kernel/utils.rs index ccf8ba99..7c17e664 100644 --- a/src-tauri/src/services/environment/kernel/utils.rs +++ b/src-tauri/src/services/environment/kernel/utils.rs @@ -69,6 +69,23 @@ pub fn exe_name() -> &'static str { return "simprint"; } +/// 校验内核目录是否包含启动所需的文件。 +/// +/// Windows 版本的 Simprint Chromium 会在主进程启动时拉起 Crashpad +/// handler;只校验 chrome.dll 的哈希无法发现缺少该子进程的残缺安装包。 +/// 当前内核可将 Crashpad handler 集成在主程序中,因此这里只要求主程序存在。 +pub fn validate_kernel_package_layout(kernel_dir: &Path) -> Result<()> { + let required_files = [exe_name()]; + for file_name in required_files { + let path = kernel_dir.join(file_name); + if !path.is_file() { + return Err(format!("内核包不完整:缺少 {}", path.display()).into()); + } + } + + Ok(()) +} + /// 核心 DLL 文件名 pub fn core_dll_name() -> &'static str { #[cfg(target_os = "windows")] @@ -150,7 +167,7 @@ pub fn emit_status( #[cfg(test)] mod tests { - use super::resolve_kernel_install_dir; + use super::{exe_name, resolve_kernel_install_dir, validate_kernel_package_layout}; use std::path::Path; #[test] @@ -164,4 +181,38 @@ mod tests { assert!(resolve_kernel_install_dir(base, "..").is_err()); assert!(resolve_kernel_install_dir(base, "nested/kernel").is_err()); } + + #[test] + fn rejects_kernel_without_browser_executable() { + let dir = std::env::temp_dir().join(format!( + "simprint-kernel-layout-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + + let result = validate_kernel_package_layout(&dir); + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn accepts_kernel_without_standalone_crashpad_handler() { + let dir = std::env::temp_dir().join(format!( + "simprint-kernel-layout-valid-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(exe_name()), b"test").unwrap(); + + assert!(validate_kernel_package_layout(&dir).is_ok()); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/src-tauri/src/services/environment/launch_runtime/paths.rs b/src-tauri/src/services/environment/launch_runtime/paths.rs index 8e89b177..aff0635c 100644 --- a/src-tauri/src/services/environment/launch_runtime/paths.rs +++ b/src-tauri/src/services/environment/launch_runtime/paths.rs @@ -1,21 +1,17 @@ -use serde_json::Value; use tauri::AppHandle; -use crate::{ - core::error::Result, infrastructure::persistence::tauri_store, - services::file_system::StorageService, -}; +use crate::{core::error::Result, services::file_system::StorageService}; use super::types::LaunchPaths; pub(super) fn get_launch_paths(app: &AppHandle) -> Result { let defaults = StorageService::get_storage_default_paths(app)?; - let storage = tauri_store::get_store_key(app, "storage"); - let storage_obj = storage.as_ref().and_then(Value::as_object); + let storage = crate::infrastructure::persistence::tauri_store::get_store_key(app, "storage"); + let storage_obj = storage.as_ref().and_then(serde_json::Value::as_object); let profiles = storage_obj .and_then(|obj| obj.get("profilesPath")) - .and_then(Value::as_str) + .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) @@ -23,7 +19,7 @@ pub(super) fn get_launch_paths(app: &AppHandle) -> Result { let cache = storage_obj .and_then(|obj| obj.get("cachePath")) - .and_then(Value::as_str) + .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned)