diff --git a/android/src/toga_android/widgets/internal/webview.py b/android/src/toga_android/widgets/internal/webview.py new file mode 100644 index 0000000000..beba38e086 --- /dev/null +++ b/android/src/toga_android/widgets/internal/webview.py @@ -0,0 +1,29 @@ +import weakref + +from android.webkit import WebResourceRequest, WebView as A_WebView, WebViewClient +from java import Override, jboolean, static_proxy + + +class TogaWebClient(static_proxy(WebViewClient)): + def __init__(self, impl): + super().__init__() + self._webview_impl_ref = weakref.ref(impl) + + @property + def webview_impl(self): + return self._webview_impl_ref() + + @Override(jboolean, [A_WebView, WebResourceRequest]) + def shouldOverrideUrlLoading(self, webview, webresourcerequest): + allow = True + if self.webview_impl.interface.on_navigation_starting._raw: + url = webresourcerequest.getUrl().toString() + result = self.webview_impl.interface.on_navigation_starting(url=url) + if isinstance(result, bool): + # on_navigation_starting handler is synchronous + allow = result + else: + # on_navigation_starting handler is asynchronous. Deny navigation until + # the user defined on_navigation_starting coroutine has completed. + allow = False + return not allow diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 7699e8f56f..88b471e52e 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -3,6 +3,7 @@ from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy +from java.lang import NoClassDefFoundError from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -25,12 +26,27 @@ def onReceiveValue(self, value): class WebView(Widget): SUPPORTS_ON_WEBVIEW_LOAD = False + ON_NAVIGATION_CONFIG_MISSING_ERROR = ( + "Can't add a WebView.on_navigation_starting handler; Have you added chaquopy." + 'defaultConfig.staticProxy("toga_android.widgets.internal.webview") to the' + "`build_gradle_extra_content` section of pyproject.toml?" + ) def create(self): self.native = A_WebView(self._native_activity) + try: + from .internal.webview import TogaWebClient + + self.SUPPORTS_ON_NAVIGATION_STARTING = True + client = TogaWebClient(self) + except NoClassDefFoundError: # pragma: no cover + # Briefcase configuration hasn't declared a static proxy + self.SUPPORTS_ON_NAVIGATION_STARTING = False + client = WebViewClient() + # Set a WebViewClient so that new links open in this activity, # rather than triggering the phone's web browser. - self.native.setWebViewClient(WebViewClient()) + self.native.setWebViewClient(client) self.settings = self.native.getSettings() self.default_user_agent = self.settings.getUserAgentString() diff --git a/changes/3442.feature.rst b/changes/3442.feature.rst new file mode 100644 index 0000000000..af9ff12988 --- /dev/null +++ b/changes/3442.feature.rst @@ -0,0 +1 @@ +The WebView widget now supports an on_navigation_starting handler to prevent user-defined URLs from being loaded diff --git a/cocoa/src/toga_cocoa/libs/webkit.py b/cocoa/src/toga_cocoa/libs/webkit.py index 9d5f9605cf..3d26a8c8c2 100644 --- a/cocoa/src/toga_cocoa/libs/webkit.py +++ b/cocoa/src/toga_cocoa/libs/webkit.py @@ -2,6 +2,8 @@ # System/Library/Frameworks/WebKit.framework ########################################################################## +from enum import IntFlag + from rubicon.objc import ObjCClass, ObjCProtocol from rubicon.objc.runtime import load_library @@ -16,3 +18,11 @@ ###################################################################### # WKFrameInfo.h WKUIDelegate = ObjCProtocol("WKUIDelegate") + + +###################################################################### +# WkNavigationDelegate.h +class WKNavigationResponsePolicy(IntFlag): + Cancel = 0 + Allow = 1 + Download = 2 diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index b33cc35349..43e2376f2e 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -1,6 +1,13 @@ from http.cookiejar import Cookie, CookieJar -from rubicon.objc import ObjCBlock, objc_id, objc_method, objc_property, py_from_ns +from rubicon.objc import ( + NSInteger, + ObjCBlock, + objc_id, + objc_method, + objc_property, + py_from_ns, +) from travertino.size import at_least from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -13,6 +20,7 @@ NSModalResponseOK, NSOpenPanel, NSURLRequest, + WKNavigationResponsePolicy, WKUIDelegate, WKWebView, ) @@ -72,7 +80,49 @@ class TogaWebView(WKWebView, protocols=[WKUIDelegate]): impl = objc_property(object, weak=True) @objc_method - def webView_didFinishNavigation_(self, navigation) -> None: + def webView_decidePolicyForNavigationAction_decisionHandler_( + self, + webview, + navigationAction, + decisionHandler, + ) -> None: + _decision_handler = ObjCBlock(decisionHandler, None, NSInteger) + if ( + str(navigationAction.request.URL) == self.impl._allowed_url + or self.impl.interface.on_navigation_starting._raw is None + ): + # If URL is pre-approved, or there's no navigation handler, + # allow the navigation. + _decision_handler(WKNavigationResponsePolicy.Allow) + self.impl._allowed_url = None + else: + url = str(navigationAction.request.URL) + allow = self.impl.interface.on_navigation_starting(url=url) + if isinstance(allow, bool | None): + # on_navigation_starting handler is synchronous + if allow: + decision = WKNavigationResponsePolicy.Allow + else: + decision = WKNavigationResponsePolicy.Cancel + + _decision_handler(decision) + else: + # on_navigation_starting handler is asynchronous. Attach a completion + # handler so that when the async handler completes, the webView + # decision handler is invoked. + + def on_navigation_decision(future): + if future.result(): + decision = WKNavigationResponsePolicy.Allow + else: + decision = WKNavigationResponsePolicy.Cancel + + ObjCBlock(decisionHandler, None, NSInteger)(decision) + + allow.add_done_callback(on_navigation_decision) + + @objc_method + def webView_didFinishNavigation_(self, webView, navigation) -> None: # It's possible for this handler to be invoked *after* the interface/impl object # has been destroyed. If the interface/impl doesn't exist there's no handler to # invoke either, so ignore the edge case. This can't be reproduced reliably, so @@ -189,6 +239,10 @@ def create(self): self.loaded_future = None + # Store any URL that has been the subject of a direct navigation request, + # as these have been pre-approved for navigation + self._allowed_url = None + # Add the layout constraints self.add_constraints() @@ -197,6 +251,10 @@ def get_url(self): return None if url == "about:blank" else url def set_url(self, value, future=None): + if self.interface.on_navigation_starting._raw: + # mark URL as being allowed + self._allowed_url = value + if value: request = NSURLRequest.requestWithURL(NSURL.URLWithString(value)) else: @@ -206,6 +264,9 @@ def set_url(self, value, future=None): self.native.loadRequest(request) def set_content(self, root_url, content): + if self.interface.on_navigation_starting._raw: + # mark URL as being allowed + self._allowed_url = root_url self.native.loadHTMLString(content, baseURL=NSURL.URLWithString(root_url)) def get_user_agent(self): diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 5b066b07a0..7b8e0fde3b 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -87,7 +87,7 @@ async def handler_with_cleanup( else: if cleanup: try: - cleanup(interface, result) + cleanup(interface, result, *args, **kwargs) except Exception as e: print("Error in async handler cleanup:", e, file=sys.stderr) traceback.print_exc() @@ -171,7 +171,7 @@ def _handler(*args: object, **kwargs: object) -> object: else: try: if cleanup: - cleanup(interface, result) + cleanup(interface, result, *args, **kwargs) return result except Exception as e: print("Error in handler cleanup:", e, file=sys.stderr) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 51371c94b1..ed1c68bb3c 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -25,6 +25,18 @@ def __call__(self, widget: WebView, **kwargs: Any) -> None: """ +class OnNavigationStartingHandler(Protocol): + def __call__(self, widget: WebView, url: str, **kwargs: Any) -> bool: + """A handler to invoke when the WebView is requesting permission to navigate or + redirect to a different URI. + + :param widget: The WebView that has requested a new URI. + :param url: The URI that has been requested. + :param kwargs: Ensures compatibility with arguments added in future versions. + :returns: True if navigation to the requested URL is allowed; False otherwise. + """ + + class WebView(Widget): def __init__( self, @@ -33,6 +45,7 @@ def __init__( url: str | None = None, content: str | None = None, user_agent: str | None = None, + on_navigation_starting: OnNavigationStartingHandler | None = None, on_webview_load: OnWebViewLoadHandler | None = None, **kwargs, ): @@ -50,6 +63,9 @@ def __init__( value provided for the `url` argument will be ignored. :param user_agent: The user agent to use for web requests. If not provided, the default user agent for the platform will be used. + :param on_navigation_starting: A handler that will be invoked when the + web view is requesting permission to navigate or redirect + to a different URI. :param on_webview_load: A handler that will be invoked when the web view finishes loading. :param kwargs: Initial style properties. @@ -61,6 +77,9 @@ def __init__( # Set the load handler before loading the first URL. self.on_webview_load = on_webview_load + # Set the handler for URL filtering + self.on_navigation_starting = on_navigation_starting + # Load both content and root URL if it's provided by the user. # Otherwise, load the URL only. if content is not None: @@ -106,6 +125,45 @@ async def load_url(self, url: str) -> asyncio.Future: self._set_url(url, future=loaded_future) return await loaded_future + @property + def on_navigation_starting(self) -> OnNavigationStartingHandler: + """A handler that will be invoked when the webview is requesting + permission to navigate or redirect to a different URI. + + The handler will receive the requested URL as an argument. It should + return `True` if navigation to the given URL is permitted, or `False` + if navigation to the URL should be blocked. + + **Note:** This is not currently supported on GTK or Qt. + """ + return self._on_navigation_starting + + @on_navigation_starting.setter + def on_navigation_starting(self, handler): + """Set the handler to invoke when the webview starts navigating.""" + if handler: + if getattr(self._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): + + def cleanup(widget, result, url=None, **kwargs): + if result is True: + # navigate to the url + self.url = url + + else: + cleanup = None + try: + # Some backends (e.g., Android) will dynamically disable + # SUPPORTS_ON_NAVIGATION_STARTING based on configuration. + # If that happens, display an appropriate error; fall back + # to a simple "non implemented" otherwise. + print(self._impl.ON_NAVIGATION_CONFIG_MISSING_ERROR) + except AttributeError: + self.factory.not_implemented("WebView.on_navigation_starting") + else: + cleanup = None + + self._on_navigation_starting = wrapped_handler(self, handler, cleanup=cleanup) + @property def on_webview_load(self) -> OnWebViewLoadHandler: """The handler to invoke when the web view finishes loading. diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 79cbf0bd5c..09aab8402e 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -148,7 +148,7 @@ def handler(*args, **kwargs): } # Cleanup method was invoked - cleanup.assert_called_once_with(obj, 42) + cleanup.assert_called_once_with(obj, 42, "arg1", "arg2", kwarg1=3, kwarg2=4) def test_function_handler_with_cleanup_error(capsys): @@ -177,7 +177,7 @@ def handler(*args, **kwargs): } # Cleanup method was invoked - cleanup.assert_called_once_with(obj, 42) + cleanup.assert_called_once_with(obj, 42, "arg1", "arg2", kwarg1=3, kwarg2=4) # Evidence of the handler cleanup error is in the log. assert ( @@ -439,7 +439,7 @@ async def handler(*args, **kwargs): } # Cleanup method was invoked - cleanup.assert_called_once_with(obj, 42) + cleanup.assert_called_once_with(obj, 42, "arg1", "arg2", kwarg1=3, kwarg2=4) async def test_coroutine_handler_with_cleanup_error(capsys): @@ -471,7 +471,7 @@ async def handler(*args, **kwargs): } # Cleanup method was invoked - cleanup.assert_called_once_with(obj, 42) + cleanup.assert_called_once_with(obj, 42, "arg1", "arg2", kwarg1=3, kwarg2=4) # Evidence of the handler cleanup error is in the log. assert ( diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 951e133a1a..e4363a929f 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -34,12 +34,14 @@ def test_widget_created(): def test_create_with_values(): """A WebView can be created with initial values.""" on_webview_load = Mock() + on_navigation_starting = Mock() widget = toga.WebView( id="foobar", url="https://beeware.org", user_agent="Custom agent", on_webview_load=on_webview_load, + on_navigation_starting=on_navigation_starting, # A style property width=256, ) @@ -50,6 +52,7 @@ def test_create_with_values(): assert widget.url == "https://beeware.org" assert widget.user_agent == "Custom agent" assert widget.on_webview_load._raw == on_webview_load + assert widget.on_navigation_starting._raw == on_navigation_starting assert widget.style.width == 256 @@ -330,3 +333,113 @@ async def delayed_cookie_retrieval(): assert cookie.path == "/" assert cookie.secure is True assert cookie.expires is None + + +def test_webview_navigation_starting_disabled(monkeypatch): + """If the backend doesn't support on_navigation_starting, a warning is raised.""" + # Temporarily set the feature attribute on the backend + monkeypatch.setattr( + DummyWebView, + "SUPPORTS_ON_NAVIGATION_STARTING", + False, + raising=False, + ) + + # Instantiate a new widget with a hobbled backend. + widget = toga.WebView() + handler = Mock() + + # Setting the handler raises a warning + with pytest.warns( + toga.NotImplementedWarning, + match=r"\[Dummy\] Not implemented: WebView\.on_navigation_starting", + ): + widget.on_navigation_starting = handler + + +def test_webview_navigation_starting_not_configured(monkeypatch, capsys): + """A warning is printed if the backend isn't fully configured.""" + # Temporarily set the feature attribute on the backend + monkeypatch.setattr( + DummyWebView, + "SUPPORTS_ON_NAVIGATION_STARTING", + False, + raising=False, + ) + monkeypatch.setattr( + DummyWebView, + "ON_NAVIGATION_CONFIG_MISSING_ERROR", + "WEBVIEW NOT CONFIGURED", + raising=False, + ) + + # Instantiate a new widget with a hobbled backend. + widget = toga.WebView() + handler = Mock() + + # Setting the handler produce + widget.on_navigation_starting = handler + + # The error was output + assert "WEBVIEW NOT CONFIGURED" in capsys.readouterr().out + + +def test_navigation_starting_no_handler(widget): + """When no handler is set, navigation should be allowed""" + widget.url = None + widget._impl.simulate_navigation_starting("https://beeware.org") + assert widget.url == "https://beeware.org" + + +@pytest.mark.parametrize( + ("initial_url", "url", "final_url"), + [ + (None, None, None), + (None, "https://beeware.org", "https://beeware.org"), + (None, "https://example.com", None), + ("https://beeware.org", "https://example.com", "https://beeware.org"), + ], +) +def test_navigation_starting_sync(widget, initial_url, url, final_url): + """Navigation can be controlled by a synchronous handler.""" + + def handler(widget, url, **kwargs): + if url == "https://beeware.org": + return True + else: + return False + + widget.url = initial_url + widget.on_navigation_starting = handler + + # test navigation to the URL + widget._impl.simulate_navigation_starting(url) + assert widget.url == final_url + + +@pytest.mark.parametrize( + ("initial_url", "url", "final_url"), + [ + (None, None, None), + (None, "https://beeware.org", "https://beeware.org"), + (None, "https://example.com", None), + ("https://beeware.org", "https://example.com", "https://beeware.org"), + ], +) +async def test_navigation_starting_async(widget, initial_url, url, final_url): + """Navigation can be controlled by an async handler.""" + + async def handler(widget, url, **kwargs): + if url == "https://beeware.org": + return True + else: + return False + + widget.url = initial_url + widget.on_navigation_starting = handler + + widget._impl.simulate_navigation_starting(url) + # A short sleep to ensure the async handler completes + await asyncio.sleep(0.01) + + assert widget.url == final_url diff --git a/docs/en/reference/api/widgets/webview.md b/docs/en/reference/api/widgets/webview.md index aa9b9243b0..5992051f0b 100644 --- a/docs/en/reference/api/widgets/webview.md +++ b/docs/en/reference/api/widgets/webview.md @@ -37,6 +37,8 @@ webview.set_content("https://example.com", "...") - On macOS 13.3 (Ventura) and later, the content inspector for your app can be opened by running Safari, [enabling the developer tools](https://support.apple.com/en-au/guide/safari/sfri20948/mac), and selecting your app's window from the "Develop" menu. On macOS versions prior to Ventura, the content inspector is not enabled by default, and is only available when your code is packaged as a full macOS app (e.g., with Briefcase). To enable debugging, run: > ```console > $ defaults write com.example.appname WebKitDeveloperExtras -bool true > ``` > > Substituting `com.example.appname` with the bundle ID for your > packaged app. +- On Android, use of the `on_navigation_starting` handler requires adding `chaquopy.defaultConfig.staticProxy("toga_android.widgets.internal.webview")` to the `build_gradle_extra_content` section of your app's `pyproject.toml` configuration. + ## Reference ::: toga.WebView diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 4b6363aa07..5e54fd3aa4 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -8,8 +8,14 @@ class WebView(Widget): def create(self): self._action("create WebView") + # attribute to store the URL allowed by user interaction or + # user on_navigation_starting handler + self._allowed_url = None def set_content(self, root_url, content): + if self.interface.on_navigation_starting: + # mark URL as being allowed + self._allowed_url = "about:blank" self._action("set content", root_url=root_url, content=content) def get_user_agent(self): @@ -23,8 +29,13 @@ def get_url(self): return self._get_value("url", None) def set_url(self, value, future=None): + if self.interface.on_navigation_starting: + # mark URL as being allowed + self._allowed_url = value self._set_value("url", value) self._set_value("loaded_future", future) + # allow the URL only once + self._allowed_url = None def get_cookies(self): self._action("cookies") @@ -53,3 +64,25 @@ def simulate_cookie_retrieval(self, cookies): for cookie in cookies: cookie_jar.set_cookie(cookie) self._cookie_result.set_result(cookie_jar) + + def simulate_navigation_starting(self, url): + """Simulate a navigation""" + allow = True + if self.interface.on_navigation_starting._raw: + if self._allowed_url == "about:blank" or self._allowed_url == url: + # URL is allowed by user code + allow = True + else: + # allow the URL only once + self._allowed_url = None + result = self.interface.on_navigation_starting(url=url) + if isinstance(result, bool): + # on_navigation_starting handler is synchronous + allow = result + else: + # on_navigation_starting handler is asynchronous + # deny navigation until the user defined on_navigation_starting + # coroutine has completed. + allow = False + if allow: + self.set_url(url) diff --git a/examples/webview/pyproject.toml b/examples/webview/pyproject.toml index 70c7994eb6..ab1d3f7217 100644 --- a/examples/webview/pyproject.toml +++ b/examples/webview/pyproject.toml @@ -53,6 +53,10 @@ build_gradle_dependencies = [ "com.google.android.material:material:1.12.0", ] +build_gradle_extra_content=""" +chaquopy.defaultConfig.staticProxy("toga_android.widgets.internal.webview") +""" + # Web deployment [tool.briefcase.app.webview.web] requires = [ diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index a5c7064712..a700af8623 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -1,8 +1,12 @@ +import asyncio + import toga from toga.constants import COLUMN, ROW class WebViewApp(toga.App): + allowed_base_url = "https://beeware.org/" + async def on_do_async_js(self, widget, **kwargs): self.label.text = repr(await self.webview.evaluate_javascript("2 + 2")) @@ -24,6 +28,32 @@ async def on_bad_js(self, widget, **kwargs): def on_webview_load(self, widget, **kwargs): self.label.text = "www loaded!" + async def on_navigate_js(self, widget, **kwargs): + await self.webview.evaluate_javascript( + 'window.location.assign("https://github.com/beeware/toga/")' + ) + + def on_navigation_starting_sync(self, widget, url, **kwargs): + # By default, on_navigation_starting_async is enabled + # To use this synchronous handler here, make a code edit below where + # self.webview is created. + print(f"on_navigation_starting_sync: {url}") + allow = True + if not url.startswith(self.allowed_base_url): + allow = False + message = f"Navigation not allowed to: {url}" + dialog = toga.InfoDialog("on_navigation_starting()", message) + asyncio.create_task(self.dialog(dialog)) + return allow + + async def on_navigation_starting_async(self, widget, url, **kwargs): + print(f"on_navigation_starting_async: {url}") + if not url.startswith(self.allowed_base_url): + message = f"Do you want to allow navigation to: {url}" + dialog = toga.QuestionDialog("on_navigation_starting_async()", message) + return await self.main_window.dialog(dialog) + return True + def on_set_url(self, widget, **kwargs): self.label.text = "Loading page..." self.webview.url = "https://beeware.org/" @@ -67,6 +97,7 @@ def startup(self): toga.Button("load URL", on_press=self.on_load_url), toga.Button("clear URL", on_press=self.on_clear_url), toga.Button("get URL", on_press=self.on_get_url), + toga.Button("JS navigate", on_press=self.on_navigate_js), ], ), toga.Box( @@ -95,6 +126,8 @@ def startup(self): url="https://beeware.org/", on_webview_load=self.on_webview_load, flex=1, + on_navigation_starting=self.on_navigation_starting_async, + # on_navigation_starting=self.on_navigation_starting_sync, ) box = toga.Box( diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index afb892d13b..2eedfd8770 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -12,6 +12,8 @@ class WebView(Widget): """GTK WebView implementation.""" + SUPPORTS_ON_NAVIGATION_STARTING = False + def create(self): if GTK_VERSION >= (4, 0, 0): # pragma: no-cover-if-gtk3 raise RuntimeError("WebView isn't supported on GTK4 (yet!)") diff --git a/iOS/src/toga_iOS/libs/webkit.py b/iOS/src/toga_iOS/libs/webkit.py index 99e38946f6..cba5e9d5df 100644 --- a/iOS/src/toga_iOS/libs/webkit.py +++ b/iOS/src/toga_iOS/libs/webkit.py @@ -2,6 +2,7 @@ # System/Library/Frameworks/WebKit.framework ########################################################################## from ctypes import cdll, util +from enum import IntFlag from rubicon.objc import ObjCClass, ObjCProtocol @@ -16,3 +17,11 @@ ###################################################################### # WKFrameInfo.h WKUIDelegate = ObjCProtocol("WKUIDelegate") + + +###################################################################### +# WkNavigationDelegate.h +class WKNavigationResponsePolicy(IntFlag): + Cancel = 0 + Allow = 1 + Download = 2 diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index dd0f6a60dd..721216fca8 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -2,6 +2,7 @@ from rubicon.objc import ( Block, + NSInteger, ObjCBlock, objc_id, objc_method, @@ -18,6 +19,7 @@ UIAlertActionStyle, UIAlertController, UIAlertControllerStyle, + WKNavigationResponsePolicy, WKUIDelegate, WKWebView, ) @@ -77,7 +79,49 @@ class TogaWebView(WKWebView, protocols=[WKUIDelegate]): impl = objc_property(object, weak=True) @objc_method - def webView_didFinishNavigation_(self, navigation) -> None: + def webView_decidePolicyForNavigationAction_decisionHandler_( + self, + webview, + navigationAction, + decisionHandler, + ) -> None: + _decision_handler = ObjCBlock(decisionHandler, None, NSInteger) + if ( + str(navigationAction.request.URL) == self.impl._allowed_url + or self.impl.interface.on_navigation_starting._raw is None + ): + # If URL is pre-approved, or there's no navigation handler, + # allow the navigation. + _decision_handler(WKNavigationResponsePolicy.Allow) + self.impl._allowed_url = None + else: + url = str(navigationAction.request.URL) + allow = self.impl.interface.on_navigation_starting(url=url) + if isinstance(allow, bool | None): + # on_navigation_starting handler is synchronous + if allow: + decision = WKNavigationResponsePolicy.Allow + else: + decision = WKNavigationResponsePolicy.Cancel + + _decision_handler(decision) + else: + # on_navigation_starting handler is asynchronous. Attach a completion + # handler so that when the async handler completes, the webView + # decision handler is invoked. + + def on_navigation_decision(future): + if future.result(): + decision = WKNavigationResponsePolicy.Allow + else: + decision = WKNavigationResponsePolicy.Cancel + + ObjCBlock(decisionHandler, None, NSInteger)(decision) + + allow.add_done_callback(on_navigation_decision) + + @objc_method + def webView_didFinishNavigation_(self, webView, navigation) -> None: # It's possible for this handler to be invoked *after* the interface/impl object # has been destroyed. If the interface/impl doesn't exist there's no handler to # invoke either, so ignore the edge case. This can't be reproduced reliably, so @@ -182,6 +226,10 @@ def create(self): self.loaded_future = None + # attribute to store the URL allowed by user interaction or + # user on_navigation_starting handler + self._allowed_url = None + # Add the layout constraints self.add_constraints() @@ -190,6 +238,9 @@ def get_url(self): return None if url == "about:blank" else url def set_url(self, value, future=None): + if self.interface.on_navigation_starting._raw: + # mark URL as being allowed + self._allowed_url = value if value: request = NSURLRequest.requestWithURL(NSURL.URLWithString(value)) else: @@ -199,6 +250,9 @@ def set_url(self, value, future=None): self.native.loadRequest(request) def set_content(self, root_url, content): + if self.interface.on_navigation_starting._raw: + # mark URL as being allowed + self._allowed_url = root_url self.native.loadHTMLString(content, baseURL=NSURL.URLWithString(root_url)) def get_user_agent(self): diff --git a/positron/src/positron/django_templates/manage.py.tmpl b/positron/src/positron/django_templates/manage.py.tmpl index 9a95520e2d..137db483d7 100644 --- a/positron/src/positron/django_templates/manage.py.tmpl +++ b/positron/src/positron/django_templates/manage.py.tmpl @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/qt/src/toga_qt/widgets/webview.py b/qt/src/toga_qt/widgets/webview.py index 6342b8e703..4842629abe 100644 --- a/qt/src/toga_qt/widgets/webview.py +++ b/qt/src/toga_qt/widgets/webview.py @@ -69,7 +69,7 @@ def handle_console_message( class WebView(Widget): - """Qt WebView implementation.""" + SUPPORTS_ON_NAVIGATION_STARTING = False def create(self): self._cookies = CookieJar() diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 7cfc84fbff..8b78f70b37 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -120,6 +120,8 @@ android.defaultConfig.python { // Pytest's assertion rewriting produces its own .pyc files. pyc.src = false } + +chaquopy.defaultConfig.staticProxy("toga_android.widgets.internal.webview") """ [tool.briefcase.app.testbed.web] diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index b86249826e..f43ed8c3bd 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -387,3 +387,117 @@ async def test_retrieve_cookies(widget, probe, on_load): assert cookie.path == "/" assert cookie.secure is True assert cookie.expires is None + + +async def test_on_navigation_starting_sync_no_handler(widget, probe, on_load): + # This test is required for full coverage because on android, setting + # the URL does not trigger shouldOverrideUrlLoading() + await widget.evaluate_javascript('window.location.assign("https://beeware.org/")') + await asyncio.sleep(2) + await widget.evaluate_javascript( + 'window.location.assign("https://beeware.org/docs/")' + ) + await asyncio.sleep(2) + assert widget.url == "https://beeware.org/docs/" + + +async def test_on_navigation_starting_sync(widget, probe, on_load): + if not getattr(widget._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): + pytest.skip("Platform doesn't support on_navigation_starting") + + # Allow navigation to any beeware.org URL. + def handler(widget, url, **kwargs): + return url.startswith("https://beeware.org/") + + widget.on_navigation_starting = handler + # test static content can be set + widget.set_content("https://example.com/", "