Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
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
17 changes: 16 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,26 @@ def onReceiveValue(self, value):

class WebView(Widget):
SUPPORTS_ON_WEBVIEW_LOAD = False
SUPPORTS_ON_NAVIGATION_STARTING = True

def create(self):
self.native = A_WebView(self._native_activity)
try:
from .webview_static_proxy import TogaWebClient

client = TogaWebClient(self)
except NoClassDefFoundError:
self.SUPPORTS_ON_NAVIGATION_STARTING = False
client = WebViewClient()
print(
'chaquopy.defaultConfig.staticProxy("toga_android.widgets'
'.webview_static_proxy") missing in pyproject.toml section '
'"build_gradle_extra_content". on_navigation_starting '
"handler is therefore not available"
)
# 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
31 changes: 31 additions & 0 deletions android/src/toga_android/widgets/webview_static_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import asyncio

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 = impl

@Override(jboolean, [A_WebView, WebResourceRequest])
def shouldOverrideUrlLoading(self, webview, webresourcerequest):
if self.webview_impl.interface.on_navigation_starting:
result = self.webview_impl.interface.on_navigation_starting(
url=webresourcerequest.getUrl().toString()
)
if isinstance(result, bool):
# on_navigation_starting handler is synchronous
allow = result
elif isinstance(result, asyncio.Future):
# on_navigation_starting handler is asynchronous
if result.done():
allow = result.result()
else:
# deny the navigation until the user himself or the user
# defined on_navigation_starting handler has allowed it
allow = False
if not allow:
return True
return False
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
2 changes: 2 additions & 0 deletions cocoa/src/toga_cocoa/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ def _completion_handler(res: int) -> None:


class WebView(Widget):
SUPPORTS_ON_NAVIGATION_STARTING = False

def create(self):
self.native = TogaWebView.alloc().init()
self.native.interface = self.interface
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
66 changes: 66 additions & 0 deletions core/src/toga/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ def __call__(self, widget: WebView, **kwargs: Any) -> None:
"""


class OnNavigationStartingHandler(Protocol):
def __call__(self, widget: WebView, **kwargs: Any) -> object:
"""A handler to invoke when the WebView is requesting permission to navigate or
redirect to a different URI.

:param widget: The WebView
:param kwargs: Ensures compatibility with arguments added in future versions.
"""


class WebView(Widget):
def __init__(
self,
Expand All @@ -33,6 +43,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 +61,13 @@ 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. The handler can be synchronous or async and must
return True for allowing the URL, False for denying the URL or an awaited
QuestionDialog. On Android, this handler needs
`chaquopy.defaultConfig.staticProxy("toga_android.widgets.webview_static_proxy")`
in the build_gradle_extra_content section of pyproject.toml
:param on_webview_load: A handler that will be invoked when the web view
finishes loading.
:param kwargs: Initial style properties.
Expand All @@ -58,9 +76,16 @@ def __init__(

self.user_agent = user_agent

# If URL is allowed by user interaction or user on_navigation_starting
# handler, this attribute is True
self._url_allowed = True

# 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 All @@ -73,6 +98,9 @@ def _create(self) -> Any:

def _set_url(self, url: str | None, future: asyncio.Future | None) -> None:
# Utility method for validating and setting the URL with a future.
if self.on_navigation_starting:
# mark URL as being allowed
self._url_allowed = True
if (url is not None) and not url.startswith(("https://", "http://")):
raise ValueError("WebView can only display http:// and https:// URLs")

Expand Down Expand Up @@ -106,6 +134,41 @@ 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. This feature is
currently only supported on Windows and Android.

The handler will receive the positional argument `widget` and the keyword
argument `url` and can be synchronous or async. It must return True for
allowing the URL, False for denying the URL or an awaited QuestionDialog
"""
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"""

def cleanup(widget, result, **kwargs):
url = kwargs.get("url", None)
if url is None:
# The user on_navigation_handler is synchronous - do nothing
return
if result is True:
# navigate to the url, the URL will automatically be marked
# as allowed
self.url = url

self._on_navigation_starting = None
if handler:
if not getattr(self._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True):
self.factory.not_implemented("WebView.on_navigation_starting")
return
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 Expand Up @@ -153,6 +216,9 @@ def set_content(self, root_url: str, content: str) -> None:
and used to resolve any relative URLs in the content.
:param content: The HTML content for the WebView
"""
if self.on_navigation_starting:
# mark URL as being allowed
self._url_allowed = True
self._impl.set_content(root_url, content)

@property
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
109 changes: 109 additions & 0 deletions core/tests/widgets/test_webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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


Expand Down Expand Up @@ -206,6 +209,21 @@ def test_set_content_with_property(widget):
)


def test_set_content_with_navigation_starting(widget):
"""Static HTML content can be loaded into the page, using a setter."""
# Set up a navigation_starting handler
handler = Mock()
widget.on_navigation_starting = handler
# Set the content
widget.content = "<h1>Fancy page</h1>"
assert_action_performed_with(
widget,
"set content",
root_url="",
content="<h1>Fancy page</h1>",
)


def test_get_content_property_error(widget):
"""Verify that using the getter on widget.content fails."""
with pytest.raises(AttributeError):
Expand Down Expand Up @@ -330,3 +348,94 @@ async def delayed_cookie_retrieval():
assert cookie.path == "/"
assert cookie.secure is True
assert cookie.expires is None


def test_webview_navigationstarting_disabled(monkeypatch):
"""If the backend doesn't support on_navigation_starting,
a warning is raised."""
try:
# Temporarily set the feature attribute on the backend
DummyWebView.SUPPORTS_ON_NAVIGATION_STARTING = 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

# The handler is not installed
assert widget.on_navigation_starting is None
finally:
# Clear the feature attribute.
del DummyWebView.SUPPORTS_ON_NAVIGATION_STARTING


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"


def test_navigation_starting_sync(widget):
"""Synchronous handler"""

def handler(widget, **kwargs):
url = kwargs.get("url", None)
if url == "https://beeware.org":
return True
else:
return False

widget.url = None
widget.on_navigation_starting = handler
# test None-URL
widget._impl.simulate_navigation_starting(None)
assert widget.url is None
# test allowed URL
widget._impl.simulate_navigation_starting("https://beeware.org")
assert widget.url == "https://beeware.org"
# test denied URL
widget._impl.simulate_navigation_starting("https://google.com")
assert widget.url == "https://beeware.org"


async def test_navigation_starting_async(widget):
"""Mocking user response"""

async def dialog_mock(url):
await asyncio.sleep(0.3)
if url == "https://beeware.org":
return True
else:
return False

"""Asynchronous handler"""

async def handler(widget, **kwargs):
url = kwargs.get("url", None)
return await dialog_mock(url)

widget.url = None
widget._url_allowed = False
widget.on_navigation_starting = handler
# test allowed URL
widget._impl.simulate_navigation_starting("https://beeware.org")
# navigation is denied until user decides
assert widget.url is None
# wait for the user response
await asyncio.sleep(0.5)
assert widget.url == "https://beeware.org"
# test denied URL
widget._impl.simulate_navigation_starting("https://google.com")
# navigation is denied until user decides
assert widget.url == "https://beeware.org"
# wait for the user response
await asyncio.sleep(0.5)
assert widget.url == "https://beeware.org"
Loading
Loading