From b56625dd3b65ed07687d04bf8b3cbeaca0b0c381 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Wed, 5 Aug 2026 10:43:13 +0200 Subject: [PATCH 1/5] Add Windows WebView2 host with CI and release jobs. Implement the C++/Win32/WebView2 native host, wire shared Elixir E2E on Windows, and add windows-e2e CI plus release artifact packaging. Co-authored-by: Cursor --- .github/workflows/ci.yml | 26 + .github/workflows/release.yml | 24 +- .gitignore | 2 + docs/status/windows.md | 59 +- native/windows/CMakeLists.txt | 70 ++ native/windows/README.md | 61 +- native/windows/src/config.cpp | 176 +++++ native/windows/src/config.hpp | 33 + native/windows/src/host_controller.cpp | 877 +++++++++++++++++++++++++ native/windows/src/host_controller.hpp | 101 +++ native/windows/src/json_util.cpp | 67 ++ native/windows/src/json_util.hpp | 27 + native/windows/src/main.cpp | 60 ++ native/windows/src/rpc_server.cpp | 232 +++++++ native/windows/src/rpc_server.hpp | 56 ++ native/windows/src/web_window.cpp | 407 ++++++++++++ native/windows/src/web_window.hpp | 98 +++ native/windows/src/win_prefix.hpp | 12 + scripts/build_windows.ps1 | 69 ++ test/e2e/e2e_test.exs | 10 +- 20 files changed, 2423 insertions(+), 44 deletions(-) create mode 100644 native/windows/CMakeLists.txt create mode 100644 native/windows/src/config.cpp create mode 100644 native/windows/src/config.hpp create mode 100644 native/windows/src/host_controller.cpp create mode 100644 native/windows/src/host_controller.hpp create mode 100644 native/windows/src/json_util.cpp create mode 100644 native/windows/src/json_util.hpp create mode 100644 native/windows/src/main.cpp create mode 100644 native/windows/src/rpc_server.cpp create mode 100644 native/windows/src/rpc_server.hpp create mode 100644 native/windows/src/web_window.cpp create mode 100644 native/windows/src/web_window.hpp create mode 100644 native/windows/src/win_prefix.hpp create mode 100644 scripts/build_windows.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dcf342..9b06c3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,29 @@ jobs: WEBKIT_DISABLE_DMABUF_RENDERER: "1" WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS: "1" LIBGL_ALWAYS_SOFTWARE: "1" + + windows-e2e: + name: Windows host + E2E + runs-on: windows-latest + env: + NO_WX: "1" + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26" + elixir-version: "1.18" + - name: Checkout desktop (path dep) + uses: actions/checkout@v4 + with: + repository: elixir-desktop/desktop + path: desktop + - name: Build host + shell: pwsh + run: .\scripts\build_windows.ps1 + - run: mix deps.get + - name: E2E + shell: pwsh + run: mix test + env: + DESKTOP_WEBVIEW_BINARY: ${{ github.workspace }}\priv\native\windows\DesktopWebView.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5256ca8..d5bd494 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,4 +62,26 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # windows: add when native/windows host exists — attach DesktopWebView-windows-* to the same draft release + windows: + name: Windows x64 artifact + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Build host + shell: pwsh + run: .\scripts\build_windows.ps1 + - name: Pack + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Copy-Item priv\native\windows\DesktopWebView.exe dist\DesktopWebView-windows-x64.exe + $hash = (Get-FileHash -Algorithm SHA256 dist\DesktopWebView-windows-x64.exe).Hash.ToLower() + Set-Content -NoNewline -Path dist\DesktopWebView-windows-x64.exe.sha256 -Value "$hash DesktopWebView-windows-x64.exe" + - uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + dist/DesktopWebView-windows-x64.exe + dist/DesktopWebView-windows-x64.exe.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b0d21f4..73f238c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,9 @@ desktop_webview-*.tar native/macos/.build/ native/macos/Package.resolved native/linux/build/ +native/windows/build/ priv/native/macos/DesktopWebView priv/native/linux/DesktopWebView +priv/native/windows/DesktopWebView.exe .DS_Store *.swp diff --git a/docs/status/windows.md b/docs/status/windows.md index 935ec9e..4c5a6fe 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -2,35 +2,36 @@ States: `done` | `partial` | `todo` | `n/a` -Host not implemented yet. Follow [docs/porting.md](../porting.md). -Binary delivery via GitHub Releases (not Hex `priv/`). +C++ / Win32 / WebView2 host under [`native/windows/`](../../native/windows/). +Local/CI binary: `priv/native/windows/DesktopWebView.exe`. +Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/`). | Feature | Status | Notes | |---------|--------|-------| -| TCP listen + `listening ` | todo | | -| `initialize` + capabilities | todo | | -| `--edw-no-beam` / argv strip | todo | | -| Ini + defaults | todo | | -| Lifetime reconnect | todo | | -| Lifetime coupled | todo | | -| Window open/show/hide/title/min size | todo | | -| Multi-window (ids) | todo | | -| Close veto → Elixir | todo | | -| Raise / iconize / shown / active | todo | | -| Webview loadURL / reload / current URL | todo | | -| Webview rebuild | todo | | -| New window → external open | todo | | -| Context menu disable | todo | | -| Menubar from DOM | todo | | -| Tray / status item | todo | | -| Apple menu | n/a | | -| Notifications | todo | | -| Icons from path / PNG | todo | | -| OS events (reopen, open url/file) | todo | | -| Locale / os_description | todo | | -| Permission policy hybrid | todo | | -| Microphone in webview | todo | | -| Camera in webview | todo | | -| Test RPC channel | todo | | -| Release artifact download | todo | | -| Ad-hoc / CI signing | todo | | +| TCP listen + `listening ` | done | E2E launcher discovery | +| `initialize` + capabilities | done | E2E setup | +| `--edw-no-beam` / argv strip | done | E2E | +| Ini + defaults | done | Same semantics as Linux/macOS | +| Lifetime reconnect | done | Packaged mode (keep listening) | +| Lifetime coupled | done | Exit on disconnect; `--edw-no-beam` exits | +| Window open/show/hide/title/min size | done | E2E window test | +| Multi-window (ids) | done | E2E | +| Close veto → Elixir | done | `WM_CLOSE` → `event.window.close_requested` | +| Raise / iconize / shown / active | done | Raise covered by E2E | +| Webview loadURL / reload / current URL | done | E2E | +| Webview rebuild | done | E2E | +| New window → external open | done | `ShellExecute` + event | +| Context menu disable | done | WebView2 settings | +| Menubar from DOM | done | E2E menu create | +| Tray / status item | done | `Shell_NotifyIcon`; E2E tray create | +| Apple menu | n/a | Successful no-op | +| Notifications | done | Balloon via tray when present; E2E | +| Icons from path / PNG | partial | Path/ICO via `LoadImage`; PNG→HICON deferred | +| OS events (reopen, open url/file) | partial | `system.open_url` done; OS reopen/file events not wired | +| Locale / os_description | done | E2E | +| Permission policy hybrid | done | E2E simulate + policy | +| Microphone in webview | done | Permission RPC + WebView2 kinds | +| Camera in webview | done | Permission RPC + WebView2 kinds | +| Test RPC channel | done | E2E | +| Release artifact download | todo | Elixir fetch/cache still pending | +| Ad-hoc / CI signing | todo | Authenticode via desktop_deployment later | diff --git a/native/windows/CMakeLists.txt b/native/windows/CMakeLists.txt new file mode 100644 index 0000000..18fae2d --- /dev/null +++ b/native/windows/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.16) +project(DesktopWebView LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) + +FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(nlohmann_json) + +set(WEBVIEW2_VERSION "1.0.2903.40") +FetchContent_Declare( + microsoft_webview2 + URL "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/${WEBVIEW2_VERSION}" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_MakeAvailable(microsoft_webview2) + +set(WEBVIEW2_ROOT "${microsoft_webview2_SOURCE_DIR}") +set(WEBVIEW2_INCLUDE "${WEBVIEW2_ROOT}/build/native/include") +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(WEBVIEW2_LIB_DIR "${WEBVIEW2_ROOT}/build/native/x64") +else() + set(WEBVIEW2_LIB_DIR "${WEBVIEW2_ROOT}/build/native/x86") +endif() + +add_executable(DesktopWebView + src/main.cpp + src/config.cpp + src/json_util.cpp + src/rpc_server.cpp + src/web_window.cpp + src/host_controller.cpp +) + +target_include_directories(DesktopWebView PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${WEBVIEW2_INCLUDE} +) + +target_compile_definitions(DesktopWebView PRIVATE + UNICODE + _UNICODE + NOMINMAX +) + +target_link_libraries(DesktopWebView PRIVATE + nlohmann_json::nlohmann_json + "${WEBVIEW2_LIB_DIR}/WebView2LoaderStatic.lib" + ws2_32 + ole32 + oleaut32 + uuid + shell32 + user32 + gdi32 + comctl32 + advapi32 + shlwapi + version + crypt32 +) + +install(TARGETS DesktopWebView RUNTIME DESTINATION bin) diff --git a/native/windows/README.md b/native/windows/README.md index 7ae593b..645ea32 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -1,18 +1,55 @@ # Windows host (WebView2) -Not implemented yet. This directory will hold the Microsoft Edge WebView2 host. +C++17 + **Win32** + **WebView2** executable `DesktopWebView.exe`. -## Specs +## Prerequisites + +- Visual Studio 2022 with MSVC x64 toolchain (`vcvars64.bat`) +- CMake 3.16+ +- [Microsoft Edge WebView2 Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) + (the host prints a clear stderr error if the runtime is missing) + +CMake fetches `Microsoft.Web.WebView2` and `nlohmann/json` during configure. + +## Build + +```powershell +# from repo root (x64 Developer PowerShell, or let the script call vcvars64) +.\scripts\build_windows.ps1 +``` + +Output is copied to `priv\native\windows\DesktopWebView.exe` for local/CI use. +Release artifact name: `DesktopWebView-windows-x64.exe` (see [packaging.md](../../docs/packaging.md)). + +## Run (dev / E2E) -- [Porting guide](../../docs/porting.md) — checklist, toolchain, E2E gate -- [Protocol](../../docs/protocol.md) — JSON-RPC + behavioral semantics -- [Packaging](../../docs/packaging.md) — Windows layout and release artifact names -- [Status](../../docs/status/windows.md) — feature matrix +```powershell +.\priv\native\windows\DesktopWebView.exe --edw-no-beam --edw-test-rpc --edw-port=0 +# prints: listening +``` -## Contract (summary) +```powershell +$env:DESKTOP_WEBVIEW_BINARY = "$PWD\priv\native\windows\DesktopWebView.exe" +mix test.e2e +``` + +## Structure + +| Path | Role | +|------|------| +| `CMakeLists.txt` | CMake project + WebView2 / JSON deps | +| `src/` | Host sources (config, RPC, windows, dispatch) | + +Do not add a native unit-test suite; exercise the binary from Elixir E2E (`mix test.e2e`). + +`menu.set_apple` is a successful no-op on Windows. + +Microphone / camera: hybrid permission RPC applies; packaged apps may also need +Windows privacy capability declarations (see [packaging.md](../../docs/packaging.md)). + +## Specs -- Native process listens on TCP; Elixir connects -- JSON-RPC 2.0 over 4-byte length-prefixed frames -- `--edw-*` host flags; remaining argv forwarded to BEAM -- Binary: `DesktopWebView-windows-x64.exe` via GitHub Releases (not Hex `priv/`) -- `menu.set_apple` → successful no-op +- [Porting guide](../../docs/porting.md) +- [Protocol](../../docs/protocol.md) +- [Packaging](../../docs/packaging.md) +- [Status](../../docs/status/windows.md) diff --git a/native/windows/src/config.cpp b/native/windows/src/config.cpp new file mode 100644 index 0000000..8b3c9ae --- /dev/null +++ b/native/windows/src/config.cpp @@ -0,0 +1,176 @@ +#include "config.hpp" + +#include "win_prefix.hpp" + +#include +#include +#include +#include + +namespace { + +std::string dirname_of(const std::string& path) { + auto pos = path.find_last_of("/\\"); + if (pos == std::string::npos) return "."; + if (pos == 0) return path.substr(0, 1); + return path.substr(0, pos); +} + +std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + char last = a.back(); + if (last == '/' || last == '\\') return a + b; + return a + "\\" + b; +} + +bool file_exists(const std::string& path) { + DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY); +} + +class Ini { + public: + static Ini parse(const std::string& text) { + Ini ini; + std::string current = "default"; + std::istringstream in(text); + std::string raw; + while (std::getline(in, raw)) { + std::string line = raw; + while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) + line.pop_back(); + size_t start = 0; + while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) start++; + line = line.substr(start); + if (line.empty() || line[0] == '#' || line[0] == ';') continue; + if (line.front() == '[' && line.back() == ']') { + current = line.substr(1, line.size() - 2); + for (auto& c : current) c = static_cast(tolower(static_cast(c))); + continue; + } + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + auto trim = [](std::string& s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin()); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back(); + }; + trim(key); + trim(val); + ini.sections_[current][key] = val; + } + return ini; + } + + std::optional get(const std::string& section, const std::string& key) const { + std::string sec = section; + for (auto& c : sec) c = static_cast(tolower(static_cast(c))); + auto it = sections_.find(sec); + if (it == sections_.end()) return std::nullopt; + auto kit = it->second.find(key); + if (kit == it->second.end()) return std::nullopt; + return kit->second; + } + + std::map section(const std::string& name) const { + std::string sec = name; + for (auto& c : sec) c = static_cast(tolower(static_cast(c))); + auto it = sections_.find(sec); + if (it == sections_.end()) return {}; + return it->second; + } + + private: + std::map> sections_; +}; + +} // namespace + +HostConfig HostConfig::parse(int argc, char** argv) { + HostConfig cfg; + bool passthrough = false; + for (int i = 1; i < argc; i++) { + std::string a = argv[i]; + if (passthrough || a == "--") { + if (a == "--") { + passthrough = true; + continue; + } + cfg.forwarded_argv.push_back(a); + continue; + } + if (a.rfind("--edw-", 0) == 0) { + std::string body = a.substr(6); + if (body == "no-beam") { + cfg.no_beam = true; + } else if (body == "test-rpc") { + cfg.test_rpc = true; + } else if (body.rfind("port=", 0) == 0) { + cfg.port = static_cast(std::stoi(body.substr(5))); + } else if (body.rfind("host=", 0) == 0) { + cfg.host = body.substr(5); + } else if (body.rfind("config=", 0) == 0) { + cfg.config_path = body.substr(7); + } else if (body.rfind("lifetime=", 0) == 0) { + auto v = body.substr(9); + cfg.lifetime = (v == "coupled") ? Lifetime::Coupled : Lifetime::Reconnect; + } else if (body.rfind("beam-path=", 0) == 0) { + cfg.beam_path = body.substr(10); + } else if (body.rfind("beam-app=", 0) == 0) { + cfg.beam_app = body.substr(9); + } else { + fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); + } + continue; + } + cfg.forwarded_argv.push_back(a); + } + cfg.apply_ini(); + if (cfg.no_beam) cfg.beam_enabled = false; + return cfg; +} + +std::string HostConfig::resources_root() const { + char buf[MAX_PATH]; + DWORD n = GetModuleFileNameA(nullptr, buf, MAX_PATH); + if (n > 0 && n < MAX_PATH) return dirname_of(buf); + return "."; +} + +std::optional HostConfig::resolve_ini_path() const { + if (config_path) return *config_path; + auto beside = join_path(resources_root(), "DesktopWebView.ini"); + if (file_exists(beside)) return beside; + return std::nullopt; +} + +void HostConfig::apply_ini() { + auto path = resolve_ini_path(); + if (!path) return; + std::ifstream in(*path); + if (!in) return; + std::ostringstream ss; + ss << in.rdbuf(); + auto ini = Ini::parse(ss.str()); + if (auto v = ini.get("network", "host")) host = *v; + if (auto v = ini.get("network", "port")) port = static_cast(std::stoi(*v)); + if (auto v = ini.get("lifetime", "mode")) { + lifetime = (*v == "coupled") ? Lifetime::Coupled : Lifetime::Reconnect; + } + if (auto v = ini.get("beam", "enabled")) { + beam_enabled = !(*v == "false" || *v == "0"); + } + if (auto v = ini.get("beam", "path")) beam_path = *v; + if (auto v = ini.get("beam", "app_name")) beam_app = *v; + if (auto v = ini.get("beam", "args")) { + beam_args.clear(); + std::istringstream args(*v); + std::string tok; + while (args >> tok) beam_args.push_back(tok); + } + if (auto v = ini.get("beam", "working_dir")) beam_working_dir = *v; + for (auto& [k, v] : ini.section("env")) { + extra_env[k] = v; + } +} diff --git a/native/windows/src/config.hpp b/native/windows/src/config.hpp new file mode 100644 index 0000000..52101ef --- /dev/null +++ b/native/windows/src/config.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include + +enum class Lifetime { Reconnect, Coupled }; + +struct HostConfig { + bool no_beam = false; + uint16_t port = 0; + std::string host = "127.0.0.1"; + std::optional config_path; + Lifetime lifetime = Lifetime::Reconnect; + bool test_rpc = false; + std::optional beam_path; + std::optional beam_app; + std::vector beam_args{"start"}; + std::optional beam_working_dir; + bool beam_enabled = true; + std::map extra_env; + std::vector forwarded_argv; + + static HostConfig parse(int argc, char** argv); + + std::string resources_root() const; + std::optional resolve_ini_path() const; + + private: + void apply_ini(); +}; diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp new file mode 100644 index 0000000..d7509ad --- /dev/null +++ b/native/windows/src/host_controller.cpp @@ -0,0 +1,877 @@ +#include "host_controller.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + char last = a.back(); + if (last == '/' || last == '\\') return a + b; + return a + "\\" + b; +} + +bool is_absolute(const std::string& p) { + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') return true; + return p.size() >= 2 && p[0] == '\\' && p[1] == '\\'; +} + +std::wstring widen(const std::string& s) { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); + std::wstring out(n > 0 ? n - 1 : 0, L'\0'); + if (n > 1) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, out.data(), n); + return out; +} + +std::vector base64_decode(const std::string& in) { + DWORD len = 0; + if (!CryptStringToBinaryA(in.c_str(), 0, CRYPT_STRING_BASE64, nullptr, &len, nullptr, nullptr)) + return {}; + std::vector out(len); + if (!CryptStringToBinaryA(in.c_str(), 0, CRYPT_STRING_BASE64, out.data(), &len, nullptr, nullptr)) + return {}; + out.resize(len); + return out; +} + +HICON icon_from_png(const std::vector& png) { + // Optional PNG→HICON path deferred; protocol accepts empty icons. + (void)png; + return nullptr; +} + +struct RequestMsg { + HostController* self; + jsonutil::Json id; + std::string method; + jsonutil::Json params; + RpcServer::ReplyFn reply; +}; + +constexpr const wchar_t* kHostClass = L"DesktopWebViewHost"; + +} // namespace + +HostController::HostController(HostConfig config) : config_(std::move(config)) {} + +HostController::~HostController() { + if (beam_process_) { + TerminateProcess(beam_process_, 0); + CloseHandle(beam_process_); + beam_process_ = nullptr; + } + for (auto& [_, tray] : trays_) { + if (tray.registered) Shell_NotifyIconW(NIM_DELETE, &tray.nid); + } + for (auto& [_, icon] : icons_) { + if (icon.icon) DestroyIcon(icon.icon); + } + for (auto& [_, menu] : menus_) destroy_menu_entry(menu); + windows_.clear(); + if (hwnd_) { + DestroyWindow(hwnd_); + hwnd_ = nullptr; + } +} + +bool HostController::start() { + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = HostController::HostWndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = kHostClass; + RegisterClassExW(&wc); + + hwnd_ = CreateWindowExW(0, kHostClass, L"DesktopWebViewHost", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, + GetModuleHandleW(nullptr), this); + if (!hwnd_) { + fprintf(stderr, "edw: failed to create host window (%lu)\n", GetLastError()); + return false; + } + + server_.set_request_handler([this](jsonutil::Json id, const std::string& method, + jsonutil::Json params, RpcServer::ReplyFn reply) { + auto* msg = new RequestMsg{this, std::move(id), method, std::move(params), std::move(reply)}; + PostMessageW(hwnd_, WM_EDW_REQUEST, 0, reinterpret_cast(msg)); + }); + + server_.set_disconnect_handler([this]() { PostMessageW(hwnd_, WM_EDW_DISCONNECT, 0, 0); }); + + if (!server_.start(config_.host, config_.port, hwnd_)) return false; + + if (config_.beam_enabled && !config_.no_beam) { + spawn_beam(); + } + return true; +} + +LRESULT CALLBACK HostController::HostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + HostController* self = nullptr; + if (msg == WM_NCCREATE) { + auto* cs = reinterpret_cast(lParam); + self = static_cast(cs->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(self)); + } else { + self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + } + if (!self) return DefWindowProcW(hwnd, msg, wParam, lParam); + if (msg == WM_NCCREATE) self->hwnd_ = hwnd; + return self->on_host_message(hwnd, msg, wParam, lParam); +} + +LRESULT HostController::on_host_message(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == RpcServer::WM_EDW_SOCKET) { + server_.on_socket_event(static_cast(wParam), WSAGETSELECTEVENT(lParam), + WSAGETSELECTERROR(lParam)); + return 0; + } + if (msg == WM_EDW_REQUEST) { + auto* m = reinterpret_cast(lParam); + handle_request(std::move(m->id), m->method, std::move(m->params), std::move(m->reply)); + delete m; + return 0; + } + if (msg == WM_EDW_DISCONNECT) { + client_disconnected(); + return 0; + } + if (msg == WM_EDW_TRAY) { + if (lParam == WM_LBUTTONUP || lParam == WM_RBUTTONUP) { + // Find tray by uID + UINT id = static_cast(wParam); + for (auto& [tid, tray] : trays_) { + if (tray.nid.uID == id) { + jsonutil::Json o{{"tray_id", tid}}; + server_.notify("event.tray.click", std::move(o)); + if (lParam == WM_RBUTTONUP && !tray.menu_id.empty()) { + auto it = menus_.find(tray.menu_id); + if (it != menus_.end() && it->second.menu) { + POINT pt{}; + GetCursorPos(&pt); + SetForegroundWindow(hwnd); + TrackPopupMenu(it->second.menu, TPM_RIGHTBUTTON, pt.x, pt.y, 0, hwnd, nullptr); + } + } + break; + } + } + } + return 0; + } + if (msg == WM_COMMAND) { + on_menu_command(LOWORD(wParam)); + return 0; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +bool HostController::on_menu_command(UINT cmd) { + for (auto& [mid, ment] : menus_) { + auto it = ment.onclicks.find(cmd); + if (it == ment.onclicks.end()) continue; + jsonutil::Json o{{"menu_id", mid}, {"onclick", it->second}}; + server_.notify("event.menu.click", std::move(o)); + return true; + } + return false; +} + +void HostController::client_disconnected() { + if (config_.lifetime == Lifetime::Coupled || config_.no_beam) { + if (beam_process_) { + TerminateProcess(beam_process_, 0); + CloseHandle(beam_process_); + beam_process_ = nullptr; + } + ExitProcess(0); + } + initialized_ = false; +} + +std::string HostController::next_id(const std::string& prefix) { + id_counter_++; + return prefix + std::to_string(id_counter_); +} + +void HostController::spawn_beam() { + auto root = config_.resources_root(); + std::string beam_dir = + config_.beam_path + ? (is_absolute(*config_.beam_path) ? *config_.beam_path : join_path(root, *config_.beam_path)) + : join_path(root, "beam"); + std::string app_name = config_.beam_app.value_or(""); + if (app_name.empty()) { + std::string bin = join_path(beam_dir, "bin"); + WIN32_FIND_DATAA fd{}; + HANDLE h = FindFirstFileA((bin + "\\*").c_str(), &fd); + if (h != INVALID_HANDLE_VALUE) { + do { + if (fd.cFileName[0] == '.') continue; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + app_name = fd.cFileName; + break; + } while (FindNextFileA(h, &fd)); + FindClose(h); + } + } + if (app_name.empty()) { + fprintf(stderr, "edw: no beam app_name and no bin script found in %s\n", beam_dir.c_str()); + return; + } + std::string script = join_path(join_path(beam_dir, "bin"), app_name); + std::string wd = + config_.beam_working_dir + ? (is_absolute(*config_.beam_working_dir) ? *config_.beam_working_dir + : join_path(root, *config_.beam_working_dir)) + : beam_dir; + + std::ostringstream cmd; + cmd << '"' << script << '"'; + for (auto& a : config_.beam_args) cmd << ' ' << a; + for (auto& a : config_.forwarded_argv) cmd << ' ' << a; + std::string cmdline = cmd.str(); + + // Build environment block + std::map env; + LPWCH strings = GetEnvironmentStringsW(); + if (strings) { + for (LPWCH p = strings; *p; p += wcslen(p) + 1) { + std::string entry; + int n = WideCharToMultiByte(CP_UTF8, 0, p, -1, nullptr, 0, nullptr, nullptr); + entry.assign(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) WideCharToMultiByte(CP_UTF8, 0, p, -1, entry.data(), n, nullptr, nullptr); + auto eq = entry.find('='); + if (eq != std::string::npos) env[entry.substr(0, eq)] = entry.substr(eq + 1); + } + FreeEnvironmentStringsW(strings); + } + env["EDW_PORT"] = std::to_string(server_.port()); + env["EDW_HOST"] = config_.host; + for (auto& [k, v] : config_.extra_env) env[k] = v; + + std::wstring env_block; + for (auto& [k, v] : env) { + env_block += widen(k + "=" + v); + env_block.push_back(L'\0'); + } + env_block.push_back(L'\0'); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + std::wstring wcmd = widen(cmdline); + std::wstring wwd = widen(wd); + std::vector mutable_cmd(wcmd.begin(), wcmd.end()); + mutable_cmd.push_back(L'\0'); + + if (!CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT, + env_block.data(), wwd.c_str(), &si, &pi)) { + fprintf(stderr, "edw: failed to spawn beam\n"); + return; + } + CloseHandle(pi.hThread); + beam_process_ = pi.hProcess; +} + +void HostController::handle_request(jsonutil::Json id, const std::string& method, + jsonutil::Json params, RpcServer::ReplyFn reply) { + if (method.rfind("test.", 0) == 0) { + if (!config_.test_rpc) { + reply(jsonutil::rpc_error(id, -32003, "Test RPC disabled")); + return; + } + bool async = false; + auto result = handle_test(method, params, id, &async, reply); + if (!async) reply(result); + return; + } + + if (method != "initialize" && !initialized_) { + reply(jsonutil::rpc_error(id, -32001, "Not initialized")); + return; + } + + jsonutil::Json out; + int err_code = 0; + std::string err_msg; + if (!dispatch_ok(method, params, &out, &err_code, &err_msg)) { + reply(jsonutil::rpc_error(id, err_code, err_msg)); + } else { + reply(jsonutil::rpc_ok(id, std::move(out))); + } +} + +bool HostController::dispatch_ok(const std::string& method, const jsonutil::Json& params, + jsonutil::Json* out_result, int* err_code, std::string* err_msg) { + try { + *out_result = dispatch(method, params); + return true; + } catch (const HostError& e) { + *err_code = e.code; + *err_msg = e.message; + return false; + } catch (const std::exception& e) { + *err_code = -32603; + *err_msg = e.what(); + return false; + } +} + +WebWindow* HostController::require_window(const jsonutil::Json& params) { + auto id = jsonutil::get_string(params, "window_id"); + if (!id) throw HostError{-32002, "unknown window"}; + auto it = windows_.find(*id); + if (it == windows_.end()) throw HostError{-32002, "unknown window"}; + return it->second.get(); +} + +WebWindow* HostController::require_web(const jsonutil::Json& params) { + auto id = jsonutil::get_string(params, "webview_id"); + if (id) { + auto wit = webviews_.find(*id); + if (wit == webviews_.end()) throw HostError{-32002, "unknown webview"}; + auto it = windows_.find(wit->second); + if (it == windows_.end()) throw HostError{-32002, "unknown webview"}; + return it->second.get(); + } + if (jsonutil::get_string(params, "window_id")) return require_window(params); + throw HostError{-32002, "unknown webview"}; +} + +void HostController::wire_window(WebWindow* w) { + w->set_handlers( + [this](const std::string& window_id) { + server_.notify("event.window.close_requested", jsonutil::Json{{"window_id", window_id}}); + }, + [this](const std::string& window_id, bool focused) { + server_.notify(focused ? "event.window.focus" : "event.window.blur", + jsonutil::Json{{"window_id", window_id}}); + }, + [this](const std::string& webview_id, const std::string& url) { + server_.notify("event.webview.new_window", + jsonutil::Json{{"webview_id", webview_id}, {"url", url}}); + open_external(url); + }, + [this](const std::string& webview_id, const std::string& url, bool error, + const std::string& message) { + if (error) { + server_.notify("event.webview.error", + jsonutil::Json{{"webview_id", webview_id}, {"message", message}}); + } else { + server_.notify("event.webview.finished", + jsonutil::Json{{"webview_id", webview_id}, {"url", url}}); + } + }, + [this](const std::string& origin, const std::string& type, const std::string& webview_id, + ICoreWebView2PermissionRequestedEventArgs* args) { + handle_permission(origin, type, webview_id, args); + }); +} + +void HostController::open_external(const std::string& url) { + ShellExecuteW(nullptr, L"open", widen(url).c_str(), nullptr, nullptr, SW_SHOWNORMAL); +} + +void HostController::handle_permission(const std::string& origin, const std::string& type, + const std::string& webview_id, + ICoreWebView2PermissionRequestedEventArgs* args) { + auto policy = permission_policy_[origin][type]; + if (policy.empty()) policy = "ask"; + + auto finish = [args](COREWEBVIEW2_PERMISSION_STATE state) { + args->put_State(state); + args->Release(); + }; + + if (policy == "allow") { + finish(COREWEBVIEW2_PERMISSION_STATE_ALLOW); + return; + } + if (policy == "deny") { + finish(COREWEBVIEW2_PERMISSION_STATE_DENY); + return; + } + + jsonutil::Json params{ + {"origin", origin}, {"type", type}, {"webview_id", webview_id}}; + server_.request("permission.request", std::move(params), [finish](jsonutil::Json result) { + std::string decision = "ask"; + if (result.is_object() && result.contains("decision") && result["decision"].is_string()) + decision = result["decision"].get(); + if (decision == "allow") + finish(COREWEBVIEW2_PERMISSION_STATE_ALLOW); + else if (decision == "deny") + finish(COREWEBVIEW2_PERMISSION_STATE_DENY); + else + finish(COREWEBVIEW2_PERMISSION_STATE_ALLOW); + }); +} + +jsonutil::Json HostController::window_open(const jsonutil::Json& params) { + auto title = jsonutil::get_string(params, "title").value_or("Desktop"); + int width = static_cast(jsonutil::get_int(params, "width").value_or(800)); + int height = static_cast(jsonutil::get_int(params, "height").value_or(600)); + auto window_id = next_id("w"); + auto webview_id = next_id("v"); + auto win = std::make_unique(window_id, webview_id, title, width, height); + wire_window(win.get()); + if (auto mw = jsonutil::get_int(params, "min_width")) { + auto mh = jsonutil::get_int(params, "min_height").value_or(0); + win->set_min_size(static_cast(*mw), static_cast(mh)); + } + win->show(); + webviews_[webview_id] = window_id; + windows_[window_id] = std::move(win); + return jsonutil::Json{{"window_id", window_id}, {"webview_id", webview_id}}; +} + +jsonutil::Json HostController::window_close(const jsonutil::Json& params) { + auto* w = require_window(params); + auto wid = w->window_id(); + for (auto it = webviews_.begin(); it != webviews_.end();) { + if (it->second == wid) + it = webviews_.erase(it); + else + ++it; + } + windows_.erase(wid); + return true; +} + +void HostController::destroy_menu_entry(MenuEntry& entry) { + if (entry.menu) { + DestroyMenu(entry.menu); + entry.menu = nullptr; + } + entry.onclicks.clear(); +} + +void HostController::append_menu_node(HMENU menu, const jsonutil::Json& node, MenuEntry& entry) { + if (!node.is_object()) return; + auto tag = jsonutil::get_string(node, "tag").value_or(""); + if (tag == "hr") { + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + return; + } + + const jsonutil::Json* attrs = jsonutil::get(node, "attrs"); + auto label_from_children = [&]() -> std::string { + if (auto* kids = jsonutil::get(node, "children"); kids && kids->is_array() && !kids->empty()) { + if ((*kids)[0].is_string()) return (*kids)[0].get(); + } + if (attrs) return jsonutil::get_string(*attrs, "label").value_or(""); + return ""; + }; + + if (tag == "menu") { + std::string label = + attrs ? jsonutil::get_string(*attrs, "label").value_or(label_from_children()) + : label_from_children(); + HMENU sub = CreatePopupMenu(); + if (auto* kids = jsonutil::get(node, "children"); kids && kids->is_array()) { + for (auto& child : *kids) append_menu_node(sub, child, entry); + } + AppendMenuW(menu, MF_POPUP, reinterpret_cast(sub), widen(label).c_str()); + return; + } + + if (tag == "item") { + std::string label = label_from_children(); + std::string onclick = + attrs ? jsonutil::get_string(*attrs, "onclick").value_or("") : ""; + UINT cmd = next_menu_cmd_++; + entry.onclicks[cmd] = onclick; + AppendMenuW(menu, MF_STRING, cmd, widen(label).c_str()); + } +} + +void HostController::build_menu_into(HMENU menu, const jsonutil::Json& dom, MenuEntry& entry) { + const jsonutil::Json* children = nullptr; + if (dom.is_object()) { + auto tag = jsonutil::get_string(dom, "tag").value_or(""); + if (tag == "menubar" || tag == "menu") { + children = jsonutil::get(dom, "children"); + } + } + if (!children && dom.is_array()) children = &dom; + if (!children || !children->is_array()) return; + for (auto& child : *children) append_menu_node(menu, child, entry); +} + +jsonutil::Json HostController::menu_create(const jsonutil::Json& params) { + auto id = next_id("m"); + MenuEntry entry; + entry.menu_id = id; + // Menubar uses CreateMenu; popup uses CreatePopupMenu. Prefer CreateMenu for menubar tag. + bool as_menubar = false; + if (auto* dom = jsonutil::get(params, "dom"); dom && dom->is_object()) { + as_menubar = jsonutil::get_string(*dom, "tag").value_or("") == "menubar"; + } + entry.menu = as_menubar ? CreateMenu() : CreatePopupMenu(); + if (auto* dom = jsonutil::get(params, "dom")) build_menu_into(entry.menu, *dom, entry); + menus_[id] = std::move(entry); + return jsonutil::Json{{"menu_id", id}}; +} + +jsonutil::Json HostController::menu_update(const jsonutil::Json& params) { + auto id = jsonutil::get_string(params, "menu_id"); + if (!id) throw HostError{-32602, "menu_id"}; + auto it = menus_.find(*id); + if (it == menus_.end()) throw HostError{-32002, "unknown menu"}; + destroy_menu_entry(it->second); + bool as_menubar = false; + if (auto* dom = jsonutil::get(params, "dom"); dom && dom->is_object()) { + as_menubar = jsonutil::get_string(*dom, "tag").value_or("") == "menubar"; + } + it->second.menu = as_menubar ? CreateMenu() : CreatePopupMenu(); + it->second.menu_id = *id; + if (auto* dom = jsonutil::get(params, "dom")) build_menu_into(it->second.menu, *dom, it->second); + return true; +} + +void HostController::update_tray_icon(TrayEntry& tray) { + HICON icon = LoadIconW(nullptr, IDI_APPLICATION); + if (!tray.icon_id.empty()) { + auto it = icons_.find(tray.icon_id); + if (it != icons_.end() && it->second.icon) icon = it->second.icon; + } + tray.nid.hIcon = icon; + if (tray.registered) { + Shell_NotifyIconW(NIM_MODIFY, &tray.nid); + } +} + +jsonutil::Json HostController::tray_create(const jsonutil::Json& params) { + auto id = next_id("tray"); + TrayEntry t; + t.icon_id = jsonutil::get_string(params, "icon_id").value_or(""); + t.menu_id = jsonutil::get_string(params, "menu_id").value_or(""); + memset(&t.nid, 0, sizeof(t.nid)); + t.nid.cbSize = sizeof(NOTIFYICONDATAW); + t.nid.hWnd = hwnd_; + t.nid.uID = static_cast(id_counter_); + t.nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + t.nid.uCallbackMessage = WM_EDW_TRAY; + wcsncpy_s(t.nid.szTip, L"DesktopWebView", _TRUNCATE); + update_tray_icon(t); + if (Shell_NotifyIconW(NIM_ADD, &t.nid)) t.registered = true; + trays_[id] = std::move(t); + return jsonutil::Json{{"tray_id", id}}; +} + +jsonutil::Json HostController::icon_create(const jsonutil::Json& params) { + auto id = next_id("icon"); + IconEntry icon; + if (auto path = jsonutil::get_string(params, "path")) { + icon.path = *path; + icon.icon = static_cast( + LoadImageW(nullptr, widen(*path).c_str(), IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE)); + } else if (auto b64 = jsonutil::get_string(params, "png_base64")) { + icon.png = base64_decode(*b64); + icon.icon = icon_from_png(icon.png); + } + icons_[id] = std::move(icon); + return jsonutil::Json{{"icon_id", id}}; +} + +jsonutil::Json HostController::notification_show(const jsonutil::Json& params) { + auto id = jsonutil::get_string(params, "id").value_or(next_id("n")); + auto title = jsonutil::get_string(params, "title").value_or(""); + auto message = jsonutil::get_string(params, "message").value_or(""); + // Best-effort: use a tray balloon if any tray exists; otherwise stderr. + if (!trays_.empty()) { + auto& tray = trays_.begin()->second; + tray.nid.uFlags = NIF_INFO | NIF_ICON | NIF_MESSAGE | NIF_TIP; + wcsncpy_s(tray.nid.szInfoTitle, widen(title).c_str(), _TRUNCATE); + wcsncpy_s(tray.nid.szInfo, widen(message).c_str(), _TRUNCATE); + tray.nid.dwInfoFlags = NIIF_INFO; + if (tray.registered) Shell_NotifyIconW(NIM_MODIFY, &tray.nid); + } else { + fprintf(stderr, "notification: %s: %s\n", title.c_str(), message.c_str()); + } + return jsonutil::Json{{"notification_id", id}}; +} + +jsonutil::Json HostController::dispatch(const std::string& method, const jsonutil::Json& params) { + const jsonutil::Json& p = params.is_object() ? params : jsonutil::Json::object(); + + if (method == "initialize") { + initialized_ = true; + return jsonutil::Json{ + {"protocol_version", 1}, + {"platform", "windows"}, + {"capabilities", + {{"window", true}, + {"webview", true}, + {"menu", true}, + {"tray", true}, + {"notification", true}, + {"permission", true}, + {"media", true}, + {"test_rpc", config_.test_rpc}}}}; + } + + if (method == "window.open") return window_open(p); + if (method == "window.close" || method == "window.destroy") return window_close(p); + if (method == "window.show") { + auto* w = require_window(p); + bool show = jsonutil::get_bool(p, "show").value_or(true); + if (show) + w->show(); + else + w->hide(); + return true; + } + if (method == "window.hide") { + require_window(p)->hide(); + return true; + } + if (method == "window.set_title") { + require_window(p)->set_title(jsonutil::get_string(p, "title").value_or("")); + return true; + } + if (method == "window.set_min_size") { + require_window(p)->set_min_size(static_cast(jsonutil::get_int(p, "width").value_or(0)), + static_cast(jsonutil::get_int(p, "height").value_or(0))); + return true; + } + if (method == "window.set_icon") { + auto* w = require_window(p); + if (auto icon_id = jsonutil::get_string(p, "icon_id")) { + auto it = icons_.find(*icon_id); + if (it != icons_.end()) w->set_icon(it->second.icon); + } + return true; + } + if (method == "window.set_menubar") { + auto* w = require_window(p); + if (auto menu_id = jsonutil::get_string(p, "menu_id")) { + auto it = menus_.find(*menu_id); + if (it != menus_.end()) { + w->set_menubar(it->second.menu); + w->set_menu_command_handler([this](UINT cmd) { return on_menu_command(cmd); }); + } + } + return true; + } + if (method == "window.iconize") { + require_window(p)->iconize(jsonutil::get_bool(p, "iconize").value_or(false)); + return true; + } + if (method == "window.shown") return require_window(p)->shown(); + if (method == "window.active") return require_window(p)->active(); + if (method == "window.raise") { + require_window(p)->raise(); + return true; + } + if (method == "window.close_veto") { + require_window(p); + return true; + } + + if (method == "webview.load_url") { + auto* w = require_web(p); + auto url = jsonutil::get_string(p, "url"); + if (!url) throw HostError{-32602, "url required"}; + w->load_url(*url); + return true; + } + if (method == "webview.reload") { + require_web(p)->reload(); + return true; + } + if (method == "webview.current_url") return require_web(p)->current_url(); + if (method == "webview.rebuild") { + auto* w = require_window(p); + auto new_id = next_id("v"); + for (auto it = webviews_.begin(); it != webviews_.end();) { + if (it->second == w->window_id()) + it = webviews_.erase(it); + else + ++it; + } + auto vid = w->rebuild(new_id); + webviews_[vid] = w->window_id(); + return jsonutil::Json{{"webview_id", vid}}; + } + if (method == "webview.set_context_menu") { + require_web(p)->set_context_menu_enabled(jsonutil::get_bool(p, "enabled").value_or(false)); + return true; + } + + if (method == "menu.create") return menu_create(p); + if (method == "menu.update") return menu_update(p); + if (method == "menu.destroy") { + if (auto id = jsonutil::get_string(p, "menu_id")) { + auto it = menus_.find(*id); + if (it != menus_.end()) { + destroy_menu_entry(it->second); + menus_.erase(it); + } + } + return true; + } + if (method == "menu.set_apple") return true; + + if (method == "tray.create") return tray_create(p); + if (method == "tray.set_icon") { + auto id = jsonutil::get_string(p, "tray_id"); + if (!id || trays_.find(*id) == trays_.end()) return false; + trays_[*id].icon_id = jsonutil::get_string(p, "icon_id").value_or(""); + update_tray_icon(trays_[*id]); + return true; + } + if (method == "tray.set_menu") { + auto id = jsonutil::get_string(p, "tray_id"); + if (!id || trays_.find(*id) == trays_.end()) return false; + trays_[*id].menu_id = jsonutil::get_string(p, "menu_id").value_or(""); + return true; + } + if (method == "tray.destroy") { + if (auto id = jsonutil::get_string(p, "tray_id")) { + auto it = trays_.find(*id); + if (it != trays_.end()) { + if (it->second.registered) Shell_NotifyIconW(NIM_DELETE, &it->second.nid); + trays_.erase(it); + } + } + return true; + } + + if (method == "notification.show") return notification_show(p); + if (method == "notification.close") return true; + + if (method == "icon.create") return icon_create(p); + if (method == "icon.destroy") { + if (auto id = jsonutil::get_string(p, "icon_id")) { + auto it = icons_.find(*id); + if (it != icons_.end()) { + if (it->second.icon) DestroyIcon(it->second.icon); + icons_.erase(it); + } + } + return true; + } + + if (method == "system.open_url") { + if (auto url = jsonutil::get_string(p, "url")) open_external(*url); + return true; + } + if (method == "system.locale") { + wchar_t buf[128]; + if (GetUserDefaultLocaleName(buf, 128) > 0) { + std::string loc; + int n = WideCharToMultiByte(CP_UTF8, 0, buf, -1, nullptr, 0, nullptr, nullptr); + loc.assign(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) WideCharToMultiByte(CP_UTF8, 0, buf, -1, loc.data(), n, nullptr, nullptr); + for (auto& c : loc) c = static_cast(tolower(static_cast(c))); + // Windows uses en-US; normalize hyphen to underscore-ish for protocol softness + for (auto& c : loc) + if (c == '-') c = '_'; + return loc; + } + return "en_us"; + } + if (method == "system.os_description") { + OSVERSIONINFOEXW vi{}; + vi.dwOSVersionInfoSize = sizeof(vi); +#pragma warning(push) +#pragma warning(disable : 4996) + GetVersionExW(reinterpret_cast(&vi)); +#pragma warning(pop) + std::ostringstream ss; + ss << "Windows " << vi.dwMajorVersion << "." << vi.dwMinorVersion << " (build " + << vi.dwBuildNumber << ")"; + return ss.str(); + } + if (method == "system.set_permission_policy") { + auto origin = jsonutil::get_string(p, "origin"); + if (!origin) throw HostError{-32602, "origin"}; + auto& map = permission_policy_[*origin]; + if (auto c = jsonutil::get_string(p, "camera")) map["camera"] = *c; + if (auto m = jsonutil::get_string(p, "microphone")) map["microphone"] = *m; + return true; + } + + if (method == "dialog.choose_file" || method == "dialog.choose_directory" || + method == "dialog.prompt") { + throw HostError{-32004, "dialog RPCs not implemented on Windows yet"}; + } + + throw HostError{-32601, "Method not found: " + method}; +} + +jsonutil::Json HostController::handle_test(const std::string& method, const jsonutil::Json& params, + const jsonutil::Json& id, bool* async_reply, + RpcServer::ReplyFn reply) { + *async_reply = false; + if (method == "test.ping") return jsonutil::rpc_ok(id, "pong"); + if (method == "test.echo") + return jsonutil::rpc_ok(id, params.is_null() ? jsonutil::Json(nullptr) : params); + if (method == "test.capabilities") { + return jsonutil::rpc_ok( + id, jsonutil::Json{{"window", true}, + {"webview", true}, + {"menu", true}, + {"tray", true}, + {"notification", true}, + {"permission", true}, + {"media", true}, + {"test_rpc", true}}); + } + if (method == "test.window.list") { + jsonutil::Json items = jsonutil::Json::array(); + for (auto& [_, w] : windows_) { + items.push_back({{"window_id", w->window_id()}, + {"webview_id", w->webview_id()}, + {"title", w->title()}, + {"url", w->current_url()}}); + } + return jsonutil::rpc_ok(id, items); + } + if (method == "test.webview.eval") { + auto wv = jsonutil::get_string(params, "webview_id"); + auto script = jsonutil::get_string(params, "script"); + if (!wv || !script) return jsonutil::rpc_error(id, -32002, "unknown webview"); + auto wit = webviews_.find(*wv); + if (wit == webviews_.end()) return jsonutil::rpc_error(id, -32002, "unknown webview"); + auto it = windows_.find(wit->second); + if (it == windows_.end()) return jsonutil::rpc_error(id, -32002, "unknown webview"); + *async_reply = true; + jsonutil::Json id_keep = id; + it->second->eval_js(*script, [this, id_keep, reply](jsonutil::Json result) { + reply(jsonutil::rpc_ok(id_keep, std::move(result))); + }); + return nullptr; + } + if (method == "test.permission.simulate") { + auto origin = jsonutil::get_string(params, "origin").value_or("http://127.0.0.1"); + auto type = jsonutil::get_string(params, "type").value_or("microphone"); + std::string wv; + if (auto v = jsonutil::get_string(params, "webview_id")) + wv = *v; + else if (!windows_.empty()) + wv = windows_.begin()->second->webview_id(); + server_.request("permission.request", + jsonutil::Json{{"origin", origin}, {"type", type}, {"webview_id", wv}}, + [](jsonutil::Json) {}); + return jsonutil::rpc_ok(id, true); + } + if (method == "test.disconnect") { + server_.close_connection(); + return jsonutil::rpc_ok(id, true); + } + if (method == "test.crash") { + ExitProcess(2); + } + return jsonutil::rpc_error(id, -32601, "Unknown test method"); +} diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp new file mode 100644 index 0000000..a748a2c --- /dev/null +++ b/native/windows/src/host_controller.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include "win_prefix.hpp" +#include "config.hpp" +#include "rpc_server.hpp" +#include "web_window.hpp" + +#include +#include +#include +#include + +struct HostError { + int code; + std::string message; +}; + +struct MenuEntry { + HMENU menu = nullptr; + std::map onclicks; // command id -> onclick + std::string menu_id; +}; + +struct TrayEntry { + std::string icon_id; + std::string menu_id; + NOTIFYICONDATAW nid{}; + bool registered = false; +}; + +struct IconEntry { + std::string path; + std::vector png; + HICON icon = nullptr; +}; + +class HostController { + public: + explicit HostController(HostConfig config); + ~HostController(); + + bool start(); + RpcServer& server() { return server_; } + HWND hwnd() const { return hwnd_; } + + static LRESULT CALLBACK HostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + static constexpr UINT WM_EDW_REQUEST = WM_APP + 10; + static constexpr UINT WM_EDW_DISCONNECT = WM_APP + 11; + static constexpr UINT WM_EDW_TRAY = WM_APP + 12; + + private: + void client_disconnected(); + void spawn_beam(); + std::string next_id(const std::string& prefix); + + void handle_request(jsonutil::Json id, const std::string& method, jsonutil::Json params, + RpcServer::ReplyFn reply); + jsonutil::Json dispatch(const std::string& method, const jsonutil::Json& params); + bool dispatch_ok(const std::string& method, const jsonutil::Json& params, jsonutil::Json* out_result, + int* err_code, std::string* err_msg); + + jsonutil::Json handle_test(const std::string& method, const jsonutil::Json& params, + const jsonutil::Json& id, bool* async_reply, RpcServer::ReplyFn reply); + + WebWindow* require_window(const jsonutil::Json& params); + WebWindow* require_web(const jsonutil::Json& params); + + jsonutil::Json window_open(const jsonutil::Json& params); + jsonutil::Json window_close(const jsonutil::Json& params); + jsonutil::Json menu_create(const jsonutil::Json& params); + jsonutil::Json menu_update(const jsonutil::Json& params); + void build_menu_into(HMENU menu, const jsonutil::Json& dom, MenuEntry& entry); + void append_menu_node(HMENU menu, const jsonutil::Json& node, MenuEntry& entry); + jsonutil::Json tray_create(const jsonutil::Json& params); + jsonutil::Json icon_create(const jsonutil::Json& params); + jsonutil::Json notification_show(const jsonutil::Json& params); + void update_tray_icon(TrayEntry& tray); + void destroy_menu_entry(MenuEntry& entry); + + void open_external(const std::string& url); + void handle_permission(const std::string& origin, const std::string& type, + const std::string& webview_id, + ICoreWebView2PermissionRequestedEventArgs* args); + void wire_window(WebWindow* w); + LRESULT on_host_message(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + bool on_menu_command(UINT cmd); + + HostConfig config_; + RpcServer server_; + HWND hwnd_ = nullptr; + bool initialized_ = false; + int id_counter_ = 0; + UINT next_menu_cmd_ = 1000; + std::map> windows_; + std::map webviews_; + std::map menus_; + std::map trays_; + std::map icons_; + std::map> permission_policy_; + HANDLE beam_process_ = nullptr; +}; diff --git a/native/windows/src/json_util.cpp b/native/windows/src/json_util.cpp new file mode 100644 index 0000000..70eaf8a --- /dev/null +++ b/native/windows/src/json_util.cpp @@ -0,0 +1,67 @@ +#include "json_util.hpp" + +namespace jsonutil { + +Json parse(const std::string& text) { + try { + return Json::parse(text); + } catch (...) { + return nullptr; + } +} + +std::string stringify(const Json& node) { return node.dump(); } + +Json rpc_ok(const Json& id, Json result) { + return Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(result)}}; +} + +Json rpc_error(const Json& id, int code, const std::string& message) { + return Json{{"jsonrpc", "2.0"}, + {"id", id}, + {"error", {{"code", code}, {"message", message}}}}; +} + +Json rpc_notification(const std::string& method, Json params) { + if (params.is_null()) params = Json::object(); + return Json{{"jsonrpc", "2.0"}, {"method", method}, {"params", std::move(params)}}; +} + +Json rpc_request(const Json& id, const std::string& method, Json params) { + if (params.is_null()) params = Json::object(); + return Json{ + {"jsonrpc", "2.0"}, {"id", id}, {"method", method}, {"params", std::move(params)}}; +} + +std::string id_key(const Json& id) { + if (id.is_null()) return "null"; + if (id.is_string()) return std::string("s:") + id.get(); + if (id.is_number_integer()) return "n:" + std::to_string(id.get()); + if (id.is_number_float()) return "n:" + std::to_string(static_cast(id.get())); + return "x"; +} + +std::optional get_string(const Json& obj, const char* key) { + if (!obj.is_object() || !obj.contains(key) || !obj[key].is_string()) return std::nullopt; + return obj[key].get(); +} + +std::optional get_bool(const Json& obj, const char* key) { + if (!obj.is_object() || !obj.contains(key) || !obj[key].is_boolean()) return std::nullopt; + return obj[key].get(); +} + +std::optional get_int(const Json& obj, const char* key) { + if (!obj.is_object() || !obj.contains(key)) return std::nullopt; + const auto& v = obj[key]; + if (v.is_number_integer()) return v.get(); + if (v.is_number_float()) return static_cast(v.get()); + return std::nullopt; +} + +const Json* get(const Json& obj, const char* key) { + if (!obj.is_object() || !obj.contains(key)) return nullptr; + return &obj[key]; +} + +} // namespace jsonutil diff --git a/native/windows/src/json_util.hpp b/native/windows/src/json_util.hpp new file mode 100644 index 0000000..987c2af --- /dev/null +++ b/native/windows/src/json_util.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include +#include + +namespace jsonutil { + +using Json = nlohmann::json; + +Json parse(const std::string& text); +std::string stringify(const Json& node); + +Json rpc_ok(const Json& id, Json result); +Json rpc_error(const Json& id, int code, const std::string& message); +Json rpc_notification(const std::string& method, Json params); +Json rpc_request(const Json& id, const std::string& method, Json params); + +std::string id_key(const Json& id); + +std::optional get_string(const Json& obj, const char* key); +std::optional get_bool(const Json& obj, const char* key); +std::optional get_int(const Json& obj, const char* key); +const Json* get(const Json& obj, const char* key); + +} // namespace jsonutil diff --git a/native/windows/src/main.cpp b/native/windows/src/main.cpp new file mode 100644 index 0000000..e12c849 --- /dev/null +++ b/native/windows/src/main.cpp @@ -0,0 +1,60 @@ +#include "config.hpp" +#include "host_controller.hpp" +#include "web_window.hpp" +#include "win_prefix.hpp" + +#include +#include +#include +#include + +namespace { + +std::vector argv_utf8() { + int argc = 0; + LPWSTR* wargv = CommandLineToArgvW(GetCommandLineW(), &argc); + std::vector out; + if (!wargv) return out; + for (int i = 0; i < argc; i++) { + int n = WideCharToMultiByte(CP_UTF8, 0, wargv[i], -1, nullptr, 0, nullptr, nullptr); + std::string s(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) WideCharToMultiByte(CP_UTF8, 0, wargv[i], -1, s.data(), n, nullptr, nullptr); + out.push_back(std::move(s)); + } + LocalFree(wargv); + return out; +} + +} // namespace + +int main(int, char**) { + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) { + fprintf(stderr, "edw: CoInitializeEx failed\n"); + return 1; + } + + auto args = argv_utf8(); + std::vector argv_ptrs; + argv_ptrs.reserve(args.size()); + for (auto& s : args) argv_ptrs.push_back(s.data()); + + auto config = HostConfig::parse(static_cast(argv_ptrs.size()), argv_ptrs.data()); + WebWindow::register_class(); + + auto host = std::make_unique(std::move(config)); + if (!host->start()) { + fprintf(stderr, "failed to start host\n"); + CoUninitialize(); + return 1; + } + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + CoUninitialize(); + return 0; +} diff --git a/native/windows/src/rpc_server.cpp b/native/windows/src/rpc_server.cpp new file mode 100644 index 0000000..9fbe46c --- /dev/null +++ b/native/windows/src/rpc_server.cpp @@ -0,0 +1,232 @@ +#include "rpc_server.hpp" + +#include +#include + +RpcServer::RpcServer() = default; + +RpcServer::~RpcServer() { + close_connection(); + if (listen_sock_ != INVALID_SOCKET) { + closesocket(listen_sock_); + listen_sock_ = INVALID_SOCKET; + } + if (wsa_started_) { + WSACleanup(); + wsa_started_ = false; + } +} + +bool RpcServer::start(const std::string& host, uint16_t port, HWND notify_hwnd) { + notify_hwnd_ = notify_hwnd; + WSADATA wsa{}; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + fprintf(stderr, "edw: WSAStartup failed\n"); + return false; + } + wsa_started_ = true; + + int family = AF_INET; + if (host.find(':') != std::string::npos) family = AF_INET6; + + listen_sock_ = socket(family, SOCK_STREAM, IPPROTO_TCP); + if (listen_sock_ == INVALID_SOCKET) { + fprintf(stderr, "edw: socket create failed\n"); + return false; + } + + BOOL reuse = TRUE; + setsockopt(listen_sock_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), + sizeof(reuse)); + + if (family == AF_INET) { + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { + fprintf(stderr, "edw: invalid host %s\n", host.c_str()); + return false; + } + if (bind(listen_sock_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + fprintf(stderr, "edw: failed to bind %s:%u\n", host.c_str(), port); + return false; + } + } else { + sockaddr_in6 addr{}; + addr.sin6_family = AF_INET6; + addr.sin6_port = htons(port); + if (inet_pton(AF_INET6, host.c_str(), &addr.sin6_addr) != 1) { + fprintf(stderr, "edw: invalid host %s\n", host.c_str()); + return false; + } + if (bind(listen_sock_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + fprintf(stderr, "edw: failed to bind %s:%u\n", host.c_str(), port); + return false; + } + } + + if (listen(listen_sock_, 4) != 0) { + fprintf(stderr, "edw: listen failed\n"); + return false; + } + + sockaddr_storage local{}; + int local_len = sizeof(local); + if (getsockname(listen_sock_, reinterpret_cast(&local), &local_len) == 0) { + if (local.ss_family == AF_INET) { + port_ = ntohs(reinterpret_cast(&local)->sin_port); + } else if (local.ss_family == AF_INET6) { + port_ = ntohs(reinterpret_cast(&local)->sin6_port); + } + } + + if (WSAAsyncSelect(listen_sock_, notify_hwnd_, WM_EDW_SOCKET, FD_ACCEPT) != 0) { + fprintf(stderr, "edw: WSAAsyncSelect listen failed\n"); + return false; + } + + fprintf(stdout, "listening %u\n", port_); + fflush(stdout); + return true; +} + +void RpcServer::on_socket_event(SOCKET s, int event, int error) { + if (error != 0) { + if (s == client_sock_) { + close_connection(); + if (on_disconnect_) on_disconnect_(); + } + return; + } + if (s == listen_sock_ && (event & FD_ACCEPT)) { + accept_client(); + return; + } + if (s == client_sock_) { + if (event & FD_READ) on_readable(); + if (event & FD_CLOSE) { + close_connection(); + if (on_disconnect_) on_disconnect_(); + } + } +} + +void RpcServer::accept_client() { + SOCKET s = accept(listen_sock_, nullptr, nullptr); + if (s == INVALID_SOCKET) return; + if (client_sock_ != INVALID_SOCKET) { + close_connection(); + } + client_sock_ = s; + buffer_.clear(); + WSAAsyncSelect(client_sock_, notify_hwnd_, WM_EDW_SOCKET, FD_READ | FD_CLOSE); +} + +void RpcServer::on_readable() { + if (client_sock_ == INVALID_SOCKET) return; + uint8_t buf[64 * 1024]; + for (;;) { + int n = recv(client_sock_, reinterpret_cast(buf), sizeof(buf), 0); + if (n > 0) { + buffer_.insert(buffer_.end(), buf, buf + n); + continue; + } + if (n == 0) { + close_connection(); + if (on_disconnect_) on_disconnect_(); + return; + } + int err = WSAGetLastError(); + if (err == WSAEWOULDBLOCK) break; + close_connection(); + if (on_disconnect_) on_disconnect_(); + return; + } + drain(); +} + +void RpcServer::drain() { + while (buffer_.size() >= 4) { + uint32_t len = 0; + memcpy(&len, buffer_.data(), 4); + len = ntohl(len); + if (buffer_.size() < 4u + len) return; + std::string payload(reinterpret_cast(buffer_.data() + 4), len); + buffer_.erase(buffer_.begin(), buffer_.begin() + 4 + static_cast(len)); + handle_payload(payload); + } +} + +void RpcServer::handle_payload(const std::string& payload) { + auto root = jsonutil::parse(payload); + if (root.is_null() || !root.is_object()) return; + + bool has_method = root.contains("method"); + bool has_id = root.contains("id"); + bool has_result_or_error = root.contains("result") || root.contains("error"); + + if (has_method && has_id) { + auto method = root["method"].get(); + auto id = root["id"]; + jsonutil::Json params = root.contains("params") ? root["params"] : jsonutil::Json(); + if (on_request_) { + on_request_(id, method, params, [this](jsonutil::Json response) { send_json(std::move(response)); }); + } + return; + } + + if (has_method && !has_id) return; + + if (has_result_or_error && has_id) { + auto key = jsonutil::id_key(root["id"]); + auto it = pending_.find(key); + if (it != pending_.end()) { + auto cb = std::move(it->second); + pending_.erase(it); + jsonutil::Json result = + root.contains("result") ? root["result"] : jsonutil::Json(nullptr); + cb(result); + } + } +} + +void RpcServer::send_json(jsonutil::Json node) { + send_raw(jsonutil::stringify(node)); +} + +void RpcServer::notify(const std::string& method, jsonutil::Json params) { + send_json(jsonutil::rpc_notification(method, std::move(params))); +} + +void RpcServer::request(const std::string& method, jsonutil::Json params, PendingCallback cb) { + int id_num = next_outbound_id_++; + jsonutil::Json id = id_num; + pending_[jsonutil::id_key(id)] = std::move(cb); + send_json(jsonutil::rpc_request(id, method, std::move(params))); +} + +void RpcServer::send_raw(const std::string& json) { + if (client_sock_ == INVALID_SOCKET) return; + uint32_t len = htonl(static_cast(json.size())); + auto send_all = [this](const char* data, int size) { + int sent = 0; + while (sent < size) { + int n = send(client_sock_, data + sent, size - sent, 0); + if (n <= 0) return false; + sent += n; + } + return true; + }; + if (!send_all(reinterpret_cast(&len), 4) || + !send_all(json.data(), static_cast(json.size()))) { + fprintf(stderr, "edw: write failed\n"); + } +} + +void RpcServer::close_connection() { + if (client_sock_ != INVALID_SOCKET) { + closesocket(client_sock_); + client_sock_ = INVALID_SOCKET; + } + buffer_.clear(); +} diff --git a/native/windows/src/rpc_server.hpp b/native/windows/src/rpc_server.hpp new file mode 100644 index 0000000..b0e6f15 --- /dev/null +++ b/native/windows/src/rpc_server.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "win_prefix.hpp" + +#include "json_util.hpp" + +#include +#include +#include +#include +#include + +class RpcServer { + public: + using ReplyFn = std::function; + using RequestHandler = + std::function; + using DisconnectHandler = std::function; + using PendingCallback = std::function; + + RpcServer(); + ~RpcServer(); + + bool start(const std::string& host, uint16_t port, HWND notify_hwnd); + uint16_t port() const { return port_; } + + void set_request_handler(RequestHandler h) { on_request_ = std::move(h); } + void set_disconnect_handler(DisconnectHandler h) { on_disconnect_ = std::move(h); } + + void on_socket_event(SOCKET s, int event, int error); + void send_json(jsonutil::Json node); + void notify(const std::string& method, jsonutil::Json params); + void request(const std::string& method, jsonutil::Json params, PendingCallback cb); + void close_connection(); + + static constexpr UINT WM_EDW_SOCKET = WM_APP + 1; + + private: + void accept_client(); + void on_readable(); + void drain(); + void handle_payload(const std::string& payload); + void send_raw(const std::string& json); + + HWND notify_hwnd_ = nullptr; + SOCKET listen_sock_ = INVALID_SOCKET; + SOCKET client_sock_ = INVALID_SOCKET; + std::vector buffer_; + uint16_t port_ = 0; + int next_outbound_id_ = 1; + std::map pending_; + RequestHandler on_request_; + DisconnectHandler on_disconnect_; + bool wsa_started_ = false; +}; diff --git a/native/windows/src/web_window.cpp b/native/windows/src/web_window.cpp new file mode 100644 index 0000000..e06aeaf --- /dev/null +++ b/native/windows/src/web_window.cpp @@ -0,0 +1,407 @@ +#include "web_window.hpp" + +#include + +using Microsoft::WRL::Callback; +using Microsoft::WRL::ComPtr; + +namespace { +constexpr const wchar_t* kClassName = L"DesktopWebViewWindow"; +bool g_class_registered = false; +} // namespace + +void WebWindow::register_class() { + if (g_class_registered) return; + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WebWindow::WndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); + wc.lpszClassName = kClassName; + RegisterClassExW(&wc); + g_class_registered = true; +} + +std::wstring WebWindow::widen(const std::string& s) const { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); + std::wstring out(n > 0 ? n - 1 : 0, L'\0'); + if (n > 1) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, out.data(), n); + return out; +} + +std::string WebWindow::narrow(const wchar_t* s) const { + if (!s || !*s) return {}; + int n = WideCharToMultiByte(CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); + std::string out(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) WideCharToMultiByte(CP_UTF8, 0, s, -1, out.data(), n, nullptr, nullptr); + return out; +} + +std::string WebWindow::origin_from_url(const std::string& url) const { + if (url.empty()) return "http://127.0.0.1"; + auto scheme_end = url.find("://"); + if (scheme_end == std::string::npos) return url; + auto host_start = scheme_end + 3; + auto path = url.find_first_of("/?#", host_start); + return path == std::string::npos ? url : url.substr(0, path); +} + +WebWindow::WebWindow(const std::string& window_id, const std::string& webview_id, + const std::string& title, int width, int height) + : window_id_(window_id), webview_id_(webview_id), title_(title) { + register_class(); + hwnd_ = CreateWindowExW(0, kClassName, widen(title).c_str(), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, + CW_USEDEFAULT, width, height, nullptr, nullptr, GetModuleHandleW(nullptr), + this); + create_webview(); +} + +WebWindow::~WebWindow() { + if (webview_) { + if (nav_completed_token_.value) + webview_->remove_NavigationCompleted(nav_completed_token_); + if (new_window_token_.value) webview_->remove_NewWindowRequested(new_window_token_); + if (permission_token_.value) webview_->remove_PermissionRequested(permission_token_); + } + if (controller_) { + controller_->Close(); + controller_ = nullptr; + } + webview_ = nullptr; + env_ = nullptr; + if (hwnd_) { + SetWindowLongPtrW(hwnd_, GWLP_USERDATA, 0); + DestroyWindow(hwnd_); + hwnd_ = nullptr; + } +} + +LRESULT CALLBACK WebWindow::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + WebWindow* self = nullptr; + if (msg == WM_NCCREATE) { + auto* cs = reinterpret_cast(lParam); + self = static_cast(cs->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(self)); + self->hwnd_ = hwnd; + } else { + self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + } + + if (!self) return DefWindowProcW(hwnd, msg, wParam, lParam); + + switch (msg) { + case WM_CLOSE: + if (self->on_close_) self->on_close_(self->window_id_); + return 0; // veto destroy + case WM_SIZE: + self->resize_webview(); + return 0; + case WM_GETMINMAXINFO: { + auto* mmi = reinterpret_cast(lParam); + if (self->min_width_ > 0) mmi->ptMinTrackSize.x = self->min_width_; + if (self->min_height_ > 0) mmi->ptMinTrackSize.y = self->min_height_; + return 0; + } + case WM_ACTIVATE: + if (self->on_focus_) { + self->on_focus_(self->window_id_, LOWORD(wParam) != WA_INACTIVE); + } + return 0; + case WM_COMMAND: + if (self->on_menu_command_ && self->on_menu_command_(LOWORD(wParam))) return 0; + break; + case WM_DESTROY: + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +void WebWindow::create_webview() { + webview_ready_ = false; + controller_ = nullptr; + webview_ = nullptr; + + wchar_t temp[MAX_PATH]; + GetTempPathW(MAX_PATH, temp); + std::wstring user_data = std::wstring(temp) + L"DesktopWebView\\" + widen(window_id_); + + HRESULT hr = CreateCoreWebView2EnvironmentWithOptions( + nullptr, user_data.c_str(), nullptr, + Callback( + [this](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { + if (FAILED(result) || !env) { + fprintf(stderr, + "edw: WebView2 environment failed (0x%08lx). Is the Evergreen Runtime " + "installed?\n", + static_cast(result)); + return result; + } + env_ = env; + return env->CreateCoreWebView2Controller( + hwnd_, + Callback( + [this](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(result) || !controller) { + fprintf(stderr, "edw: WebView2 controller failed (0x%08lx)\n", + static_cast(result)); + return result; + } + controller_ = controller; + controller_->get_CoreWebView2(&webview_); + webview_ready_ = true; + resize_webview(); + attach_handlers(); + flush_pending_url(); + return S_OK; + }) + .Get()); + }) + .Get()); + + if (FAILED(hr)) { + fprintf(stderr, + "edw: CreateCoreWebView2EnvironmentWithOptions failed (0x%08lx). Is the Evergreen " + "Runtime installed?\n", + static_cast(hr)); + } +} + +void WebWindow::attach_handlers() { + if (!webview_) return; + + webview_->add_NavigationCompleted( + Callback( + [this](ICoreWebView2* sender, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT { + BOOL ok = FALSE; + args->get_IsSuccess(&ok); + if (ok) { + LPWSTR raw = nullptr; + sender->get_Source(&raw); + std::string url = narrow(raw); + if (raw) CoTaskMemFree(raw); + if (on_nav_) on_nav_(webview_id_, url, false, ""); + } else { + COREWEBVIEW2_WEB_ERROR_STATUS status{}; + args->get_WebErrorStatus(&status); + if (on_nav_) + on_nav_(webview_id_, "", true, "navigation failed: " + std::to_string(status)); + } + return S_OK; + }) + .Get(), + &nav_completed_token_); + + webview_->add_NewWindowRequested( + Callback( + [this](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { + LPWSTR raw = nullptr; + args->get_Uri(&raw); + std::string url = narrow(raw); + if (raw) CoTaskMemFree(raw); + args->put_Handled(TRUE); + if (on_new_window_) on_new_window_(webview_id_, url); + return S_OK; + }) + .Get(), + &new_window_token_); + + webview_->add_PermissionRequested( + Callback( + [this](ICoreWebView2*, ICoreWebView2PermissionRequestedEventArgs* args) -> HRESULT { + if (!on_permission_) return S_OK; + COREWEBVIEW2_PERMISSION_KIND kind{}; + args->get_PermissionKind(&kind); + std::string type = "microphone"; + if (kind == COREWEBVIEW2_PERMISSION_KIND_CAMERA) type = "camera"; + else if (kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE) type = "microphone"; + LPWSTR raw = nullptr; + args->get_Uri(&raw); + std::string origin = origin_from_url(narrow(raw)); + if (raw) CoTaskMemFree(raw); + args->AddRef(); + on_permission_(origin, type, webview_id_, args); + return S_OK; + }) + .Get(), + &permission_token_); + + // Context menu: use ICoreWebView2_11 if available; otherwise Settings AreDefaultContextMenusEnabled + ComPtr settings; + if (SUCCEEDED(webview_->get_Settings(&settings)) && settings) { + settings->put_AreDefaultContextMenusEnabled(context_menu_enabled_ ? TRUE : FALSE); + } +} + +void WebWindow::resize_webview() { + if (!controller_ || !hwnd_) return; + RECT rc{}; + GetClientRect(hwnd_, &rc); + controller_->put_Bounds(rc); +} + +void WebWindow::flush_pending_url() { + if (!webview_ready_ || !webview_) return; + if (pending_url_) { + last_url_ = *pending_url_; + webview_->Navigate(widen(*pending_url_).c_str()); + pending_url_.reset(); + } else if (last_url_) { + webview_->Navigate(widen(*last_url_).c_str()); + } +} + +void WebWindow::set_handlers(CloseHandler on_close, FocusHandler on_focus, + NewWindowHandler on_new_window, NavHandler on_nav, + PermissionHandler on_permission) { + on_close_ = std::move(on_close); + on_focus_ = std::move(on_focus); + on_new_window_ = std::move(on_new_window); + on_nav_ = std::move(on_nav); + on_permission_ = std::move(on_permission); +} + +void WebWindow::load_url(const std::string& url) { + last_url_ = url; + if (!webview_ready_ || !webview_) { + pending_url_ = url; + return; + } + webview_->Navigate(widen(url).c_str()); +} + +void WebWindow::reload() { + if (webview_ready_ && webview_) webview_->Reload(); +} + +std::string WebWindow::current_url() const { + if (webview_ready_ && webview_) { + LPWSTR raw = nullptr; + if (SUCCEEDED(webview_->get_Source(&raw)) && raw) { + std::string url = narrow(raw); + CoTaskMemFree(raw); + if (!url.empty() && url != "about:blank") return url; + } + } + return last_url_.value_or(""); +} + +std::string WebWindow::rebuild(const std::string& new_webview_id) { + if (webview_) { + if (nav_completed_token_.value) + webview_->remove_NavigationCompleted(nav_completed_token_); + if (new_window_token_.value) webview_->remove_NewWindowRequested(new_window_token_); + if (permission_token_.value) webview_->remove_PermissionRequested(permission_token_); + nav_completed_token_ = {}; + new_window_token_ = {}; + permission_token_ = {}; + } + if (controller_) { + controller_->Close(); + controller_ = nullptr; + } + webview_ = nullptr; + webview_id_ = new_webview_id; + if (last_url_) pending_url_ = last_url_; + create_webview(); + return webview_id_; +} + +void WebWindow::set_context_menu_enabled(bool enabled) { + context_menu_enabled_ = enabled; + if (webview_) { + ComPtr settings; + if (SUCCEEDED(webview_->get_Settings(&settings)) && settings) { + settings->put_AreDefaultContextMenusEnabled(enabled ? TRUE : FALSE); + } + } +} + +void WebWindow::set_title(const std::string& title) { + title_ = title; + if (hwnd_) SetWindowTextW(hwnd_, widen(title).c_str()); +} + +void WebWindow::set_min_size(int width, int height) { + min_width_ = width; + min_height_ = height; +} + +void WebWindow::show() { + if (!hwnd_) return; + ShowWindow(hwnd_, SW_SHOW); + visible_ = true; +} + +void WebWindow::hide() { + if (!hwnd_) return; + ShowWindow(hwnd_, SW_HIDE); + visible_ = false; +} + +void WebWindow::raise() { + if (!hwnd_) return; + ShowWindow(hwnd_, SW_SHOW); + SetForegroundWindow(hwnd_); + visible_ = true; +} + +void WebWindow::iconize(bool iconize) { + if (!hwnd_) return; + ShowWindow(hwnd_, iconize ? SW_MINIMIZE : SW_RESTORE); +} + +bool WebWindow::shown() const { return hwnd_ && IsWindowVisible(hwnd_); } + +bool WebWindow::active() const { return hwnd_ && GetForegroundWindow() == hwnd_; } + +void WebWindow::set_icon(HICON icon) { + if (!hwnd_ || !icon) return; + SendMessageW(hwnd_, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); + SendMessageW(hwnd_, WM_SETICON, ICON_SMALL, reinterpret_cast(icon)); +} + +void WebWindow::set_menubar(HMENU menu) { + menubar_ = menu; + if (hwnd_) SetMenu(hwnd_, menu); +} + +void WebWindow::set_menu_command_handler(std::function handler) { + on_menu_command_ = std::move(handler); +} + +void WebWindow::eval_js(const std::string& script, std::function cb) { + if (!webview_ready_ || !webview_) { + cb(jsonutil::Json{{"error", "webview not ready"}}); + return; + } + auto cb_ptr = std::make_shared>(std::move(cb)); + webview_->ExecuteScript( + widen(script).c_str(), + Callback( + [cb_ptr](HRESULT error, LPCWSTR result_json) -> HRESULT { + if (FAILED(error)) { + (*cb_ptr)(jsonutil::Json{{"error", "execute script failed"}}); + return S_OK; + } + try { + std::string narrow_json; + if (result_json) { + int n = WideCharToMultiByte(CP_UTF8, 0, result_json, -1, nullptr, 0, nullptr, nullptr); + narrow_json.assign(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) + WideCharToMultiByte(CP_UTF8, 0, result_json, -1, narrow_json.data(), n, nullptr, + nullptr); + } + auto parsed = jsonutil::Json::parse(narrow_json.empty() ? "null" : narrow_json); + (*cb_ptr)(parsed); + } catch (...) { + (*cb_ptr)(jsonutil::Json(nullptr)); + } + return S_OK; + }) + .Get()); +} diff --git a/native/windows/src/web_window.hpp b/native/windows/src/web_window.hpp new file mode 100644 index 0000000..c582d9c --- /dev/null +++ b/native/windows/src/web_window.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include "win_prefix.hpp" + +#include "json_util.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +class WebWindow { + public: + using CloseHandler = std::function; + using FocusHandler = std::function; + using NewWindowHandler = + std::function; + using NavHandler = std::function; + using PermissionHandler = std::function; + + WebWindow(const std::string& window_id, const std::string& webview_id, const std::string& title, + int width, int height); + ~WebWindow(); + + const std::string& window_id() const { return window_id_; } + const std::string& webview_id() const { return webview_id_; } + HWND hwnd() const { return hwnd_; } + const std::string& title() const { return title_; } + const std::optional& last_url() const { return last_url_; } + + void set_handlers(CloseHandler on_close, FocusHandler on_focus, NewWindowHandler on_new_window, + NavHandler on_nav, PermissionHandler on_permission); + + void load_url(const std::string& url); + void reload(); + std::string current_url() const; + std::string rebuild(const std::string& new_webview_id); + void set_context_menu_enabled(bool enabled); + void set_title(const std::string& title); + void set_min_size(int width, int height); + void show(); + void hide(); + void raise(); + void iconize(bool iconize); + bool shown() const; + bool active() const; + void set_icon(HICON icon); + void set_menubar(HMENU menu); + void set_menu_command_handler(std::function handler); + + void eval_js(const std::string& script, std::function cb); + + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + static void register_class(); + + private: + void create_webview(); + void attach_handlers(); + void resize_webview(); + void flush_pending_url(); + std::string origin_from_url(const std::string& url) const; + std::wstring widen(const std::string& s) const; + std::string narrow(const wchar_t* s) const; + + std::string window_id_; + std::string webview_id_; + std::string title_; + HWND hwnd_ = nullptr; + HMENU menubar_ = nullptr; + int min_width_ = 0; + int min_height_ = 0; + bool visible_ = false; + bool context_menu_enabled_ = false; + std::optional last_url_; + std::optional pending_url_; + bool webview_ready_ = false; + + Microsoft::WRL::ComPtr env_; + Microsoft::WRL::ComPtr controller_; + Microsoft::WRL::ComPtr webview_; + EventRegistrationToken nav_completed_token_{}; + EventRegistrationToken new_window_token_{}; + EventRegistrationToken permission_token_{}; + + CloseHandler on_close_; + FocusHandler on_focus_; + NewWindowHandler on_new_window_; + NavHandler on_nav_; + PermissionHandler on_permission_; + std::function on_menu_command_; +}; diff --git a/native/windows/src/win_prefix.hpp b/native/windows/src/win_prefix.hpp new file mode 100644 index 0000000..2120f73 --- /dev/null +++ b/native/windows/src/win_prefix.hpp @@ -0,0 +1,12 @@ +#pragma once + +#ifndef NOMINMAX +#define NOMINMAX +#endif + +// Winsock2 must come before Windows.h +#include +#include +#include +#include +#include diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 new file mode 100644 index 0000000..d45a182 --- /dev/null +++ b/scripts/build_windows.ps1 @@ -0,0 +1,69 @@ +# Build DesktopWebView.exe (x64) and copy to priv/native/windows/ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +if (-not $Root) { $Root = (Resolve-Path "$PSScriptRoot\..").Path } + +$OutDir = Join-Path $Root "priv\native\windows" +$BuildDir = Join-Path $Root "native\windows\build" +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null + +function Enter-VsDevShell { + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { throw "vswhere.exe not found" } + $vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { throw "Visual Studio with MSVC not found" } + $vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvars64.bat" + if (-not (Test-Path $vcvars)) { throw "vcvars64.bat not found at $vcvars" } + + $temp = [System.IO.Path]::GetTempFileName() + ".cmd" + @" +@echo off +call "$vcvars" >nul +set > "$temp.env" +"@ | Set-Content -Path $temp -Encoding ASCII + cmd /c $temp + Get-Content "$temp.env" | ForEach-Object { + if ($_ -match '^(.*?)=(.*)$') { + Set-Item -Path "env:$($matches[1])" -Value $matches[2] + } + } + Remove-Item $temp -ErrorAction SilentlyContinue + Remove-Item "$temp.env" -ErrorAction SilentlyContinue +} + +# Ensure x64 cl is available +$cl = Get-Command cl -ErrorAction SilentlyContinue +if (-not $cl -or ($cl.Source -notmatch 'Hostx64\\x64')) { + Write-Host "Entering VS x64 environment..." + Enter-VsDevShell +} + +$cmake = Get-Command cmake -ErrorAction SilentlyContinue +if (-not $cmake) { throw "cmake not found on PATH" } + +Push-Location $Root +try { + & cmake -S (Join-Path $Root "native\windows") -B $BuildDir -G "Ninja" -DCMAKE_BUILD_TYPE=Release + if ($LASTEXITCODE -ne 0) { + # Fallback to default generator (VS) + & cmake -S (Join-Path $Root "native\windows") -B $BuildDir -DCMAKE_BUILD_TYPE=Release + if ($LASTEXITCODE -ne 0) { throw "cmake configure failed" } + } + & cmake --build $BuildDir --config Release + if ($LASTEXITCODE -ne 0) { throw "cmake build failed" } + + $exeCandidates = @( + (Join-Path $BuildDir "DesktopWebView.exe"), + (Join-Path $BuildDir "Release\DesktopWebView.exe"), + (Join-Path $BuildDir "RelWithDebInfo\DesktopWebView.exe") + ) + $built = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $built) { throw "DesktopWebView.exe not found under $BuildDir" } + + Copy-Item -Force $built (Join-Path $OutDir "DesktopWebView.exe") + Write-Host "Built $(Join-Path $OutDir 'DesktopWebView.exe')" +} finally { + Pop-Location +} diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index 63123da..d35a960 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -9,7 +9,7 @@ defmodule DesktopWebview.E2ETest do unless Binary.available?() do flunk( "DesktopWebView binary missing at #{Binary.path()}; " <> - "run ./scripts/build_macos.sh or ./scripts/build_linux.sh" + "run ./scripts/build_macos.sh, ./scripts/build_linux.sh, or .\\scripts\\build_windows.ps1" ) end @@ -26,7 +26,13 @@ defmodule DesktopWebview.E2ETest do end # Also kill by name in case port already closed - System.cmd("pkill", ["-f", "DesktopWebView --edw-no-beam"], stderr_to_stdout: true) + case :os.type() do + {:win32, _} -> + System.cmd("taskkill", ["/F", "/IM", "DesktopWebView.exe"], stderr_to_stdout: true) + + _ -> + System.cmd("pkill", ["-f", "DesktopWebView --edw-no-beam"], stderr_to_stdout: true) + end end) # Ensure transport is fresh From fe39f05640a89466ed52ecb906d0f5b287284881 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Wed, 5 Aug 2026 11:15:44 +0200 Subject: [PATCH 2/5] Simplify Windows WebView2 host internals. Deduplicate UTF-8/path helpers, drop dead icon/menu state, harden RPC framing and permission deferrals, and fix the Windows build script generator fallback. Co-authored-by: Cursor --- native/windows/CMakeLists.txt | 1 - native/windows/src/config.cpp | 22 +---- native/windows/src/host_controller.cpp | 110 +++++++------------------ native/windows/src/host_controller.hpp | 4 - native/windows/src/main.cpp | 9 +- native/windows/src/rpc_server.cpp | 27 ++++-- native/windows/src/web_window.cpp | 98 +++++++--------------- native/windows/src/web_window.hpp | 13 +-- native/windows/src/win_util.hpp | 47 +++++++++++ scripts/build_windows.ps1 | 6 +- 10 files changed, 135 insertions(+), 202 deletions(-) create mode 100644 native/windows/src/win_util.hpp diff --git a/native/windows/CMakeLists.txt b/native/windows/CMakeLists.txt index 18fae2d..3f65034 100644 --- a/native/windows/CMakeLists.txt +++ b/native/windows/CMakeLists.txt @@ -64,7 +64,6 @@ target_link_libraries(DesktopWebView PRIVATE advapi32 shlwapi version - crypt32 ) install(TARGETS DesktopWebView RUNTIME DESTINATION bin) diff --git a/native/windows/src/config.cpp b/native/windows/src/config.cpp index 8b3c9ae..7502c32 100644 --- a/native/windows/src/config.cpp +++ b/native/windows/src/config.cpp @@ -1,6 +1,5 @@ #include "config.hpp" - -#include "win_prefix.hpp" +#include "win_util.hpp" #include #include @@ -9,25 +8,6 @@ namespace { -std::string dirname_of(const std::string& path) { - auto pos = path.find_last_of("/\\"); - if (pos == std::string::npos) return "."; - if (pos == 0) return path.substr(0, 1); - return path.substr(0, pos); -} - -std::string join_path(const std::string& a, const std::string& b) { - if (a.empty()) return b; - char last = a.back(); - if (last == '/' || last == '\\') return a + b; - return a + "\\" + b; -} - -bool file_exists(const std::string& path) { - DWORD attrs = GetFileAttributesA(path.c_str()); - return attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY); -} - class Ini { public: static Ini parse(const std::string& text) { diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index d7509ad..3a4bef4 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -1,53 +1,14 @@ #include "host_controller.hpp" +#include "win_util.hpp" -#include -#include - -#include #include #include #include -#include #include namespace { -std::string join_path(const std::string& a, const std::string& b) { - if (a.empty()) return b; - char last = a.back(); - if (last == '/' || last == '\\') return a + b; - return a + "\\" + b; -} - -bool is_absolute(const std::string& p) { - if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') return true; - return p.size() >= 2 && p[0] == '\\' && p[1] == '\\'; -} - -std::wstring widen(const std::string& s) { - if (s.empty()) return L""; - int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); - std::wstring out(n > 0 ? n - 1 : 0, L'\0'); - if (n > 1) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, out.data(), n); - return out; -} - -std::vector base64_decode(const std::string& in) { - DWORD len = 0; - if (!CryptStringToBinaryA(in.c_str(), 0, CRYPT_STRING_BASE64, nullptr, &len, nullptr, nullptr)) - return {}; - std::vector out(len); - if (!CryptStringToBinaryA(in.c_str(), 0, CRYPT_STRING_BASE64, out.data(), &len, nullptr, nullptr)) - return {}; - out.resize(len); - return out; -} - -HICON icon_from_png(const std::vector& png) { - // Optional PNG→HICON path deferred; protocol accepts empty icons. - (void)png; - return nullptr; -} +constexpr const wchar_t* kHostClass = L"DesktopWebViewHost"; struct RequestMsg { HostController* self; @@ -57,8 +18,6 @@ struct RequestMsg { RpcServer::ReplyFn reply; }; -constexpr const wchar_t* kHostClass = L"DesktopWebViewHost"; - } // namespace HostController::HostController(HostConfig config) : config_(std::move(config)) {} @@ -206,7 +165,7 @@ void HostController::spawn_beam() { auto root = config_.resources_root(); std::string beam_dir = config_.beam_path - ? (is_absolute(*config_.beam_path) ? *config_.beam_path : join_path(root, *config_.beam_path)) + ? (is_absolute_path(*config_.beam_path) ? *config_.beam_path : join_path(root, *config_.beam_path)) : join_path(root, "beam"); std::string app_name = config_.beam_app.value_or(""); if (app_name.empty()) { @@ -230,7 +189,7 @@ void HostController::spawn_beam() { std::string script = join_path(join_path(beam_dir, "bin"), app_name); std::string wd = config_.beam_working_dir - ? (is_absolute(*config_.beam_working_dir) ? *config_.beam_working_dir + ? (is_absolute_path(*config_.beam_working_dir) ? *config_.beam_working_dir : join_path(root, *config_.beam_working_dir)) : beam_dir; @@ -245,10 +204,7 @@ void HostController::spawn_beam() { LPWCH strings = GetEnvironmentStringsW(); if (strings) { for (LPWCH p = strings; *p; p += wcslen(p) + 1) { - std::string entry; - int n = WideCharToMultiByte(CP_UTF8, 0, p, -1, nullptr, 0, nullptr, nullptr); - entry.assign(n > 0 ? n - 1 : 0, '\0'); - if (n > 1) WideCharToMultiByte(CP_UTF8, 0, p, -1, entry.data(), n, nullptr, nullptr); + std::string entry = wide_to_utf8(p); auto eq = entry.find('='); if (eq != std::string::npos) env[entry.substr(0, eq)] = entry.substr(eq + 1); } @@ -260,7 +216,7 @@ void HostController::spawn_beam() { std::wstring env_block; for (auto& [k, v] : env) { - env_block += widen(k + "=" + v); + env_block += utf8_to_wide(k + "=" + v); env_block.push_back(L'\0'); } env_block.push_back(L'\0'); @@ -268,8 +224,8 @@ void HostController::spawn_beam() { STARTUPINFOW si{}; si.cb = sizeof(si); PROCESS_INFORMATION pi{}; - std::wstring wcmd = widen(cmdline); - std::wstring wwd = widen(wd); + std::wstring wcmd = utf8_to_wide(cmdline); + std::wstring wwd = utf8_to_wide(wd); std::vector mutable_cmd(wcmd.begin(), wcmd.end()); mutable_cmd.push_back(L'\0'); @@ -375,10 +331,11 @@ void HostController::wire_window(WebWindow* w) { ICoreWebView2PermissionRequestedEventArgs* args) { handle_permission(origin, type, webview_id, args); }); + w->set_menu_command_handler([this](UINT cmd) { return on_menu_command(cmd); }); } void HostController::open_external(const std::string& url) { - ShellExecuteW(nullptr, L"open", widen(url).c_str(), nullptr, nullptr, SW_SHOWNORMAL); + ShellExecuteW(nullptr, L"open", utf8_to_wide(url).c_str(), nullptr, nullptr, SW_SHOWNORMAL); } void HostController::handle_permission(const std::string& origin, const std::string& type, @@ -387,8 +344,15 @@ void HostController::handle_permission(const std::string& origin, const std::str auto policy = permission_policy_[origin][type]; if (policy.empty()) policy = "ask"; - auto finish = [args](COREWEBVIEW2_PERMISSION_STATE state) { + ICoreWebView2Deferral* deferral = nullptr; + args->GetDeferral(&deferral); + + auto finish = [args, deferral](COREWEBVIEW2_PERMISSION_STATE state) { args->put_State(state); + if (deferral) { + deferral->Complete(); + deferral->Release(); + } args->Release(); }; @@ -401,12 +365,9 @@ void HostController::handle_permission(const std::string& origin, const std::str return; } - jsonutil::Json params{ - {"origin", origin}, {"type", type}, {"webview_id", webview_id}}; + jsonutil::Json params{{"origin", origin}, {"type", type}, {"webview_id", webview_id}}; server_.request("permission.request", std::move(params), [finish](jsonutil::Json result) { - std::string decision = "ask"; - if (result.is_object() && result.contains("decision") && result["decision"].is_string()) - decision = result["decision"].get(); + auto decision = jsonutil::get_string(result, "decision").value_or("ask"); if (decision == "allow") finish(COREWEBVIEW2_PERMISSION_STATE_ALLOW); else if (decision == "deny") @@ -480,7 +441,7 @@ void HostController::append_menu_node(HMENU menu, const jsonutil::Json& node, Me if (auto* kids = jsonutil::get(node, "children"); kids && kids->is_array()) { for (auto& child : *kids) append_menu_node(sub, child, entry); } - AppendMenuW(menu, MF_POPUP, reinterpret_cast(sub), widen(label).c_str()); + AppendMenuW(menu, MF_POPUP, reinterpret_cast(sub), utf8_to_wide(label).c_str()); return; } @@ -490,7 +451,7 @@ void HostController::append_menu_node(HMENU menu, const jsonutil::Json& node, Me attrs ? jsonutil::get_string(*attrs, "onclick").value_or("") : ""; UINT cmd = next_menu_cmd_++; entry.onclicks[cmd] = onclick; - AppendMenuW(menu, MF_STRING, cmd, widen(label).c_str()); + AppendMenuW(menu, MF_STRING, cmd, utf8_to_wide(label).c_str()); } } @@ -510,8 +471,6 @@ void HostController::build_menu_into(HMENU menu, const jsonutil::Json& dom, Menu jsonutil::Json HostController::menu_create(const jsonutil::Json& params) { auto id = next_id("m"); MenuEntry entry; - entry.menu_id = id; - // Menubar uses CreateMenu; popup uses CreatePopupMenu. Prefer CreateMenu for menubar tag. bool as_menubar = false; if (auto* dom = jsonutil::get(params, "dom"); dom && dom->is_object()) { as_menubar = jsonutil::get_string(*dom, "tag").value_or("") == "menubar"; @@ -533,7 +492,6 @@ jsonutil::Json HostController::menu_update(const jsonutil::Json& params) { as_menubar = jsonutil::get_string(*dom, "tag").value_or("") == "menubar"; } it->second.menu = as_menubar ? CreateMenu() : CreatePopupMenu(); - it->second.menu_id = *id; if (auto* dom = jsonutil::get(params, "dom")) build_menu_into(it->second.menu, *dom, it->second); return true; } @@ -572,13 +530,10 @@ jsonutil::Json HostController::icon_create(const jsonutil::Json& params) { auto id = next_id("icon"); IconEntry icon; if (auto path = jsonutil::get_string(params, "path")) { - icon.path = *path; - icon.icon = static_cast( - LoadImageW(nullptr, widen(*path).c_str(), IMAGE_ICON, 0, 0, LR_LOADFROMFILE | LR_DEFAULTSIZE)); - } else if (auto b64 = jsonutil::get_string(params, "png_base64")) { - icon.png = base64_decode(*b64); - icon.icon = icon_from_png(icon.png); + icon.icon = static_cast(LoadImageW(nullptr, utf8_to_wide(*path).c_str(), IMAGE_ICON, 0, 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE)); } + // png_base64 accepted for protocol compatibility; decoding deferred (status: partial). icons_[id] = std::move(icon); return jsonutil::Json{{"icon_id", id}}; } @@ -591,8 +546,8 @@ jsonutil::Json HostController::notification_show(const jsonutil::Json& params) { if (!trays_.empty()) { auto& tray = trays_.begin()->second; tray.nid.uFlags = NIF_INFO | NIF_ICON | NIF_MESSAGE | NIF_TIP; - wcsncpy_s(tray.nid.szInfoTitle, widen(title).c_str(), _TRUNCATE); - wcsncpy_s(tray.nid.szInfo, widen(message).c_str(), _TRUNCATE); + wcsncpy_s(tray.nid.szInfoTitle, utf8_to_wide(title).c_str(), _TRUNCATE); + wcsncpy_s(tray.nid.szInfo, utf8_to_wide(message).c_str(), _TRUNCATE); tray.nid.dwInfoFlags = NIIF_INFO; if (tray.registered) Shell_NotifyIconW(NIM_MODIFY, &tray.nid); } else { @@ -656,10 +611,7 @@ jsonutil::Json HostController::dispatch(const std::string& method, const jsonuti auto* w = require_window(p); if (auto menu_id = jsonutil::get_string(p, "menu_id")) { auto it = menus_.find(*menu_id); - if (it != menus_.end()) { - w->set_menubar(it->second.menu); - w->set_menu_command_handler([this](UINT cmd) { return on_menu_command(cmd); }); - } + if (it != menus_.end()) w->set_menubar(it->second.menu); } return true; } @@ -769,12 +721,8 @@ jsonutil::Json HostController::dispatch(const std::string& method, const jsonuti if (method == "system.locale") { wchar_t buf[128]; if (GetUserDefaultLocaleName(buf, 128) > 0) { - std::string loc; - int n = WideCharToMultiByte(CP_UTF8, 0, buf, -1, nullptr, 0, nullptr, nullptr); - loc.assign(n > 0 ? n - 1 : 0, '\0'); - if (n > 1) WideCharToMultiByte(CP_UTF8, 0, buf, -1, loc.data(), n, nullptr, nullptr); + std::string loc = wide_to_utf8(buf); for (auto& c : loc) c = static_cast(tolower(static_cast(c))); - // Windows uses en-US; normalize hyphen to underscore-ish for protocol softness for (auto& c : loc) if (c == '-') c = '_'; return loc; diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp index a748a2c..88d1953 100644 --- a/native/windows/src/host_controller.hpp +++ b/native/windows/src/host_controller.hpp @@ -8,7 +8,6 @@ #include #include #include -#include struct HostError { int code; @@ -18,7 +17,6 @@ struct HostError { struct MenuEntry { HMENU menu = nullptr; std::map onclicks; // command id -> onclick - std::string menu_id; }; struct TrayEntry { @@ -29,8 +27,6 @@ struct TrayEntry { }; struct IconEntry { - std::string path; - std::vector png; HICON icon = nullptr; }; diff --git a/native/windows/src/main.cpp b/native/windows/src/main.cpp index e12c849..b18a698 100644 --- a/native/windows/src/main.cpp +++ b/native/windows/src/main.cpp @@ -1,7 +1,7 @@ #include "config.hpp" #include "host_controller.hpp" #include "web_window.hpp" -#include "win_prefix.hpp" +#include "win_util.hpp" #include #include @@ -15,12 +15,7 @@ std::vector argv_utf8() { LPWSTR* wargv = CommandLineToArgvW(GetCommandLineW(), &argc); std::vector out; if (!wargv) return out; - for (int i = 0; i < argc; i++) { - int n = WideCharToMultiByte(CP_UTF8, 0, wargv[i], -1, nullptr, 0, nullptr, nullptr); - std::string s(n > 0 ? n - 1 : 0, '\0'); - if (n > 1) WideCharToMultiByte(CP_UTF8, 0, wargv[i], -1, s.data(), n, nullptr, nullptr); - out.push_back(std::move(s)); - } + for (int i = 0; i < argc; i++) out.push_back(wide_to_utf8(wargv[i])); LocalFree(wargv); return out; } diff --git a/native/windows/src/rpc_server.cpp b/native/windows/src/rpc_server.cpp index 9fbe46c..be09ff9 100644 --- a/native/windows/src/rpc_server.cpp +++ b/native/windows/src/rpc_server.cpp @@ -146,15 +146,24 @@ void RpcServer::on_readable() { } void RpcServer::drain() { - while (buffer_.size() >= 4) { + constexpr uint32_t kMaxFrame = 16u * 1024u * 1024u; + size_t off = 0; + while (buffer_.size() - off >= 4) { uint32_t len = 0; - memcpy(&len, buffer_.data(), 4); + memcpy(&len, buffer_.data() + off, 4); len = ntohl(len); - if (buffer_.size() < 4u + len) return; - std::string payload(reinterpret_cast(buffer_.data() + 4), len); - buffer_.erase(buffer_.begin(), buffer_.begin() + 4 + static_cast(len)); + if (len > kMaxFrame) { + fprintf(stderr, "edw: frame too large (%u)\n", len); + close_connection(); + if (on_disconnect_) on_disconnect_(); + return; + } + if (buffer_.size() - off < 4u + len) break; + std::string payload(reinterpret_cast(buffer_.data() + off + 4), len); + off += 4 + len; handle_payload(payload); } + if (off > 0) buffer_.erase(buffer_.begin(), buffer_.begin() + static_cast(off)); } void RpcServer::handle_payload(const std::string& payload) { @@ -166,7 +175,8 @@ void RpcServer::handle_payload(const std::string& payload) { bool has_result_or_error = root.contains("result") || root.contains("error"); if (has_method && has_id) { - auto method = root["method"].get(); + auto method = jsonutil::get_string(root, "method").value_or(""); + if (method.empty()) return; auto id = root["id"]; jsonutil::Json params = root.contains("params") ? root["params"] : jsonutil::Json(); if (on_request_) { @@ -229,4 +239,9 @@ void RpcServer::close_connection() { client_sock_ = INVALID_SOCKET; } buffer_.clear(); + auto pending = std::move(pending_); + pending_.clear(); + for (auto& [_, cb] : pending) { + if (cb) cb(nullptr); + } } diff --git a/native/windows/src/web_window.cpp b/native/windows/src/web_window.cpp index e06aeaf..30c1589 100644 --- a/native/windows/src/web_window.cpp +++ b/native/windows/src/web_window.cpp @@ -1,4 +1,5 @@ #include "web_window.hpp" +#include "win_util.hpp" #include @@ -23,22 +24,6 @@ void WebWindow::register_class() { g_class_registered = true; } -std::wstring WebWindow::widen(const std::string& s) const { - if (s.empty()) return L""; - int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); - std::wstring out(n > 0 ? n - 1 : 0, L'\0'); - if (n > 1) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, out.data(), n); - return out; -} - -std::string WebWindow::narrow(const wchar_t* s) const { - if (!s || !*s) return {}; - int n = WideCharToMultiByte(CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); - std::string out(n > 0 ? n - 1 : 0, '\0'); - if (n > 1) WideCharToMultiByte(CP_UTF8, 0, s, -1, out.data(), n, nullptr, nullptr); - return out; -} - std::string WebWindow::origin_from_url(const std::string& url) const { if (url.empty()) return "http://127.0.0.1"; auto scheme_end = url.find("://"); @@ -52,9 +37,9 @@ WebWindow::WebWindow(const std::string& window_id, const std::string& webview_id const std::string& title, int width, int height) : window_id_(window_id), webview_id_(webview_id), title_(title) { register_class(); - hwnd_ = CreateWindowExW(0, kClassName, widen(title).c_str(), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, - CW_USEDEFAULT, width, height, nullptr, nullptr, GetModuleHandleW(nullptr), - this); + hwnd_ = CreateWindowExW(0, kClassName, utf8_to_wide(title).c_str(), WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, width, height, nullptr, nullptr, + GetModuleHandleW(nullptr), this); create_webview(); } @@ -94,7 +79,7 @@ LRESULT CALLBACK WebWindow::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM l switch (msg) { case WM_CLOSE: if (self->on_close_) self->on_close_(self->window_id_); - return 0; // veto destroy + return 0; case WM_SIZE: self->resize_webview(); return 0; @@ -112,8 +97,6 @@ LRESULT CALLBACK WebWindow::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM l case WM_COMMAND: if (self->on_menu_command_ && self->on_menu_command_(LOWORD(wParam))) return 0; break; - case WM_DESTROY: - return 0; default: break; } @@ -127,7 +110,7 @@ void WebWindow::create_webview() { wchar_t temp[MAX_PATH]; GetTempPathW(MAX_PATH, temp); - std::wstring user_data = std::wstring(temp) + L"DesktopWebView\\" + widen(window_id_); + std::wstring user_data = std::wstring(temp) + L"DesktopWebView\\" + utf8_to_wide(window_id_); HRESULT hr = CreateCoreWebView2EnvironmentWithOptions( nullptr, user_data.c_str(), nullptr, @@ -155,7 +138,7 @@ void WebWindow::create_webview() { webview_ready_ = true; resize_webview(); attach_handlers(); - flush_pending_url(); + navigate_if_ready(); return S_OK; }) .Get()); @@ -181,7 +164,7 @@ void WebWindow::attach_handlers() { if (ok) { LPWSTR raw = nullptr; sender->get_Source(&raw); - std::string url = narrow(raw); + std::string url = wide_to_utf8(raw); if (raw) CoTaskMemFree(raw); if (on_nav_) on_nav_(webview_id_, url, false, ""); } else { @@ -200,7 +183,7 @@ void WebWindow::attach_handlers() { [this](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { LPWSTR raw = nullptr; args->get_Uri(&raw); - std::string url = narrow(raw); + std::string url = wide_to_utf8(raw); if (raw) CoTaskMemFree(raw); args->put_Handled(TRUE); if (on_new_window_) on_new_window_(webview_id_, url); @@ -220,7 +203,7 @@ void WebWindow::attach_handlers() { else if (kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE) type = "microphone"; LPWSTR raw = nullptr; args->get_Uri(&raw); - std::string origin = origin_from_url(narrow(raw)); + std::string origin = origin_from_url(wide_to_utf8(raw)); if (raw) CoTaskMemFree(raw); args->AddRef(); on_permission_(origin, type, webview_id_, args); @@ -229,7 +212,6 @@ void WebWindow::attach_handlers() { .Get(), &permission_token_); - // Context menu: use ICoreWebView2_11 if available; otherwise Settings AreDefaultContextMenusEnabled ComPtr settings; if (SUCCEEDED(webview_->get_Settings(&settings)) && settings) { settings->put_AreDefaultContextMenusEnabled(context_menu_enabled_ ? TRUE : FALSE); @@ -243,15 +225,9 @@ void WebWindow::resize_webview() { controller_->put_Bounds(rc); } -void WebWindow::flush_pending_url() { - if (!webview_ready_ || !webview_) return; - if (pending_url_) { - last_url_ = *pending_url_; - webview_->Navigate(widen(*pending_url_).c_str()); - pending_url_.reset(); - } else if (last_url_) { - webview_->Navigate(widen(*last_url_).c_str()); - } +void WebWindow::navigate_if_ready() { + if (!webview_ready_ || !webview_ || !last_url_) return; + webview_->Navigate(utf8_to_wide(*last_url_).c_str()); } void WebWindow::set_handlers(CloseHandler on_close, FocusHandler on_focus, @@ -264,13 +240,13 @@ void WebWindow::set_handlers(CloseHandler on_close, FocusHandler on_focus, on_permission_ = std::move(on_permission); } +void WebWindow::set_menu_command_handler(std::function handler) { + on_menu_command_ = std::move(handler); +} + void WebWindow::load_url(const std::string& url) { last_url_ = url; - if (!webview_ready_ || !webview_) { - pending_url_ = url; - return; - } - webview_->Navigate(widen(url).c_str()); + navigate_if_ready(); } void WebWindow::reload() { @@ -281,7 +257,7 @@ std::string WebWindow::current_url() const { if (webview_ready_ && webview_) { LPWSTR raw = nullptr; if (SUCCEEDED(webview_->get_Source(&raw)) && raw) { - std::string url = narrow(raw); + std::string url = wide_to_utf8(raw); CoTaskMemFree(raw); if (!url.empty() && url != "about:blank") return url; } @@ -305,7 +281,6 @@ std::string WebWindow::rebuild(const std::string& new_webview_id) { } webview_ = nullptr; webview_id_ = new_webview_id; - if (last_url_) pending_url_ = last_url_; create_webview(); return webview_id_; } @@ -322,7 +297,7 @@ void WebWindow::set_context_menu_enabled(bool enabled) { void WebWindow::set_title(const std::string& title) { title_ = title; - if (hwnd_) SetWindowTextW(hwnd_, widen(title).c_str()); + if (hwnd_) SetWindowTextW(hwnd_, utf8_to_wide(title).c_str()); } void WebWindow::set_min_size(int width, int height) { @@ -331,22 +306,17 @@ void WebWindow::set_min_size(int width, int height) { } void WebWindow::show() { - if (!hwnd_) return; - ShowWindow(hwnd_, SW_SHOW); - visible_ = true; + if (hwnd_) ShowWindow(hwnd_, SW_SHOW); } void WebWindow::hide() { - if (!hwnd_) return; - ShowWindow(hwnd_, SW_HIDE); - visible_ = false; + if (hwnd_) ShowWindow(hwnd_, SW_HIDE); } void WebWindow::raise() { if (!hwnd_) return; ShowWindow(hwnd_, SW_SHOW); SetForegroundWindow(hwnd_); - visible_ = true; } void WebWindow::iconize(bool iconize) { @@ -365,14 +335,9 @@ void WebWindow::set_icon(HICON icon) { } void WebWindow::set_menubar(HMENU menu) { - menubar_ = menu; if (hwnd_) SetMenu(hwnd_, menu); } -void WebWindow::set_menu_command_handler(std::function handler) { - on_menu_command_ = std::move(handler); -} - void WebWindow::eval_js(const std::string& script, std::function cb) { if (!webview_ready_ || !webview_) { cb(jsonutil::Json{{"error", "webview not ready"}}); @@ -380,26 +345,19 @@ void WebWindow::eval_js(const std::string& script, std::function>(std::move(cb)); webview_->ExecuteScript( - widen(script).c_str(), + utf8_to_wide(script).c_str(), Callback( [cb_ptr](HRESULT error, LPCWSTR result_json) -> HRESULT { if (FAILED(error)) { (*cb_ptr)(jsonutil::Json{{"error", "execute script failed"}}); return S_OK; } - try { - std::string narrow_json; - if (result_json) { - int n = WideCharToMultiByte(CP_UTF8, 0, result_json, -1, nullptr, 0, nullptr, nullptr); - narrow_json.assign(n > 0 ? n - 1 : 0, '\0'); - if (n > 1) - WideCharToMultiByte(CP_UTF8, 0, result_json, -1, narrow_json.data(), n, nullptr, - nullptr); - } - auto parsed = jsonutil::Json::parse(narrow_json.empty() ? "null" : narrow_json); + auto text = wide_to_utf8(result_json); + auto parsed = jsonutil::parse(text.empty() ? "null" : text); + if (parsed.is_null() && !text.empty() && text != "null") { + (*cb_ptr)(jsonutil::Json{{"error", "invalid script result json"}}); + } else { (*cb_ptr)(parsed); - } catch (...) { - (*cb_ptr)(jsonutil::Json(nullptr)); } return S_OK; }) diff --git a/native/windows/src/web_window.hpp b/native/windows/src/web_window.hpp index c582d9c..275f6ab 100644 --- a/native/windows/src/web_window.hpp +++ b/native/windows/src/web_window.hpp @@ -1,8 +1,7 @@ #pragma once -#include "win_prefix.hpp" - #include "json_util.hpp" +#include "win_prefix.hpp" #include #include @@ -11,7 +10,6 @@ #include #include #include -#include class WebWindow { public: @@ -37,6 +35,7 @@ class WebWindow { void set_handlers(CloseHandler on_close, FocusHandler on_focus, NewWindowHandler on_new_window, NavHandler on_nav, PermissionHandler on_permission); + void set_menu_command_handler(std::function handler); void load_url(const std::string& url); void reload(); @@ -53,7 +52,6 @@ class WebWindow { bool active() const; void set_icon(HICON icon); void set_menubar(HMENU menu); - void set_menu_command_handler(std::function handler); void eval_js(const std::string& script, std::function cb); @@ -64,22 +62,17 @@ class WebWindow { void create_webview(); void attach_handlers(); void resize_webview(); - void flush_pending_url(); + void navigate_if_ready(); std::string origin_from_url(const std::string& url) const; - std::wstring widen(const std::string& s) const; - std::string narrow(const wchar_t* s) const; std::string window_id_; std::string webview_id_; std::string title_; HWND hwnd_ = nullptr; - HMENU menubar_ = nullptr; int min_width_ = 0; int min_height_ = 0; - bool visible_ = false; bool context_menu_enabled_ = false; std::optional last_url_; - std::optional pending_url_; bool webview_ready_ = false; Microsoft::WRL::ComPtr env_; diff --git a/native/windows/src/win_util.hpp b/native/windows/src/win_util.hpp new file mode 100644 index 0000000..f56bc8c --- /dev/null +++ b/native/windows/src/win_util.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "win_prefix.hpp" + +#include +#include +#include + +inline std::wstring utf8_to_wide(const std::string& s) { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0); + std::wstring out(n > 0 ? n - 1 : 0, L'\0'); + if (n > 1) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, out.data(), n); + return out; +} + +inline std::string wide_to_utf8(const wchar_t* s) { + if (!s || !*s) return {}; + int n = WideCharToMultiByte(CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); + std::string out(n > 0 ? n - 1 : 0, '\0'); + if (n > 1) WideCharToMultiByte(CP_UTF8, 0, s, -1, out.data(), n, nullptr, nullptr); + return out; +} + +inline std::string dirname_of(const std::string& path) { + auto pos = path.find_last_of("/\\"); + if (pos == std::string::npos) return "."; + if (pos == 0) return path.substr(0, 1); + return path.substr(0, pos); +} + +inline std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + char last = a.back(); + if (last == '/' || last == '\\') return a + b; + return a + "\\" + b; +} + +inline bool file_exists(const std::string& path) { + DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY); +} + +inline bool is_absolute_path(const std::string& p) { + if (p.size() >= 2 && std::isalpha(static_cast(p[0])) && p[1] == ':') return true; + return p.size() >= 2 && p[0] == '\\' && p[1] == '\\'; +} diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index d45a182..7e86ea1 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -47,11 +47,13 @@ Push-Location $Root try { & cmake -S (Join-Path $Root "native\windows") -B $BuildDir -G "Ninja" -DCMAKE_BUILD_TYPE=Release if ($LASTEXITCODE -ne 0) { - # Fallback to default generator (VS) + Write-Host "Ninja configure failed; wiping build dir and retrying with default generator..." + Remove-Item -Recurse -Force $BuildDir -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null & cmake -S (Join-Path $Root "native\windows") -B $BuildDir -DCMAKE_BUILD_TYPE=Release if ($LASTEXITCODE -ne 0) { throw "cmake configure failed" } } - & cmake --build $BuildDir --config Release + & cmake --build $BuildDir --config Release --parallel if ($LASTEXITCODE -ne 0) { throw "cmake build failed" } $exeCandidates = @( From 0a070fbd785da48cae1ff8aa905f1d71708ffac7 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 24 Aug 2026 11:28:28 +0200 Subject: [PATCH 3/5] Queue WebView2 JS eval until the controller and navigation are ready. CI failed because test.webview.eval ran while CreateCoreWebView2Controller was still pending and returned an error map instead of a string. Co-authored-by: Cursor --- native/windows/src/web_window.cpp | 33 ++++++++++++++++++++++++++----- native/windows/src/web_window.hpp | 5 +++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/native/windows/src/web_window.cpp b/native/windows/src/web_window.cpp index 30c1589..aaeb077 100644 --- a/native/windows/src/web_window.cpp +++ b/native/windows/src/web_window.cpp @@ -44,6 +44,7 @@ WebWindow::WebWindow(const std::string& window_id, const std::string& webview_id } WebWindow::~WebWindow() { + fail_pending_evals("webview destroyed"); if (webview_) { if (nav_completed_token_.value) webview_->remove_NavigationCompleted(nav_completed_token_); @@ -121,6 +122,7 @@ void WebWindow::create_webview() { "edw: WebView2 environment failed (0x%08lx). Is the Evergreen Runtime " "installed?\n", static_cast(result)); + fail_pending_evals("webview2 runtime missing"); return result; } env_ = env; @@ -131,6 +133,7 @@ void WebWindow::create_webview() { if (FAILED(result) || !controller) { fprintf(stderr, "edw: WebView2 controller failed (0x%08lx)\n", static_cast(result)); + fail_pending_evals("webview2 controller failed"); return result; } controller_ = controller; @@ -139,6 +142,7 @@ void WebWindow::create_webview() { resize_webview(); attach_handlers(); navigate_if_ready(); + if (!last_url_) flush_pending_evals(); return S_OK; }) .Get()); @@ -150,6 +154,7 @@ void WebWindow::create_webview() { "edw: CreateCoreWebView2EnvironmentWithOptions failed (0x%08lx). Is the Evergreen " "Runtime installed?\n", static_cast(hr)); + fail_pending_evals("webview2 runtime missing"); } } @@ -173,6 +178,7 @@ void WebWindow::attach_handlers() { if (on_nav_) on_nav_(webview_id_, "", true, "navigation failed: " + std::to_string(status)); } + flush_pending_evals(); return S_OK; }) .Get(), @@ -338,11 +344,7 @@ void WebWindow::set_menubar(HMENU menu) { if (hwnd_) SetMenu(hwnd_, menu); } -void WebWindow::eval_js(const std::string& script, std::function cb) { - if (!webview_ready_ || !webview_) { - cb(jsonutil::Json{{"error", "webview not ready"}}); - return; - } +void WebWindow::run_eval(const std::string& script, std::function cb) { auto cb_ptr = std::make_shared>(std::move(cb)); webview_->ExecuteScript( utf8_to_wide(script).c_str(), @@ -363,3 +365,24 @@ void WebWindow::eval_js(const std::string& script, std::function cb) { + if (!webview_ready_ || !webview_) { + pending_evals_.emplace_back(script, std::move(cb)); + return; + } + run_eval(script, std::move(cb)); +} diff --git a/native/windows/src/web_window.hpp b/native/windows/src/web_window.hpp index 275f6ab..3659364 100644 --- a/native/windows/src/web_window.hpp +++ b/native/windows/src/web_window.hpp @@ -10,6 +10,7 @@ #include #include #include +#include class WebWindow { public: @@ -63,6 +64,9 @@ class WebWindow { void attach_handlers(); void resize_webview(); void navigate_if_ready(); + void run_eval(const std::string& script, std::function cb); + void flush_pending_evals(); + void fail_pending_evals(const std::string& message); std::string origin_from_url(const std::string& url) const; std::string window_id_; @@ -74,6 +78,7 @@ class WebWindow { bool context_menu_enabled_ = false; std::optional last_url_; bool webview_ready_ = false; + std::vector>> pending_evals_; Microsoft::WRL::ComPtr env_; Microsoft::WRL::ComPtr controller_; From 304d6761b3675219c4d021591fe6fbcd44cbf62a Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 24 Aug 2026 11:45:26 +0200 Subject: [PATCH 4/5] Add Windows session-reset test RPCs and flush queued JS evals on WebView2 ready. Master E2E now requires test.tray.list and test.session.reset. Also stop waiting on NavigationCompleted before eval, which timed out on GitHub runners. Co-authored-by: Cursor --- native/windows/src/host_controller.cpp | 28 ++++++++++++++++++++++++++ native/windows/src/host_controller.hpp | 1 + native/windows/src/web_window.cpp | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index 3a4bef4..911f90a 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -145,6 +145,7 @@ bool HostController::on_menu_command(UINT cmd) { } void HostController::client_disconnected() { + reset_session(); if (config_.lifetime == Lifetime::Coupled || config_.no_beam) { if (beam_process_) { TerminateProcess(beam_process_, 0); @@ -153,6 +154,22 @@ void HostController::client_disconnected() { } ExitProcess(0); } +} + +void HostController::reset_session() { + for (auto& [_, tray] : trays_) { + if (tray.registered) Shell_NotifyIconW(NIM_DELETE, &tray.nid); + } + trays_.clear(); + windows_.clear(); + webviews_.clear(); + for (auto& [_, menu] : menus_) destroy_menu_entry(menu); + menus_.clear(); + for (auto& [_, icon] : icons_) { + if (icon.icon) DestroyIcon(icon.icon); + } + icons_.clear(); + permission_policy_.clear(); initialized_ = false; } @@ -786,6 +803,17 @@ jsonutil::Json HostController::handle_test(const std::string& method, const json } return jsonutil::rpc_ok(id, items); } + if (method == "test.tray.list") { + jsonutil::Json items = jsonutil::Json::array(); + for (auto& [tid, _] : trays_) { + items.push_back({{"tray_id", tid}}); + } + return jsonutil::rpc_ok(id, items); + } + if (method == "test.session.reset") { + reset_session(); + return jsonutil::rpc_ok(id, true); + } if (method == "test.webview.eval") { auto wv = jsonutil::get_string(params, "webview_id"); auto script = jsonutil::get_string(params, "script"); diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp index 88d1953..82f248f 100644 --- a/native/windows/src/host_controller.hpp +++ b/native/windows/src/host_controller.hpp @@ -46,6 +46,7 @@ class HostController { private: void client_disconnected(); + void reset_session(); void spawn_beam(); std::string next_id(const std::string& prefix); diff --git a/native/windows/src/web_window.cpp b/native/windows/src/web_window.cpp index aaeb077..09da633 100644 --- a/native/windows/src/web_window.cpp +++ b/native/windows/src/web_window.cpp @@ -142,7 +142,7 @@ void WebWindow::create_webview() { resize_webview(); attach_handlers(); navigate_if_ready(); - if (!last_url_) flush_pending_evals(); + flush_pending_evals(); return S_OK; }) .Get()); From f2b0d80dc5975b4ea7fe9428322529fefdbef5b7 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 24 Aug 2026 11:53:59 +0200 Subject: [PATCH 5/5] Make WebView2 visible and disable GPU in CI so JS eval can complete. GitHub windows-latest was hanging test.webview.eval; show the controller and pass software/GPU flags used by other CI WebView2 hosts. Co-authored-by: Cursor --- .github/workflows/ci.yml | 1 + native/windows/src/web_window.cpp | 11 ++++++++++- test/e2e/e2e_test.exs | 9 +++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b06c3d..b3a5a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,3 +111,4 @@ jobs: run: mix test env: DESKTOP_WEBVIEW_BINARY: ${{ github.workspace }}\priv\native\windows\DesktopWebView.exe + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: --disable-gpu --disable-gpu-compositing diff --git a/native/windows/src/web_window.cpp b/native/windows/src/web_window.cpp index 09da633..cb95cfa 100644 --- a/native/windows/src/web_window.cpp +++ b/native/windows/src/web_window.cpp @@ -1,6 +1,8 @@ #include "web_window.hpp" #include "win_util.hpp" +#include + #include using Microsoft::WRL::Callback; @@ -113,8 +115,14 @@ void WebWindow::create_webview() { GetTempPathW(MAX_PATH, temp); std::wstring user_data = std::wstring(temp) + L"DesktopWebView\\" + utf8_to_wide(window_id_); + auto options = Microsoft::WRL::Make(); + if (options) { + options->put_AdditionalBrowserArguments( + L"--disable-gpu --disable-gpu-compositing --disable-software-rasterizer"); + } + HRESULT hr = CreateCoreWebView2EnvironmentWithOptions( - nullptr, user_data.c_str(), nullptr, + nullptr, user_data.c_str(), options.Get(), Callback( [this](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { if (FAILED(result) || !env) { @@ -138,6 +146,7 @@ void WebWindow::create_webview() { } controller_ = controller; controller_->get_CoreWebView2(&webview_); + controller_->put_IsVisible(TRUE); webview_ready_ = true; resize_webview(); attach_handlers(); diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index 9072b45..2e00f18 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -221,10 +221,11 @@ defmodule DesktopWebview.E2ETest do # JS eval fixture marker assert {:ok, result} = - Transport.call("test.webview.eval", %{ - "webview_id" => vid, - "script" => "document.title" - }) + Transport.call( + "test.webview.eval", + %{"webview_id" => vid, "script" => "document.title"}, + 15_000 + ) assert result == "EDW Media Fixture" or is_binary(result) end