From 29dca31b814fecb1f0c563e7e34d86bd6a48a72c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 11:58:35 +0000 Subject: [PATCH 1/6] 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 a684ae5b..ef98bc3d 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 0f9e84bb..5dfd167e 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 00000000..a834d6fb --- /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 00000000..a3ca2674 --- /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 00000000..133fa573 --- /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()); +} From 79802299ca6f99e1f2a10b32fce840be91799e42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 14:14:31 +0000 Subject: [PATCH 2/6] daemon: add PATCH/DELETE /api/workspaces/:hash (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the desktop -> daemon migration. 让 webview JS bridge 不再独占 workspace rename / remove —— 浏览器降级模式以及未来"webview 不可用时 ShellExec 默认浏览器跑 daemon URL"的路径下,前端也能通过纯 HTTP 完成 workspace CRUD。 - OPTIONS /api/workspaces/:CORS preflight - PATCH /api/workspaces/ body {"name": "<新名>"} → 200 workspace JSON · workspace_registry 未挂载 → 503 · 缺 name / 空 name / 非法 JSON → 400 · 未知 hash → 404 - DELETE /api/workspaces/ → 204 No Content (registry.hide,desktop_visible 写 false,不删 hash 目录 / session / 用户文件) 实现复用 WorkspaceRegistry::set_name / hide,语义跟原 desktop aceDesktop_* bridge 完全一致。下一步 (P2) 加 native 操作下沉端点。 --- src/web/server.cpp | 88 +++++++++++++++++++++++++++++ tests/web/web_server_smoke_test.cpp | 78 +++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/web/server.cpp b/src/web/server.cpp index 674bf6e5..776873e7 100644 --- a/src/web/server.cpp +++ b/src/web/server.cpp @@ -767,6 +767,10 @@ struct WebServer::Impl { ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { + return cors_preflight(req); + }); CROW_ROUTE(app, "/api/workspaces//sessions").methods(crow::HTTPMethod::Options) ([this](const crow::request& req, const std::string&) { return cors_preflight(req); @@ -942,6 +946,90 @@ struct WebServer::Impl { r.add_header("Content-Type", "application/json"); return with_cors(req, std::move(r)); }); + + // PATCH /api/workspaces/:hash:行内重命名,body {"name": "<新名>"}。 + // 等价于原 desktop bridge aceDesktop_renameWorkspace 的下沉版本 —— + // 浏览器降级模式下前端也能改名,而不必走 webview JS-bridge。 + // 失败语义: + // - workspace_registry 未挂载 → 503 + // - body 不是合法 JSON → 400 + // - 缺 name / name 为空字符串 → 400 + // - hash 未知 / set_name 返 false → 404 + // 成功返回 200 + 与 GET /api/workspaces 列表项同构的 workspace JSON, + // 方便前端拿到 hash 后立即覆盖本地缓存。 + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::PATCH) + ([this](const crow::request& req, const std::string& hash) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (!deps.workspace_registry) { + crow::response r(503); + r.body = R"({"error":"workspace registry unavailable"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + std::string name; + try { + auto j = json::parse(req.body); + name = j.value("name", std::string{}); + } catch (const std::exception& e) { + crow::response r(400); + r.body = json{{"error", std::string("bad json: ") + e.what()}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (name.empty()) { + crow::response r(400); + r.body = R"({"error":"name required"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (!deps.workspace_registry->set_name(projects_dir(), hash, name)) { + crow::response r(404); + r.body = R"({"error":"workspace not found"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + auto m = deps.workspace_registry->get(hash); + if (!m.has_value()) { + // 写盘成功但内存被并发 scan 清掉 — 罕见但要兜底,客户端拿 + // 200 + 空体当作"再 GET 一次列表"。 + crow::response r(200); + r.body = R"({"hash":"")" + hash + R"(""})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] workspace renamed hash=" + hash + " name=" + name); + crow::response r(200); + r.body = workspace_to_json(*m).dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + + // DELETE /api/workspaces/:hash:从 Desktop 项目列表隐藏 workspace。 + // 等价于原 desktop bridge aceDesktop_removeWorkspace。语义跟 registry::hide + // 完全一致 ── 写 desktop_visible=false,不删 hash 目录、session、用户文件。 + // 失败: + // - workspace_registry 未挂载 → 503 + // - hash 未知 / 隐藏失败 → 404 + // 成功 → 204 No Content。 + CROW_ROUTE(app, "/api/workspaces/").methods(crow::HTTPMethod::DELETE) + ([this](const crow::request& req, const std::string& hash) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (!deps.workspace_registry) { + crow::response r(503); + r.body = R"({"error":"workspace registry unavailable"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (!deps.workspace_registry->hide(projects_dir(), hash)) { + crow::response r(404); + r.body = R"({"error":"workspace not found"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] workspace hidden hash=" + hash); + crow::response r(204); + return with_cors(req, std::move(r)); + }); } void register_static() { diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 3f1d04da..87ad128c 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -410,6 +410,84 @@ TEST(WebServerHttp, WorkspaceListRefreshesExternalRename) { EXPECT_EQ(ws_list[0]["name"], "renamed-from-desktop"); } +// 场景: PATCH /api/workspaces/:hash 改 name 成功路径。 +// 返回 200 + 与列表项同构 JSON,且后续 GET 列表里 name 已更新。 +TEST(WebServerHttp, WorkspaceRenameViaHttpUpdatesName) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto patch = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", "renamed-via-http"}}.dump()}); + ASSERT_EQ(patch.status_code, 200) << patch.text; + auto updated = json::parse(patch.text); + EXPECT_EQ(updated["hash"], hash); + EXPECT_EQ(updated["name"], "renamed-via-http"); + + auto get_ws = cpr::Get(cpr::Url{fx.url("/api/workspaces")}); + ASSERT_EQ(get_ws.status_code, 200) << get_ws.text; + auto ws_list = json::parse(get_ws.text); + ASSERT_TRUE(ws_list.is_array()); + ASSERT_FALSE(ws_list.empty()); + EXPECT_EQ(ws_list[0]["hash"], hash); + EXPECT_EQ(ws_list[0]["name"], "renamed-via-http"); +} + +// 场景: PATCH 不带 name 字段(或 name 为空字符串)→ 400,内存 cache 不动。 +TEST(WebServerHttp, WorkspaceRenameRejectsMissingOrEmptyName) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto no_name = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + EXPECT_EQ(no_name.status_code, 400) << no_name.text; + + auto empty_name = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", ""}}.dump()}); + EXPECT_EQ(empty_name.status_code, 400) << empty_name.text; + + auto bad_json = cpr::Patch(cpr::Url{fx.url("/api/workspaces/" + hash)}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"not json"}); + EXPECT_EQ(bad_json.status_code, 400) << bad_json.text; +} + +// 场景: PATCH 指向未知 hash → 404,不创建新 entry。 +TEST(WebServerHttp, WorkspaceRenameRejectsUnknownHash) { + WebServerFixture fx; + auto patch = cpr::Patch(cpr::Url{fx.url("/api/workspaces/0000000000000000")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"name", "ghost"}}.dump()}); + EXPECT_EQ(patch.status_code, 404) << patch.text; +} + +// 场景: DELETE /api/workspaces/:hash 成功 → 204,后续 GET 列表里它消失。 +// 不动 hash 目录或 session 文件 — registry.hide 只翻 desktop_visible 标志。 +TEST(WebServerHttp, WorkspaceDeleteHidesFromList) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + + auto del = cpr::Delete(cpr::Url{fx.url("/api/workspaces/" + hash)}); + EXPECT_EQ(del.status_code, 204) << del.text; + + auto get_ws = cpr::Get(cpr::Url{fx.url("/api/workspaces")}); + ASSERT_EQ(get_ws.status_code, 200) << get_ws.text; + auto ws_list = json::parse(get_ws.text); + ASSERT_TRUE(ws_list.is_array()); + for (const auto& w : ws_list) { + EXPECT_NE(w.value("hash", std::string{}), hash); + } +} + +// 场景: DELETE 指向未知 hash → 404,不影响其他 workspace。 +TEST(WebServerHttp, WorkspaceDeleteRejectsUnknownHash) { + WebServerFixture fx; + auto del = cpr::Delete(cpr::Url{fx.url("/api/workspaces/0000000000000000")}); + EXPECT_EQ(del.status_code, 404) << del.text; +} + // 场景:在非 daemon 启动 cwd 的 workspace 里 fork,新 session 必须用源 // workspace cwd 装回 registry;否则前端切到新 fork 时 WS 会报 unknown session。 TEST(WebServerHttp, ForkWorkspaceSessionResumesInSourceWorkspace) { From 381fbf2fe99fa75a04274afcf6e3a95c6f1c2499 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 14:19:33 +0000 Subject: [PATCH 3/6] daemon: add POST /api/system/{open-in-explorer,pick-folder} (P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the desktop -> daemon migration. 把 webview JS bridge 里两个"必须 native"动作下沉到 daemon: - aceDesktop_openInExplorer → POST /api/system/open-in-explorer - aceDesktop_addWorkspace 的 folder picker → POST /api/system/pick-folder daemon 跟前端 (webview 或浏览器) 跑在同一台机器、同一 session、同一用户 权限,完全可以代执行 ShellExecuteW / IFileOpenDialog。这样浏览器降级模式 (WebView2 不可用时直接 ShellExec 系统默认浏览器跑 daemon URL) 下,前端 所有 UI 路径都不依赖 webview-specific JS bridge,行为跟 webview 模式一致。 POST /api/system/open-in-explorer body {"path": ""} · 复用 desktop::open_directory_in_file_manager,白名单 = workspace registry 里所有 cwd → 浏览器中恶意页面不能让 daemon 打开 System32 · 错误码 400 / 403 / 404 / 500,跟 launcher 错误字符串映射 · launcher 是 ShellExecuteW (Win) / open (mac) / xdg-open (Linux) POST /api/system/pick-folder · 复用 desktop::pick_folder(nullptr),Windows 走 IFileOpenDialog (自管 CoInitializeEx),POSIX MVP 阶段返 nullopt · 同步阻塞;Crow thread pool 阻塞一个 handler 线程不影响其它请求 · 选定 → 200 {ok,path};取消 / 平台不支持 → 200 {ok:false,canceled:true} 测试:open-in-explorer 三条入参校验(白名单 / 缺 path / 不存在) + POSIX 环境下 pick-folder stub 行为。真"打开 Explorer / 弹文件夹选择"的端到端 路径在 headless CI 跑不动,留作手动 e2e。 --- src/web/server.cpp | 133 ++++++++++++++++++++++++++++ tests/web/web_server_smoke_test.cpp | 58 ++++++++++++ 2 files changed, 191 insertions(+) diff --git a/src/web/server.cpp b/src/web/server.cpp index 776873e7..498ffa69 100644 --- a/src/web/server.cpp +++ b/src/web/server.cpp @@ -5,6 +5,8 @@ #include "static_assets.hpp" #include "../config/config.hpp" #include "../config/saved_models_editor.hpp" +#include "../desktop/folder_picker.hpp" +#include "../desktop/open_in_explorer.hpp" #include "../desktop/workspace_registry.hpp" #include "../provider/llm_provider.hpp" #include "../session/ask_user_question_prompter.hpp" @@ -758,6 +760,7 @@ struct WebServer::Impl { register_skills(); register_commands(); register_mcp(); + register_system(); register_websocket(); register_static(); } @@ -2536,6 +2539,136 @@ struct WebServer::Impl { }); } + // ----------------------------------------------------------------- + // /api/system/* 原 desktop JS-bridge 下沉端点。 + // + // 设计动机:WebView2 不可用时(企业内网常见,Edge >=126 不许第三方借用), + // 前端要能直接跑在系统浏览器里访问 daemon。但 "在资源管理器里打开目录"、 + // "弹文件夹选择" 这两个动作浏览器物理上做不了 —— 浏览器只是 UI 层, + // daemon 跟 webview 同一台机器、同一 session、同一用户权限,完全可以代 + // 执行 native 操作。把这两个动作下沉到 daemon HTTP 后,webview 模式与 + // 浏览器降级模式 UI 路径完全一致。 + // + // 安全:loopback-only + token 认证已经保证只有本机有效用户能命中端点。 + // 路径白名单(workspace cwd 列表)在 open-in-explorer 里再加一道,避免 + // 浏览器里的恶意 web page 让 daemon 打开 C:\Windows\System32 之类。 + // pick-folder 是用户主动触发,无白名单意义(用户本身就在选目录)。 + // ----------------------------------------------------------------- + void register_system() { + CROW_ROUTE(app, "/api/system/open-in-explorer").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { + return cors_preflight(req); + }); + CROW_ROUTE(app, "/api/system/pick-folder").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { + return cors_preflight(req); + }); + + // POST /api/system/open-in-explorer body {"path": ""} + // 在系统文件管理器里打开 path 目录。等价于原 aceDesktop_openInExplorer。 + // 路径必须是已注册 workspace cwd 或其子目录(白名单)。 + // - 非法 JSON / 缺 path → 400 + // - 路径不是绝对路径 / 不存在 → 404 (validate_open_directory_request) + // - 不在白名单内 → 403 + // - launcher 失败 → 500 + // 成功 → 200 {"ok": true} + CROW_ROUTE(app, "/api/system/open-in-explorer").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + + std::string path; + try { + auto j = json::parse(req.body); + path = j.value("path", std::string{}); + } catch (const std::exception& e) { + crow::response r(400); + r.body = json{{"error", std::string("bad json: ") + e.what()}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + if (path.empty()) { + crow::response r(400); + r.body = R"({"error":"path required"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + + // 白名单 = 当前所有已注册 workspace 的 cwd。is_under_allowed_root + // 内部做大小写归一(Windows) + 子目录前缀匹配。 + std::vector allowed_roots; + if (deps.workspace_registry) { + deps.workspace_registry->scan(projects_dir()); + for (const auto& m : deps.workspace_registry->list()) { + if (!m.cwd.empty()) allowed_roots.push_back(m.cwd); + } + } + + auto result = acecode::desktop::open_directory_in_file_manager( + path, allowed_roots); + if (!result.ok) { + // 错误信息粒度:不存在 → 404;白名单拒绝 → 403;其它(launcher + // 失败 / fork 失败 / ShellExecute 失败)→ 500。validate_* + // 已经返回字符串,这里按 prefix 分类。 + int status = 500; + if (result.error.find("not an existing directory") != std::string::npos || + result.error == "path required" || + result.error == "failed to resolve path") { + status = 404; + } else if (result.error.find("outside registered workspaces") != std::string::npos) { + status = 403; + } else if (result.error == "path must be absolute") { + status = 400; + } + LOG_INFO("[web] open-in-explorer rejected path=" + path + + " status=" + std::to_string(status) + + " error=" + result.error); + crow::response r(status); + r.body = json{{"error", result.error}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] open-in-explorer ok path=" + path); + crow::response r(200); + r.body = R"({"ok":true})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + + // POST /api/system/pick-folder + // body 可选 {} (后续可扩 title / initial_dir,本期 MVP 不传)。 + // 调起 native folder picker (Windows: IFileOpenDialog;POSIX MVP 阶段 + // 直接 503 - "platform not supported")。同步阻塞直到用户选定 / 取消。 + // 用户选了 → 200 {"ok": true, "path": ""} + // 用户取消 → 200 {"ok": false, "canceled": true} + // 平台不支持 → 503 + // + // 阻塞行为:Crow 的 thread pool 一个 handler thread 阻塞不影响其他 + // 请求。folder picker 的 owner 是 nullptr(daemon 没主窗口),Windows + // 上可能弹在任务栏闪烁需要用户点亮;前端调用方应当通过 UI 文案提示 + // "请在任务栏切换到文件夹选择对话框"。 + CROW_ROUTE(app, "/api/system/pick-folder").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + auto picked = acecode::desktop::pick_folder(nullptr); + if (!picked.has_value()) { + // pick_folder 在 POSIX MVP 阶段直接返 nullopt 不区分"取消" vs + // "平台不支持";Windows 上 nullopt = 用户取消 / 失败。最稳的 + // 客户端语义:把 nullopt 一律当作 "canceled",前端如果重试 + // 多次仍 canceled 再提示用户手输路径(降级路径已在 P3 规划)。 + LOG_INFO("[web] pick-folder returned no selection"); + crow::response r(200); + r.body = R"({"ok":false,"canceled":true})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } + LOG_INFO("[web] pick-folder selected path=" + *picked); + crow::response r(200); + r.body = json{{"ok", true}, {"path", *picked}}.dump(); + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + }); + } + // ----------------------------------------------------------------- // WebSocket: /ws/sessions/:id (spec Section 10) // ----------------------------------------------------------------- diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 87ad128c..75d6eb78 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -488,6 +488,64 @@ TEST(WebServerHttp, WorkspaceDeleteRejectsUnknownHash) { EXPECT_EQ(del.status_code, 404) << del.text; } +// /api/system/open-in-explorer 入参校验 — 我们不验证 ShellExecute 真的拉起 +// Explorer(那是 e2e + 平台相关行为),只验证 daemon 做了 (1) 路径白名单 +// 拒绝越界,(2) 路径不存在 404,(3) 缺参 400。**真正打开 Explorer 的 launch +// 路径无法在 headless 测试机里跑**,所以这条路径在 e2e 阶段手动验证。 +TEST(WebServerHttp, SystemOpenInExplorerRejectsPathOutsideWorkspace) { + WebServerFixture fx; + + auto outside_dir = fx.tmp_dir / "outside"; + std::filesystem::create_directories(outside_dir); + + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"path", outside_dir.string()}}.dump()}); + EXPECT_EQ(post.status_code, 403) << post.text; +} + +TEST(WebServerHttp, SystemOpenInExplorerRejectsMissingPath) { + WebServerFixture fx; + + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + EXPECT_EQ(post.status_code, 400) << post.text; + + auto bad_json = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"not json"}); + EXPECT_EQ(bad_json.status_code, 400) << bad_json.text; +} + +TEST(WebServerHttp, SystemOpenInExplorerRejectsNonexistentPath) { + WebServerFixture fx; + + auto ghost = (fx.cwd_dir / "this-subdir-does-not-exist").string(); + auto post = cpr::Post(cpr::Url{fx.url("/api/system/open-in-explorer")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"path", ghost}}.dump()}); + EXPECT_EQ(post.status_code, 404) << post.text; +} + +// pick-folder 端点本身在 POSIX CI 上 pick_folder() 返 nullopt +// (MVP 没接 GTK/Cocoa),Windows GUI 上 IFileOpenDialog 同步阻塞需要用户 +// 交互。两种环境下都不适合 e2e 测真"弹窗+选目录"。这里只验证 endpoint 存活 +// + 返回结构稳定(POSIX 上必返 {ok:false,canceled:true}),POST 不带 body 也 +// 不报 500。Windows 上跑这条会卡住等用户,所以 Windows CI 跳过。 +#ifndef _WIN32 +TEST(WebServerHttp, SystemPickFolderReturnsCanceledOnPosixStub) { + WebServerFixture fx; + auto post = cpr::Post(cpr::Url{fx.url("/api/system/pick-folder")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{"{}"}); + ASSERT_EQ(post.status_code, 200) << post.text; + auto body = json::parse(post.text); + EXPECT_EQ(body["ok"], false); + EXPECT_EQ(body["canceled"], true); +} +#endif + // 场景:在非 daemon 启动 cwd 的 workspace 里 fork,新 session 必须用源 // workspace cwd 装回 registry;否则前端切到新 fork 时 WS 会报 unknown session。 TEST(WebServerHttp, ForkWorkspaceSessionResumesInSourceWorkspace) { From 4ea16528b19da19899497fbaa647fa383370d803 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 14:25:46 +0000 Subject: [PATCH 4/6] web: route workspace CRUD + openInExplorer + folder picker through daemon HTTP (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the desktop -> daemon migration. P1/P2 把业务接口落到 daemon HTTP, P3 让前端真正调它们,WebView2 不可用的浏览器降级模式下也能完整跑 workspace 管理与"在资源管理器中打开"功能。 lib/api.js 新增 4 个客户端方法,对应 P1/P2 的新 daemon route: - renameWorkspace(hash, name) → PATCH /api/workspaces/:hash - removeWorkspace(hash) → DELETE /api/workspaces/:hash - openInExplorer(path) → POST /api/system/open-in-explorer - pickFolder() → POST /api/system/pick-folder components/Sidebar.jsx 三个 handler 改 HTTP-first: - onRename:走 api.renameWorkspace,bridge 退化为 fire-and-forget 副作用 - removeWorkspace:走 api.removeWorkspace;前端按"删完取首项当 active" 替代原 bridge 返的 active_workspace_hash hint - onAddWorkspace:抽 pickWorkspaceCwd() — webview 模式优先调 bridge(原行 为不变);浏览器降级模式走 api.pickFolder() 让 daemon 弹 native 文件夹 对话框,再调 api.registerWorkspace 入册。File System Access API 的 handle 拿不到绝对路径,不接它,以免 daemon 端 register 时缺 cwd - 列表的 "remove" 按钮可见性不再依赖 bridge,永远显示(daemon HTTP 已覆盖) components/DesktopContextMenu.jsx: - openTargetInExplorer 改走 api.openInExplorer,不再 require window.aceDesktop_openInExplorer - isDesktopShell() 把 loopback host 也认作 capable —— 浏览器从 daemon URL 访问时,右键菜单依然可用(复制/粘贴/在资源管理器中打开等都通) webview 模式下旧 bridge 仍可触达但已不承担业务通路;P4 的浏览器降级流程 打通后再考虑 P5 清理 desktop 进程里的冗余 bridge 实现。 --- web/src/components/DesktopContextMenu.jsx | 31 ++++++--- web/src/components/Sidebar.jsx | 82 +++++++++++++++++------ web/src/lib/api.js | 6 ++ 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/web/src/components/DesktopContextMenu.jsx b/web/src/components/DesktopContextMenu.jsx index 087058ac..660ab7a0 100644 --- a/web/src/components/DesktopContextMenu.jsx +++ b/web/src/components/DesktopContextMenu.jsx @@ -8,8 +8,13 @@ import { openInExplorerTargetFromElement, sessionPinTargetFromElement, } from '../lib/desktopContextMenu.js'; +import { createApi } from '../lib/api.js'; import { toast } from './Toast.jsx'; +// 单例 default api client — createApi(null) 内部从全局 _baseOrigin / _baseToken +// 取值(由 setBase 在 App 启动时配好),所以这里不需要 prop drill / context。 +const apiClient = createApi(); + const MENU_WIDTH = 176; const MENU_ROW_HEIGHT = 30; const MENU_PADDING = 8; @@ -36,7 +41,14 @@ const TEXT_INPUT_TYPES = new Set([ ]); function isDesktopShell() { - return !!(window.__ACECODE_DESKTOP_SHELL__ || window.aceDesktop_openDevTools || window.aceDesktop_openInExplorer); + // 原 detection 只看 native bridge,浏览器降级模式会整个禁用右键菜单(连 + // 复制/粘贴/全选都灰)。把"在资源管理器中打开"下沉到 daemon HTTP 之后, + // 浏览器走 loopback 也能享受这条菜单 — 把 loopback host 也认作"可用"。 + if (window.__ACECODE_DESKTOP_SHELL__) return true; + if (typeof window.aceDesktop_openDevTools === 'function') return true; + if (typeof window.aceDesktop_openInExplorer === 'function') return true; + const host = window.location?.hostname || ''; + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]'; } function parseDesktopResult(value) { @@ -139,19 +151,20 @@ async function pasteIntoTarget(target) { } async function openTargetInExplorer(openTarget) { - if (!openTarget?.path || typeof window.aceDesktop_openInExplorer !== 'function') { - toast({ kind: 'err', text: '无法打开:desktop bridge 不可用' }); + if (!openTarget?.path) { + toast({ kind: 'err', text: '无法打开:缺少路径' }); return; } + // HTTP-first:daemon 跟前端同一台机器同一 session,POST /api/system/open- + // in-explorer 走 ShellExecuteW / open / xdg-open。这条路径浏览器降级模式也 + // 一样跑得通,webview 层不再持有业务逻辑。 try { - const result = parseDesktopResult(await window.aceDesktop_openInExplorer(openTarget.path)); - if (!result?.ok) { - toast({ kind: 'err', text: '打开失败:' + (result?.error || '') }); - return; - } + await apiClient.openInExplorer(openTarget.path); toast({ kind: 'ok', text: '已在资源管理器中打开' }); } catch (e) { - toast({ kind: 'err', text: '打开异常:' + (e?.message || '') }); + // daemon 端 403 = 白名单拒绝(不在已注册 workspace cwd 内) + const msg = e?.body?.error || e?.message || ''; + toast({ kind: 'err', text: '打开失败:' + msg }); } } diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index eaaeee50..64a20c53 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -555,31 +555,39 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome }; const onRename = async (hash, name) => { - if (!hasDesktopBridge()) throw new Error('not in desktop mode'); - const r = parseDesktopResult(await window.aceDesktop_renameWorkspace(hash, name)); - if (!r.ok) throw new Error(r.error || 'rename failed'); + // 主路径走 daemon HTTP — 浏览器降级模式也能用。webview 模式下 daemon 同源 + // 同进程组,延迟可忽略;同时为了不丢 native 副作用(webview 在 sidebar + // 视图态外可能有其它缓存),保留 bridge 调用作为 fire-and-forget,失败 + // 不影响业务结果。 + await api.renameWorkspace(hash, name); + if (typeof window.aceDesktop_renameWorkspace === 'function') { + try { await window.aceDesktop_renameWorkspace(hash, name); } catch { /* best-effort */ } + } setWorkspaces((prev) => prev.map((w) => w.hash === hash ? { ...w, name } : w)); await refresh(hash); }; const removeWorkspace = async (ws) => { if (!ws?.hash) return; - if (!hasDesktopRemoveWorkspace()) { - toast({ kind: 'info', text: '需在 desktop shell 中使用' }); - return; - } const ok = window.confirm( `从桌面项目列表移除“${ws.name || ws.hash}”?\n\n不会删除项目文件、会话或 .acecode 数据。之后可通过“添加项目”重新显示。`, ); if (!ok) return; try { - const r = parseDesktopResult(await window.aceDesktop_removeWorkspace(ws.hash)); - if (!r?.ok) throw new Error(r?.error || 'remove failed'); + // HTTP-first;原 bridge 路径返 r.active_workspace_hash 这种 native + // 建议下一活跃 workspace,daemon 不算这个,客户端按"还剩第一个或保 + // 持现状"算等价结果(下文 fallback)。bridge 仍 fire-and-forget 调 + // 用以清理 native 托盘菜单缓存等副作用。 + await api.removeWorkspace(ws.hash); + if (typeof window.aceDesktop_removeWorkspace === 'function') { + try { await window.aceDesktop_removeWorkspace(ws.hash); } catch { /* best-effort */ } + } const remaining = workspaces.filter((w) => w.hash !== ws.hash); - const nextHash = r.active_workspace_hash - || ((ws.active || activeWorkspaceHash === ws.hash) ? (remaining[0]?.hash || '') : activeWorkspaceHash); + const nextHash = (ws.active || activeWorkspaceHash === ws.hash) + ? (remaining[0]?.hash || '') + : activeWorkspaceHash; setWorkspaces(remaining.map((w) => ({ ...w, active: w.hash === nextHash }))); setSessions((prev) => prev.filter((s) => (s.workspace_hash || s.workspaceHash || '') !== ws.hash)); @@ -658,16 +666,52 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome } }; - const onAddWorkspace = async () => { - if (!hasDesktopBridge()) { - toast({ kind: 'info', text: '需在 desktop shell 中使用' }); - return; + // pick + register 工作流: + // 1) webview 模式:优先调 native bridge,一次拿到 cwd + register 全套 + // (跟原行为完全一致,避免 webview 用户体验回归) + // 2) Chromium 系浏览器:试 showDirectoryPicker (File System Access API) + // 取目录 name,但拿不到绝对路径(API 安全限制) → 退到方案 3 + // 3) 其它浏览器(Firefox / 旧 Edge 已不可能) / 方案 2 拿不到 cwd: + // 调 daemon POST /api/system/pick-folder 让 daemon 弹 native folder + // picker,拿到绝对路径。daemon 跟用户同一 session、同一权限,在 + // Windows 上走 IFileOpenDialog,POSIX MVP 阶段 daemon 返 canceled。 + const pickWorkspaceCwd = async () => { + if (typeof window.aceDesktop_addWorkspace === 'function') { + const ws = parseDesktopResult(await window.aceDesktop_addWorkspace()); + if (ws == null) return null; + if (!ws || !ws.hash) return null; + return { cwd: ws.cwd, prefilled: ws }; } + // 浏览器降级模式:File System Access API 拿不到绝对路径(handle 不暴露 + // 真实 cwd),所以直接走 daemon picker。除非以后接入 OPFS / DirectoryHandle + // 的 cwd 推断,不然这条更可靠。 try { - const ws = parseDesktopResult(await window.aceDesktop_addWorkspace()); - if (ws == null) return; + const r = await api.pickFolder(); + if (r && r.ok && r.path) return { cwd: r.path }; + return null; // 用户取消 / 平台不支持 + } catch (e) { + throw e; + } + }; + + const onAddWorkspace = async () => { + try { + const picked = await pickWorkspaceCwd(); + if (!picked) return; + let ws = picked.prefilled || null; + if (!ws || !ws.hash) { + try { + ws = await api.registerWorkspace(picked.cwd); + } catch (e) { + toast({ kind: 'err', text: '添加项目失败:' + (e.message || '') }); + return; + } + } else { + // bridge 已经在 desktop 端 register 过;再 POST 一次幂等(daemon 端 + // register_new 已存在时直接返回已有 meta,不重写 workspace.json)。 + try { await api.registerWorkspace(ws.cwd); } catch { /* 幂等容错 */ } + } if (!ws || !ws.hash) return; - try { await api.registerWorkspace(ws.cwd); } catch { /* daemon 可能已入册;忽略 */ } onActivate(ws); } catch (e) { toast({ kind: 'err', text: '添加项目失败:' + (e.message || '') }); @@ -740,7 +784,7 @@ export function Sidebar({ activeId, onSelect, collapsed, width = 200, onOpenHome onRename={onRename} onActivate={onActivate} onNewSession={createSessionInWorkspace} - onRemove={hasDesktopRemoveWorkspace() ? removeWorkspace : undefined} + onRemove={removeWorkspace} onTogglePin={togglePinnedSession} /> ); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 3c7e77b4..78be7b41 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -72,6 +72,12 @@ export function createApi(base = null) { health: () => request('GET', '/api/health', undefined, base), listWorkspaces: () => request('GET', '/api/workspaces', undefined, base), registerWorkspace:(cwd) => request('POST', '/api/workspaces', {cwd}, base), + renameWorkspace: (hash, name) => request('PATCH', `/api/workspaces/${encodeURIComponent(hash)}`, {name}, base), + removeWorkspace: (hash) => request('DELETE', `/api/workspaces/${encodeURIComponent(hash)}`, undefined, base), + // 系统层操作的 daemon-side 实现 — webview 不可用 / 浏览器降级时业务也能跑。 + // 见 src/web/server.cpp::register_system 与 openspec/decisions/desktop-down-sink。 + openInExplorer: (path) => request('POST', '/api/system/open-in-explorer', {path}, base), + pickFolder: () => request('POST', '/api/system/pick-folder', {}, base), listSessions: () => request('GET', '/api/sessions', undefined, base), createSession: (opts={}) => request('POST', '/api/sessions', opts, base), resumeSession: (id) => request('POST', `/api/sessions/${encodeURIComponent(id)}/resume`, {}, base), From 75abc15c14cbe81b035877a33264682adce06efe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 14:33:18 +0000 Subject: [PATCH 5/6] desktop: fall back to system default browser when WebView2 is unavailable (P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the desktop -> daemon migration. WebView2 在企业 / 信创机器(Edge >=126 不允许借浏览器二进制 + 没装独立 Evergreen Runtime)上必然失败 — 现在 不再走"弹错误 MessageBox 后退出"的死胡同,改成询问用户是否在系统默认浏览器 中继续。P1-P3 已经把业务逻辑全下沉到 daemon HTTP,前端在浏览器里跑功能完整。 src/desktop/web_host.cpp: · WebHost::Impl::Impl 第一段 (默认 Loader + offscreen→nullptr) 失败 + 第 二段 (Edge browser folder fallback) 也失败时,不再 show 自己的 MessageBox + ExitProcess(1) — 改成 rethrow,由 wWinMain 上层接管。 · 删 anonymous-namespace 的 show_webview2_failure_message_box helper。 src/desktop/main.cpp::wWinMain: · 在 WebHost host(...) 构造点开 try 块,catch (...) 内进入"浏览器降级"流程 (用 catch(...) 兜底因为 webview::exception 是否派生自 std::exception 取决 于 webview/webview 版本,内部用 throw; + catch (std::exception&) 抽 what()) · 询问用户 (MessageBoxW MB_YESNO + 中文文案 + 排查信息): - No → pool.stop_all() + return 1 - Yes → ShellExecuteW(url) 调起系统默认浏览器,desktop 进程保持运行 (daemon 被 Job Object KILL_ON_JOB_CLOSE 绑生死,desktop 不在 daemon 跟着死) → 空 Windows message loop 等系统/任务管理器 结束,Windows session logoff 时 WM_QUIT 自然退出 loop。 · ShellExecuteW 失败时弹文案让用户手动拷贝 URL,desktop 仍挂着。 · URL 在 onboarding daemon 未起来时仍是 about:blank,降级也跑不通,弹错 退出避免误以为浏览器是空白页。 · POSIX 路径 webview 异常 rethrow 给顶层 catch,POSIX 不构造浏览器降级 (那里走 WKWebView / WebKitGTK,跟 WebView2 路径不重叠)。 后续(P5 / future)托盘"退出"菜单让用户不必开任务管理器结束 desktop;以及 清理 desktop 进程里的冗余 workspace bridge(已被 HTTP 全覆盖)。 --- src/desktop/main.cpp | 101 +++++++++++++++++++++++++++++++++++++++ src/desktop/web_host.cpp | 40 ++-------------- 2 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index ef98bc3d..066678c6 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -496,6 +496,11 @@ int main(int, char**) { // 父窗口在屏幕外保持可见状态完成 WebView2 渲染,页面 ready 后再移回 // 当前屏幕中央。这样用户启动时只看到透明 icon,不会看到白屏。 const bool desktop_debug = is_desktop_debug_mode(); + // WebHost 构造可能抛 webview::exception(WebView2 Runtime 缺失等)。Impl + // ctor 内部已经做两层 fallback(默认 Loader → Edge 浏览器目录),都失败时 + // rethrow 由这里接管 — 询问用户是否在系统默认浏览器中继续运行,daemon + // 保留后台,所有业务逻辑走 daemon HTTP(P1-P3 已下沉)。 + try { WebHost host(/*debug=*/desktop_debug, WebHost::StartupWindowMode::OffscreenUntilReady); host.set_title("ACECode"); host.set_size(kDefaultDesktopWindowWidth, kDefaultDesktopWindowHeight); @@ -1079,6 +1084,102 @@ int main(int, char**) { auto failures = pool.stop_all(); return failures.empty() ? 0 : 100; // 部分失败返回非零便于诊断 + } catch (...) { + // webview/webview 的 webview::exception 是否派生自 std::exception 我们 + // 不直接 include webview.h(为了让 main.cpp 跟 webview 解耦),所以用 + // catch (...) 兜底而非 catch (std::exception&)。webview_ex_what() 内 + // 部对 std::exception 取 what(),其它情形 fallback 到固定字符串。 + const std::string what_str = []() -> std::string { + try { throw; } + catch (const std::exception& se) { return se.what(); } + catch (...) { return "(unknown WebView2 init exception)"; } + }(); + // WebHost 构造失败 — 进入"系统默认浏览器"降级流程。所有 daemon + // / pool 状态在 try 外定义,可在 catch 内继续操作;host / 托盘 / + // 通知尚未初始化(异常发生在 host ctor 内),不需要 shutdown。 + // + // 选择 daemon 后台保留 + ShellExecuteW 打开 URL 的设计原因: + // 1) daemon 已由 desktop 进程 spawn 在 Job Object 内,且 supervisor + // 设了 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — desktop 退出 Job 关 + // daemon 跟着死。要让 daemon 继续服务浏览器,desktop 进程必须 + // "挂着"(简单 message loop 等系统/用户结束)。 + // 2) 业务逻辑在 P1-P3 已全部下沉到 daemon HTTP,浏览器里 UI 完整。 + // Tier A 的窗口 chrome / 托盘 / 系统通知降级缺失,前端 detect + // bridge 不存在自动隐藏对应 UI。 + LOG_ERROR(std::string("[desktop] WebView2 initialization failed: ") + + what_str); + +#ifdef _WIN32 + // 询问用户是否继续。MB_YESNO + 中文文案 + IT 排查信息。 + const std::wstring prompt = + L"ACECode 桌面版无法初始化 WebView2 组件。\n\n" + L"可能的原因:\n" + L" · 未安装 \"Microsoft Edge WebView2 Runtime\"(注意:仅有 Edge 浏览器并不等价)\n" + L" · WebView2 用户数据目录损坏(可删除 %LOCALAPPDATA%\\acecode-desktop\\EBWebView)\n" + L" · 杀毒/EDR 拦截 msedgewebview2.exe\n\n" + L"是否改用系统默认浏览器继续使用 ACECode?\n" + L"(daemon 后台保留,前端所有业务功能可在浏览器内完成)\n\n" + L"详细日志:%USERPROFILE%\\.acecode\\logs\\desktop-*.log"; + int choice = ::MessageBoxW(nullptr, prompt.c_str(), + L"ACECode WebView2 启动失败", + MB_YESNO | MB_ICONWARNING | MB_SETFOREGROUND); + if (choice != IDYES) { + LOG_INFO("[desktop] user declined browser fallback, exiting"); + pool.stop_all(); + return 1; + } + + // 拿到 daemon URL。url 在 try 块外定义,onboarding daemon 启动失败 + // 时仍是 onboarding_url() 占位符 — 那种状态降级也没意义,直接弹错。 + if (url.empty() || url == onboarding_url()) { + ::MessageBoxW(nullptr, + L"daemon 未就绪,无法启动浏览器模式。\n" + L"请查看日志后重试,或联系 IT。", + L"ACECode 启动失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + pool.stop_all(); + return 1; + } + + const std::wstring wurl = acecode::utf8_to_wide(url); + HINSTANCE sh = ::ShellExecuteW(nullptr, L"open", wurl.c_str(), + nullptr, nullptr, SW_SHOWNORMAL); + if (reinterpret_cast(sh) <= 32) { + const DWORD le = ::GetLastError(); + LOG_ERROR("[desktop] ShellExecuteW failed gle=" + std::to_string(le) + + " url=" + url); + const std::wstring fallback_msg = + L"无法启动默认浏览器。请手动复制下面 URL 在浏览器打开:\n\n" + + acecode::utf8_to_wide(url); + ::MessageBoxW(nullptr, fallback_msg.c_str(), + L"ACECode 浏览器降级失败", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + // 不退出 — 用户拷贝 URL 后可以自行打开,desktop 进程仍需保持 + // 让 daemon 活,跌到下面 message loop。 + } else { + LOG_INFO("[desktop] browser fallback launched, url=" + url); + } + + // 简易 message loop 让 desktop 进程保持运行 — daemon 由 Job Object + // 绑生死,desktop 不在 daemon 跟着 kill。退出方式:任务管理器(P5 + // 计划补一个最小托盘"退出"菜单避免用户必须开任务管理器)。Windows + // session logoff 时 WM_QUIT 会广播到所有进程,loop 自然退出。 + MSG msg; + while (::GetMessageW(&msg, nullptr, 0, 0) > 0) { + ::TranslateMessage(&msg); + ::DispatchMessageW(&msg); + } + LOG_INFO("[desktop] browser-fallback message loop exited"); + pool.stop_all(); + return 0; +#else + // POSIX 上 webview/webview 走 WKWebView / WebKitGTK,异常路径跟 + // WebView2 完全不同,这里不构造浏览器降级 UI,直接 rethrow 给外层 + // 顶层 catch(MessageBox 在 POSIX 上没意义,顶层 catch 用 fprintf)。 + (void)what_str; + throw; +#endif + } }; // end of run lambda try { diff --git a/src/desktop/web_host.cpp b/src/desktop/web_host.cpp index 5dfd167e..1dd8dfb9 100644 --- a/src/desktop/web_host.cpp +++ b/src/desktop/web_host.cpp @@ -297,37 +297,6 @@ 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 { @@ -416,9 +385,9 @@ struct WebHost::Impl { 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); + "fall back to; rethrowing for wWinMain browser-fallback " + "branch"); + throw; // wWinMain 的 catch 决定弹 MessageBox 询问浏览器降级 } const std::wstring folder_w = edge_folder->wstring(); LOG_INFO(std::string("[desktop] retrying WebView2 with Edge browser " @@ -436,8 +405,7 @@ struct WebHost::Impl { } 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); + throw; // 同上 — 让 wWinMain 接管降级 / 用户确认 } } if (custom_window) { From 3457d459bc90d6dc996d559fdcf3b0f8ef3f2b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 15:57:49 +0000 Subject: [PATCH 6/6] desktop: silent --app browser fallback + tray quit menu (P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4 在 WebView2 失败时弹了一个 YesNo 询问"是否用浏览器继续",然后 ShellExecuteW 默认浏览器。两个体验问题: (a) 多一步打扰用户;现在 P1-P3 已把业务全下沉到 daemon HTTP,降级路径本就 跟原 webview UI 等价,没必要让用户选 (b) 默认浏览器开普通 tab,带地址栏 / 标签栏 / 书签栏,不像独立 native app P5 改成静默直进 + 优先 Chromium 系浏览器的 `--app=` 模式启动,Chromium 的 app 模式渲染无地址栏单窗口、独立 taskbar 图标,体感最接近原生 webview。同时 给降级流程加最小托盘(图标 + "重新打开窗口" + "退出"菜单),让用户不必任务 管理器结束 desktop 进程(daemon 由 Job Object KILL_ON_JOB_CLOSE 绑生死)。 新文件 src/desktop/chromium_app_launcher.{hpp,cpp}: - find_chromium_app_browser_in(roots): 纯函数,按 (Edge > Chrome) 优先级 跨 root 扫 ///Application/。Edge 优先因为 同事场景里 Edge 几乎必装,且 UI 跟 native app 体感最接近。 - find_chromium_app_browser(): SHGetKnownFolderPath PF + PFx86 调上面纯函数 - launch_chromium_app_mode(exe, url): CreateProcessW 起 ` --app=""`, detached(不进 desktop Job Object,但浏览器是用户进程,desktop 退出 Job 关 daemon kill 后浏览器 page 加载失败也是预期)。 二线 Chromium(360 / QQ / Brave / Vivaldi)有的不兼容 --app= 或参数改名, 贸然走会出现"浏览器开了但啥也没显示"的诡异体验,本期不试,留给 ShellExecuteW 兜底默认浏览器。 main.cpp 浏览器降级流程改写: - 去掉 MessageBoxW MB_YESNO 询问,catch (...) 内直接进降级 - daemon URL 未就绪(onboarding 占位)仍弹错退出 — 否则会开空白窗口 - open_app_window lambda:Chromium 系优先 --app=,失败 fallback ShellExecuteW;两条都失败时弹文案让用户手动拷贝 URL,desktop 仍挂着 - init_tray_icon 在 catch 内独立注册一份(正常路径的 line 525 没机会 执行 — 异常发生在 host ctor 上面),on_show 重开 app 窗口, on_quit -> PostQuitMessage(0) 退 message loop - 退出走 shutdown_tray_icon + pool.stop_all tests/desktop/chromium_app_launcher_test.cpp 覆盖纯函数: - 空 roots / 无浏览器 root → nullopt - Edge / Chrome 各自命中 - 同 root 双装 → Edge 优先 - 跨 root → Edge 仍优先于 Chrome - 空字符串 root 跳过 - 路径是目录而非文件 → 不算命中 --- src/desktop/chromium_app_launcher.cpp | 158 +++++++++++++++++++ src/desktop/chromium_app_launcher.hpp | 54 +++++++ src/desktop/main.cpp | 98 +++++++----- tests/desktop/chromium_app_launcher_test.cpp | 132 ++++++++++++++++ 4 files changed, 404 insertions(+), 38 deletions(-) create mode 100644 src/desktop/chromium_app_launcher.cpp create mode 100644 src/desktop/chromium_app_launcher.hpp create mode 100644 tests/desktop/chromium_app_launcher_test.cpp diff --git a/src/desktop/chromium_app_launcher.cpp b/src/desktop/chromium_app_launcher.cpp new file mode 100644 index 00000000..939b850f --- /dev/null +++ b/src/desktop/chromium_app_launcher.cpp @@ -0,0 +1,158 @@ +// chromium_app_launcher 实现。设计 + 调用时机见 chromium_app_launcher.hpp。 + +#include "chromium_app_launcher.hpp" + +#include "../utils/encoding.hpp" +#include "../utils/logger.hpp" + +#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 { + +// 候选 Chromium 系浏览器,(/, display_name)。Edge +// 优先 — 同事场景里 Edge 几乎必装,且 Edge UI 跟我们想要的 "原生 app" 体感 +// 最接近。Chrome 次之。其它二线 Chromium(360/QQ/Brave/Vivaldi)有的不兼容 +// --app= 或参数改了名,本期不试,留给 ShellExecuteW 兜底默认浏览器。 +constexpr std::array, 2> kBrowserCandidates = {{ + {"Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"}, + {"Google/Chrome/Application/chrome.exe", "Google Chrome"}, +}}; + +bool path_exists_file(const fs::path& p) { + std::error_code ec; + return fs::is_regular_file(p, ec) && !ec; +} + +} // namespace + +std::optional find_chromium_app_browser_in( + const std::vector& roots) { + // 先按候选浏览器顺序遍历,再按 roots 顺序;这样 Edge 在任一 root 命中 + // 都优先于 Chrome。 + for (const auto& [rel_path, display_name] : kBrowserCandidates) { + for (const auto& root : roots) { + if (root.empty()) continue; + fs::path candidate = root / rel_path; + if (path_exists_file(candidate)) { + return ChromiumBrowser{candidate, display_name}; + } + } + } + return std::nullopt; +} + +#ifdef _WIN32 + +namespace { + +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_chromium_app_browser() { + 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("[chromium_app] SHGetKnownFolderPath returned no ProgramFiles paths"); + return std::nullopt; + } + return find_chromium_app_browser_in(roots); +} + +bool launch_chromium_app_mode(const fs::path& exe, + const std::string& url, + std::string& error) { + // 拼 command line:CreateProcessW 的 lpCommandLine 必须可写,且第一个 + // 实参约定是程序名(被 argv[0] 拿)。带空格的路径要加引号。 + // "" --app="" + std::wstring cmd; + cmd.reserve(exe.wstring().size() + url.size() + 16); + cmd.push_back(L'"'); + cmd += exe.wstring(); + cmd.push_back(L'"'); + cmd += L" --app=\""; + cmd += acecode::utf8_to_wide(url); + cmd.push_back(L'"'); + + // lpCommandLine 在 CreateProcessW 路径下可能被内部修改(strtok 风格), + // 必须传非 const 缓冲区。std::wstring::data() 自 C++17 起返回可写指针。 + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + + // 不传 lpApplicationName 而是让 CreateProcessW 从 cmd 第一个 token 解析, + // 这样 Chrome / Edge 自己也能依赖 argv[0] 正确推断安装目录。 + // 不带 CREATE_SUSPENDED / CREATE_BREAKAWAY_FROM_JOB —— 浏览器进程加入 + // desktop 的 Job 也无所谓(浏览器自己创建子进程时会用 + // PROCESS_BREAKAWAY,绕开 KILL_ON_JOB_CLOSE);desktop 退出时浏览器跟着 + // kill 也是预期(用户从托盘 quit 时,浏览器开着没意义)。 + BOOL ok = ::CreateProcessW( + /*lpApplicationName=*/nullptr, + /*lpCommandLine=*/cmd.data(), + /*lpProcessAttributes=*/nullptr, + /*lpThreadAttributes=*/nullptr, + /*bInheritHandles=*/FALSE, + /*dwCreationFlags=*/0, + /*lpEnvironment=*/nullptr, + /*lpCurrentDirectory=*/nullptr, + &si, &pi); + if (!ok) { + const DWORD gle = ::GetLastError(); + error = "CreateProcessW failed, gle=" + std::to_string(gle); + return false; + } + // 父进程不等待子进程结束,直接关掉句柄。 + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + return true; +} + +#else // _WIN32 + +std::optional find_chromium_app_browser() { + return std::nullopt; +} + +bool launch_chromium_app_mode(const fs::path& /*exe*/, + const std::string& /*url*/, + std::string& error) { + error = "platform not supported"; + return false; +} + +#endif // _WIN32 + +} // namespace acecode::desktop diff --git a/src/desktop/chromium_app_launcher.hpp b/src/desktop/chromium_app_launcher.hpp new file mode 100644 index 00000000..9d41046c --- /dev/null +++ b/src/desktop/chromium_app_launcher.hpp @@ -0,0 +1,54 @@ +#pragma once + +// "Chromium app 模式" 浏览器启动器 — 把已经在跑的 daemon URL 喂给一个无地址 +// 栏 / 无标签栏 / 无书签栏的浏览器窗口,让用户体感是一个独立 native app, +// 而不是"被丢回浏览器"。所有 Chromium 系浏览器(Edge / Chrome / Brave 等) +// 都支持 `--app=` 参数。 +// +// 使用场景:WebView2 不可用(企业 / 信创机器,Edge >=126 不允许第三方借用) +// 时,acecode-desktop 进入"浏览器降级"流程,优先尝试 app 模式 — 用户感知最 +// 接近原生 webview;失败再 fallback 到 ShellExecuteW 系统默认浏览器(可能 +// 是 IE / Firefox 等非 Chromium,只能用普通 tab)。 +// +// 平台:Windows-only。POSIX 上头文件保持可见但实现是 stub(macOS / Linux +// 桌面壳本期未对 WebView2 / WKWebView 失败做浏览器降级)。 + +#include +#include +#include +#include + +namespace acecode::desktop { + +struct ChromiumBrowser { + std::filesystem::path exe; // 浏览器主进程的绝对路径 + std::string display_name; // 用于日志的可读名(Edge / Chrome / ...) +}; + +// 在给定根目录列表中查找 Chromium 系浏览器主可执行。纯函数,unit test +// 喂临时目录覆盖。返回首个命中(按 candidates_per_root 的优先级顺序)。 +// +// 期望路径形态:///Application/。我们只列业内 +// 主流的 Edge + Chrome 两条 — 二线 Chromium 系(360/QQ/Brave/...)有的不 +// 兼容 --app= 或参数被改名,贸然走会出现"浏览器开了但啥也没显示"的诡异 +// 体验,不如直接 fallback 给 ShellExecuteW 让用户看到原始默认浏览器。 +std::optional find_chromium_app_browser_in( + const std::vector& roots); + +// Windows 系统调用版:SHGetKnownFolderPath 拿 ProgramFiles / ProgramFiles(x86), +// 调上面纯函数。POSIX stub。 +std::optional find_chromium_app_browser(); + +// 启动 browser.exe 用 --app=url 把窗口拉起来,detached 模式(不阻塞、不 +// 加入 desktop 的 Job Object — 浏览器是用户进程,desktop 退出时不该跟着 +// kill,虽然 daemon 会因 Job 关闭被 kill 让浏览器加载失败,但这是用户从 +// 托盘 quit 的预期行为)。 +// +// 返回 true = CreateProcessW 成功(进程已 spawn,不保证窗口已渲染); +// false + error 字符串 = CreateProcessW 失败,调用方应当 fallback。POSIX +// 上始终返 false / "platform not supported"。 +bool launch_chromium_app_mode(const std::filesystem::path& exe, + const std::string& url, + std::string& error); + +} // namespace acecode::desktop diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index 066678c6..7bac16ee 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -14,6 +14,7 @@ // daemon 端通过 workspace-aware API 在同一进程内服务多个 workspace。 #include "daemon_pool.hpp" +#include "chromium_app_launcher.hpp" #include "dpi_win.hpp" #include "folder_picker.hpp" #include "notifications_win.hpp" @@ -1110,66 +1111,87 @@ int main(int, char**) { what_str); #ifdef _WIN32 - // 询问用户是否继续。MB_YESNO + 中文文案 + IT 排查信息。 - const std::wstring prompt = - L"ACECode 桌面版无法初始化 WebView2 组件。\n\n" - L"可能的原因:\n" - L" · 未安装 \"Microsoft Edge WebView2 Runtime\"(注意:仅有 Edge 浏览器并不等价)\n" - L" · WebView2 用户数据目录损坏(可删除 %LOCALAPPDATA%\\acecode-desktop\\EBWebView)\n" - L" · 杀毒/EDR 拦截 msedgewebview2.exe\n\n" - L"是否改用系统默认浏览器继续使用 ACECode?\n" - L"(daemon 后台保留,前端所有业务功能可在浏览器内完成)\n\n" - L"详细日志:%USERPROFILE%\\.acecode\\logs\\desktop-*.log"; - int choice = ::MessageBoxW(nullptr, prompt.c_str(), - L"ACECode WebView2 启动失败", - MB_YESNO | MB_ICONWARNING | MB_SETFOREGROUND); - if (choice != IDYES) { - LOG_INFO("[desktop] user declined browser fallback, exiting"); - pool.stop_all(); - return 1; - } - - // 拿到 daemon URL。url 在 try 块外定义,onboarding daemon 启动失败 - // 时仍是 onboarding_url() 占位符 — 那种状态降级也没意义,直接弹错。 + // daemon URL 必须就绪;onboarding daemon 启动失败时仍是 about:blank 占 + // 位符,降级也没意义 — 这种情况直接弹错退出避免用户看到一个空白浏览 + // 器窗口。 if (url.empty() || url == onboarding_url()) { ::MessageBoxW(nullptr, L"daemon 未就绪,无法启动浏览器模式。\n" - L"请查看日志后重试,或联系 IT。", + L"请查看 %USERPROFILE%\\.acecode\\logs\\desktop-*.log 后重试。", L"ACECode 启动失败", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); pool.stop_all(); return 1; } - const std::wstring wurl = acecode::utf8_to_wide(url); - HINSTANCE sh = ::ShellExecuteW(nullptr, L"open", wurl.c_str(), - nullptr, nullptr, SW_SHOWNORMAL); - if (reinterpret_cast(sh) <= 32) { - const DWORD le = ::GetLastError(); - LOG_ERROR("[desktop] ShellExecuteW failed gle=" + std::to_string(le) + - " url=" + url); + // 静默降级:优先 Chromium 系浏览器 --app= 假装独立 native 窗口 + // (无地址栏 / 无标签栏 / 独立 taskbar 图标),用户体感最接近原生 + // webview。找不到 Chromium 时 fallback ShellExecuteW 走系统默认浏览 + // 器(可能是 Firefox / 其它,只能用普通 tab,UX 退化但功能完整)。 + auto open_app_window = [&url]() -> bool { + auto found = acecode::desktop::find_chromium_app_browser(); + if (found.has_value()) { + std::string err; + if (acecode::desktop::launch_chromium_app_mode(found->exe, url, err)) { + LOG_INFO("[desktop] launched browser app-mode via " + + found->display_name + ": " + found->exe.string() + + " --app=" + url); + return true; + } + LOG_WARN("[desktop] " + found->display_name + + " --app= launch failed: " + err + + " — falling back to ShellExecuteW"); + } else { + LOG_INFO("[desktop] no Chromium-based browser found for app-mode; " + "using ShellExecuteW default browser"); + } + const std::wstring wurl = acecode::utf8_to_wide(url); + HINSTANCE sh = ::ShellExecuteW(nullptr, L"open", wurl.c_str(), + nullptr, nullptr, SW_SHOWNORMAL); + if (reinterpret_cast(sh) > 32) { + LOG_INFO("[desktop] launched default browser tab: " + url); + return true; + } + LOG_ERROR("[desktop] ShellExecuteW failed gle=" + + std::to_string(::GetLastError()) + " url=" + url); + return false; + }; + + if (!open_app_window()) { const std::wstring fallback_msg = - L"无法启动默认浏览器。请手动复制下面 URL 在浏览器打开:\n\n" + + L"无法启动浏览器。请手动复制下面 URL 在浏览器打开:\n\n" + acecode::utf8_to_wide(url); ::MessageBoxW(nullptr, fallback_msg.c_str(), L"ACECode 浏览器降级失败", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); - // 不退出 — 用户拷贝 URL 后可以自行打开,desktop 进程仍需保持 - // 让 daemon 活,跌到下面 message loop。 - } else { - LOG_INFO("[desktop] browser fallback launched, url=" + url); + // 不立即退出 — 用户拷贝 URL 后可以自行打开,desktop 进程仍保持 + // 让 daemon 活,下面跌入 message loop。 + } + + // 浏览器降级模式下的托盘 — 给用户一个看得见的"退出"出口,否则 + // desktop 进程只能任务管理器结束(daemon 被 Job Object 绑生死)。 + // on_show 重新拉起 app 窗口(用户关闭浏览器窗口想再开时用)。 + // on_quit 走 PostQuitMessage 退 message loop,后续走 stop_all。 + void* tray_message_hwnd_fallback = nullptr; + bool tray_ok_fallback = init_tray_icon( + /*on_show=*/[&open_app_window]() { open_app_window(); }, + /*on_quit=*/[]() { ::PostQuitMessage(0); }, + &tray_message_hwnd_fallback); + if (!tray_ok_fallback) { + LOG_WARN("[desktop] browser-fallback tray init failed; user must end " + "process via Task Manager to stop daemon"); } - // 简易 message loop 让 desktop 进程保持运行 — daemon 由 Job Object - // 绑生死,desktop 不在 daemon 跟着 kill。退出方式:任务管理器(P5 - // 计划补一个最小托盘"退出"菜单避免用户必须开任务管理器)。Windows - // session logoff 时 WM_QUIT 会广播到所有进程,loop 自然退出。 + // Message loop — daemon 由 Job Object 跟 desktop 绑生死,所以这里必 + // 须让 desktop 挂着。Windows session logoff 时 WM_QUIT 广播让 loop + // 自然退出;托盘 "退出" 也走 PostQuitMessage(0) 同样路径。 MSG msg; while (::GetMessageW(&msg, nullptr, 0, 0) > 0) { ::TranslateMessage(&msg); ::DispatchMessageW(&msg); } LOG_INFO("[desktop] browser-fallback message loop exited"); + if (tray_ok_fallback) shutdown_tray_icon(); pool.stop_all(); return 0; #else diff --git a/tests/desktop/chromium_app_launcher_test.cpp b/tests/desktop/chromium_app_launcher_test.cpp new file mode 100644 index 00000000..41c3feeb --- /dev/null +++ b/tests/desktop/chromium_app_launcher_test.cpp @@ -0,0 +1,132 @@ +// 覆盖 src/desktop/chromium_app_launcher.cpp 中的纯函数 find_chromium_app_browser_in。 +// 系统调用版 find_chromium_app_browser() 依赖 SHGetKnownFolderPath,unit test +// 直接喂临时目录列表更直观。launch_chromium_app_mode() 启 CreateProcessW +// 无法在 headless CI 跑,留作手动 e2e。 + +#include + +#include "desktop/chromium_app_launcher.hpp" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using acecode::desktop::find_chromium_app_browser_in; + +namespace { + +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_chromium_app_" + + 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_; +}; + +fs::path make_browser_exe(const fs::path& root, const std::string& rel) { + fs::path full = root / rel; + std::error_code ec; + fs::create_directories(full.parent_path(), ec); + EXPECT_FALSE(ec) << ec.message(); + std::ofstream(full) << "stub"; + return full; +} + +} // namespace + +TEST(ChromiumAppLauncher, EmptyRootsReturnsNullopt) { + EXPECT_FALSE(find_chromium_app_browser_in({}).has_value()); +} + +TEST(ChromiumAppLauncher, RootWithoutAnyBrowserReturnsNullopt) { + TempDir d; + EXPECT_FALSE(find_chromium_app_browser_in({d.path()}).has_value()); +} + +// 单 root 装了 Edge → 命中 Edge,display_name 标记正确。 +TEST(ChromiumAppLauncher, FindsEdgeInProgramFiles) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); + EXPECT_EQ(r->display_name, "Microsoft Edge"); +} + +// 单 root 装了 Chrome → 命中 Chrome。 +TEST(ChromiumAppLauncher, FindsChromeInProgramFiles) { + TempDir d; + fs::path chrome = make_browser_exe(d.path(), "Google/Chrome/Application/chrome.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), chrome.lexically_normal()); + EXPECT_EQ(r->display_name, "Google Chrome"); +} + +// 同一 root 同时装 Edge + Chrome → Edge 优先(用户体感更接近 native app)。 +TEST(ChromiumAppLauncher, EdgePreferredOverChromeWhenBothPresent) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + make_browser_exe(d.path(), "Google/Chrome/Application/chrome.exe"); + auto r = find_chromium_app_browser_in({d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); + EXPECT_EQ(r->display_name, "Microsoft Edge"); +} + +// 两个 root,Edge 在第二个,Chrome 在第一个 → 仍然 Edge 优先(按候选浏览器 +// 顺序而不是 root 顺序,避免 PFx86 只装 Chrome 时漏掉 PF 的 Edge)。 +TEST(ChromiumAppLauncher, EdgePreferredAcrossRoots) { + TempDir pf, pfx86; + make_browser_exe(pfx86.path(), "Google/Chrome/Application/chrome.exe"); + fs::path edge = make_browser_exe(pf.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({pf.path(), pfx86.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); +} + +// 空字符串 root 元素静默跳过(防御性 — SHGetKnownFolderPath 失败可能塞空)。 +TEST(ChromiumAppLauncher, EmptyRootEntryIgnored) { + TempDir d; + fs::path edge = make_browser_exe(d.path(), "Microsoft/Edge/Application/msedge.exe"); + auto r = find_chromium_app_browser_in({fs::path(), d.path()}); + ASSERT_TRUE(r.has_value()); + EXPECT_EQ(r->exe.lexically_normal(), edge.lexically_normal()); +} + +// 路径上是目录而非文件 → 不算命中。 +TEST(ChromiumAppLauncher, DirectoryAtExePathIsNotAMatch) { + TempDir d; + std::error_code ec; + fs::create_directories(d.path() / "Microsoft/Edge/Application/msedge.exe", ec); + EXPECT_FALSE(find_chromium_app_browser_in({d.path()}).has_value()); +}