Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
a476f73
* implented on_navigation_started for winforms
t-arn May 13, 2025
23e2747
* fixed some issues
t-arn May 13, 2025
4c6777d
* added change note
t-arn May 13, 2025
4849755
fixed pre-commit issue
t-arn May 13, 2025
7f335d6
removed unnecessary set_on_navigation_starting() method
t-arn May 13, 2025
a209b71
* removed unnecessary TogaNavigationEvent
t-arn May 26, 2025
c6119ba
* windows implementation now supports synchronous and asynchronous on…
t-arn Jun 11, 2025
98ec9fd
refactored the code for Windows to use the cleanup method of wrapped_…
t-arn Jun 13, 2025
39af762
defined on_navigation_starting cleanup method as inner method
t-arn Jun 16, 2025
44814b2
extended wrapped_handler and handler_with_cleanup to pass kwargs to t…
t-arn Nov 5, 2025
951a94b
Renamed the class of the WebView example to match the current code
t-arn Nov 5, 2025
d7751b2
* fixed calling the on_navigation_starting handler on Android
t-arn Nov 7, 2025
8830607
* fixed app crash on Android when staticProxy setting is missing in p…
t-arn Nov 10, 2025
81fd425
Merge branch 'main' into webview_tarn_323ec36
t-arn Nov 10, 2025
0483945
fixed staticProxy in pyproject.toml
t-arn Nov 10, 2025
6fde361
adjusted test_handlers.py for the changed cleanup handler
t-arn Nov 17, 2025
2251c6d
Applied changes as requested by review
t-arn Nov 17, 2025
0c9072f
* added asynchronous on_navigation_starting handler for Android
t-arn Nov 18, 2025
8d70393
fixed trailing blanks
t-arn Nov 18, 2025
7862ff8
fixed tests for the added *args parameter passed to handler cleanup
t-arn Nov 18, 2025
82ebcf0
* fixed call to handler cleanup
t-arn Nov 18, 2025
ef4149d
added core tests
t-arn Nov 26, 2025
d6f9ab5
* fixing pre-commit failures
t-arn Nov 27, 2025
1cb8fbf
* fixed test_webview_navigationstarting_disabled
t-arn Nov 27, 2025
7444b1a
fixed test_navigation_starting_async
t-arn Nov 27, 2025
48e9af8
fixed test_webview.py
t-arn Nov 27, 2025
5773f03
fixed test_webview.py
t-arn Nov 27, 2025
b7a6221
* added test for None URL
t-arn Nov 28, 2025
02f5fdc
* made on_navigation_starting more resilient to doubled events
t-arn Dec 5, 2025
4a43c28
added test for setting static content
t-arn Dec 5, 2025
6408ca1
added test for async on_navigation_starting handler
t-arn Dec 5, 2025
c291950
* fixed android set_content
t-arn Dec 5, 2025
576b8e7
* improved testbed tests
t-arn Dec 5, 2025
2c28ae2
fixed testbed tests
t-arn Dec 8, 2025
fb52091
* skipping tests on unsupported platforms
t-arn Dec 9, 2025
1989161
* fixed coverage problem
t-arn Dec 12, 2025
f363042
* adjusted testbed tests to make them run on android as well
t-arn Dec 13, 2025
cec0b41
* removed local files
t-arn Dec 13, 2025
4ada2c4
* fixed test_cleanup() for android
t-arn Dec 13, 2025
68ca259
* removed unnecessary code on android
t-arn Dec 13, 2025
4a3ffe3
added test for full coverage on android
t-arn Dec 14, 2025
721009f
implemented requested changes from review
t-arn Dec 17, 2025
a47105a
fixed dummy backend
t-arn Dec 17, 2025
7604eec
removed unnecessary code
t-arn Dec 17, 2025
69f8877
Merge branch 'main' into webview_tarn_323ec36
freakboy3742 Dec 21, 2025
7cebd4d
Miscellaneous cleanups.
freakboy3742 Dec 22, 2025
14b1b30
Correct some test edge cases.
freakboy3742 Dec 22, 2025
0521b39
Fixes for testbed app.
freakboy3742 Dec 22, 2025
ab79870
Correct checks for presence of a navigation handler.
freakboy3742 Dec 29, 2025
f85ddea
Correct winforms tests of navigation handlers.
freakboy3742 Dec 29, 2025
66cb542
Make widget support a probe property.
freakboy3742 Dec 29, 2025
d04808e
Add on_navigation_starting support for iOS and macOS.
freakboy3742 Dec 29, 2025
3962896
Add an example test of JS navigation.
freakboy3742 Dec 29, 2025
49afc69
Simplify and speed up webview naviation tests.
freakboy3742 Dec 29, 2025
5ed0fb2
Mark Qt as not supporting navigation control.
freakboy3742 Dec 29, 2025
064fe55
Use widget property instead of duplicating in probe.
freakboy3742 Dec 29, 2025
29c4a5e
Check the right property on the widget.
freakboy3742 Dec 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions android/src/toga_android/widgets/internal/webview.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 17 additions & 1 deletion android/src/toga_android/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions changes/3442.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The WebView widget now supports an on_navigation_starting handler to prevent user-defined URLs from being loaded
10 changes: 10 additions & 0 deletions cocoa/src/toga_cocoa/libs/webkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,3 +18,11 @@
######################################################################
# WKFrameInfo.h
WKUIDelegate = ObjCProtocol("WKUIDelegate")


######################################################################
# WkNavigationDelegate.h
class WKNavigationResponsePolicy(IntFlag):
Cancel = 0
Allow = 1
Download = 2
65 changes: 63 additions & 2 deletions cocoa/src/toga_cocoa/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,6 +20,7 @@
NSModalResponseOK,
NSOpenPanel,
NSURLRequest,
WKNavigationResponsePolicy,
WKUIDelegate,
WKWebView,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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:
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions core/src/toga/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions core/src/toga/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
):
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions core/tests/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 (
Expand Down
Loading