diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index 387acda..7889f18 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -86,7 +86,7 @@ jobs: retention-days: 1 macos: - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout Repo uses: actions/checkout@v4 diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index d6850f2..88022ab 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -52,6 +52,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -116,6 +118,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -145,6 +149,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -294,7 +300,7 @@ jobs: # ── Desktop visual e2e (macOS WKWebView) ────────────────────────────────── e2e-macos: needs: build-natives - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 45 steps: - name: Checkout code diff --git a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h index 705afac..bf45f5b 100644 --- a/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h +++ b/webview-compose/src/jvmMain/native/windows/compose_webview_internal.h @@ -37,9 +37,11 @@ struct ComposeWebViewState { ComPtr webview2; ComPtr cookieManager; + /* The device of the host window's composition tree, shared by the views in that window, and this + * view's own visual in the tree. */ ComPtr dcompDevice; - ComPtr dcompTarget; ComPtr rootVisual; + bool inWindowTree = false; int xPx = 0; int yPx = 0; @@ -98,7 +100,7 @@ std::wstring compose_webview_utf8_to_wide(const std::string &u); std::string compose_webview_json_escape(const std::string &s); ComposeWebViewState *compose_webview_state_from_handle(jlong handle); -void compose_webview_pump_until_done(const std::atomic &done); +void compose_webview_pump_until_done(const std::vector &before, const std::atomic &done); void compose_webview_apply_bounds(ComposeWebViewState &s); void compose_webview_apply_rounded_clip(ComposeWebViewState &s); diff --git a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp index 62cb7ee..947e2db 100644 --- a/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp +++ b/webview-compose/src/jvmMain/native/windows/view_lifecycle.cpp @@ -1,5 +1,6 @@ #include "compose_webview_internal.h" +#include #include #include @@ -70,12 +71,142 @@ ComposeWebViewState *compose_webview_unregister(jlong handle) { /* ── Helpers ────────────────────────────────────────────────────────────── */ -void compose_webview_pump_until_done(const std::atomic &done) { +/* The module WebView2's objects come from, known once it has handed one over. */ +static HMODULE g_engineModule = nullptr; + +/* The module holding [address]. */ +static HMODULE moduleAt(const void *address) { + HMODULE module = nullptr; + if (!address) return nullptr; + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module); + return module; +} + +static BOOL CALLBACK addWindow(HWND window, LPARAM windows) { + reinterpret_cast *>(windows)->push_back(window); + return TRUE; +} + +/* The calling thread's windows: its top-level ones, those inside them, and its message-only ones. */ +static std::vector threadWindows() { + const DWORD thread = GetCurrentThreadId(); + std::vector windows; + EnumThreadWindows(thread, addWindow, reinterpret_cast(&windows)); + const size_t topLevel = windows.size(); + for (size_t i = 0; i < topLevel; i++) { + EnumChildWindows(windows[i], addWindow, reinterpret_cast(&windows)); + } + for (HWND w = nullptr; (w = FindWindowExW(HWND_MESSAGE, w, nullptr, nullptr)) != nullptr;) { + windows.push_back(w); + } + windows.erase( + std::remove_if(windows.begin(), windows.end(), [thread](HWND w) { + return GetWindowThreadProcessId(w, nullptr) != thread; + }), + windows.end()); + return windows; +} + +/* + * Whether [window] is one of WebView2's: of a class whose procedure is in WebView2's module. Until + * its first environment tells which module that is, a window is WebView2's when the call being + * waited on made it, so it is not among [before], the thread's windows from before that call. + */ +static bool isEngineWindow(HWND window, const std::vector &before) { + if (g_engineModule) { + const ULONG_PTR classProcedure = GetClassLongPtrW(window, GCLP_WNDPROC); + return moduleAt(reinterpret_cast(classProcedure)) == g_engineModule; + } + return std::find(before.begin(), before.end(), window) == before.end(); +} + +/* + * Waits until [done]: WebView2 finishes an asynchronous call through the calling thread's message + * queue. Only what is queued for WebView2's own windows is dispatched meanwhile. Everything else on + * the thread is the host's and stays queued for its loop. The host is in the middle of the call + * that waits here, and can only half act on a message handed to it now: its handlers would run + * inside that call, and its event loop sets aside a wake-up or a request to redraw it receives + * while busy, then sleeps waiting for the one that was taken off the queue here. + */ +void compose_webview_pump_until_done(const std::vector &before, const std::atomic &done) { + const HWND threadMessages = reinterpret_cast(static_cast(-1)); while (!done.load(std::memory_order_acquire)) { + bool dispatched = false; MSG msg; - if (GetMessageW(&msg, nullptr, 0, 0) <= 0) break; - TranslateMessage(&msg); - DispatchMessageW(&msg); + /* Takes nothing off the queue: a call into it is what lets through what other threads sent. */ + PeekMessageW(&msg, threadMessages, 0, 0, PM_NOREMOVE); + for (HWND window : threadWindows()) { + if (!isEngineWindow(window, before)) continue; + while (PeekMessageW(&msg, window, 0, 0, PM_REMOVE)) { + DispatchMessageW(&msg); + dispatched = true; + if (done.load(std::memory_order_acquire)) return; + } + } + if (!dispatched) MsgWaitForMultipleObjectsEx(0, nullptr, 5, QS_ALLINPUT, 0); + } +} + +/* Says on stderr which step of creating a view failed, and with what result. */ +static void logCreateFailure(const char *step, HRESULT hr) { + fprintf(stderr, "compose_webview: %s failed: 0x%08lX\n", step, static_cast(hr)); + fflush(stderr); +} + +/* + * The DirectComposition tree of one host window. A window takes a single topmost composition target, + * so the views in it share this one, each with a visual of its own under [root]. Touched on the + * window's UI thread only, where views are created and released. + */ +struct WindowTree { + ComPtr device; + ComPtr target; + ComPtr root; + int views = 0; +}; +static std::unordered_map g_windowTrees; + +/* Gives [s] its visual in its window's tree, which the window's first view creates. */ +static HRESULT joinWindowTree(ComposeWebViewState *s) { + auto it = g_windowTrees.find(s->parent); + if (it == g_windowTrees.end()) { + WindowTree tree; + HRESULT hr = DCompositionCreateDevice(nullptr, IID_PPV_ARGS(&tree.device)); + if (SUCCEEDED(hr)) hr = tree.device->CreateTargetForHwnd(s->parent, TRUE, &tree.target); + if (SUCCEEDED(hr)) hr = tree.device->CreateVisual(&tree.root); + if (SUCCEEDED(hr)) hr = tree.target->SetRoot(tree.root.Get()); + if (FAILED(hr)) return hr; + it = g_windowTrees.emplace(s->parent, std::move(tree)).first; + } + WindowTree &tree = it->second; + ComPtr visual; + HRESULT hr = tree.device->CreateVisual(&visual); + if (SUCCEEDED(hr)) hr = tree.root->AddVisual(visual.Get(), TRUE, nullptr); + if (FAILED(hr)) { + if (tree.views == 0) g_windowTrees.erase(it); + return hr; + } + tree.views++; + s->dcompDevice = tree.device; + s->rootVisual = visual; + s->inWindowTree = true; + return S_OK; +} + +/* Takes [s]'s visual out of its window's tree; the window's last view takes the tree with it. */ +static void leaveWindowTree(ComposeWebViewState *s) { + if (!s->inWindowTree) return; + s->inWindowTree = false; + auto it = g_windowTrees.find(s->parent); + if (it == g_windowTrees.end()) return; + WindowTree &tree = it->second; + if (s->rootVisual) tree.root->RemoveVisual(s->rootVisual.Get()); + if (--tree.views <= 0) { + g_windowTrees.erase(it); + } else { + tree.device->Commit(); } } @@ -193,6 +324,7 @@ ComposeWebViewState *compose_webview_create( auto *s = new ComposeWebViewState(); s->parent = parent; + const std::vector before = threadWindows(); std::wstring userDataFolder = opts.dataDirectory; if (userDataFolder.empty() && opts.incognito) { @@ -212,16 +344,23 @@ ComposeWebViewState *compose_webview_create( Callback( [&](HRESULT result, ICoreWebView2Environment *env) -> HRESULT { envResult = result; - if (SUCCEEDED(result) && env) s->env = env; + if (SUCCEEDED(result) && env) { + s->env = env; + /* A COM object starts with the address of its table of methods, which is in + * the module that implements it. */ + g_engineModule = moduleAt(*reinterpret_cast(env)); + } envDone.store(true, std::memory_order_release); return S_OK; }).Get()); if (FAILED(hr)) { + logCreateFailure("starting the WebView2 environment", hr); delete s; return nullptr; } - compose_webview_pump_until_done(envDone); + compose_webview_pump_until_done(before, envDone); if (FAILED(envResult) || !s->env) { + logCreateFailure("creating the WebView2 environment", envResult); delete s; return nullptr; } @@ -230,6 +369,7 @@ ComposeWebViewState *compose_webview_create( HRESULT ccResult = E_FAIL; ComPtr env3; if (FAILED(s->env.As(&env3)) || !env3) { + logCreateFailure("getting ICoreWebView2Environment3", E_NOINTERFACE); delete s; return nullptr; } @@ -243,20 +383,24 @@ ComposeWebViewState *compose_webview_create( return S_OK; }).Get()); if (FAILED(hr)) { + logCreateFailure("starting the composition controller", hr); delete s; return nullptr; } - compose_webview_pump_until_done(ccDone); + compose_webview_pump_until_done(before, ccDone); if (FAILED(ccResult) || !s->compController) { + logCreateFailure("creating the composition controller", ccResult); delete s; return nullptr; } if (FAILED(s->compController.As(&s->controller)) || !s->controller) { + logCreateFailure("getting the controller", E_NOINTERFACE); delete s; return nullptr; } if (FAILED(s->controller->get_CoreWebView2(&s->webview)) || !s->webview) { + logCreateFailure("getting the web view", E_FAIL); delete s; return nullptr; } @@ -305,14 +449,16 @@ ComposeWebViewState *compose_webview_create( s->controller->put_ZoomFactor(opts.zoomLevel); } - if (FAILED(DCompositionCreateDevice(nullptr, IID_PPV_ARGS(&s->dcompDevice))) || - FAILED(s->dcompDevice->CreateTargetForHwnd(parent, TRUE, &s->dcompTarget)) || - FAILED(s->dcompDevice->CreateVisual(&s->rootVisual))) { + hr = joinWindowTree(s); + if (FAILED(hr)) { + logCreateFailure("joining the window's composition tree", hr); delete s; return nullptr; } - s->dcompTarget->SetRoot(s->rootVisual.Get()); - if (FAILED(s->compController->put_RootVisualTarget(s->rootVisual.Get()))) { + hr = s->compController->put_RootVisualTarget(s->rootVisual.Get()); + if (FAILED(hr)) { + logCreateFailure("setting the view's root visual", hr); + leaveWindowTree(s); delete s; return nullptr; } @@ -371,6 +517,7 @@ void compose_webview_release(ComposeWebViewState *s) { compose_webview_uninstall_parent_subclass(s); compose_webview_unhook_events(s); if (s->controller) s->controller->Close(); + leaveWindowTree(s); delete s; }