From 29dca31b814fecb1f0c563e7e34d86bd6a48a72c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 11:58:35 +0000 Subject: [PATCH] desktop: fall back to Edge browser folder when WebView2 Runtime is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 很多企业 / 信创环境的机器只装了 Microsoft Edge 浏览器,而没有单独安装 "Microsoft Edge WebView2 Runtime"。现状下 acecode-desktop 走 WebView2Loader 默认 Runtime 发现路径直接抛 webview::exception,被 wWinMain 一路裸抛触发 Windows "未经处理的异常" 调试器对话框 — 普通用户既看不懂也无从下手。 WebView2Loader 公开了 WEBVIEW2_BROWSER_EXECUTABLE_FOLDER 环境变量(等价于 CreateCoreWebView2EnvironmentWithOptions 的 browserExecutableFolder 参数), 而 Edge 100+ 浏览器目录里都自带同版本的 msedgewebview2.exe,可以直接复用 避免分发 ~180MB 的 Fixed Version Runtime。 新增 src/desktop/webview2_runtime_probe.{hpp,cpp},暴露纯函数 find_edge_browser_folder_in(roots) + Win32 系统调用版 find_edge_browser_folder()。Impl ctor 被改成三段式:默认 Loader → Edge 浏览器目录 fallback → 终态 MessageBox + ExitProcess(1)。wWinMain 同时包顶层 try/catch 兜底任何其他启动期异常,避免再有"无声秒退"。 测试覆盖纯函数版本(版本号字典序陷阱、缺 exe 跳过、跨 root global latest 等)。 --- src/desktop/main.cpp | 52 ++++++ src/desktop/web_host.cpp | 101 ++++++++-- src/desktop/webview2_runtime_probe.cpp | 174 ++++++++++++++++++ src/desktop/webview2_runtime_probe.hpp | 52 ++++++ tests/desktop/webview2_runtime_probe_test.cpp | 168 +++++++++++++++++ 5 files changed, 535 insertions(+), 12 deletions(-) create mode 100644 src/desktop/webview2_runtime_probe.cpp create mode 100644 src/desktop/webview2_runtime_probe.hpp create mode 100644 tests/desktop/webview2_runtime_probe_test.cpp diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index a684ae5bb..ef98bc3d8 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -35,6 +35,7 @@ #include #include +#include #include #include #include @@ -316,6 +317,16 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { #else int main(int, char**) { #endif + // 顶层 try/catch:wWinMain 是 Windows 子系统的 EXE 入口,任何未捕获的 + // C++ 异常会触发 std::terminate → 系统弹"未经处理的异常"调试器对话框, + // 普通用户既看不懂也无从下手。把整个 body 包成 IIFE lambda 让 catch 兜 + // 底:落 LOG_ERROR、弹中文 MessageBox 给用户可执行的修复指引,然后 + // return 1 走正常退出。 + // + // 注意 logger 初始化也在 lambda 内,因为它本身也可能抛(磁盘满 / 路径 + // 受 GPO 锁)。catch 里依然先调 LOG_ERROR(失败时是 no-op,不影响 + // MessageBox 给用户提示)。 + auto run = []() -> int { using namespace acecode::desktop; // desktop 自己的日志路径: ~/.acecode/logs/desktop-.log。和 daemon @@ -1068,4 +1079,45 @@ int main(int, char**) { auto failures = pool.stop_all(); return failures.empty() ? 0 : 100; // 部分失败返回非零便于诊断 + }; // end of run lambda + + try { + return run(); + } catch (const std::exception& e) { + LOG_ERROR(std::string("[desktop] unhandled exception during startup: ") + e.what()); +#ifdef _WIN32 + const std::string body = std::string( + "ACECode 桌面版启动时遇到未预期的错误,即将退出。\n\n" + "请把以下日志文件发给 IT/开发以便定位:\n" + " %USERPROFILE%\\.acecode\\logs\\desktop-*.log\n\n" + "异常信息:\n") + e.what(); + const int wlen = ::MultiByteToWideChar(CP_UTF8, 0, body.c_str(), + static_cast(body.size()), nullptr, 0); + std::wstring wbody; + if (wlen > 0) { + wbody.resize(static_cast(wlen)); + ::MultiByteToWideChar(CP_UTF8, 0, body.c_str(), + static_cast(body.size()), wbody.data(), wlen); + } + ::MessageBoxW(nullptr, + wbody.empty() ? L"Unhandled exception during startup." : wbody.c_str(), + L"ACECode 启动失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); +#else + std::fprintf(stderr, "[desktop] unhandled exception: %s\n", e.what()); +#endif + return 1; + } catch (...) { + LOG_ERROR("[desktop] unhandled non-std::exception during startup"); +#ifdef _WIN32 + ::MessageBoxW(nullptr, + L"ACECode 桌面版启动时遇到未预期的错误,即将退出。\n" + L"请把日志文件 %USERPROFILE%\\.acecode\\logs\\desktop-*.log 发给 IT 团队。", + L"ACECode 启动失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); +#else + std::fprintf(stderr, "[desktop] unhandled non-std::exception during startup\n"); +#endif + return 1; + } } diff --git a/src/desktop/web_host.cpp b/src/desktop/web_host.cpp index 0f9e84bb9..5dfd167ef 100644 --- a/src/desktop/web_host.cpp +++ b/src/desktop/web_host.cpp @@ -1,6 +1,7 @@ #include "web_host.hpp" #include "web_host_close_policy.hpp" +#include "webview2_runtime_probe.hpp" #include "window_chrome.hpp" #include "../utils/logger.hpp" @@ -296,6 +297,37 @@ void center_window_on_monitor(HWND hwnd, const RECT& monitor) { ::SetWindowPos(hwnd, nullptr, x, y, w, h, SWP_NOZORDER | SWP_NOACTIVATE); } +// 终态失败弹窗:WebView2 默认路径 + Edge 浏览器 fallback 都失败时,给用户 +// 一个可读中文提示(原本是 wWinMain 上面那个"未经处理的异常"调试器对话框, +// 普通用户看不懂也帮不上忙)。reason 透传 webview::exception::what(),通常 +// 含 HRESULT;接进 MessageBox 文案末尾,IT 排查时直接复制就行。 +void show_webview2_failure_message_box(const char* reason) { + const std::string body = + "ACECode 桌面版无法初始化 WebView2 组件。\n\n" + "可能的原因与解决办法:\n" + " 1. 未安装 \"Microsoft Edge WebView2 Runtime\"(注意:仅有 Edge 浏览器并不等价)。\n" + " 请到 https://developer.microsoft.com/microsoft-edge/webview2/ 下载 Evergreen Standalone Installer 安装。\n" + " 2. WebView2 用户数据目录损坏。请尝试删除以下目录后重试:\n" + " %LOCALAPPDATA%\\acecode-desktop\\EBWebView\n" + " 3. 杀毒/EDR 软件拦截了 msedgewebview2.exe 的启动,请将其加入信任。\n\n" + "详细日志:%USERPROFILE%\\.acecode\\logs\\desktop-*.log\n\n" + "失败原因(供 IT 排查):\n"; + std::string full = body + (reason ? reason : "(unknown)"); + + const int wlen = ::MultiByteToWideChar(CP_UTF8, 0, full.c_str(), + static_cast(full.size()), nullptr, 0); + std::wstring wbody; + if (wlen > 0) { + wbody.resize(static_cast(wlen)); + ::MultiByteToWideChar(CP_UTF8, 0, full.c_str(), + static_cast(full.size()), wbody.data(), wlen); + } + ::MessageBoxW(nullptr, + wbody.empty() ? L"WebView2 initialization failed." : wbody.c_str(), + L"ACECode 启动失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); +} + } // namespace struct ComApartment { @@ -348,20 +380,65 @@ struct WebHost::Impl { : nullptr), offscreen_until_ready(custom_window != nullptr), com(custom_window != nullptr) { + // 三段式构造: + // (1) 默认 Loader 路径,优先 offscreen custom_window;失败 → 切 + // 自管 nullptr 父窗口再试一次(沿用现有降级)。 + // (2) (1) 整段还是抛 → 探测 Edge 浏览器自带的 msedgewebview2.exe + // 目录,通过 WEBVIEW2_BROWSER_EXECUTABLE_FOLDER 环境变量 + // (WebView2Loader.dll 公开的覆盖钩子)指过去再试。 + // (3) Edge fallback 仍失败或没找到 Edge → 弹中文 MessageBox 给用户 + // 可执行的修复指引,LOG_ERROR 落盘后 ExitProcess(1) 而不是 + // 让 webview::exception 一路裸抛到 wWinMain — 那样普通用户 + // 只会看到 Windows "未经处理的异常" 调试器对话框,完全看不懂。 + auto make_webview_default_path = [&]() -> std::unique_ptr { + try { + return std::make_unique( + debug, + custom_window ? static_cast(custom_window) : nullptr); + } catch (const webview::exception& e) { + if (!custom_window) throw; + LOG_WARN(std::string("[desktop] offscreen WebView host failed; " + "falling back to default window: ") + e.what()); + if (::IsWindow(custom_window)) { + ::DestroyWindow(custom_window); + } + custom_window = nullptr; + offscreen_until_ready = false; + return std::make_unique(debug, nullptr); + } + }; + try { - w = std::make_unique( - debug, - custom_window ? static_cast(custom_window) : nullptr); - } catch (const webview::exception& e) { - if (!custom_window) throw; - LOG_WARN(std::string("[desktop] offscreen WebView host failed; falling back: ") + - e.what()); - if (::IsWindow(custom_window)) { - ::DestroyWindow(custom_window); + w = make_webview_default_path(); + } catch (const webview::exception& e1) { + LOG_WARN(std::string("[desktop] WebView2 default loader path failed: ") + + e1.what()); + auto edge_folder = find_edge_browser_folder(); + if (!edge_folder.has_value()) { + LOG_ERROR("[desktop] no Microsoft Edge browser folder found to " + "fall back to; aborting startup"); + show_webview2_failure_message_box(e1.what()); + ::ExitProcess(1); + } + const std::wstring folder_w = edge_folder->wstring(); + LOG_INFO(std::string("[desktop] retrying WebView2 with Edge browser " + "folder: ") + edge_folder->string()); + if (!::SetEnvironmentVariableW(L"WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", + folder_w.c_str())) { + LOG_ERROR("[desktop] SetEnvironmentVariableW(" + "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER) failed, last_error=" + + std::to_string(::GetLastError())); + } + try { + // custom_window 在 default 路径内已被清掉(如果走过 offscreen), + // 这里直接喂 nullptr 让 webview 自己造窗口最稳妥。 + w = std::make_unique(debug, nullptr); + } catch (const webview::exception& e2) { + LOG_ERROR(std::string("[desktop] WebView2 Edge browser folder " + "fallback also failed: ") + e2.what()); + show_webview2_failure_message_box(e2.what()); + ::ExitProcess(1); } - custom_window = nullptr; - offscreen_until_ready = false; - w = std::make_unique(debug, nullptr); } if (custom_window) { resize_webview_widget(custom_window); diff --git a/src/desktop/webview2_runtime_probe.cpp b/src/desktop/webview2_runtime_probe.cpp new file mode 100644 index 000000000..a834d6fb4 --- /dev/null +++ b/src/desktop/webview2_runtime_probe.cpp @@ -0,0 +1,174 @@ +// WebView2 Runtime 探测实现。设计 + 调用时机见 webview2_runtime_probe.hpp。 + +#include "webview2_runtime_probe.hpp" + +#include "../utils/logger.hpp" + +#include +#include +#include +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include +#endif + +namespace fs = std::filesystem; + +namespace acecode::desktop { + +namespace { + +constexpr const char* kEdgeWebViewExecutableName = "msedgewebview2.exe"; + +// 把版本号字符串("100.0.1234.56")拆 4 段非负整数。失败返回 std::nullopt +// (整段不是 4 位、含非数字字符、任何一段超过 uint32 上限都视为非版本)。 +std::optional> parse_version_4(const std::string& s) { + std::array out{0, 0, 0, 0}; + std::size_t segment = 0; + std::uint64_t acc = 0; + bool any_digit_in_segment = false; + for (std::size_t i = 0; i <= s.size(); ++i) { + const bool at_end = (i == s.size()); + const char c = at_end ? '.' : s[i]; + if (c == '.') { + if (!any_digit_in_segment) return std::nullopt; + if (segment >= 4) return std::nullopt; + if (acc > 0xFFFFFFFFu) return std::nullopt; + out[segment] = static_cast(acc); + ++segment; + acc = 0; + any_digit_in_segment = false; + continue; + } + if (c < '0' || c > '9') return std::nullopt; + acc = acc * 10 + static_cast(c - '0'); + if (acc > 0xFFFFFFFFu) return std::nullopt; + any_digit_in_segment = true; + } + if (segment != 4) return std::nullopt; + return out; +} + +// Edge 浏览器标准布局 /Microsoft/Edge/Application/。我们只用这一条 +// 路径 — Edge Beta/Dev/Canary 各有自己的 root,但同事场景下用户装的就是 +// 正式版 Edge,本期不扫 Beta/Dev/Canary,避免误用 Canary 这种快速变化通道。 +fs::path edge_application_dir(const fs::path& root) { + return root / "Microsoft" / "Edge" / "Application"; +} + +// 在一个 Application 目录下扫所有合法版本子目录,挑最大版本 + 验证 +// msedgewebview2.exe 存在。返回 (version, folder),无候选返回 nullopt。 +std::optional, fs::path>> +pick_latest_in_application_dir(const fs::path& application_dir) { + std::error_code ec; + if (!fs::is_directory(application_dir, ec) || ec) { + return std::nullopt; + } + + std::optional> best_version; + fs::path best_folder; + + fs::directory_iterator it(application_dir, fs::directory_options::skip_permission_denied, ec); + if (ec) return std::nullopt; + + for (const auto& entry : it) { + std::error_code ec_entry; + if (!entry.is_directory(ec_entry) || ec_entry) continue; + + const std::string name = entry.path().filename().string(); + auto parsed = parse_version_4(name); + if (!parsed.has_value()) continue; + + const fs::path candidate_exe = entry.path() / kEdgeWebViewExecutableName; + std::error_code exe_ec; + if (!fs::is_regular_file(candidate_exe, exe_ec) || exe_ec) { + // Edge 浏览器某些 channel 切换时会保留只剩 Resources 的旧版本号 + // 目录,但没了 msedgewebview2.exe — 直接跳过,免得后面 set env + // 指向死路径。 + continue; + } + + if (!best_version.has_value() || *parsed > *best_version) { + best_version = parsed; + best_folder = entry.path(); + } + } + + if (!best_version.has_value()) return std::nullopt; + return std::make_pair(*best_version, best_folder); +} + +} // namespace + +std::optional find_edge_browser_folder_in( + const std::vector& roots) { + std::optional> best_version; + fs::path best_folder; + + for (const auto& root : roots) { + if (root.empty()) continue; + auto pick = pick_latest_in_application_dir(edge_application_dir(root)); + if (!pick.has_value()) continue; + + if (!best_version.has_value() || pick->first > *best_version) { + best_version = pick->first; + best_folder = pick->second; + } + } + + if (!best_version.has_value()) return std::nullopt; + return best_folder; +} + +#ifdef _WIN32 +namespace { + +// SHGetKnownFolderPath 拿 KNOWNFOLDERID 对应路径。失败返回空 path。COM +// 不需要 init —— SHGetKnownFolderPath 走的是 NTDLL 路径不依赖 apartment。 +fs::path known_folder_path(REFKNOWNFOLDERID id) { + PWSTR raw = nullptr; + HRESULT hr = ::SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, nullptr, &raw); + if (FAILED(hr) || !raw) { + if (raw) ::CoTaskMemFree(raw); + return {}; + } + fs::path result(raw); + ::CoTaskMemFree(raw); + return result; +} + +} // namespace + +std::optional find_edge_browser_folder() { + std::vector roots; + if (auto pf = known_folder_path(FOLDERID_ProgramFiles); !pf.empty()) { + roots.push_back(std::move(pf)); + } + if (auto pfx86 = known_folder_path(FOLDERID_ProgramFilesX86); !pfx86.empty()) { + roots.push_back(std::move(pfx86)); + } + if (roots.empty()) { + LOG_WARN("[webview2_probe] SHGetKnownFolderPath returned no ProgramFiles paths"); + return std::nullopt; + } + return find_edge_browser_folder_in(roots); +} + +#else // _WIN32 + +std::optional find_edge_browser_folder() { + return std::nullopt; +} + +#endif // _WIN32 + +} // namespace acecode::desktop diff --git a/src/desktop/webview2_runtime_probe.hpp b/src/desktop/webview2_runtime_probe.hpp new file mode 100644 index 000000000..a3ca26745 --- /dev/null +++ b/src/desktop/webview2_runtime_probe.hpp @@ -0,0 +1,52 @@ +#pragma once + +// WebView2 Runtime 探测 — 给 acecode-desktop 在系统未安装独立 WebView2 +// Evergreen Runtime 时找一条 fallback。 +// +// 背景:许多企业 / 信创环境的机器只装了 Microsoft Edge 浏览器,而没有单独 +// 安装 "Microsoft Edge WebView2 Runtime"。第三方 WebView2 应用(包括 +// acecode-desktop)默认走 WebView2Loader.dll 的 Evergreen Runtime 发现 +// 路径,这条路径不会去看 Edge 浏览器目录,导致 CreateCoreWebView2- +// EnvironmentWithOptions 失败。绝大多数 Edge 100+ 版本的浏览器目录里也 +// 自带 msedgewebview2.exe + 同版本 dll,可以直接通过把 +// WEBVIEW2_BROWSER_EXECUTABLE_FOLDER 环境变量指过去复用,等价于 +// CreateCoreWebView2EnvironmentWithOptions 的 browserExecutableFolder +// 参数(WebView2Loader.dll 加载时会优先读这个 env)。 +// +// 调用时机:WebHost 第一次构造 webview::webview 失败时(catch 里),不要 +// 在启动早期主动 set,以免覆盖正常装了 Evergreen Runtime 的用户路径。 +// +// 平台:Windows-only;POSIX 上头文件保持可见,但实现返回空(macOS / Linux +// 走 WKWebView / WebKitGTK,跟 WebView2 无关)。 + +#include +#include +#include + +namespace acecode::desktop { + +// 在给定的根目录列表中查找最新版本的 Edge 浏览器自带 msedgewebview2.exe +// 所在文件夹。纯函数,不调系统 API,unit test 喂临时目录覆盖。 +// +// 期望的目录结构(Edge 浏览器标准布局): +// /Microsoft/Edge/Application//msedgewebview2.exe +// version 必须是 4 段数字格式 "..."(过滤 "SetupMetrics" / +// "Installer" / 回滚备份目录等非版本 entry)。 +// +// 返回:命中时 = 包含 msedgewebview2.exe 的那个 目录绝对路径。 +// 未命中 = std::nullopt。 +// +// 排序:存在多个版本子目录时,按 4 段版本号数值字典序选最大版本。 +// 多个 root 都命中时,**返回所有命中里版本号最大的那个**(不是按 root 顺序), +// 这样 PF 与 PFx86 都装了 Edge 的机器拿到最新一份。 +std::optional find_edge_browser_folder_in( + const std::vector& roots); + +// Windows 系统调用版:用 SHGetKnownFolderPath 拿 ProgramFiles 与 +// ProgramFiles(x86) 两个根,调上面的纯函数。POSIX 上始终返回 std::nullopt。 +// +// 失败语义和 find_edge_browser_folder_in 一致:返回 nullopt 表示当前机器 +// 没有可复用的 Edge 浏览器二进制,调用方应当向用户提示安装 WebView2 Runtime。 +std::optional find_edge_browser_folder(); + +} // namespace acecode::desktop diff --git a/tests/desktop/webview2_runtime_probe_test.cpp b/tests/desktop/webview2_runtime_probe_test.cpp new file mode 100644 index 000000000..133fa573b --- /dev/null +++ b/tests/desktop/webview2_runtime_probe_test.cpp @@ -0,0 +1,168 @@ +// 覆盖 src/desktop/webview2_runtime_probe.cpp 中的纯函数 find_edge_browser_folder_in。 +// 系统调用版 find_edge_browser_folder() 不在测试范围 — 它依赖 SHGetKnownFolderPath, +// 在 unit test 进程里直接喂临时目录列表更直观。 + +#include + +#include "desktop/webview2_runtime_probe.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::desktop::find_edge_browser_folder_in; + +namespace { + +// 单测用临时根目录,析构时清理。各 case 独立目录,避免并发污染。 +class TempDir { +public: + TempDir() { + std::random_device rd; + std::mt19937 rng(rd()); + std::uniform_int_distribution dist(0, 0x7FFFFFFF); + for (int attempt = 0; attempt < 8; ++attempt) { + const std::string name = "acecode_webview2_probe_" + + std::to_string(dist(rng)); + fs::path candidate = fs::temp_directory_path() / name; + std::error_code ec; + if (fs::create_directories(candidate, ec) && !ec) { + path_ = candidate; + return; + } + } + ADD_FAILURE() << "could not create unique temp dir"; + } + + ~TempDir() { + std::error_code ec; + fs::remove_all(path_, ec); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + const fs::path& path() const { return path_; } + +private: + fs::path path_; +}; + +// 在 root 下造一个 Edge 浏览器风格的版本子目录,可选地写一个 placeholder +// msedgewebview2.exe(传 false 时只造目录、不造文件 — 用于覆盖 +// "目录在但 exe 缺失" 的过滤分支)。 +fs::path make_version_folder(const fs::path& root, + const std::string& version, + bool with_exe = true) { + fs::path application = root / "Microsoft" / "Edge" / "Application"; + fs::path folder = application / version; + std::error_code ec; + fs::create_directories(folder, ec); + EXPECT_FALSE(ec) << ec.message(); + if (with_exe) { + std::ofstream(folder / "msedgewebview2.exe") << "stub"; + } + return folder; +} + +} // namespace + +// 完全空的根目录列表 → nullopt +TEST(Webview2RuntimeProbe, EmptyRootsReturnsNullopt) { + EXPECT_FALSE(find_edge_browser_folder_in({}).has_value()); +} + +// 根目录存在但里面没有 Edge\Application → nullopt +TEST(Webview2RuntimeProbe, RootWithoutEdgeReturnsNullopt) { + TempDir d; + auto result = find_edge_browser_folder_in({d.path()}); + EXPECT_FALSE(result.has_value()); +} + +// 单一版本子目录 + msedgewebview2.exe → 命中并返回该目录 +TEST(Webview2RuntimeProbe, SingleVersionWithExeIsPicked) { + TempDir d; + fs::path expected = make_version_folder(d.path(), "120.0.2210.91"); + auto result = find_edge_browser_folder_in({d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), expected.lexically_normal()); +} + +// 多个合法版本 → 选 4 段版本号最大的 +TEST(Webview2RuntimeProbe, PicksLatestVersionByNumericCompare) { + TempDir d; + make_version_folder(d.path(), "100.0.1185.39"); + make_version_folder(d.path(), "120.0.2210.91"); + fs::path latest = make_version_folder(d.path(), "131.0.2903.86"); + make_version_folder(d.path(), "129.0.2792.79"); + + auto result = find_edge_browser_folder_in({d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), latest.lexically_normal()); +} + +// 字典序陷阱:"99.0.1.1" 字符串字典序 > "100.0.1.1",但数值 99 < 100, +// 实现必须按数值比较。 +TEST(Webview2RuntimeProbe, NumericNotLexicographicVersionOrder) { + TempDir d; + make_version_folder(d.path(), "99.0.1.1"); + fs::path latest = make_version_folder(d.path(), "100.0.1.1"); + + auto result = find_edge_browser_folder_in({d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), latest.lexically_normal()); +} + +// 非 4 段版本号格式的目录(SetupMetrics、Installer、3 段、空段、字母)被忽略 +TEST(Webview2RuntimeProbe, FiltersNonVersionFolders) { + TempDir d; + make_version_folder(d.path(), "SetupMetrics"); + make_version_folder(d.path(), "Installer"); + make_version_folder(d.path(), "100.0.1234"); // 只 3 段 + make_version_folder(d.path(), "100..0.1.1"); // 含空段 + make_version_folder(d.path(), "100.0.1.1a"); // 含字母 + fs::path good = make_version_folder(d.path(), "120.0.2210.91"); + + auto result = find_edge_browser_folder_in({d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), good.lexically_normal()); +} + +// 版本目录在但 msedgewebview2.exe 缺失(Edge channel 切换残留场景)→ 跳过。 +// 否则会把死路径喂给 SetEnvironmentVariableW,WebView2 加载时再炸一次。 +TEST(Webview2RuntimeProbe, SkipsVersionFolderWithoutExecutable) { + TempDir d; + make_version_folder(d.path(), "131.0.2903.86", /*with_exe=*/false); + fs::path with_exe = make_version_folder(d.path(), "120.0.2210.91"); + + auto result = find_edge_browser_folder_in({d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), with_exe.lexically_normal()); +} + +// 多个 root 都装了 Edge:返回所有 root 命中里版本号最大的(模拟 PF 与 PFx86 +// 都装了 Edge 的机器,选最新一份)。 +TEST(Webview2RuntimeProbe, AcrossMultipleRootsPicksGlobalLatest) { + TempDir pf; + TempDir pfx86; + make_version_folder(pf.path(), "120.0.2210.91"); + fs::path latest = make_version_folder(pfx86.path(), "131.0.2903.86"); + + auto result = find_edge_browser_folder_in({pf.path(), pfx86.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), latest.lexically_normal()); +} + +// 空字符串 root 元素被静默跳过(防御性 — SHGetKnownFolderPath 失败时 +// production 代码会插入空 path)。 +TEST(Webview2RuntimeProbe, EmptyRootEntryIgnored) { + TempDir d; + fs::path good = make_version_folder(d.path(), "120.0.2210.91"); + auto result = find_edge_browser_folder_in({fs::path(), d.path()}); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->lexically_normal(), good.lexically_normal()); +}