From da9bf96fd7c1f1fdc07bdb54a4660526ac759f71 Mon Sep 17 00:00:00 2001 From: Rbqwow <55343783+Rbqwow@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:19:51 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=E5=B0=86webview2=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E4=BF=AE=E6=94=B9=E4=B8=BA=E8=A7=A3=E5=8E=8B=E5=B9=B6?= =?UTF-8?q?=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/main.rs | 14 +- src-tauri/src/webview2/detection.rs | 1 + src-tauri/src/webview2/dialog.rs | 35 ++- src-tauri/src/webview2/install.rs | 402 +++++++++++++++++++++------- 4 files changed, 355 insertions(+), 97 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 10cdf91..178815b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,10 +15,22 @@ fn main() { // 确保目录存在 let _ = std::fs::create_dir_all(&webview_data_dir); std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data_dir); + + // 检测已缓存的 WebView2 固定版本运行时 + let webview2_runtime_dir = exe_dir.join("webview2_runtime"); + if webview2_runtime_dir.is_dir() { + std::env::set_var( + "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", + &webview2_runtime_dir, + ); + } } } - if !webview2::ensure_webview2() { + // 已有本地运行时时跳过检测,否则检测系统安装或自动下载 + if std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER").is_none() + && !webview2::ensure_webview2() + { std::process::exit(1); } diff --git a/src-tauri/src/webview2/detection.rs b/src-tauri/src/webview2/detection.rs index 05ac866..649a7b7 100644 --- a/src-tauri/src/webview2/detection.rs +++ b/src-tauri/src/webview2/detection.rs @@ -97,6 +97,7 @@ pub fn is_webview2_installed() -> bool { /// - HKCU\Software\Microsoft\Edge\WebView2\BrowserExecutableFolder (设置为空字符串表示禁用) /// /// 返回 Some(reason) 如果被禁用,None 如果未被禁用 + pub fn is_webview2_disabled() -> Option { // 检查组策略禁用(通过 BrowserExecutableFolder 设置为特定值或空) // 参考: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#detect-if-a-suitable-webview2-runtime-is-already-installed diff --git a/src-tauri/src/webview2/dialog.rs b/src-tauri/src/webview2/dialog.rs index aa74d22..e01622f 100644 --- a/src-tauri/src/webview2/dialog.rs +++ b/src-tauri/src/webview2/dialog.rs @@ -44,6 +44,7 @@ struct DialogState { status_hwnd: Option, button_hwnd: Option, hfont: Option, + dialog_type: Option, } impl DialogState { @@ -51,6 +52,7 @@ impl DialogState { self.progress_hwnd = None; self.status_hwnd = None; self.button_hwnd = None; + self.dialog_type = None; } } @@ -90,7 +92,17 @@ unsafe extern "system" fn dialog_wnd_proc( } LRESULT(0) } - WM_DIALOG_CLOSE | WM_CLOSE => { + WM_CLOSE => { + // 用户点击 X 关闭窗口:进度对话框直接退出进程(此时 Tauri 尚未启动) + DIALOG_STATE.with(|s| { + if s.borrow().dialog_type == Some(DialogType::Progress) { + std::process::exit(0); + } + }); + PostQuitMessage(0); + LRESULT(0) + } + WM_DIALOG_CLOSE => { PostQuitMessage(0); LRESULT(0) } @@ -216,15 +228,27 @@ impl CustomDialog { RegisterClassW(&wc); let title_wide = to_wide(&title_owned); + let wnd_style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU; + // width/height represent desired client area; compute actual window size + let mut rc = windows::Win32::Foundation::RECT { + left: 0, + top: 0, + right: width, + bottom: height, + }; + let _ = AdjustWindowRect(&mut rc, wnd_style, false); + let wnd_w = rc.right - rc.left; + let wnd_h = rc.bottom - rc.top; + let hwnd = CreateWindowExW( WINDOW_EX_STYLE::default(), PCWSTR::from_raw(class_name.as_ptr()), PCWSTR::from_raw(title_wide.as_ptr()), - WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU, + wnd_style, CW_USEDEFAULT, CW_USEDEFAULT, - width, - height, + wnd_w, + wnd_h, None, None, hinstance, @@ -232,7 +256,7 @@ impl CustomDialog { ) .unwrap_or_default(); - center_window(hwnd, width, height); + center_window(hwnd, wnd_w, wnd_h); const MARGIN: i32 = 24; const BTN_W: i32 = 96; @@ -279,6 +303,7 @@ impl CustomDialog { let mut g = s.borrow_mut(); g.status_hwnd = Some(status_hwnd); g.progress_hwnd = Some(progressbar_hwnd); + g.dialog_type = Some(dialog_type); }); } DialogType::Success | DialogType::Error => { diff --git a/src-tauri/src/webview2/install.rs b/src-tauri/src/webview2/install.rs index ec7ab78..d91ea89 100644 --- a/src-tauri/src/webview2/install.rs +++ b/src-tauri/src/webview2/install.rs @@ -1,68 +1,260 @@ -//! WebView2 下载与安装 +//! WebView2 下载与本地解压 //! -//! 使用 **Evergreen Bootstrapper(常青引导程序)**:约 2MB 的小型安装包, -//! 运行时会按本机架构(x86/x64/ARM64)从微软服务器下载并安装 WebView2 运行时, -//! 安装后纳入 Evergreen 自动更新。需联网完成安装。 -//! 标识: `evergreen-bootstrapper-description` +//! 从微软官方 CDN 下载 **Fixed Version Runtime(固定版本运行时)**, +//! 解压到程序目录的 `webview2_runtime/` 下,通过环境变量 +//! `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` 指定运行时路径,不影响系统。 use std::io::Read; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; use super::detection::{is_webview2_disabled, is_webview2_installed}; use super::dialog::CustomDialog; -/// Evergreen Bootstrapper 下载地址(fwlink 永久链接)。 -const DOWNLOAD_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; +/// WebView2 Fixed Version Runtime 版本号。 +/// 更新版本时需同步更新 `GUID_X64` 和 `GUID_ARM64`。 +/// GUID 可在 https://developer.microsoft.com/en-us/microsoft-edge/webview2/ 页面 +/// 从 Fixed Version 的下载链接中获取, +/// 或前往 https://github.com/nicehash/NiceHashQuickMiner/releases 查看 +const WEBVIEW2_VERSION: &str = "145.0.3800.65"; +const GUID_X64: &str = "c411606c-d282-4304-8420-8ae6b1dd3e9a"; +const GUID_ARM64: &str = "2d2cf37b-d24c-4c72-b5bc-e8061e7a7583"; -/// 手动下载说明页(含 Bootstrapper 与 Standalone x86/x64/ARM64)。 -const MANUAL_DOWNLOAD_URL: &str = "https://aka.ms/webview2installer"; +/// 隐藏控制台窗口标志 +const CREATE_NO_WINDOW: u32 = 0x08000000; -fn show_webview2_disabled_dialog(reason: &str) { - let message = format!( - "检测到 WebView2 已被禁用:\r\n{}\r\n\r\n\ - 【什么是 WebView2?】\r\n\ - WebView2 是微软提供的网页渲染组件,本程序依赖它来\r\n\ - 显示界面。如果 WebView2 被禁用,程序将无法正常运行。\r\n\r\n\ - 【如何解决?】\r\n\ - 方法一:如果使用了 Edge Blocker 等工具\r\n\ - - 打开 Edge Blocker,点击\"Unblock\"解除禁用\r\n\ - - 或删除注册表中的 IFEO 拦截项\r\n\r\n\ - 方法二:修改组策略(需要管理员权限)\r\n\ - 1. 按 Win + R,输入 gpedit.msc\r\n\ - 2. 导航到:计算机配置 > 管理模板 > Microsoft Edge WebView2\r\n\ - 3. 将相关策略设置为\"未配置\"或\"已启用\"\r\n\r\n\ - 方法三:加入我们的 QQ 群,获取帮助和支持\r\n\ - - 群号可在我们的官网或文档底部找到\r\n\r\n", - reason - ); - CustomDialog::show_error("WebView2 组件已被禁用", &message); +/// 获取当前架构对应的下载标签和 GUID +fn get_arch_info() -> Result<(&'static str, &'static str), String> { + match std::env::consts::ARCH { + "x86_64" => Ok(("x64", GUID_X64)), + "aarch64" => Ok(("arm64", GUID_ARM64)), + other => Err(format!("不支持的架构: {}", other)), + } +} + +/// 获取 WebView2 固定版本运行时的目录路径(exe 同级目录) +pub fn get_webview2_runtime_dir() -> Result { + let exe_path = std::env::current_exe().map_err(|e| format!("获取程序路径失败: {}", e))?; + let exe_dir = exe_path + .parent() + .ok_or_else(|| "无法获取程序目录".to_string())?; + Ok(exe_dir.join("webview2_runtime")) } -fn show_install_failed_dialog(error: &str) { +fn show_download_failed_dialog(error: &str) { + let (arch_label, _) = get_arch_info().unwrap_or(("x64", "")); + let cab_name = format!( + "Microsoft.WebView2.FixedVersionRuntime.{}.{}.cab", + WEBVIEW2_VERSION, arch_label + ); let message = format!( - "自动安装失败:{}\r\n\r\n\ - 请手动下载安装:\r\n\ + "系统 WebView2 不可用,下载独立 WebView2 运行时失败:\r\n\ {}\r\n\r\n\ - 安装完成后重启程序。", - error, MANUAL_DOWNLOAD_URL + 【方法一】检查网络连接后重启程序重试\r\n\r\n\ + 【方法二】手动下载 cab 文件并放到程序同目录\r\n\ + 1. 前往 https://aka.ms/webview2installer\r\n\ + 选择 \"Fixed Version\" 下载对应架构({})的 cab 文件\r\n\ + 2. 将下载的 cab 文件(文件名类似 {})\r\n\ + 放到本程序 exe 所在目录下\r\n\ + 3. 重启程序,将自动检测并解压使用\r\n\r\n\ + 【方法三】手动安装系统 WebView2 运行时\r\n\ + 前往 https://aka.ms/webview2installer\r\n\ + 下载 Evergreen Bootstrapper,运行安装后重启电脑即可", + error, arch_label, cab_name ); - CustomDialog::show_error("WebView2 安装失败", &message); + CustomDialog::show_error("WebView2 下载失败", &message); +} + +/// 递归复制目录内容 +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { + std::fs::create_dir_all(dst) + .map_err(|e| format!("无法创建目录 [{}]: {}", dst.display(), e))?; + + for entry in std::fs::read_dir(src) + .map_err(|e| format!("无法读取目录 [{}]: {}", src.display(), e))? + { + let entry = entry.map_err(|e| format!("无法读取目录条目: {}", e))?; + let src_item = entry.path(); + let dst_item = dst.join(entry.file_name()); + + if src_item.is_dir() { + copy_dir_recursive(&src_item, &dst_item)?; + } else { + std::fs::copy(&src_item, &dst_item).map_err(|e| { + format!( + "无法复制文件 [{}] -> [{}]: {}", + src_item.display(), + dst_item.display(), + e + ) + })?; + } + } + Ok(()) +} + +/// 解压 cab 文件到 WebView2 运行时目录 +fn extract_cab_to_runtime(cab_path: &std::path::Path, runtime_dir: &std::path::Path) -> Result<(), String> { + let temp_dir = std::env::temp_dir(); + let extract_temp = temp_dir.join("mxu_webview2_extract"); + + let _ = std::fs::remove_dir_all(&extract_temp); + std::fs::create_dir_all(&extract_temp) + .map_err(|e| format!("创建临时目录失败: {}", e))?; + + let status = std::process::Command::new("expand.exe") + .arg(cab_path) + .arg("-F:*") + .arg(&extract_temp) + .creation_flags(CREATE_NO_WINDOW) + .status() + .map_err(|e| format!("运行 expand.exe 失败: {}", e))?; + + if !status.success() { + let _ = std::fs::remove_dir_all(&extract_temp); + return Err(format!( + "解压失败,退出码: {}", + status.code().unwrap_or(-1) + )); + } + + // cab 解压后文件可能在版本子目录中 + let mut source_dir = extract_temp.clone(); + if let Ok(entries) = std::fs::read_dir(&extract_temp) { + for entry in entries.flatten() { + if entry.path().is_dir() + && entry + .file_name() + .to_string_lossy() + .starts_with("Microsoft.WebView2") + { + source_dir = entry.path(); + break; + } + } + } + + // 准备目标目录 + if runtime_dir.exists() { + let _ = std::fs::remove_dir_all(runtime_dir); + } + std::fs::create_dir_all(runtime_dir) + .map_err(|e| format!("创建运行时目录失败: {}", e))?; + + copy_dir_recursive(&source_dir, runtime_dir)?; + + let _ = std::fs::remove_dir_all(&extract_temp); + Ok(()) +} + +/// 检测 exe 同目录下是否存在已下载的 cab 文件,供网络不佳的用户手动放置使用。 +/// 优先使用架构匹配的 cab 文件;仅存在不匹配的则弹出警告并返回 None 继续下载。 +fn try_extract_local_cab(runtime_dir: &std::path::Path) -> Option> { + let exe_path = std::env::current_exe().ok()?; + let exe_dir = exe_path.parent()?; + let (expected_arch, _) = get_arch_info().ok()?; + + // 收集所有 cab 文件,区分架构匹配与不匹配 + let mut matched: Option = None; + let mut mismatched_arch: Option = None; + + if let Ok(entries) = std::fs::read_dir(exe_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("Microsoft.WebView2.FixedVersionRuntime.") + && name_str.ends_with(".cab") + { + let cab_arch = name_str + .trim_end_matches(".cab") + .rsplit('.') + .next() + .unwrap_or(""); + if cab_arch.eq_ignore_ascii_case(expected_arch) { + matched = Some(entry.path()); + break; + } else { + mismatched_arch = Some(cab_arch.to_string()); + } + } + } + } + + // 优先使用架构匹配的 cab + if let Some(cab_path) = matched { + let progress_dialog = CustomDialog::new_progress( + "正在解压 WebView2", + "检测到本地 WebView2 运行时 cab 文件,正在解压...", + ); + + let result = extract_cab_to_runtime(&cab_path, runtime_dir); + + if let Some(pw) = progress_dialog { + pw.close(); + } + + if result.is_ok() { + let _ = std::fs::remove_file(&cab_path); + } + return Some(result); + } + + // 仅存在不匹配的 cab,弹窗提示 + if let Some(cab_arch) = mismatched_arch { + CustomDialog::show_error( + "WebView2 架构不匹配", + &format!( + "检测到本地 WebView2 运行时 cab 文件,但架构不匹配:\r\n\ + 文件架构: {}\r\n\ + 系统架构: {}\r\n\r\n\ + 将忽略该文件并尝试在线下载正确版本。", + cab_arch, expected_arch + ), + ); + } + + None } -pub fn download_and_install() -> Result<(), String> { - let progress_dialog = - CustomDialog::new_progress("正在安装 WebView2", "正在下载 WebView2 运行时..."); +/// 下载或解压 WebView2 Fixed Version Runtime 到本地 +pub fn download_and_extract() -> Result<(), String> { + let (arch_label, guid) = get_arch_info()?; + let cab_name = format!( + "Microsoft.WebView2.FixedVersionRuntime.{}.{}.cab", + WEBVIEW2_VERSION, arch_label + ); + let download_url = format!( + "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/{}/{}", + guid, cab_name + ); + + let runtime_dir = get_webview2_runtime_dir()?; + + // 优先检测 exe 同目录下是否存在已下载的 cab 文件 + if let Some(result) = try_extract_local_cab(&runtime_dir) { + if result.is_ok() { + std::env::set_var("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", &runtime_dir); + } + return result; + } + + let progress_dialog = CustomDialog::new_progress( + "正在下载 WebView2", + "系统 WebView2 不可用,正在下载独立 WebView2...", + ); let temp_dir = std::env::temp_dir(); - let installer_path = temp_dir.join("MicrosoftEdgeWebview2Setup.exe"); + let cab_path = temp_dir.join(&cab_name); - let download_result = (|| -> Result, String> { + // 下载 cab 文件(流式写入磁盘) + let download_result = (|| -> Result<(), String> { let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(300)) + .connect_timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; let response = client - .get(DOWNLOAD_URL) + .get(&download_url) .send() .map_err(|e| format!("网络请求失败: {}", e))?; @@ -72,9 +264,11 @@ pub fn download_and_install() -> Result<(), String> { let total_size = response.content_length().unwrap_or(0); let mut downloaded: u64 = 0; - let mut buffer = Vec::new(); - let mut reader = response; - let mut chunk = [0u8; 8192]; + let mut reader = std::io::BufReader::with_capacity(256 * 1024, response); + let mut file = std::fs::File::create(&cab_path) + .map_err(|e| format!("创建下载文件失败: {}", e))?; + let mut chunk = [0u8; 256 * 1024]; + let mut last_ui_update = std::time::Instant::now(); loop { let bytes_read = reader @@ -85,78 +279,104 @@ pub fn download_and_install() -> Result<(), String> { break; } - buffer.extend_from_slice(&chunk[..bytes_read]); + std::io::Write::write_all(&mut file, &chunk[..bytes_read]) + .map_err(|e| format!("写入文件失败: {}", e))?; downloaded += bytes_read as u64; - if let Some(ref pw) = progress_dialog { - if total_size > 0 { - let percent = ((downloaded as f64 / total_size as f64) * 100.0) as u32; - pw.set_progress(percent); - pw.set_status(&format!( - "正在下载... {:.1} MB / {:.1} MB", - downloaded as f64 / 1024.0 / 1024.0, - total_size as f64 / 1024.0 / 1024.0 - )); - } else { - pw.set_status(&format!( - "正在下载... {:.1} MB", - downloaded as f64 / 1024.0 / 1024.0 - )); + // 节流 UI 更新,避免 SendMessageW 跨线程同步调用阻塞下载 + if last_ui_update.elapsed() >= std::time::Duration::from_millis(200) { + last_ui_update = std::time::Instant::now(); + if let Some(ref pw) = progress_dialog { + if total_size > 0 { + let percent = ((downloaded as f64 / total_size as f64) * 100.0) as u32; + pw.set_progress(percent); + pw.set_status(&format!( + "正在下载独立 WebView2... {:.1} MB / {:.1} MB", + downloaded as f64 / 1024.0 / 1024.0, + total_size as f64 / 1024.0 / 1024.0 + )); + } else { + pw.set_status(&format!( + "正在下载独立 WebView2... {:.1} MB", + downloaded as f64 / 1024.0 / 1024.0 + )); + } } } } - if let Some(ref pw) = progress_dialog { - pw.set_progress(100); - pw.set_status("正在安装..."); + Ok(()) + })(); + + let download_err = download_result.err(); + if let Some(ref e) = download_err { + if let Some(pw) = progress_dialog { + pw.close(); } + let _ = std::fs::remove_file(&cab_path); + return Err(e.clone()); + } - Ok(buffer) - })(); + // 更新进度:解压中 + if let Some(ref pw) = progress_dialog { + pw.set_progress(100); + pw.set_status("正在解压..."); + } + + // 解压 cab 文件 + let extract_result = extract_cab_to_runtime(&cab_path, &runtime_dir); if let Some(pw) = progress_dialog { pw.close(); } - let buffer = download_result?; + // 清理下载的 cab 文件 + let _ = std::fs::remove_file(&cab_path); - std::fs::write(&installer_path, &buffer).map_err(|e| format!("保存安装程序失败: {}", e))?; - - let status = std::process::Command::new(&installer_path) - .args(["/silent", "/install"]) - .status() - .map_err(|e| format!("运行安装程序失败: {}", e))?; + extract_result?; - let _ = std::fs::remove_file(&installer_path); + // 设置环境变量供当前进程使用 + std::env::set_var("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", &runtime_dir); - let exit_code = status.code().unwrap_or(-1); - if status.success() || exit_code == -2147219416 { - Ok(()) - } else { - Err(format!( - "安装程序退出码: {} (0x{:X})", - exit_code, exit_code as u32 - )) - } + Ok(()) } +/// 确保 WebView2 可用:优先使用系统安装,不可用时自动下载独立运行时 pub fn ensure_webview2() -> bool { - // 首先检查 WebView2 是否被禁用 + // 检测 WebView2 是否被禁用,弹窗提示后继续走独立运行时流程 if let Some(reason) = is_webview2_disabled() { - show_webview2_disabled_dialog(&reason); - return false; - } - - // 检查是否已安装 - if is_webview2_installed() { + CustomDialog::show_error( + "系统 WebView2 已被禁用", + &format!( + "检测到系统 WebView2 已被禁用:\r\n{}\r\n\r\n\ + 【什么是 WebView2?】\r\n\ + WebView2 是微软提供的网页渲染组件,本程序依赖它来\r\n\ + 显示界面。如果 WebView2 被禁用,程序将无法正常运行。\r\n\r\n\ + 【如何解决?】\r\n\ + 方法一:如果使用了 Edge Blocker 等工具\r\n\ + - 打开 Edge Blocker,点击\"Unblock\"解除禁用\r\n\ + - 或删除注册表中的 IFEO 拦截项\r\n\r\n\ + 方法二:修改组策略(需要管理员权限)\r\n\ + 1. 按 Win + R,输入 gpedit.msc\r\n\ + 2. 导航到:计算机配置 > 管理模板 > Microsoft Edge WebView2\r\n\ + 3. 将相关策略设置为\"未配置\"或\"已启用\"\r\n\r\n\ + 方法三:加入我们的 QQ 群,获取帮助和支持\r\n\ + - 群号可在我们的官网或文档底部找到\r\n\r\n\ + 点击确定后将尝试下载独立 WebView2 运行时以继续运行。\r\n\ + 若想恢复使用系统 WebView2,请手动删除 exe 目录下的 webview2_runtime 文件夹", + reason + ), + ); + } else if is_webview2_installed() { + // 系统 WebView2 可用且未被禁用,直接使用 return true; } - // 尝试下载安装 - match download_and_install() { + // 系统不可用或被禁用,下载独立 WebView2 运行时 + match download_and_extract() { Ok(()) => true, Err(e) => { - show_install_failed_dialog(&e); + show_download_failed_dialog(&e); false } } From 4c60ca14cbb9dfc951edfcffcb85155ceb7a3aba Mon Sep 17 00:00:00 2001 From: Rbqwow <55343783+Rbqwow@users.noreply.github.com> Date: Fri, 20 Feb 2026 06:55:46 +0800 Subject: [PATCH 2/2] chore: review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改进架构不支持的错误信息,注明仅支持 x64/ARM64 - 递归复制时检查并跳过符号链接 - 添加 TOCTOU 竞态条件注释说明 - 删除/创建 runtime_dir 时改进文件锁错误提示 - 下载完成后 flush 文件缓冲 - HTTP 客户端显式设置 TLS 证书验证 - 添加 HTTP 整体超时 (600s) 防止无限挂起 - SendMessageW 安全性注释说明必须同步调用 - 验证 webview2_runtime 目录包含 msedgewebview2.exe - expand.exe 从 System32 解析并为下载临时文件添加 PID 前缀 - 从 %SystemRoot%\System32 解析 expand.exe 完整路径,不依赖 PATH - expand.exe 不存在时给出明确路径提示 - 下载的临时 cab 文件名添加 PID 前缀,避免并发实例冲突 - show_download_failed_dialog 不再默认回退 x64,架构不支持时展示专门提示 - 删除 runtime_dir 前通过 symlink_metadata 检查符号链接/重解析点,拒绝操作以防任意目录删除 - 新增 validate_runtime_dir 在设置环境变量前校验 msedgewebview2.exe 存在 - 下载临时 cab 路径已在上次提交中添加 PID 前缀 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src-tauri/src/main.rs | 5 +- src-tauri/src/webview2/detection.rs | 1 - src-tauri/src/webview2/dialog.rs | 3 + src-tauri/src/webview2/install.rs | 168 ++++++++++++++++++++++------ 4 files changed, 142 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 178815b..d7ebd64 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,8 +17,11 @@ fn main() { std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data_dir); // 检测已缓存的 WebView2 固定版本运行时 + // 验证目录包含关键文件以确保运行时完整可用 let webview2_runtime_dir = exe_dir.join("webview2_runtime"); - if webview2_runtime_dir.is_dir() { + if webview2_runtime_dir.is_dir() + && webview2_runtime_dir.join("msedgewebview2.exe").exists() + { std::env::set_var( "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", &webview2_runtime_dir, diff --git a/src-tauri/src/webview2/detection.rs b/src-tauri/src/webview2/detection.rs index 649a7b7..05ac866 100644 --- a/src-tauri/src/webview2/detection.rs +++ b/src-tauri/src/webview2/detection.rs @@ -97,7 +97,6 @@ pub fn is_webview2_installed() -> bool { /// - HKCU\Software\Microsoft\Edge\WebView2\BrowserExecutableFolder (设置为空字符串表示禁用) /// /// 返回 Some(reason) 如果被禁用,None 如果未被禁用 - pub fn is_webview2_disabled() -> Option { // 检查组策略禁用(通过 BrowserExecutableFolder 设置为特定值或空) // 参考: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#detect-if-a-suitable-webview2-runtime-is-already-installed diff --git a/src-tauri/src/webview2/dialog.rs b/src-tauri/src/webview2/dialog.rs index e01622f..e554dae 100644 --- a/src-tauri/src/webview2/dialog.rs +++ b/src-tauri/src/webview2/dialog.rs @@ -405,6 +405,9 @@ impl CustomDialog { } pub(crate) fn set_status(&self, text: &str) { + // 安全说明:wide_text 分配在栈上,其指针通过 LPARAM 传递给 UI 线程。 + // 这里必须使用 SendMessageW(同步)而非 PostMessageW(异步), + // 因为 SendMessageW 会阻塞直到消息处理完成,确保 wide_text 在被使用期间有效。 let wide_text = to_wide(text); unsafe { let _ = SendMessageW( diff --git a/src-tauri/src/webview2/install.rs b/src-tauri/src/webview2/install.rs index d91ea89..6bdfa5a 100644 --- a/src-tauri/src/webview2/install.rs +++ b/src-tauri/src/webview2/install.rs @@ -28,7 +28,10 @@ fn get_arch_info() -> Result<(&'static str, &'static str), String> { match std::env::consts::ARCH { "x86_64" => Ok(("x64", GUID_X64)), "aarch64" => Ok(("arm64", GUID_ARM64)), - other => Err(format!("不支持的架构: {}", other)), + other => Err(format!( + "不支持的 CPU 架构: {}。当前应用仅支持 64 位 Windows(x64、ARM64),请在 64 位系统上运行。", + other + )), } } @@ -41,28 +44,54 @@ pub fn get_webview2_runtime_dir() -> Result { Ok(exe_dir.join("webview2_runtime")) } +/// 验证运行时目录包含关键可执行文件 +fn validate_runtime_dir(runtime_dir: &std::path::Path) -> Result<(), String> { + if !runtime_dir.join("msedgewebview2.exe").exists() { + return Err( + "解压后的 WebView2 运行时目录不完整(未找到 msedgewebview2.exe)。\n\ + 请删除 webview2_runtime/ 目录后重启程序重试。".to_string() + ); + } + Ok(()) +} + fn show_download_failed_dialog(error: &str) { - let (arch_label, _) = get_arch_info().unwrap_or(("x64", "")); - let cab_name = format!( - "Microsoft.WebView2.FixedVersionRuntime.{}.{}.cab", - WEBVIEW2_VERSION, arch_label - ); - let message = format!( - "系统 WebView2 不可用,下载独立 WebView2 运行时失败:\r\n\ - {}\r\n\r\n\ - 【方法一】检查网络连接后重启程序重试\r\n\r\n\ - 【方法二】手动下载 cab 文件并放到程序同目录\r\n\ - 1. 前往 https://aka.ms/webview2installer\r\n\ - 选择 \"Fixed Version\" 下载对应架构({})的 cab 文件\r\n\ - 2. 将下载的 cab 文件(文件名类似 {})\r\n\ - 放到本程序 exe 所在目录下\r\n\ - 3. 重启程序,将自动检测并解压使用\r\n\r\n\ - 【方法三】手动安装系统 WebView2 运行时\r\n\ - 前往 https://aka.ms/webview2installer\r\n\ - 下载 Evergreen Bootstrapper,运行安装后重启电脑即可", - error, arch_label, cab_name - ); - CustomDialog::show_error("WebView2 下载失败", &message); + match get_arch_info() { + Ok((arch_label, _)) => { + let cab_name = format!( + "Microsoft.WebView2.FixedVersionRuntime.{}.{}.cab", + WEBVIEW2_VERSION, arch_label + ); + let message = format!( + "系统 WebView2 不可用,下载独立 WebView2 运行时失败:\r\n\ + {}\r\n\r\n\ + 【方法一】检查网络连接后重启程序重试\r\n\r\n\ + 【方法二】手动下载 cab 文件并放到程序同目录\r\n\ + 1. 前往 https://aka.ms/webview2installer\r\n\ + 选择 \"Fixed Version\" 下载对应架构({})的 cab 文件\r\n\ + 2. 将下载的 cab 文件(文件名类似 {})\r\n\ + 放到本程序 exe 所在目录下\r\n\ + 3. 重启程序,将自动检测并解压使用\r\n\r\n\ + 【方法三】手动安装系统 WebView2 运行时\r\n\ + 前往 https://aka.ms/webview2installer\r\n\ + 下载 Evergreen Bootstrapper,运行安装后重启电脑即可", + error, arch_label, cab_name + ); + CustomDialog::show_error("WebView2 下载失败", &message); + } + Err(arch_err) => { + let message = format!( + "系统 WebView2 不可用,下载独立 WebView2 运行时失败:\r\n\ + {}\r\n\r\n\ + 此外,无法判断当前系统架构:{}\r\n\r\n\ + 【手动安装系统 WebView2 运行时】\r\n\ + 前往 https://aka.ms/webview2installer\r\n\ + 下载 Evergreen Bootstrapper,运行安装后重启电脑即可", + error, arch_err + ); + CustomDialog::show_error("WebView2 下载失败", &message); + } + } } /// 递归复制目录内容 @@ -77,7 +106,13 @@ fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<() let src_item = entry.path(); let dst_item = dst.join(entry.file_name()); - if src_item.is_dir() { + let file_type = entry.file_type().map_err(|e| format!("无法获取文件类型: {}", e))?; + if file_type.is_symlink() { + // WebView2 cab 中不应包含符号链接,跳过以避免安全风险 + continue; + } + + if file_type.is_dir() { copy_dir_recursive(&src_item, &dst_item)?; } else { std::fs::copy(&src_item, &dst_item).map_err(|e| { @@ -93,16 +128,35 @@ fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<() Ok(()) } +/// 获取 expand.exe 的完整路径(从 System32 目录解析) +fn get_expand_exe_path() -> Result { + let system_root = std::env::var("SystemRoot") + .unwrap_or_else(|_| r"C:\Windows".to_string()); + let expand_path = std::path::PathBuf::from(&system_root) + .join("System32") + .join("expand.exe"); + if expand_path.exists() { + Ok(expand_path) + } else { + Err(format!( + "未找到 expand.exe,请确认系统完整性。\n预期路径: {}", + expand_path.display() + )) + } +} + /// 解压 cab 文件到 WebView2 运行时目录 fn extract_cab_to_runtime(cab_path: &std::path::Path, runtime_dir: &std::path::Path) -> Result<(), String> { + let expand_exe = get_expand_exe_path()?; + let temp_dir = std::env::temp_dir(); - let extract_temp = temp_dir.join("mxu_webview2_extract"); + let extract_temp = temp_dir.join(format!("mxu_webview2_extract_{}", std::process::id())); let _ = std::fs::remove_dir_all(&extract_temp); std::fs::create_dir_all(&extract_temp) .map_err(|e| format!("创建临时目录失败: {}", e))?; - let status = std::process::Command::new("expand.exe") + let status = std::process::Command::new(&expand_exe) .arg(cab_path) .arg("-F:*") .arg(&extract_temp) @@ -134,12 +188,41 @@ fn extract_cab_to_runtime(cab_path: &std::path::Path, runtime_dir: &std::path::P } } - // 准备目标目录 + // 准备目标目录:删除前检查是否为符号链接/重解析点,防止通过构造链接删除任意目录 if runtime_dir.exists() { - let _ = std::fs::remove_dir_all(runtime_dir); + let meta = std::fs::symlink_metadata(runtime_dir) + .map_err(|e| format!("读取运行时目录元数据失败: {}", e))?; + if meta.file_type().is_symlink() { + return Err(format!( + "运行时目录 [{}] 是符号链接或重解析点,出于安全原因拒绝操作。\n\ + 请手动删除该链接后重试。", + runtime_dir.display() + )); + } + if let Err(e) = std::fs::remove_dir_all(runtime_dir) { + let msg = if e.kind() == std::io::ErrorKind::PermissionDenied { + format!( + "删除旧的 WebView2 运行时目录失败,可能有正在运行的程序正在使用该目录。\n\n\ + 请关闭所有已运行的本应用实例后重试。\n\n系统错误: {}", + e + ) + } else { + format!("删除旧的 WebView2 运行时目录失败: {}", e) + }; + return Err(msg); + } } - std::fs::create_dir_all(runtime_dir) - .map_err(|e| format!("创建运行时目录失败: {}", e))?; + std::fs::create_dir_all(runtime_dir).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + format!( + "创建运行时目录失败,可能缺少权限或有程序占用该路径。\n\n\ + 请关闭所有已运行的本应用实例或以管理员身份重新运行。\n\n系统错误: {}", + e + ) + } else { + format!("创建运行时目录失败: {}", e) + } + })?; copy_dir_recursive(&source_dir, runtime_dir)?; @@ -181,6 +264,8 @@ fn try_extract_local_cab(runtime_dir: &std::path::Path) -> Option Option { + let _ = std::fs::remove_file(&cab_path); + return Some(Ok(())); + } + Err(_) => { + // 本地 cab 解压失败(可能文件损坏或被移除),删除并回退到在线下载 + let _ = std::fs::remove_file(&cab_path); + return None; + } } - return Some(result); } // 仅存在不匹配的 cab,弹窗提示 @@ -233,6 +325,7 @@ pub fn download_and_extract() -> Result<(), String> { // 优先检测 exe 同目录下是否存在已下载的 cab 文件 if let Some(result) = try_extract_local_cab(&runtime_dir) { if result.is_ok() { + validate_runtime_dir(&runtime_dir)?; std::env::set_var("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", &runtime_dir); } return result; @@ -244,12 +337,15 @@ pub fn download_and_extract() -> Result<(), String> { ); let temp_dir = std::env::temp_dir(); - let cab_path = temp_dir.join(&cab_name); + let cab_path = temp_dir.join(format!("{}_{}", std::process::id(), &cab_name)); // 下载 cab 文件(流式写入磁盘) let download_result = (|| -> Result<(), String> { let client = reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(false) + .tls_built_in_root_certs(true) .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(600)) .build() .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; @@ -305,6 +401,9 @@ pub fn download_and_extract() -> Result<(), String> { } } + std::io::Write::flush(&mut file) + .map_err(|e| format!("刷新文件缓冲失败: {}", e))?; + Ok(()) })(); @@ -335,6 +434,9 @@ pub fn download_and_extract() -> Result<(), String> { extract_result?; + // 校验运行时目录完整性 + validate_runtime_dir(&runtime_dir)?; + // 设置环境变量供当前进程使用 std::env::set_var("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", &runtime_dir);