Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/desktop/chromium_app_launcher.cpp
Original file line number Diff line number Diff line change
@@ -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 <array>
#include <string>
#include <system_error>

#ifdef _WIN32
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <shlobj.h>
# include <knownfolders.h>
#endif

namespace fs = std::filesystem;

namespace acecode::desktop {

namespace {

// 候选 Chromium 系浏览器,(<root>/<relative-from-root>, display_name)。Edge
// 优先 — 同事场景里 Edge 几乎必装,且 Edge UI 跟我们想要的 "原生 app" 体感
// 最接近。Chrome 次之。其它二线 Chromium(360/QQ/Brave/Vivaldi)有的不兼容
// --app= 或参数改了名,本期不试,留给 ShellExecuteW 兜底默认浏览器。
constexpr std::array<std::pair<const char*, const char*>, 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<ChromiumBrowser> find_chromium_app_browser_in(
const std::vector<fs::path>& 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<ChromiumBrowser> find_chromium_app_browser() {
std::vector<fs::path> 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] 拿)。带空格的路径要加引号。
// "<exe>" --app="<url>"
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<ChromiumBrowser> 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
54 changes: 54 additions & 0 deletions src/desktop/chromium_app_launcher.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#pragma once

// "Chromium app 模式" 浏览器启动器 — 把已经在跑的 daemon URL 喂给一个无地址
// 栏 / 无标签栏 / 无书签栏的浏览器窗口,让用户体感是一个独立 native app,
// 而不是"被丢回浏览器"。所有 Chromium 系浏览器(Edge / Chrome / Brave 等)
// 都支持 `--app=<url>` 参数。
//
// 使用场景:WebView2 不可用(企业 / 信创机器,Edge >=126 不允许第三方借用)
// 时,acecode-desktop 进入"浏览器降级"流程,优先尝试 app 模式 — 用户感知最
// 接近原生 webview;失败再 fallback 到 ShellExecuteW 系统默认浏览器(可能
// 是 IE / Firefox 等非 Chromium,只能用普通 tab)。
//
// 平台:Windows-only。POSIX 上头文件保持可见但实现是 stub(macOS / Linux
// 桌面壳本期未对 WebView2 / WKWebView 失败做浏览器降级)。

#include <filesystem>
#include <optional>
#include <string>
#include <vector>

namespace acecode::desktop {

struct ChromiumBrowser {
std::filesystem::path exe; // 浏览器主进程的绝对路径
std::string display_name; // 用于日志的可读名(Edge / Chrome / ...)
};

// 在给定根目录列表中查找 Chromium 系浏览器主可执行。纯函数,unit test
// 喂临时目录覆盖。返回首个命中(按 candidates_per_root 的优先级顺序)。
//
// 期望路径形态:<root>/<vendor>/<product>/Application/<exe>。我们只列业内
// 主流的 Edge + Chrome 两条 — 二线 Chromium 系(360/QQ/Brave/...)有的不
// 兼容 --app= 或参数被改名,贸然走会出现"浏览器开了但啥也没显示"的诡异
// 体验,不如直接 fallback 给 ShellExecuteW 让用户看到原始默认浏览器。
std::optional<ChromiumBrowser> find_chromium_app_browser_in(
const std::vector<std::filesystem::path>& roots);

// Windows 系统调用版:SHGetKnownFolderPath 拿 ProgramFiles / ProgramFiles(x86),
// 调上面纯函数。POSIX stub。
std::optional<ChromiumBrowser> 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
123 changes: 123 additions & 0 deletions src/desktop/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -496,6 +497,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);
Expand Down Expand Up @@ -1079,6 +1085,123 @@ 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
// daemon URL 必须就绪;onboarding daemon 启动失败时仍是 about:blank 占
// 位符,降级也没意义 — 这种情况直接弹错退出避免用户看到一个空白浏览
// 器窗口。
if (url.empty() || url == onboarding_url()) {
::MessageBoxW(nullptr,
L"daemon 未就绪,无法启动浏览器模式。\n"
L"请查看 %USERPROFILE%\\.acecode\\logs\\desktop-*.log 后重试。",
L"ACECode 启动失败",
MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
pool.stop_all();
return 1;
}

// 静默降级:优先 Chromium 系浏览器 --app=<url> 假装独立 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<intptr_t>(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" +
acecode::utf8_to_wide(url);
::MessageBoxW(nullptr, fallback_msg.c_str(),
L"ACECode 浏览器降级失败",
MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
// 不立即退出 — 用户拷贝 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 — 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
// POSIX 上 webview/webview 走 WKWebView / WebKitGTK,异常路径跟
// WebView2 完全不同,这里不构造浏览器降级 UI,直接 rethrow 给外层
// 顶层 catch(MessageBox 在 POSIX 上没意义,顶层 catch 用 fprintf)。
(void)what_str;
throw;
#endif
}
}; // end of run lambda

try {
Expand Down
9 changes: 4 additions & 5 deletions src/desktop/web_host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,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 "
Expand All @@ -436,8 +436,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) {
Expand Down
Loading
Loading