diff --git a/AGENTS.md b/AGENTS.md index 16a0784..4092f3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ config :desktop, :menu_adapter, DesktopWebview.Menu.Adapter |------|---------| | `lib/desktop_webview/` | Elixir transport, launcher, backend, menu adapter | | `native/macos/` | Swift + AppKit + WKWebView host (primary) | -| `native/windows/` | WebView2 host (scaffold until implemented) | +| `native/windows/` | WebView2 host | | `native/linux/` | WebKitGTK host (scaffold until implemented) | | `docs/` | Protocol, packaging, **porting**, integration, per-platform status | | `test/` | Unit + Elixir E2E (drives the native binary) | diff --git a/README.md b/README.md index 9d77f09..2a7989e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ spawns the BEAM release; in development an existing BEAM can launch the host wit | Platform | Engine | Status | |----------|--------|--------| | macOS | WKWebView | Primary — see [docs/status/macos.md](docs/status/macos.md) | -| Windows | WebView2 | Planned — [docs/status/windows.md](docs/status/windows.md) | +| Windows | WebView2 | Done — [docs/status/windows.md](docs/status/windows.md) | | Linux | WebKitGTK | Usable — [docs/status/linux.md](docs/status/linux.md) | ## Quick start (development) diff --git a/docs/status/windows.md b/docs/status/windows.md index 4c5a6fe..1ecfb61 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -32,6 +32,8 @@ Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/ | Permission policy hybrid | done | E2E simulate + policy | | Microphone in webview | done | Permission RPC + WebView2 kinds | | Camera in webview | done | Permission RPC + WebView2 kinds | +| Native dialogs | done | IFileOpenDialog + Win32 prompt | +| Host-driven BEAM restart | done | `restart_beam` ini + process wait | | 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/src/config.cpp b/native/windows/src/config.cpp index 7502c32..fa4f37e 100644 --- a/native/windows/src/config.cpp +++ b/native/windows/src/config.cpp @@ -150,6 +150,15 @@ void HostConfig::apply_ini() { while (args >> tok) beam_args.push_back(tok); } if (auto v = ini.get("beam", "working_dir")) beam_working_dir = *v; + if (auto v = ini.get("lifetime", "restart_beam")) { + restart_beam = !(*v == "false" || *v == "0"); + } + if (auto v = ini.get("lifetime", "restart_max_attempts")) { + restart_max_attempts = std::stoi(*v); + } + if (auto v = ini.get("lifetime", "restart_backoff_ms")) { + restart_backoff_ms = static_cast(std::stoul(*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 index 52101ef..f3b0058 100644 --- a/native/windows/src/config.hpp +++ b/native/windows/src/config.hpp @@ -20,6 +20,10 @@ struct HostConfig { std::vector beam_args{"start"}; std::optional beam_working_dir; bool beam_enabled = true; + // Host-driven BEAM restart (packaged host-first; replaces heart). + bool restart_beam = true; + int restart_max_attempts = 0; + uint32_t restart_backoff_ms = 500; std::map extra_env; std::vector forwarded_argv; diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index 911f90a..ea7f339 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -1,10 +1,15 @@ #include "host_controller.hpp" #include "win_util.hpp" +#include +#include +#include #include #include #include +#include #include +#include namespace { @@ -23,6 +28,12 @@ struct RequestMsg { HostController::HostController(HostConfig config) : config_(std::move(config)) {} HostController::~HostController() { + quit_initiated_ = true; + if (respawn_timer_id_) { + KillTimer(hwnd_, respawn_timer_id_); + respawn_timer_id_ = 0; + } + clear_beam_watch(); if (beam_process_) { TerminateProcess(beam_process_, 0); CloseHandle(beam_process_); @@ -103,6 +114,25 @@ LRESULT HostController::on_host_message(HWND hwnd, UINT msg, WPARAM wParam, LPAR client_disconnected(); return 0; } + if (msg == WM_EDW_BEAM_EXIT) { + beam_did_exit(); + return 0; + } + if (msg == WM_EDW_RESPAWN) { + respawn_timer_id_ = 0; + if (config_.beam_enabled && !config_.no_beam) { + spawn_beam(); + } + return 0; + } + if (msg == WM_TIMER && wParam == 1) { + KillTimer(hwnd, 1); + respawn_timer_id_ = 0; + if (config_.beam_enabled && !config_.no_beam) { + spawn_beam(); + } + return 0; + } if (msg == WM_EDW_TRAY) { if (lParam == WM_LBUTTONUP || lParam == WM_RBUTTONUP) { // Find tray by uID @@ -147,6 +177,8 @@ bool HostController::on_menu_command(UINT cmd) { void HostController::client_disconnected() { reset_session(); if (config_.lifetime == Lifetime::Coupled || config_.no_beam) { + quit_initiated_ = true; + clear_beam_watch(); if (beam_process_) { TerminateProcess(beam_process_, 0); CloseHandle(beam_process_); @@ -156,6 +188,78 @@ void HostController::client_disconnected() { } } +void HostController::clear_beam_watch() { + if (beam_wait_) { + UnregisterWaitEx(beam_wait_, INVALID_HANDLE_VALUE); + beam_wait_ = nullptr; + } +} + +void HostController::watch_beam_process() { + clear_beam_watch(); + if (!beam_process_ || !hwnd_) return; + // RegisterWaitForSingleObject callback runs on a thread pool thread; bounce + // back to the UI thread via PostMessage so we can respawn safely. + HANDLE wait = nullptr; + if (!RegisterWaitForSingleObject( + &wait, beam_process_, + [](PVOID ctx, BOOLEAN /*timed_out*/) { + auto* self = static_cast(ctx); + if (self && self->hwnd_) { + PostMessageW(self->hwnd_, WM_EDW_BEAM_EXIT, 0, 0); + } + }, + this, INFINITE, WT_EXECUTEONLYONCE)) { + fprintf(stderr, "edw: RegisterWaitForSingleObject failed (%lu)\n", GetLastError()); + return; + } + beam_wait_ = wait; +} + +void HostController::beam_did_exit() { + clear_beam_watch(); + if (beam_process_) { + CloseHandle(beam_process_); + beam_process_ = nullptr; + } + reset_session(); + if (respawn_timer_id_) { + KillTimer(hwnd_, respawn_timer_id_); + respawn_timer_id_ = 0; + } + if (quit_initiated_) return; + if (expected_beam_exit_) { + expected_beam_exit_ = false; + return; + } + if (should_respawn_beam()) { + schedule_beam_respawn(); + } +} + +bool HostController::should_respawn_beam() { + if (!config_.restart_beam) return false; + if (config_.restart_max_attempts > 0 && + beam_restart_attempts_ >= config_.restart_max_attempts) { + fprintf(stderr, "edw: beam exited; restart limit reached, terminating host\n"); + PostQuitMessage(1); + return false; + } + return true; +} + +void HostController::schedule_beam_respawn() { + beam_restart_attempts_ += 1; + int shift = (std::min)(beam_restart_attempts_ - 1, 4); + uint32_t multiplier = static_cast(1) << shift; + uint32_t backoff = (std::min)(config_.restart_backoff_ms * multiplier, 5000u); + respawn_timer_id_ = SetTimer(hwnd_, 1, backoff, nullptr); + if (!respawn_timer_id_) { + fprintf(stderr, "edw: SetTimer failed; respawning immediately\n"); + spawn_beam(); + } +} + void HostController::reset_session() { for (auto& [_, tray] : trays_) { if (tray.registered) Shell_NotifyIconW(NIM_DELETE, &tray.nid); @@ -190,13 +294,22 @@ void HostController::spawn_beam() { WIN32_FIND_DATAA fd{}; HANDLE h = FindFirstFileA((bin + "\\*").c_str(), &fd); if (h != INVALID_HANDLE_VALUE) { + std::string first_any; + std::string first_bat; do { if (fd.cFileName[0] == '.') continue; if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - app_name = fd.cFileName; - break; + std::string name = fd.cFileName; + if (first_any.empty()) first_any = name; + auto lower = name; + for (auto& c : lower) c = static_cast(tolower(static_cast(c))); + if (first_bat.empty() && lower.size() >= 4 && + (lower.substr(lower.size() - 4) == ".bat" || lower.substr(lower.size() - 4) == ".cmd")) { + first_bat = name; + } } while (FindNextFileA(h, &fd)); FindClose(h); + app_name = !first_bat.empty() ? first_bat : first_any; } } if (app_name.empty()) { @@ -204,14 +317,30 @@ void HostController::spawn_beam() { return; } std::string script = join_path(join_path(beam_dir, "bin"), app_name); + if (!file_exists(script)) { + if (file_exists(script + ".bat")) + script += ".bat"; + else if (file_exists(script + ".cmd")) + script += ".cmd"; + } std::string wd = config_.beam_working_dir ? (is_absolute_path(*config_.beam_working_dir) ? *config_.beam_working_dir : join_path(root, *config_.beam_working_dir)) : beam_dir; + auto lower_script = script; + for (auto& c : lower_script) c = static_cast(tolower(static_cast(c))); + bool is_batch = lower_script.size() >= 4 && (lower_script.substr(lower_script.size() - 4) == ".bat" || + lower_script.substr(lower_script.size() - 4) == ".cmd"); + std::ostringstream cmd; - cmd << '"' << script << '"'; + if (is_batch) { + // CreateProcess cannot launch .bat directly; go through cmd.exe. + cmd << "cmd.exe /c \"" << script << "\""; + } else { + cmd << '"' << script << '"'; + } for (auto& a : config_.beam_args) cmd << ' ' << a; for (auto& a : config_.forwarded_argv) cmd << ' ' << a; std::string cmdline = cmd.str(); @@ -246,13 +375,21 @@ void HostController::spawn_beam() { std::vector mutable_cmd(wcmd.begin(), wcmd.end()); mutable_cmd.push_back(L'\0'); + clear_beam_watch(); + if (beam_process_) { + CloseHandle(beam_process_); + beam_process_ = nullptr; + } + 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"); + fprintf(stderr, "edw: failed to spawn beam (%lu): %s\n", GetLastError(), cmdline.c_str()); return; } CloseHandle(pi.hThread); beam_process_ = pi.hProcess; + beam_restart_attempts_ = 0; + watch_beam_process(); } void HostController::handle_request(jsonutil::Json id, const std::string& method, @@ -767,14 +904,180 @@ jsonutil::Json HostController::dispatch(const std::string& method, const jsonuti return true; } - if (method == "dialog.choose_file" || method == "dialog.choose_directory" || - method == "dialog.prompt") { - throw HostError{-32004, "dialog RPCs not implemented on Windows yet"}; + if (method == "system.prepare_quit") { + // Elixir is about to exit intentionally (Updater restart or clean quit). + // Treat the forthcoming BEAM exit as expected so we do not respawn. + expected_beam_exit_ = true; + quit_initiated_ = true; + return true; } + if (method == "dialog.choose_file") return dialog_choose(p, false); + if (method == "dialog.choose_directory") return dialog_choose(p, true); + if (method == "dialog.prompt") return dialog_prompt(p); + throw HostError{-32601, "Method not found: " + method}; } +jsonutil::Json HostController::dialog_choose(const jsonutil::Json& params, bool directories) { + IFileOpenDialog* dialog = nullptr; + HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&dialog)); + if (FAILED(hr) || !dialog) throw HostError{-32603, "FileOpenDialog unavailable"}; + + DWORD options = 0; + dialog->GetOptions(&options); + options |= FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST; + if (directories) options |= FOS_PICKFOLDERS; + dialog->SetOptions(options); + + if (auto title = jsonutil::get_string(params, "title")) { + dialog->SetTitle(utf8_to_wide(*title).c_str()); + } + if (auto path = jsonutil::get_string(params, "default_path"); path && !path->empty()) { + IShellItem* folder = nullptr; + if (SUCCEEDED(SHCreateItemFromParsingName(utf8_to_wide(*path).c_str(), nullptr, + IID_PPV_ARGS(&folder))) && + folder) { + dialog->SetFolder(folder); + folder->Release(); + } + } + + hr = dialog->Show(hwnd_); + if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + dialog->Release(); + return nullptr; + } + if (FAILED(hr)) { + dialog->Release(); + throw HostError{-32603, "dialog failed"}; + } + + IShellItem* item = nullptr; + hr = dialog->GetResult(&item); + dialog->Release(); + if (FAILED(hr) || !item) return nullptr; + + PWSTR file_path = nullptr; + hr = item->GetDisplayName(SIGDN_FILESYSPATH, &file_path); + item->Release(); + if (FAILED(hr) || !file_path) return nullptr; + + std::string path = wide_to_utf8(file_path); + CoTaskMemFree(file_path); + return jsonutil::Json{{"path", path}}; +} + +namespace { + +struct PromptState { + std::wstring title; + std::wstring message; + std::wstring value; + bool accepted = false; +}; + +INT_PTR CALLBACK PromptDlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + auto* state = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_INITDIALOG: { + state = reinterpret_cast(lParam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(state)); + SetWindowTextW(hwnd, state->title.c_str()); + SetDlgItemTextW(hwnd, 1001, state->message.c_str()); + SetDlgItemTextW(hwnd, 1002, state->value.c_str()); + return TRUE; + } + case WM_COMMAND: + if (LOWORD(wParam) == IDOK) { + wchar_t buf[1024]; + GetDlgItemTextW(hwnd, 1002, buf, 1024); + if (state) { + state->value = buf; + state->accepted = true; + } + EndDialog(hwnd, IDOK); + return TRUE; + } + if (LOWORD(wParam) == IDCANCEL) { + EndDialog(hwnd, IDCANCEL); + return TRUE; + } + break; + } + return FALSE; +} + +} // namespace + +jsonutil::Json HostController::dialog_prompt(const jsonutil::Json& params) { + PromptState state; + state.title = utf8_to_wide(jsonutil::get_string(params, "title").value_or("")); + state.message = utf8_to_wide(jsonutil::get_string(params, "message").value_or("")); + state.value = utf8_to_wide(jsonutil::get_string(params, "default_value").value_or("")); + + // In-memory dialog template: label, edit, OK, Cancel. + std::vector bytes; + bytes.reserve(1024); + auto align4 = [&]() { + while (bytes.size() % 4) bytes.push_back(0); + }; + auto append_words = [&](std::initializer_list words) { + for (WORD w : words) { + bytes.push_back(static_cast(w & 0xff)); + bytes.push_back(static_cast((w >> 8) & 0xff)); + } + }; + auto append_wstring = [&](const wchar_t* s) { + while (*s) { + append_words({static_cast(*s)}); + ++s; + } + append_words({0}); + }; + + DLGTEMPLATE header{}; + header.style = DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU; + header.cdit = 4; + header.cx = 220; + header.cy = 90; + const auto header_off = bytes.size(); + bytes.resize(header_off + sizeof(DLGTEMPLATE)); + memcpy(bytes.data() + header_off, &header, sizeof(header)); + append_words({0, 0}); // menu, class + append_wstring(L""); // title + + auto add_control = [&](DWORD style, short x, short y, short cx, short cy, WORD id, WORD class_atom, + const wchar_t* text) { + align4(); + DLGITEMTEMPLATE item{}; + item.style = style | WS_CHILD | WS_VISIBLE; + item.x = x; + item.y = y; + item.cx = cx; + item.cy = cy; + item.id = id; + const auto off = bytes.size(); + bytes.resize(off + sizeof(DLGITEMTEMPLATE)); + memcpy(bytes.data() + off, &item, sizeof(item)); + append_words({0xFFFF, class_atom}); + append_wstring(text); + append_words({0}); // creation data + }; + + add_control(SS_LEFT, 8, 8, 200, 24, 1001, 0x0082, L""); + add_control(ES_LEFT | ES_AUTOHSCROLL | WS_BORDER | WS_TABSTOP, 8, 36, 200, 14, 1002, 0x0081, L""); + add_control(BS_DEFPUSHBUTTON | WS_TABSTOP, 100, 60, 50, 14, IDOK, 0x0080, L"OK"); + add_control(BS_PUSHBUTTON | WS_TABSTOP, 156, 60, 50, 14, IDCANCEL, 0x0080, L"Cancel"); + + INT_PTR result = DialogBoxIndirectParamW( + GetModuleHandleW(nullptr), reinterpret_cast(bytes.data()), hwnd_, PromptDlgProc, + reinterpret_cast(&state)); + if (result != IDOK || !state.accepted) return nullptr; + return jsonutil::Json{{"value", wide_to_utf8(state.value.c_str())}}; +} + jsonutil::Json HostController::handle_test(const std::string& method, const jsonutil::Json& params, const jsonutil::Json& id, bool* async_reply, RpcServer::ReplyFn reply) { diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp index 82f248f..f66355f 100644 --- a/native/windows/src/host_controller.hpp +++ b/native/windows/src/host_controller.hpp @@ -5,6 +5,7 @@ #include "rpc_server.hpp" #include "web_window.hpp" +#include #include #include #include @@ -43,11 +44,18 @@ class HostController { 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; + static constexpr UINT WM_EDW_BEAM_EXIT = WM_APP + 13; + static constexpr UINT WM_EDW_RESPAWN = WM_APP + 14; private: void client_disconnected(); void reset_session(); void spawn_beam(); + void beam_did_exit(); + bool should_respawn_beam(); + void schedule_beam_respawn(); + void watch_beam_process(); + void clear_beam_watch(); std::string next_id(const std::string& prefix); void handle_request(jsonutil::Json id, const std::string& method, jsonutil::Json params, @@ -74,6 +82,9 @@ class HostController { void update_tray_icon(TrayEntry& tray); void destroy_menu_entry(MenuEntry& entry); + jsonutil::Json dialog_choose(const jsonutil::Json& params, bool directories); + jsonutil::Json dialog_prompt(const jsonutil::Json& params); + void open_external(const std::string& url); void handle_permission(const std::string& origin, const std::string& type, const std::string& webview_id, @@ -86,8 +97,12 @@ class HostController { RpcServer server_; HWND hwnd_ = nullptr; bool initialized_ = false; + bool quit_initiated_ = false; + bool expected_beam_exit_ = false; int id_counter_ = 0; + int beam_restart_attempts_ = 0; UINT next_menu_cmd_ = 1000; + UINT_PTR respawn_timer_id_ = 0; std::map> windows_; std::map webviews_; std::map menus_; @@ -95,4 +110,5 @@ class HostController { std::map icons_; std::map> permission_policy_; HANDLE beam_process_ = nullptr; + HANDLE beam_wait_ = nullptr; }; diff --git a/native/windows/src/win_prefix.hpp b/native/windows/src/win_prefix.hpp index 2120f73..ddca2ec 100644 --- a/native/windows/src/win_prefix.hpp +++ b/native/windows/src/win_prefix.hpp @@ -8,5 +8,8 @@ #include #include #include +#include #include #include +#include +#include diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 7e86ea1..c106e1d 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -9,63 +9,40 @@ $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" - @" +$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" } + +$cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" +$bat = Join-Path $env:TEMP ("edw-build-" + [guid]::NewGuid().ToString("n") + ".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 -} +call "$vcvars" || exit /b 1 +cd /d "$Root" +cmake -S native\windows -B native\windows\build -G Ninja -DCMAKE_BUILD_TYPE=Release +if errorlevel 1 ( + rmdir /s /q native\windows\build 2>nul + mkdir native\windows\build + cmake -S native\windows -B native\windows\build -DCMAKE_BUILD_TYPE=Release || exit /b 1 +) +cmake --build native\windows\build --config Release --parallel || exit /b 1 +if exist native\windows\build\DesktopWebView.exe ( + copy /y native\windows\build\DesktopWebView.exe priv\native\windows\DesktopWebView.exe >nul +) else if exist native\windows\build\Release\DesktopWebView.exe ( + copy /y native\windows\build\Release\DesktopWebView.exe priv\native\windows\DesktopWebView.exe >nul +) else ( + echo DesktopWebView.exe not found + exit /b 1 +) +echo Built priv\native\windows\DesktopWebView.exe +"@ | Set-Content -Path $bat -Encoding ASCII -$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) { - 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 --parallel - 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')" + & $cmdExe /c $bat + if ($LASTEXITCODE -ne 0) { throw "Windows host build failed with exit $LASTEXITCODE" } } finally { - Pop-Location + Remove-Item $bat -ErrorAction SilentlyContinue }