From 0f0a85ecb390fe59b55922a3880537bcf2464489 Mon Sep 17 00:00:00 2001 From: Jacob Penn Date: Fri, 22 May 2026 13:30:55 -0700 Subject: [PATCH] WebView: forward keys to host responder chain by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OS web surface (WKWebView on macOS, WebView2 on Windows) becomes first responder on click and otherwise swallows every keystroke, which prevents plugin hosts from receiving transport, MIDI-typing, and DAW shortcut keys while the WebView has focus. This patch flips the default routing so keys go to the host responder chain unless the JS layer reports that an editable DOM element has focus. Public surface: - WebBrowserComponent::setEditableFocusActive(bool) — runtime toggle - Auto-registered native function __juceSetEditableFocusActive — JS entry point exposed via JUCE's existing getNativeFunction helper, only registered when native integration is already in use (no security regression for components loading untrusted content) macOS: extends the existing WebViewKeyEquivalentResponder with keyDown:/keyUp: overrides and adjusts performKeyEquivalent: so Cmd-shortcuts also bubble to the host when not editing. State is held in a std::atomic exposed per-instance through an ivar pointer, mirroring the existing LastFocusChange* ivar pattern. Wired into both WKWebViewImpl and the legacy WebViewImpl for consistency. Windows: switches CreateCoreWebView2Controller to CreateCoreWebView2ControllerWithOptions with AllowHostInputProcessing=TRUE so character keys (spacebar, QWERTY) reach the host's window proc, with a capability-gated fallback to plain Create when the environment or controller options interface isn't available. AcceleratorKeyPressed is subscribed unconditionally to cover Tab/Esc/F-keys/modifier combos on older runtimes — its put_Handled value tracks editableFocusActive too. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../misc/juce_WebBrowserComponent.cpp | 39 +++++++- .../misc/juce_WebBrowserComponent.h | 30 ++++++ .../native/juce_WebBrowserComponent_mac.mm | 78 +++++++++++++++ .../juce_WebBrowserComponent_windows.cpp | 98 +++++++++++++++++-- 4 files changed, 234 insertions(+), 11 deletions(-) diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp index b675b623bbd..b396bdd3f4e 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.cpp @@ -378,10 +378,30 @@ class NativeEventListeners class WebBrowserComponent::Impl { public: - Impl (WebBrowserComponent& ownerIn, const Options& optionsIn) + Impl (WebBrowserComponent& ownerIn, const Options& optionsInRaw) : owner (ownerIn), options ([&] { + // Auto-register a native function so JS can flip the editable-focus + // routing without every consumer wiring up their own bridge plumbing. + // Only registered when native integration is already in use — adding + // it unconditionally would force `withNativeIntegrationEnabled` on for + // every WebBrowserComponent, which JUCE flags as a security risk for + // components loading untrusted content. + const bool nativeIntegrationInUse = optionsInRaw.getNativeIntegrationsEnabled() + || ! optionsInRaw.getNativeFunctions().empty(); + + const auto optionsIn = nativeIntegrationInUse + ? optionsInRaw.withNativeFunction ( + "__juceSetEditableFocusActive", + [this] (const Array& args, const std::function& completion) + { + if (! args.isEmpty()) + setEditableFocusActive (static_cast (args[0])); + completion ({}); + }) + : optionsInRaw; + makeFunctionsProviderIfNecessary (nativeFunctionsProvider, *this, optionsIn); if (nativeFunctionsProvider.has_value()) @@ -483,6 +503,11 @@ class WebBrowserComponent::Impl platform->focusGainedWithDirection (type, dir); } + void setEditableFocusActive (bool active) + { + platform->setEditableFocusActive (active); + } + struct Platform; private: @@ -503,6 +528,13 @@ class WebBrowserComponent::Impl virtual void focusGainedWithDirection (FocusChangeType, FocusChangeDirection) {} virtual void fallbackPaint (Graphics&) {} + + // Default no-op — platforms that support host keyboard pass-through + // (macOS WKWebView, Windows WebView2) override this to flip the + // routing of OS-level key events between the host responder chain + // and the embedded DOM. See WebBrowserComponent::setEditableFocusActive + // for the semantics. + virtual void setEditableFocusActive (bool) {} }; static void makeFunctionsProviderIfNecessary (std::optional& provider, @@ -724,6 +756,11 @@ void WebBrowserComponent::focusGainedWithDirection (FocusChangeType type, impl->focusGainedWithDirection (type, direction); } +void WebBrowserComponent::setEditableFocusActive (bool editableFocusActive) +{ + impl->setEditableFocusActive (editableFocusActive); +} + #endif } // namespace juce diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h index f80e59cb1f3..73b65d7fa02 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h @@ -609,6 +609,36 @@ class JUCE_API WebBrowserComponent : public Component */ void emitEventIfBrowserIsVisible (const Identifier& eventId, const var& object); + /** Controls whether key events received by the underlying OS web surface should be + forwarded to the host responder chain instead of being consumed by the web view. + + When the WebBrowserComponent is hosted inside a plugin, the OS web surface + (WKWebView on macOS, WebView2 on Windows) becomes first responder on click and + swallows keystrokes — preventing host shortcuts (transport, MIDI typing keyboards, + etc.) from reaching the DAW. JUCE's default behaviour is to forward those keys + to the host so the DAW remains controllable. Call this with `true` while an + editable DOM element (e.g. an ``) has focus so character input reaches + the DOM, and `false` again when the editable element loses focus. + + Frontend code can drive this through the auto-registered native function + `__juceSetEditableFocusActive`, available via JUCE's `getNativeFunction` helper: + + @code + import { getNativeFunction } from 'juce-framework-frontend'; + const setEditableFocus = getNativeFunction('__juceSetEditableFocusActive'); + document.addEventListener('focusin', e => { + const t = e.target; + const editable = + t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + t instanceof HTMLSelectElement || + (t instanceof HTMLElement && t.isContentEditable); + setEditableFocus(editable); + }); + @endcode + */ + void setEditableFocusActive (bool editableFocusActive); + //============================================================================== /** This callback is called when the browser is about to navigate to a new location. diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm index d2ab19eb652..a36acbca2bf 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm @@ -176,6 +176,22 @@ static var fromObject (id object) return getIvar (instance, lastFocusChangeMemberName); } +// Per-instance pointer to the WKWebViewImpl's std::atomic tracking whether +// the JS layer has reported editable DOM focus. Read on every key event; when +// false (the default) the responder forwards keys to the host so the DAW +// receives transport and shortcut keys instead of WKWebView swallowing them. +static const char* editableFocusActiveMemberName = "editableFocusActiveHandle"; + +[[maybe_unused]] static void setEditableFocusActiveHandle (id instance, std::atomic* flag) +{ + object_setInstanceVariable (instance, editableFocusActiveMemberName, flag); +} + +[[maybe_unused]] static std::atomic* getEditableFocusActiveHandle (id instance) +{ + return getIvar*> (instance, editableFocusActiveMemberName); +} + #if JUCE_MAC template struct WebViewKeyEquivalentResponder final : public ObjCClass @@ -186,10 +202,55 @@ explicit WebViewKeyEquivalentResponder (bool acceptsFirstMouse) : Base ("WebViewKeyEquivalentResponder_") { this->template addIvar (lastFocusChangeMemberName); + this->template addIvar*> (editableFocusActiveMemberName); + + // When an editable DOM element has focus (signalled by JS via + // __juceSetEditableFocusActive), keys are routed to WKWebView's default + // handlers so character input reaches the DOM. Otherwise we forward them + // to the next responder so the plugin host (DAW) receives shortcuts and + // MIDI-typing keys. Default is "forward to host" — the OS web surface + // would otherwise swallow every keystroke once it became first responder. + // + // The lambdas passed to addMethod must be captureless so JUCE's + // toFnPtr can lower them to plain function pointers, so the + // editable-focus read is inlined into each method rather than living + // in a local helper closure. + + this->addMethod (@selector (keyDown:), + [] (id self, SEL selector, NSEvent* event) + { + auto* handle = getEditableFocusActiveHandle (self); + + if (handle != nullptr && handle->load()) + Base::template sendSuperclassMessage (self, selector, event); + else + [[self nextResponder] keyDown:event]; + }); + + this->addMethod (@selector (keyUp:), + [] (id self, SEL selector, NSEvent* event) + { + auto* handle = getEditableFocusActiveHandle (self); + + if (handle != nullptr && handle->load()) + Base::template sendSuperclassMessage (self, selector, event); + else + [[self nextResponder] keyUp:event]; + }); this->addMethod (@selector (performKeyEquivalent:), [] (id self, SEL selector, NSEvent* event) { + // Outside of editable focus, return NO so AppKit walks + // the responder chain past us — that lets host Cmd-shortcuts + // (DAW save, undo, transport variants) reach the plugin + // host instead of being trapped by WKWebView. + auto* handle = getEditableFocusActiveHandle (self); + const bool editableFocusActive = handle != nullptr && handle->load(); + + if (! editableFocusActive) + return (BOOL) NO; + const auto isCommandDown = [event] { const auto modifierFlags = [event modifierFlags]; @@ -702,6 +763,7 @@ static void displayError (WebBrowserComponent* owner, NSError* error) groupName: nsEmptyString()]); setLastFocusChangeHandle (webView.get(), &lastFocusChange); + setEditableFocusActiveHandle (webView.get(), &editableFocusActive); webView.get().customUserAgent = juceStringToNS (userAgent); @@ -809,9 +871,15 @@ void evaluateJavascript (const String&, WebBrowserComponent::EvaluationCallback) jassertfalse; } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + private: WebBrowserComponent& browser; LastFocusChange lastFocusChange; + std::atomic editableFocusActive { false }; ObjCObjectHandle webView; ObjCObjectHandle clickListener; }; @@ -903,6 +971,7 @@ void evaluateJavascript (const String&, WebBrowserComponent::EvaluationCallback) configuration: config.get()]); setLastFocusChangeHandle (webView.get(), &lastFocusChange); + setEditableFocusActiveHandle (webView.get(), &editableFocusActive); #else webView.reset ([[WKWebView alloc] initWithFrame: CGRectMake (0, 0, 100.0f, 100.0f) configuration: config.get()]); @@ -1208,6 +1277,11 @@ void evaluateJavascript (const String& script, WebBrowserComponent::EvaluationCa }]; } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + private: static inline auto blankPageUrl = "about:blank"; @@ -1215,6 +1289,10 @@ void evaluateJavascript (const String& script, WebBrowserComponent::EvaluationCa DelegateConnector delegateConnector; bool allowAccessToEnclosingDirectory = false; LastFocusChange lastFocusChange; + // Default false → keyDown forwards to the host responder chain. Flipped + // to true by the JS layer via __juceSetEditableFocusActive when an + // editable DOM element gains focus, so character input reaches the DOM. + std::atomic editableFocusActive { false }; ObjCObjectHandle webView; ObjCObjectHandle webViewDelegate; String lastRequestedUrl, lastLoadedUrl; diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp index 1f2afa47212..cbf3c64e313 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp @@ -517,6 +517,11 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom webViewController->MoveFocus (moveFocusReason); } + void setEditableFocusActive (bool active) override + { + editableFocusActive.store (active, std::memory_order_relaxed); + } + ~WebView2() override { if (webView2ConstructionHelper.webView2BeingCreated == this) @@ -961,6 +966,27 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom return S_OK; }).Get(), &moveFocusRequestedToken); + + // AcceleratorKeyPressed fires for accelerator keys (Tab, Esc, + // F-keys, modifier combos) before WebView2 processes them. Marking + // put_Handled(FALSE) yields the key to the host's window proc so + // the plugin host (DAW) receives Cmd/Ctrl shortcuts and transport + // controls — the default behaviour users expect. When the JS layer + // has reported editable DOM focus we set put_Handled(TRUE) so the + // WebView keeps the key for in-DOM editing (e.g. Tab to indent, + // Esc to dismiss a popover). This complements + // ICoreWebView2ControllerOptions2::AllowHostInputProcessing, which + // covers the character keys (spacebar, QWERTY) that + // AcceleratorKeyPressed does not fire for. + webViewController->add_AcceleratorKeyPressed ( + Callback ( + [this] (ICoreWebView2Controller*, ICoreWebView2AcceleratorKeyPressedEventArgs* args) -> HRESULT + { + if (args != nullptr) + args->put_Handled (editableFocusActive.load (std::memory_order_relaxed) ? TRUE : FALSE); + + return S_OK; + }).Get(), &acceleratorKeyPressedToken); } } @@ -994,6 +1020,9 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom { if (moveFocusRequestedToken.value != 0) webViewController->remove_MoveFocusRequested (moveFocusRequestedToken); + + if (acceleratorKeyPressedToken.value != 0) + webViewController->remove_AcceleratorKeyPressed (acceleratorKeyPressedToken); } } @@ -1057,8 +1086,8 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom webView2ConstructionHelper.viewsWaitingForCreation.erase (this); webView2ConstructionHelper.webView2BeingCreated = this; - webViewHandle.environment->CreateCoreWebView2Controller ((HWND) peer->getNativeHandle(), - Callback ( + const auto hostHwnd = (HWND) peer->getNativeHandle(); + auto completionHandler = Callback ( [weakThis = WeakReference { this }] (HRESULT, ICoreWebView2Controller* controller) -> HRESULT { if (weakThis != nullptr) @@ -1137,7 +1166,49 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom } return S_OK; - }).Get()); + }); + + // Prefer CreateCoreWebView2ControllerWithOptions so we can enable + // AllowHostInputProcessing — that routes keyboard/mouse input + // through the host's window proc, letting the plugin host (DAW) + // see character keys (spacebar, QWERTY) that WebView2 would + // otherwise consume entirely. AcceleratorKeyPressed handles the + // accelerator-key subset for runtimes/SDKs that don't expose the + // option. Falling back to the plain Create function keeps the + // backend working when either the environment (older runtime) or + // the controller options (older SDK) interface is unavailable. + const auto createWithHostInputProcessing = [&]() -> HRESULT + { + ComSmartPtr environment10; + webViewHandle.environment.QueryInterface (environment10); + + if (environment10 == nullptr) + return E_NOINTERFACE; + + ComSmartPtr controllerOptions; + + if (environment10->CreateCoreWebView2ControllerOptions (controllerOptions.resetAndGetPointerAddress()) != S_OK + || controllerOptions == nullptr) + { + return E_NOINTERFACE; + } + + ComSmartPtr controllerOptions2; + controllerOptions.QueryInterface (controllerOptions2); + + if (controllerOptions2 == nullptr) + return E_NOINTERFACE; + + if (controllerOptions2->put_AllowHostInputProcessing (TRUE) != S_OK) + return E_FAIL; + + return environment10->CreateCoreWebView2ControllerWithOptions (hostHwnd, + controllerOptions.get(), + completionHandler.Get()); + }(); + + if (! SUCCEEDED (createWithHostInputProcessing)) + webViewHandle.environment->CreateCoreWebView2Controller (hostHwnd, completionHandler.Get()); } } @@ -1206,16 +1277,23 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom ComSmartPtr webViewController; ComSmartPtr webView; - EventRegistrationToken navigationStartingToken { 0 }, - newWindowRequestedToken { 0 }, - windowCloseRequestedToken { 0 }, - navigationCompletedToken { 0 }, - webResourceRequestedToken { 0 }, - moveFocusRequestedToken { 0 }, - webMessageReceivedToken { 0 }; + EventRegistrationToken navigationStartingToken { 0 }, + newWindowRequestedToken { 0 }, + windowCloseRequestedToken { 0 }, + navigationCompletedToken { 0 }, + webResourceRequestedToken { 0 }, + moveFocusRequestedToken { 0 }, + webMessageReceivedToken { 0 }, + acceleratorKeyPressedToken { 0 }; bool inMoveFocusRequested = false; + // Default false → AcceleratorKeyPressed releases keys to the host's window + // proc so the DAW receives shortcuts. Flipped to true by the JS layer via + // __juceSetEditableFocusActive whenever an editable DOM element gains + // focus, so the WebView retains keys for in-DOM editing. + std::atomic editableFocusActive { false }; + struct URLRequest { String url;