Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 25 additions & 3 deletions android/src/toga_android/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import json
from http.cookiejar import CookieJar

from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient
from java import dynamic_proxy
from android.webkit import (
ValueCallback,
WebResourceRequest,
WebView as A_WebView,
WebViewClient,
)
from java import Override, dynamic_proxy, jboolean, static_proxy

from toga.widgets.webview import CookiesResult, JavaScriptResult

Expand All @@ -23,14 +28,31 @@ def onReceiveValue(self, value):
self.result.set_result(res)


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:
allow = self.webview_impl.interface.on_navigation_starting(
webresourcerequest.getUrl().toString()
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't checked, but this could be a problem if on_navigation_starting is an async callback, because the returned value will be a future.

if not allow:
return True
return False


class WebView(Widget):
SUPPORTS_ON_WEBVIEW_LOAD = False

def create(self):
self.native = A_WebView(self._native_activity)
# Set a WebViewClient so that new links open in this activity,
# rather than triggering the phone's web browser.
self.native.setWebViewClient(WebViewClient())
client = TogaWebClient(self)
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
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 @@ -80,6 +80,8 @@ def acceptsFirstResponder(self) -> bool:


class WebView(Widget):
SUPPORTS_ON_NAVIGATION_STARTING = False

def create(self):
self.native = TogaWebView.alloc().init()
self.native.interface = self.interface
Expand Down
72 changes: 72 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) -> object:
"""


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,11 @@ 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
: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 +74,16 @@ def __init__(

self.user_agent = user_agent

# If URL is allowed by user interaction or user on_navigation_starting
# handler, the count will be set to 0
self._url_count = 0

# 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 +96,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_count = 0
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 @@ -104,6 +130,49 @@ 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):
"""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 arguments `widget` and `url` and can
be synchronous or async. It must return True for allowing the URL,
False for denying the URL or an awaited QuestionDialog

:returns: The function ``callable`` that is called by this navigation event.
"""
return self._on_navigation_starting

@on_navigation_starting.setter
def on_navigation_starting(self, handler, url=None):
"""Set the handler to invoke when the webview starts navigating"""

def cleanup(widget, result):
try:
msg = f"on_navigation_starting.cleanup, url={url}, "
msg += f"result={str(result)}"
print(msg)
print(f"widget._requested_url={widget._requested_url}")
if url is None:
# The user on_navigation_handler is synchronous - do nothing
return
if result is True:
print(f"Navigating to {url}")
# navigate to the url, the URL will automatically be marked
# as allowed
self.url = url
except Exception as ex:
print(f"on_navigation_starting.cleanup exception: {str(ex)}")

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 @@ -151,6 +220,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_count = 0
self._impl.set_content(root_url, content)

@property
Expand Down
4 changes: 4 additions & 0 deletions examples/webview/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.webview")
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm... Requiring users to manually inject an "extra content" block to make a feature work is a problematic requirement. I understand why it's needed - but we possibly need to solve the bigger problem of allowing toga to declare a list of "classes that need a static proxy".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. But this probably means that this PR cannot be merged any time soon, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends how essential the change is.

If a WebView will continue to work on Android, but this feature won't work without the extra content declaration, we can probably merge it. We will need to add a set of platform notes in the docs, and we'll need to prioritise finding a better fix - but I don't think it needs to be a complete blocker.

However, if Webview (or the whole Android backend) won't work at all if this addition isn't in place, then we'll need to solve that problem first.


# Web deployment
[tool.briefcase.app.webview.web]
requires = [
Expand Down
24 changes: 24 additions & 0 deletions examples/webview/webview/app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import asyncio

import toga
from toga.constants import COLUMN, ROW
from toga.style import Pack


class ExampleWebView(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"))

Expand All @@ -22,6 +26,24 @@ def on_bad_js_result(self, result, *, exception=None):
def on_webview_load(self, widget, **kwargs):
self.label.text = "www loaded!"

def on_navigation_starting_sync(self, widget, url):
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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be easier to make on_navigation_starting an async callback, and then await the response.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example app now has both, a synchronous and an async handler

return allow

async def on_navigation_starting_async(self, widget, url):
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/"
Expand Down Expand Up @@ -91,6 +113,8 @@ def startup(self):
url="https://beeware.org/",
on_webview_load=self.on_webview_load,
style=Pack(flex=1),
on_navigation_starting=self.on_navigation_starting_async,
# on_navigation_starting=self.on_navigation_starting_sync,
)

box = toga.Box(
Expand Down
2 changes: 2 additions & 0 deletions gtk/src/toga_gtk/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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!)")
Expand Down
2 changes: 2 additions & 0 deletions iOS/src/toga_iOS/widgets/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ def webView_didFinishNavigation_(self, navigation) -> None:


class WebView(Widget):
SUPPORTS_ON_NAVIGATION_STARTING = False

def create(self):
self.native = TogaWebView.alloc().init()
self.native.interface = self.interface
Expand Down
36 changes: 35 additions & 1 deletion winforms/src/toga_winforms/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json
import webbrowser
from http.cookiejar import Cookie, CookieJar
Expand Down Expand Up @@ -41,7 +42,6 @@ def cookies_completion_handler(result):
"""

def _completion_handler(task):

# Initialize a CookieJar to store cookies
cookie_jar = CookieJar()

Expand Down Expand Up @@ -136,6 +136,10 @@ def winforms_initialization_completed(self, sender, args):
settings.IsSwipeNavigationEnabled = False
settings.IsZoomControlEnabled = True

self.native.CoreWebView2.NavigationStarting += WeakrefCallable(
self.winforms_navigation_starting
)

for task in self.pending_tasks:
task()
self.pending_tasks = None
Expand Down Expand Up @@ -179,6 +183,36 @@ def winforms_navigation_completed(self, sender, args):
self.loaded_future.set_result(None)
self.loaded_future = None

def winforms_navigation_starting(self, sender, event):
print(f"winforms_navigation_starting: {event.Uri}")
if self.interface.on_navigation_starting:
print("checking URL permission...")
self.interface._url_count += 1
if self.interface._url_count == 1:
# URL is allowed by user code
print("URL is allowed by user code")
allow = True
else:
result = self.interface.on_navigation_starting(url=event.Uri)
if isinstance(result, bool):
# on_navigation_starting handler is synchronous
print(f"synchronous handler, result={str(result)}")
allow = result
elif isinstance(result, asyncio.Future):
# on_navigation_starting handler is asynchronous
self.interface._requested_url = event.Uri # should not be needed
if result.done():
allow = result.result()
print(f"asynchronous handler, result={str(allow)}")
else:
# deny the navigation until the user himself or the user
# defined on_navigation_starting handler has allowed it
allow = False
print("waiting for permission")
if not allow:
print("Denying navigation")
event.Cancel = True

def get_url(self):
source = self.native.Source
if source is None: # pragma: nocover
Expand Down
Loading