From b56625dd3b65ed07687d04bf8b3cbeaca0b0c381 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Wed, 5 Aug 2026 10:43:13 +0200 Subject: [PATCH 1/2] 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/2] 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 = @(