From a476f73f6c8a983266d02d334a8fb8233316680d Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 13 May 2025 07:34:22 +0200 Subject: [PATCH 01/27] * implented on_navigation_started for winforms * implented on_navigation_started for android * extended the webview example --- android/src/toga_android/widgets/webview.py | 34 +++++++++++++++++-- cocoa/src/toga_cocoa/widgets/webview.py | 2 ++ core/src/toga/widgets/webview.py | 26 ++++++++++++++ examples/webview/webview/app.py | 16 +++++++++ gtk/src/toga_gtk/widgets/webview.py | 1 + iOS/src/toga_iOS/widgets/webview.py | 2 ++ winforms/src/toga_winforms/widgets/webview.py | 12 +++++++ 7 files changed, 91 insertions(+), 2 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 7699e8f56f..5b431c05cd 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -2,7 +2,7 @@ from http.cookiejar import CookieJar from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient -from java import dynamic_proxy +from java import dynamic_proxy, static_proxy, constructor, jboolean, jclass, Override from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -23,6 +23,31 @@ def onReceiveValue(self, value): self.result.set_result(res) +class TogaNavigationEvent(): + def __init__(self, webresourcerequest): + self.request = webresourcerequest + self.cancel = False + + +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: + event = TogaNavigationEvent(webresourcerequest) + allow = self.webview_impl.interface.on_navigation_starting( + webresourcerequest.getUrl().toString() + ) + if not allow: + event.cancel = True + event = None + return True + return False + + class WebView(Widget): SUPPORTS_ON_WEBVIEW_LOAD = False @@ -30,7 +55,8 @@ 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() @@ -86,3 +112,7 @@ def evaluate_javascript(self, javascript, on_result=None): self.native.evaluateJavascript(javascript, ReceiveString(result)) return result + + def set_on_navigation_starting(self, handler): + # print(f"set_on_navigation_starting") + pass \ No newline at end of file diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index c7b79835a0..9db5d850fe 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -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 diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 8fc79c7d09..868463be79 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -33,6 +33,7 @@ def __init__( url: str | None = None, content: str | None = None, user_agent: str | None = None, + on_navigation_starting = None, on_webview_load: OnWebViewLoadHandler | None = None, **kwargs, ): @@ -50,6 +51,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. @@ -60,6 +64,7 @@ def __init__( # Set the load handler before loading the first URL. self.on_webview_load = on_webview_load + 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. @@ -104,6 +109,27 @@ 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 must + return True to allow the navigation or False to deny the navigation to the URL. + + :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): + """Set the handler to invoke when the webview starts navigating """ + if handler and not getattr(self._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): + self.factory.not_implemented("WebView.on_navigation_starting") + + self._on_navigation_starting = wrapped_handler(self, handler) + self._impl.set_on_navigation_starting(self._on_navigation_starting) + @property def on_webview_load(self) -> OnWebViewLoadHandler: """The handler to invoke when the web view finishes loading. diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 46fc8066db..8a9ba98eae 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -1,9 +1,12 @@ import toga from toga.constants import COLUMN, ROW from toga.style import Pack +import asyncio 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")) @@ -22,6 +25,16 @@ 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(self, widget, url): +        # print(f"on_navigation_starting: {url}") +        allow = True +        if !url.startswith(allowed_base_url): + allow = False + message = f"Navigation not allowed to: {url}" + dialog = toga.InfoDialog("on_navigation_starting()", message) + task = asyncio.create_task(self.dialog(dialog)) + return allow +            def on_set_url(self, widget, **kwargs): self.label.text = "Loading page..." self.webview.url = "https://beeware.org/" @@ -92,6 +105,9 @@ def startup(self): on_webview_load=self.on_webview_load, style=Pack(flex=1), ) + # activate web navigation filtering on supported platforms + if getattr(self.webview._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): + self.webview.on_navigation_starting = self.on_navigation_starting box = toga.Box( children=[ diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 6e135795d5..ea548e8a35 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -10,6 +10,7 @@ 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 diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index f89f00dcd5..0f683998e8 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -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 diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 71d71698d2..bbf4313f22 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -136,6 +136,8 @@ def winforms_initialization_completed(self, sender, args): settings.IsSwipeNavigationEnabled = False settings.IsZoomControlEnabled = True + self.native.CoreWebView2.NavigationStarting += self.winforms_navigation_starting + for task in self.pending_tasks: task() self.pending_tasks = None @@ -179,6 +181,13 @@ 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: + allow = self.interface.on_navigation_starting(event.Uri) + if not allow: + event.Cancel = True + def get_url(self): source = self.native.Source if source is None: # pragma: nocover @@ -234,6 +243,9 @@ def get_cookies(self): return result + def set_on_navigation_starting(self, handler): + pass + def evaluate_javascript(self, javascript, on_result=None): result = JavaScriptResult(on_result) task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() From 23e2747dd044d380763ac57b384d327ed2b1bd7a Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 13 May 2025 13:31:57 +0200 Subject: [PATCH 02/27] * fixed some issues --- android/src/toga_android/widgets/webview.py | 13 +++++++++---- examples/webview/pyproject.toml | 4 ++++ examples/webview/webview/app.py | 13 +++++++------ winforms/src/toga_winforms/widgets/webview.py | 8 +++++--- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 5b431c05cd..19f711b77c 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -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, static_proxy, constructor, jboolean, jclass, Override +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 @@ -23,7 +28,7 @@ def onReceiveValue(self, value): self.result.set_result(res) -class TogaNavigationEvent(): +class TogaNavigationEvent: def __init__(self, webresourcerequest): self.request = webresourcerequest self.cancel = False @@ -115,4 +120,4 @@ def evaluate_javascript(self, javascript, on_result=None): def set_on_navigation_starting(self, handler): # print(f"set_on_navigation_starting") - pass \ No newline at end of file + pass diff --git a/examples/webview/pyproject.toml b/examples/webview/pyproject.toml index 70c7994eb6..edfb984bb5 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.webview") +""" + # Web deployment [tool.briefcase.app.webview.web] requires = [ diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 8a9ba98eae..60b5707604 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -1,7 +1,8 @@ +import asyncio + import toga from toga.constants import COLUMN, ROW from toga.style import Pack -import asyncio class ExampleWebView(toga.App): @@ -26,15 +27,15 @@ def on_webview_load(self, widget, **kwargs): self.label.text = "www loaded!" def on_navigation_starting(self, widget, url): -        # print(f"on_navigation_starting: {url}") -        allow = True -        if !url.startswith(allowed_base_url): + print(f"on_navigation_starting: {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) - task = asyncio.create_task(self.dialog(dialog)) + asyncio.create_task(self.dialog(dialog)) return allow -            + def on_set_url(self, widget, **kwargs): self.label.text = "Loading page..." self.webview.url = "https://beeware.org/" diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index bbf4313f22..ba5d0c7ff1 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -136,8 +136,10 @@ def winforms_initialization_completed(self, sender, args): settings.IsSwipeNavigationEnabled = False settings.IsZoomControlEnabled = True - self.native.CoreWebView2.NavigationStarting += self.winforms_navigation_starting - + self.native.CoreWebView2.NavigationStarting += ( + self.winforms_navigation_starting + ) + for task in self.pending_tasks: task() self.pending_tasks = None @@ -182,7 +184,7 @@ def winforms_navigation_completed(self, sender, args): self.loaded_future = None def winforms_navigation_starting(self, sender, event): - print(f"winforms_navigation_starting: {event.Uri}") + # print(f"winforms_navigation_starting: {event.Uri}") if self.interface.on_navigation_starting: allow = self.interface.on_navigation_starting(event.Uri) if not allow: From 4c6777d6cd10190cb40c614c7997ce5e03242dc0 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 13 May 2025 14:03:26 +0200 Subject: [PATCH 03/27] * added change note * applied black --- changes/3442.feature.rst | 1 + core/src/toga/widgets/webview.py | 18 +++++++++--------- winforms/src/toga_winforms/widgets/webview.py | 1 - 3 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 changes/3442.feature.rst 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/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 868463be79..e71a01d1e3 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -33,7 +33,7 @@ def __init__( url: str | None = None, content: str | None = None, user_agent: str | None = None, - on_navigation_starting = None, + on_navigation_starting=None, on_webview_load: OnWebViewLoadHandler | None = None, **kwargs, ): @@ -51,9 +51,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_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. @@ -111,10 +111,10 @@ async def load_url(self, url: str) -> asyncio.Future: @property def on_navigation_starting(self): - """A handler that will be invoked when the webview is requesting + """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 must + + The handler will receive the arguments `widget` and `url` and must return True to allow the navigation or False to deny the navigation to the URL. :returns: The function ``callable`` that is called by this navigation event. @@ -123,10 +123,10 @@ def on_navigation_starting(self): @on_navigation_starting.setter def on_navigation_starting(self, handler): - """Set the handler to invoke when the webview starts navigating """ + """Set the handler to invoke when the webview starts navigating""" if handler and not getattr(self._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): self.factory.not_implemented("WebView.on_navigation_starting") - + self._on_navigation_starting = wrapped_handler(self, handler) self._impl.set_on_navigation_starting(self._on_navigation_starting) diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index ba5d0c7ff1..5cf56694f6 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -41,7 +41,6 @@ def cookies_completion_handler(result): """ def _completion_handler(task): - # Initialize a CookieJar to store cookies cookie_jar = CookieJar() From 4849755bd8a06e3cfbeaac5151210d9c1c7776c5 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 13 May 2025 14:07:19 +0200 Subject: [PATCH 04/27] fixed pre-commit issue --- gtk/src/toga_gtk/widgets/webview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index ea548e8a35..d35c09b00b 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -10,6 +10,7 @@ class WebView(Widget): """GTK WebView implementation.""" + SUPPORTS_ON_NAVIGATION_STARTING = False def create(self): From 7f335d69907f549b3d42cc654bc9a6f10bcb2149 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 13 May 2025 14:44:17 +0200 Subject: [PATCH 05/27] removed unnecessary set_on_navigation_starting() method --- android/src/toga_android/widgets/webview.py | 4 ---- core/src/toga/widgets/webview.py | 1 - winforms/src/toga_winforms/widgets/webview.py | 3 --- 3 files changed, 8 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 19f711b77c..3743dcf835 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -117,7 +117,3 @@ def evaluate_javascript(self, javascript, on_result=None): self.native.evaluateJavascript(javascript, ReceiveString(result)) return result - - def set_on_navigation_starting(self, handler): - # print(f"set_on_navigation_starting") - pass diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index e71a01d1e3..17a7d1e0da 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -128,7 +128,6 @@ def on_navigation_starting(self, handler): self.factory.not_implemented("WebView.on_navigation_starting") self._on_navigation_starting = wrapped_handler(self, handler) - self._impl.set_on_navigation_starting(self._on_navigation_starting) @property def on_webview_load(self) -> OnWebViewLoadHandler: diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 5cf56694f6..4231b8f961 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -244,9 +244,6 @@ def get_cookies(self): return result - def set_on_navigation_starting(self, handler): - pass - def evaluate_javascript(self, javascript, on_result=None): result = JavaScriptResult(on_result) task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() From a209b71b29dac0de48730e6c24e9b180f6a46bb0 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Mon, 26 May 2025 08:12:02 +0200 Subject: [PATCH 06/27] * removed unnecessary TogaNavigationEvent * added type declaration for handler * added WeakrefCallable wrapper --- android/src/toga_android/widgets/webview.py | 9 --------- core/src/toga/widgets/webview.py | 12 +++++++++++- winforms/src/toga_winforms/widgets/webview.py | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 3743dcf835..ac2400d70e 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -28,12 +28,6 @@ def onReceiveValue(self, value): self.result.set_result(res) -class TogaNavigationEvent: - def __init__(self, webresourcerequest): - self.request = webresourcerequest - self.cancel = False - - class TogaWebClient(static_proxy(WebViewClient)): def __init__(self, impl): super().__init__() @@ -42,13 +36,10 @@ def __init__(self, impl): @Override(jboolean, [A_WebView, WebResourceRequest]) def shouldOverrideUrlLoading(self, webview, webresourcerequest): if self.webview_impl.interface.on_navigation_starting: - event = TogaNavigationEvent(webresourcerequest) allow = self.webview_impl.interface.on_navigation_starting( webresourcerequest.getUrl().toString() ) if not allow: - event.cancel = True - event = None return True return False diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 17a7d1e0da..efaaf5f7db 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -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, @@ -33,7 +43,7 @@ def __init__( url: str | None = None, content: str | None = None, user_agent: str | None = None, - on_navigation_starting=None, + on_navigation_starting: OnNavigationStartingHandler | None = None, on_webview_load: OnWebViewLoadHandler | None = None, **kwargs, ): diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 4231b8f961..cd495b14d6 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -135,7 +135,7 @@ def winforms_initialization_completed(self, sender, args): settings.IsSwipeNavigationEnabled = False settings.IsZoomControlEnabled = True - self.native.CoreWebView2.NavigationStarting += ( + self.native.CoreWebView2.NavigationStarting += WeakrefCallable( self.winforms_navigation_starting ) From c6119bab62ec739996aebbd920544555a97cf92f Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Wed, 11 Jun 2025 19:15:52 +0200 Subject: [PATCH 07/27] * windows implementation now supports synchronous and asynchronous on_navigation_starting handlers --- core/src/toga/widgets/webview.py | 53 ++++++++++++++++--- examples/webview/webview/app.py | 19 +++++-- winforms/src/toga_winforms/widgets/webview.py | 48 ++++++++++++++++- 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index efaaf5f7db..318c06ade6 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -63,7 +63,9 @@ def __init__( 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. + to a different URI. The handler can be synchronous or async and must + return 1 for allowing the URL, 0 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. @@ -72,8 +74,18 @@ def __init__( self.user_agent = user_agent + # list for URLs allowed by the user code (usually based on some regex in the + # on_navigation_starting handler) + self._code_allowed_urls = [] + # lists for URLs explicitly allowed or denied by the user + self._user_allowed_urls = [] + self._user_denied_urls = [] + 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. @@ -88,6 +100,8 @@ 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. + # mark this URL as set from code (for using in on_navigation_starting_handler) + 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") @@ -105,6 +119,7 @@ def url(self) -> str | None: @url.setter def url(self, value: str | None) -> None: + print(f"webview url.setter: {value}") self._set_url(value, future=None) async def load_url(self, url: str) -> asyncio.Future: @@ -124,8 +139,9 @@ 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 must - return True to allow the navigation or False to deny the navigation to the URL. + The handler will receive the arguments `widget` and `url` and can + be synchronous or async. It must return 1 for allowing the URL, + 0 for denying the URL or an awaited QuestionDialog :returns: The function ``callable`` that is called by this navigation event. """ @@ -134,10 +150,31 @@ def on_navigation_starting(self): @on_navigation_starting.setter def on_navigation_starting(self, handler): """Set the handler to invoke when the webview starts navigating""" - if handler and not getattr(self._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): - self.factory.not_implemented("WebView.on_navigation_starting") - - self._on_navigation_starting = wrapped_handler(self, handler) + 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) + + def on_navigation_starting_callback(self, future, url=None): + try: + msg = f"on_navigation_starting_callback, url={url}, " + msg += f"result={str(future.result())}" + print(msg) + if future.result() == 1: + self._code_allowed_urls.append(url) + print(f"Adding code allowed URL={url}") + print(f"Navigating to {url}") + self.url = url # navigate to the url + elif future.result() is True: + self._user_allowed_urls.append(url) + print(f"Adding user allowed URL={url}") + print(f"Navigating to {url}") + self.url = url # navigate to the url + elif future.result() is False: + self._user_denied_urls.append(url) + except Exception as ex: + print(f"on_navigation_starting_callback exception: {str(ex)}") @property def on_webview_load(self) -> OnWebViewLoadHandler: @@ -186,6 +223,8 @@ 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 """ + # mark this URL as set from code (for using in on_navigation_starting_handler) + self._url_count = 0 self._impl.set_content(root_url, content) @property diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 60b5707604..d7f0f1cbf6 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -26,16 +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(self, widget, url): - print(f"on_navigation_starting: {url}") - allow = True + def on_navigation_starting_sync(self, widget, url): + print(f"on_navigation_starting_sync: {url}") + allow = 1 if not url.startswith(self.allowed_base_url): - allow = False + allow = 0 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): + 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 1 + def on_set_url(self, widget, **kwargs): self.label.text = "Loading page..." self.webview.url = "https://beeware.org/" @@ -108,7 +116,8 @@ def startup(self): ) # activate web navigation filtering on supported platforms if getattr(self.webview._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): - self.webview.on_navigation_starting = self.on_navigation_starting + self.webview.on_navigation_starting = self.on_navigation_starting_async + # self.webview.on_navigation_starting = self.on_navigation_starting_sync box = toga.Box( children=[ diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index cd495b14d6..05a8c493e6 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -1,3 +1,5 @@ +import asyncio +import functools import json import webbrowser from http.cookiejar import Cookie, CookieJar @@ -183,10 +185,52 @@ def winforms_navigation_completed(self, sender, args): self.loaded_future = None def winforms_navigation_starting(self, sender, event): - # print(f"winforms_navigation_starting: {event.Uri}") + print(f"winforms_navigation_starting: {event.Uri}") + self.interface._url_count += 1 if self.interface.on_navigation_starting: - allow = self.interface.on_navigation_starting(event.Uri) + print("checking URL permission...") + if self.interface._url_count == 1: + print("Allow the URL set from code") + allow = True + elif event.Uri in self.interface._code_allowed_urls: + # URL is allowed by user on_navigation_starting handler + print("URL is allowed by user on_navigation_starting handler") + allow = True + # prevent the list from growing endlessly + self.interface._code_allowed_urls.remove(event.Uri) + elif event.Uri in self.interface._user_allowed_urls: + # URL is allowed by user interaction + print("URL is allowed by user") + allow = True + elif event.Uri in self.interface._user_denied_urls: + # URL is denied by user interaction + print("URL is denied by user") + allow = False + else: + result = self.interface.on_navigation_starting(event.Uri) + print("winforms_navigation_starting()") + if isinstance(result, int): + # on_navigation_starting handler is synchronous + print(f"synchronous handler, result={str(result)}") + allow = True if result == 1 else False + elif isinstance(result, asyncio.Future): + # on_navigation_starting handler is asynchronous + if result.done(): + allow = result.result() + print(f"asynchronous handler, result={str(allow)}") + else: + # deny the navigation until the user + # or the user on_navigation_starting handler has allowed it + allow = False + print("waiting for permission") + result.add_done_callback( + functools.partial( + self.interface.on_navigation_starting_callback, + url=event.Uri, + ) + ) if not allow: + print("Denying navigation") event.Cancel = True def get_url(self): From 98ec9fd8485e933e8aa8e72f2ac1c774fc42b226 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Fri, 13 Jun 2025 16:29:08 +0200 Subject: [PATCH 08/27] refactored the code for Windows to use the cleanup method of wrapped_handler --- core/src/toga/widgets/webview.py | 58 +++++++++---------- examples/webview/webview/app.py | 12 ++-- winforms/src/toga_winforms/widgets/webview.py | 38 +++--------- 3 files changed, 43 insertions(+), 65 deletions(-) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 318c06ade6..e7405c0370 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -64,7 +64,7 @@ def __init__( :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 1 for allowing the URL, 0 for denying the URL or an awaited + 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. @@ -74,13 +74,13 @@ def __init__( self.user_agent = user_agent - # list for URLs allowed by the user code (usually based on some regex in the - # on_navigation_starting handler) - self._code_allowed_urls = [] - # lists for URLs explicitly allowed or denied by the user - self._user_allowed_urls = [] - self._user_denied_urls = [] + # If URL is allowed by user interaction or user on_navigation_starting + # handler, the count will be set to 0 self._url_count = 0 + # For passing the requested URL to the cleanup method of + # the user on_navigation_starting async handler. Stays to None for synchronous + # handlers + self._requested_url = None # Set the load handler before loading the first URL. self.on_webview_load = on_webview_load @@ -100,8 +100,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. - # mark this URL as set from code (for using in on_navigation_starting_handler) - self._url_count = 0 + 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") @@ -119,7 +120,6 @@ def url(self) -> str | None: @url.setter def url(self, value: str | None) -> None: - print(f"webview url.setter: {value}") self._set_url(value, future=None) async def load_url(self, url: str) -> asyncio.Future: @@ -140,39 +140,38 @@ def on_navigation_starting(self): 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 1 for allowing the URL, - 0 for denying the URL or an awaited QuestionDialog + 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): + def on_navigation_starting(self, handler, url=url): """Set the handler to invoke when the webview starts navigating""" + 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) + self._on_navigation_starting = wrapped_handler( + self, handler, cleanup=self.on_navigation_starting_callback + ) - def on_navigation_starting_callback(self, future, url=None): + def on_navigation_starting_callback(self, widget, result): try: + url = widget._requested_url msg = f"on_navigation_starting_callback, url={url}, " - msg += f"result={str(future.result())}" + msg += f"result={str(result)}" print(msg) - if future.result() == 1: - self._code_allowed_urls.append(url) - print(f"Adding code allowed URL={url}") - print(f"Navigating to {url}") - self.url = url # navigate to the url - elif future.result() is True: - self._user_allowed_urls.append(url) - print(f"Adding user allowed URL={url}") + if url is None: + # The user on_navigation_handler is synchronous - do nothing + return + if result is True: print(f"Navigating to {url}") - self.url = url # navigate to the url - elif future.result() is False: - self._user_denied_urls.append(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_callback exception: {str(ex)}") @@ -223,8 +222,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 """ - # mark this URL as set from code (for using in on_navigation_starting_handler) - self._url_count = 0 + if self.on_navigation_starting: + # mark URL as being allowed + self._url_count = 0 self._impl.set_content(root_url, content) @property diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index d7f0f1cbf6..be04af731d 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -28,9 +28,9 @@ def on_webview_load(self, widget, **kwargs): def on_navigation_starting_sync(self, widget, url): print(f"on_navigation_starting_sync: {url}") - allow = 1 + allow = True if not url.startswith(self.allowed_base_url): - allow = 0 + allow = False message = f"Navigation not allowed to: {url}" dialog = toga.InfoDialog("on_navigation_starting()", message) asyncio.create_task(self.dialog(dialog)) @@ -42,7 +42,7 @@ async def on_navigation_starting_async(self, widget, 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 1 + return True def on_set_url(self, widget, **kwargs): self.label.text = "Loading page..." @@ -113,11 +113,9 @@ 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, ) - # activate web navigation filtering on supported platforms - if getattr(self.webview._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True): - self.webview.on_navigation_starting = self.on_navigation_starting_async - # self.webview.on_navigation_starting = self.on_navigation_starting_sync box = toga.Box( children=[ diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 05a8c493e6..48c6a82955 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -1,5 +1,4 @@ import asyncio -import functools import json import webbrowser from http.cookiejar import Cookie, CookieJar @@ -186,49 +185,30 @@ def winforms_navigation_completed(self, sender, args): def winforms_navigation_starting(self, sender, event): print(f"winforms_navigation_starting: {event.Uri}") - self.interface._url_count += 1 if self.interface.on_navigation_starting: print("checking URL permission...") + self.interface._url_count += 1 if self.interface._url_count == 1: - print("Allow the URL set from code") + # URL is allowed by user code + print("URL is allowed by user code") allow = True - elif event.Uri in self.interface._code_allowed_urls: - # URL is allowed by user on_navigation_starting handler - print("URL is allowed by user on_navigation_starting handler") - allow = True - # prevent the list from growing endlessly - self.interface._code_allowed_urls.remove(event.Uri) - elif event.Uri in self.interface._user_allowed_urls: - # URL is allowed by user interaction - print("URL is allowed by user") - allow = True - elif event.Uri in self.interface._user_denied_urls: - # URL is denied by user interaction - print("URL is denied by user") - allow = False else: - result = self.interface.on_navigation_starting(event.Uri) - print("winforms_navigation_starting()") - if isinstance(result, int): + 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 = True if result == 1 else False + allow = result elif isinstance(result, asyncio.Future): # on_navigation_starting handler is asynchronous + self.interface._requested_url = event.Uri if result.done(): allow = result.result() print(f"asynchronous handler, result={str(allow)}") else: - # deny the navigation until the user - # or the user on_navigation_starting handler has allowed it + # deny the navigation until the user himself or the user + # defined on_navigation_starting handler has allowed it allow = False print("waiting for permission") - result.add_done_callback( - functools.partial( - self.interface.on_navigation_starting_callback, - url=event.Uri, - ) - ) if not allow: print("Denying navigation") event.Cancel = True From 39af7627825c308714923872e7fb2b32e0c023e0 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Mon, 16 Jun 2025 17:18:39 +0200 Subject: [PATCH 09/27] defined on_navigation_starting cleanup method as inner method --- core/src/toga/widgets/webview.py | 42 +++++++++---------- examples/webview/webview/app.py | 4 +- winforms/src/toga_winforms/widgets/webview.py | 2 +- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index e7405c0370..61caeaaee4 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -77,10 +77,6 @@ def __init__( # If URL is allowed by user interaction or user on_navigation_starting # handler, the count will be set to 0 self._url_count = 0 - # For passing the requested URL to the cleanup method of - # the user on_navigation_starting async handler. Stays to None for synchronous - # handlers - self._requested_url = None # Set the load handler before loading the first URL. self.on_webview_load = on_webview_load @@ -148,33 +144,35 @@ def on_navigation_starting(self): return self._on_navigation_starting @on_navigation_starting.setter - def on_navigation_starting(self, handler, url=url): + 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=self.on_navigation_starting_callback + self, handler, cleanup=cleanup ) - def on_navigation_starting_callback(self, widget, result): - try: - url = widget._requested_url - msg = f"on_navigation_starting_callback, url={url}, " - msg += f"result={str(result)}" - print(msg) - 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_callback exception: {str(ex)}") - @property def on_webview_load(self) -> OnWebViewLoadHandler: """The handler to invoke when the web view finishes loading. diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index be04af731d..4578eca064 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -113,8 +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, + on_navigation_starting=self.on_navigation_starting_async, + # on_navigation_starting=self.on_navigation_starting_sync, ) box = toga.Box( diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 48c6a82955..a84d38f9c7 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -200,7 +200,7 @@ def winforms_navigation_starting(self, sender, event): allow = result elif isinstance(result, asyncio.Future): # on_navigation_starting handler is asynchronous - self.interface._requested_url = event.Uri + self.interface._requested_url = event.Uri # should not be needed if result.done(): allow = result.result() print(f"asynchronous handler, result={str(allow)}") From 44814b21424b07be0552a581a1216fca768514fa Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Wed, 5 Nov 2025 09:42:36 +0100 Subject: [PATCH 10/27] extended wrapped_handler and handler_with_cleanup to pass kwargs to the cleanup method --- core/src/toga/handlers.py | 4 ++-- core/src/toga/widgets/webview.py | 6 +++--- examples/webview/webview/app.py | 6 ++++-- winforms/src/toga_winforms/widgets/webview.py | 1 - 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 20b829a1d8..bf963d4b47 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -86,7 +86,7 @@ async def handler_with_cleanup( else: if cleanup: try: - cleanup(interface, result) + cleanup(interface, result, **kwargs) except Exception as e: print("Error in async handler cleanup:", e, file=sys.stderr) traceback.print_exc() @@ -170,7 +170,7 @@ def _handler(*args: object, **kwargs: object) -> object: else: try: if cleanup: - cleanup(interface, result) + cleanup(interface, result, **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 61caeaaee4..20961db0d3 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -144,15 +144,15 @@ def on_navigation_starting(self): return self._on_navigation_starting @on_navigation_starting.setter - def on_navigation_starting(self, handler, url=None): + def on_navigation_starting(self, handler): """Set the handler to invoke when the webview starts navigating""" - def cleanup(widget, result): + def cleanup(widget, result, **kwargs): + url = kwargs.get("url", None) 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 diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 4578eca064..c2fe7b0363 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -26,7 +26,8 @@ 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): + def on_navigation_starting_sync(self, widget, **kwargs): + url = kwargs.get("url", None) print(f"on_navigation_starting_sync: {url}") allow = True if not url.startswith(self.allowed_base_url): @@ -36,7 +37,8 @@ def on_navigation_starting_sync(self, widget, url): asyncio.create_task(self.dialog(dialog)) return allow - async def on_navigation_starting_async(self, widget, url): + async def on_navigation_starting_async(self, widget, **kwargs): + url = kwargs.get("url", "No URL awailable") 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}" diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index a84d38f9c7..c941dee625 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -200,7 +200,6 @@ def winforms_navigation_starting(self, sender, event): 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)}") From 951a94b9407ba9e1836128087be99d4c7b522954 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Wed, 5 Nov 2025 09:50:54 +0100 Subject: [PATCH 11/27] Renamed the class of the WebView example to match the current code --- examples/webview/webview/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index c2fe7b0363..6e63db92fd 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -5,7 +5,7 @@ from toga.style import Pack -class ExampleWebView(toga.App): +class WebViewApp(toga.App): allowed_base_url = "https://beeware.org/" async def on_do_async_js(self, widget, **kwargs): @@ -133,7 +133,7 @@ def startup(self): def main(): - return ExampleWebView("Toga WebView Demo", "org.beeware.toga.examples.webview") + return WebViewApp("Toga WebView Demo", "org.beeware.toga.examples.webview") if __name__ == "__main__": From d7751b23703a7bdf02c84d408e70778619049252 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Fri, 7 Nov 2025 07:06:27 +0100 Subject: [PATCH 12/27] * fixed calling the on_navigation_starting handler on Android --- android/src/toga_android/widgets/webview.py | 2 +- examples/webview/webview/app.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index ac2400d70e..db2903e09a 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -37,7 +37,7 @@ def __init__(self, impl): def shouldOverrideUrlLoading(self, webview, webresourcerequest): if self.webview_impl.interface.on_navigation_starting: allow = self.webview_impl.interface.on_navigation_starting( - webresourcerequest.getUrl().toString() + url=webresourcerequest.getUrl().toString() ) if not allow: return True diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 6e63db92fd..b324620364 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -115,8 +115,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, + # on_navigation_starting=self.on_navigation_starting_async, + on_navigation_starting=self.on_navigation_starting_sync, ) box = toga.Box( From 883060703ba2e175787a98ed946a256de62ca88b Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Mon, 10 Nov 2025 17:50:40 +0100 Subject: [PATCH 13/27] * fixed app crash on Android when staticProxy setting is missing in pyproject.toml * improve docstring for on_navigation_starting * updated app.py to include the latest changes from main --- android/src/toga_android/widgets/webview.py | 37 +++++++------------ .../widgets/webview_static_proxy.py | 21 +++++++++++ core/src/toga/widgets/webview.py | 9 +++-- examples/webview/webview/app.py | 32 +++++++++------- 4 files changed, 57 insertions(+), 42 deletions(-) create mode 100644 android/src/toga_android/widgets/webview_static_proxy.py diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index db2903e09a..466681b9c9 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,13 +1,8 @@ import json from http.cookiejar import CookieJar -from android.webkit import ( - ValueCallback, - WebResourceRequest, - WebView as A_WebView, - WebViewClient, -) -from java import Override, dynamic_proxy, jboolean, static_proxy +from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient +from java import dynamic_proxy from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -28,30 +23,24 @@ 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( - url=webresourcerequest.getUrl().toString() - ) - 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) + try: + from .webview_static_proxy import TogaWebClient + + client = TogaWebClient(self) + except BaseException: + client = WebViewClient() + msg = "chaquopy.defaultConfig.staticProxy" + msg += '("toga_android.widgets.webview_static_proxy") ' + msg += 'missing in pyproject.toml section "build_gradle_extra_content"\n' + msg += "on_navigation_starting handler is therefore not available" + print(msg) # Set a WebViewClient so that new links open in this activity, # rather than triggering the phone's web browser. - client = TogaWebClient(self) self.native.setWebViewClient(client) self.settings = self.native.getSettings() diff --git a/android/src/toga_android/widgets/webview_static_proxy.py b/android/src/toga_android/widgets/webview_static_proxy.py new file mode 100644 index 0000000000..177571d0cb --- /dev/null +++ b/android/src/toga_android/widgets/webview_static_proxy.py @@ -0,0 +1,21 @@ +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: + allow = self.webview_impl.interface.on_navigation_starting( + url=webresourcerequest.getUrl().toString() + ) + if not allow: + return True + return False + + +# TogaWebClient diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 20961db0d3..e3822cc874 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -133,11 +133,12 @@ async def load_url(self, url: str) -> asyncio.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. + permission to navigate or redirect to a different URI. This feature is + currently only supported on Windows and Android. - 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 + 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 :returns: The function ``callable`` that is called by this navigation event. """ diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index b324620364..34f1ab05eb 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -2,7 +2,6 @@ import toga from toga.constants import COLUMN, ROW -from toga.style import Pack class WebViewApp(toga.App): @@ -17,10 +16,13 @@ def on_good_js(self, widget, **kwargs): "+ navigator.userAgent;" ) - def on_bad_js(self, widget, **kwargs): - self.webview.evaluate_javascript("invalid js", on_result=self.on_bad_js_result) - - def on_bad_js_result(self, result, *, exception=None): + async def on_bad_js(self, widget, **kwargs): + try: + result = await self.webview.evaluate_javascript("invalid js") + exception = None + except Exception as exc: + result = None + exception = exc self.label.text = f"{result=!r}, {exception=!r}" def on_webview_load(self, widget, **kwargs): @@ -78,12 +80,12 @@ def on_set_agent(self, widget, **kwargs): def startup(self): self.main_window = toga.MainWindow() - self.label = toga.Label("www is loading |", style=Pack(flex=1, margin=5)) + self.label = toga.Label("www is loading |", flex=1, margin=5) button_box = toga.Box( children=[ toga.Box( - style=Pack(direction=ROW), + direction=ROW, children=[ toga.Button("set URL", on_press=self.on_set_url), toga.Button("load URL", on_press=self.on_load_url), @@ -92,7 +94,7 @@ def startup(self): ], ), toga.Box( - style=Pack(direction=ROW), + direction=ROW, children=[ toga.Button("2 + 2", on_press=self.on_do_async_js), toga.Button("good js", on_press=self.on_good_js), @@ -101,20 +103,22 @@ def startup(self): ], ), toga.Box( - style=Pack(direction=ROW), + direction=ROW, children=[ toga.Button("set agent", on_press=self.on_set_agent), toga.Button("get agent", on_press=self.on_get_agent), ], ), ], - style=Pack(flex=0, direction=COLUMN, margin=5), + flex=0, + direction=COLUMN, + margin=5, ) self.webview = toga.WebView( url="https://beeware.org/", on_webview_load=self.on_webview_load, - style=Pack(flex=1), + flex=1, # on_navigation_starting=self.on_navigation_starting_async, on_navigation_starting=self.on_navigation_starting_sync, ) @@ -125,7 +129,8 @@ def startup(self): self.label, self.webview, ], - style=Pack(flex=1, direction=COLUMN), + flex=1, + direction=COLUMN, ) self.main_window.content = box @@ -137,5 +142,4 @@ def main(): if __name__ == "__main__": - app = main() - app.main_loop() + main().main_loop() From 0483945a0ddbf3ed8fdcbf6537b78898eafc7b38 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Mon, 10 Nov 2025 18:49:07 +0100 Subject: [PATCH 14/27] fixed staticProxy in pyproject.toml --- examples/webview/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webview/pyproject.toml b/examples/webview/pyproject.toml index edfb984bb5..630e812dcc 100644 --- a/examples/webview/pyproject.toml +++ b/examples/webview/pyproject.toml @@ -54,7 +54,7 @@ build_gradle_dependencies = [ ] build_gradle_extra_content=""" -chaquopy.defaultConfig.staticProxy("toga_android.widgets.webview") +chaquopy.defaultConfig.staticProxy("toga_android.widgets.webview_static_proxy") """ # Web deployment From 6fde3616d8916b02ae23e9d6ce1a77e922bdada7 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Mon, 17 Nov 2025 17:33:47 +0100 Subject: [PATCH 15/27] adjusted test_handlers.py for the changed cleanup handler --- core/tests/test_handlers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 79cbf0bd5c..62bc8e2553 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, 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, 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, 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, kwarg1=3, kwarg2=4) # Evidence of the handler cleanup error is in the log. assert ( From 2251c6deb50ed1a39f2287df30dbf2edf6e9800f Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 18 Nov 2025 00:02:35 +0100 Subject: [PATCH 16/27] Applied changes as requested by review --- android/src/toga_android/widgets/webview.py | 18 ++++++++++++------ .../widgets/webview_static_proxy.py | 3 --- core/src/toga/handlers.py | 2 +- core/src/toga/widgets/webview.py | 14 ++++++-------- winforms/src/toga_winforms/widgets/webview.py | 5 +++-- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 466681b9c9..d876c91263 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,8 +1,10 @@ import json from http.cookiejar import CookieJar + 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 @@ -32,13 +34,17 @@ def create(self): from .webview_static_proxy import TogaWebClient client = TogaWebClient(self) - except BaseException: + except NoClassDefFoundError: client = WebViewClient() - msg = "chaquopy.defaultConfig.staticProxy" - msg += '("toga_android.widgets.webview_static_proxy") ' - msg += 'missing in pyproject.toml section "build_gradle_extra_content"\n' - msg += "on_navigation_starting handler is therefore not available" - print(msg) + if self.interface.on_navigation_starting: + import sys + 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', + file=sys.stderr + ) # Set a WebViewClient so that new links open in this activity, # rather than triggering the phone's web browser. self.native.setWebViewClient(client) diff --git a/android/src/toga_android/widgets/webview_static_proxy.py b/android/src/toga_android/widgets/webview_static_proxy.py index 177571d0cb..85f9af10e8 100644 --- a/android/src/toga_android/widgets/webview_static_proxy.py +++ b/android/src/toga_android/widgets/webview_static_proxy.py @@ -16,6 +16,3 @@ def shouldOverrideUrlLoading(self, webview, webresourcerequest): if not allow: return True return False - - -# TogaWebClient diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 064843023e..7e11eaecf2 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, **kwargs) + cleanup(interface, result, *args, **kwargs) except Exception as e: print("Error in async handler cleanup:", e, file=sys.stderr) traceback.print_exc() diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index dd5906ddfa..b1cb0ff22d 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -73,10 +73,10 @@ def __init__( super().__init__(id, style, **kwargs) 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 + # 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 @@ -98,7 +98,7 @@ 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 + 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") @@ -133,7 +133,7 @@ async def load_url(self, url: str) -> asyncio.Future: return await loaded_future @property - def on_navigation_starting(self): + 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. @@ -141,8 +141,6 @@ def on_navigation_starting(self): 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 - - :returns: The function ``callable`` that is called by this navigation event. """ return self._on_navigation_starting @@ -225,7 +223,7 @@ def set_content(self, root_url: str, content: str) -> None: """ if self.on_navigation_starting: # mark URL as being allowed - self._url_count = 0 + self._url_allowed = True self._impl.set_content(root_url, content) @property diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index ad73272f87..1597dcd686 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -187,11 +187,12 @@ 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: + if self.interface._url_allowed: # URL is allowed by user code print("URL is allowed by user code") allow = True + # allow the URL only this time + self.interface._url_allowed = False else: result = self.interface.on_navigation_starting(url=event.Uri) if isinstance(result, bool): From 0c9072f6a4ee53d0d6e824a43f80987c4c163477 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 18 Nov 2025 11:29:38 +0100 Subject: [PATCH 17/27] * added asynchronous on_navigation_starting handler for Android * fixed too long lines * set asynchronous handler in example app --- android/src/toga_android/widgets/webview.py | 18 ++++++++---------- .../widgets/webview_static_proxy.py | 15 ++++++++++++++- examples/webview/webview/app.py | 4 ++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index d876c91263..13ce49143b 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,7 +1,6 @@ import json from http.cookiejar import CookieJar - from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy from java.lang import NoClassDefFoundError @@ -27,6 +26,7 @@ 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) @@ -35,16 +35,14 @@ def create(self): client = TogaWebClient(self) except NoClassDefFoundError: + self.SUPPORTS_ON_NAVIGATION_STARTING = False client = WebViewClient() - if self.interface.on_navigation_starting: - import sys - 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', - file=sys.stderr - ) + 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(client) diff --git a/android/src/toga_android/widgets/webview_static_proxy.py b/android/src/toga_android/widgets/webview_static_proxy.py index 85f9af10e8..d12e4ca667 100644 --- a/android/src/toga_android/widgets/webview_static_proxy.py +++ b/android/src/toga_android/widgets/webview_static_proxy.py @@ -1,3 +1,5 @@ +import asyncio + from android.webkit import WebResourceRequest, WebView as A_WebView, WebViewClient from java import Override, jboolean, static_proxy @@ -10,9 +12,20 @@ def __init__(self, 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( + 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 diff --git a/examples/webview/webview/app.py b/examples/webview/webview/app.py index 34f1ab05eb..66ab2a51c2 100644 --- a/examples/webview/webview/app.py +++ b/examples/webview/webview/app.py @@ -119,8 +119,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, + on_navigation_starting=self.on_navigation_starting_async, + # on_navigation_starting=self.on_navigation_starting_sync, ) box = toga.Box( From 8d70393bcef00a096805eba9d2b46ae5894167c0 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 18 Nov 2025 11:50:21 +0100 Subject: [PATCH 18/27] fixed trailing blanks --- core/src/toga/widgets/webview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index b1cb0ff22d..4d7f669a21 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -73,7 +73,7 @@ def __init__( super().__init__(id, style, **kwargs) 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 From 7862ff82c332722ed59f800ca0f50a17bc5d8026 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 18 Nov 2025 12:42:41 +0100 Subject: [PATCH 19/27] fixed tests for the added *args parameter passed to handler cleanup --- core/tests/test_handlers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 62bc8e2553..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, kwarg1=3, kwarg2=4) + 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, kwarg1=3, kwarg2=4) + 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, kwarg1=3, kwarg2=4) + 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, kwarg1=3, kwarg2=4) + cleanup.assert_called_once_with(obj, 42, "arg1", "arg2", kwarg1=3, kwarg2=4) # Evidence of the handler cleanup error is in the log. assert ( From 82ebcf09a2dd21fcc837bac84551013446b86157 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Tue, 18 Nov 2025 14:08:18 +0100 Subject: [PATCH 20/27] * fixed call to handler cleanup * adjusted WebView documentation for Android's staticProxy * added Android staticProxy for testbed application * removed debugging prints --- android/src/toga_android/widgets/webview.py | 4 ++-- core/src/toga/handlers.py | 2 +- core/src/toga/widgets/webview.py | 8 +++----- testbed/pyproject.toml | 2 ++ winforms/src/toga_winforms/widgets/webview.py | 9 ++------- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 13ce49143b..15fe7849e4 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -39,8 +39,8 @@ def create(self): client = WebViewClient() print( 'chaquopy.defaultConfig.staticProxy("toga_android.widgets' - '.webview_static_proxy") missing in pyproject.toml section' - ' "build_gradle_extra_content". on_navigation_starting ' + '.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, diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 7e11eaecf2..070b8bbb1b 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -171,7 +171,7 @@ def _handler(*args: object, **kwargs: object) -> object: else: try: if cleanup: - cleanup(interface, result, **kwargs) + 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 4d7f669a21..4e72033bbe 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -65,7 +65,9 @@ def __init__( 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 + 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. @@ -151,14 +153,10 @@ def on_navigation_starting(self, handler): def cleanup(widget, result, **kwargs): url = kwargs.get("url", None) try: - msg = f"on_navigation_starting.cleanup, url={url}, " - msg += f"result={str(result)}" - print(msg) 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 diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 264525efc3..eb88f3932c 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -115,6 +115,8 @@ android.defaultConfig.python { // Pytest's assertion rewriting produces its own .pyc files. pyc.src = false } + +chaquopy.defaultConfig.staticProxy("toga_android.widgets.webview_static_proxy") """ [tool.briefcase.app.testbed.web] diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 1597dcd686..9c0e2c378e 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -184,12 +184,10 @@ def winforms_navigation_completed(self, sender, args): 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...") + # check URL permission if self.interface._url_allowed: # URL is allowed by user code - print("URL is allowed by user code") allow = True # allow the URL only this time self.interface._url_allowed = False @@ -197,20 +195,17 @@ def winforms_navigation_starting(self, sender, event): 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 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") + # Deny navigation event.Cancel = True def get_url(self): From ef4149df2a6ccf9c5bde57145924dabff5c4aa46 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 00:37:19 +0100 Subject: [PATCH 21/27] added core tests --- core/tests/widgets/test_webview.py | 84 +++++++++++++++++++++++++ dummy/src/toga_dummy/widgets/webview.py | 26 ++++++++ 2 files changed, 110 insertions(+) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 951e133a1a..fa5200af15 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,84 @@ 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 + + # But the handler is still installed + assert widget.on_navigation_starting._raw == handler + 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 allowed URL + widget._impl.simulate_navigation("https://beeware.org") + assert widget.url == "https://beeware.org" + # test denied URL + widget._impl.simulate_navigation("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.on_navigation_starting = handler + # test allowed URL + widget._impl.simulate_navigation("https://beeware.org") + # navigation is denied until user decides + assert widget.url is None + # wait for the user response + asyncio.sleep(0.5) + assert widget.url == "https://beeware.org" + # test denied URL + widget._impl.simulate_navigation("https://google.com") + # navigation is denied until user decides + assert widget.url == "https://beeware.org" + # wait for the user response + asyncio.sleep(0.5) + assert widget.url == "https://beeware.org" diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 4b6363aa07..677a9367b9 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -1,3 +1,4 @@ +import asyncio from http.cookiejar import CookieJar from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -53,3 +54,28 @@ 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: + if self.interface._url_allowed: + # URL is allowed by user code + allow = True + # allow the URL only this time + self.interface._url_allowed = False + else: + result = self.interface.on_navigation_starting(url=url) + 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 allow: + self.set_url(url) From d6f9ab532551b24a48e81f2a0cda216b68136be3 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 06:40:53 +0100 Subject: [PATCH 22/27] * fixing pre-commit failures --- core/tests/widgets/test_webview.py | 10 ++++++++-- positron/src/positron/django_templates/manage.py.tmpl | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index fa5200af15..a064bb8d91 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -336,7 +336,7 @@ async def delayed_cookie_retrieval(): def test_webview_navigationstarting_disabled(monkeypatch): - """If the backend doesn't support on_navigation_starting, + """If the backend doesn't support on_navigation_starting, a warning is raised.""" try: # Temporarily set the feature attribute on the backend @@ -359,6 +359,7 @@ def test_webview_navigationstarting_disabled(monkeypatch): # 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""" @@ -366,8 +367,10 @@ def test_navigation_starting_no_handler(widget): 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": @@ -384,8 +387,10 @@ def handler(widget, **kwargs): widget._impl.simulate_navigation("https://google.com") assert widget.url == "https://beeware.org" + async def test_navigation_starting_async(widget): - """ Mocking user response""" + """Mocking user response""" + async def dialog_mock(url): await asyncio.sleep(0.3) if url == "https://beeware.org": @@ -394,6 +399,7 @@ async def dialog_mock(url): return False """Asynchronous handler""" + async def handler(widget, **kwargs): url = kwargs.get("url", None) return await dialog_mock(url) 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 From 1cb8fbf2a1c9e98b3b8c30ff1c60edece7c7a110 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 08:04:55 +0100 Subject: [PATCH 23/27] * fixed test_webview_navigationstarting_disabled * fixed test_navigation_starting_async --- core/tests/widgets/test_webview.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index a064bb8d91..cbf396538c 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -353,8 +353,8 @@ def test_webview_navigationstarting_disabled(monkeypatch): ): widget.on_navigation_starting = handler - # But the handler is still installed - assert widget.on_navigation_starting._raw == handler + # The handler is not installed + assert widget.on_navigation_starting is None finally: # Clear the feature attribute. del DummyWebView.SUPPORTS_ON_NAVIGATION_STARTING @@ -405,6 +405,7 @@ async def handler(widget, **kwargs): return await dialog_mock(url) widget.url = None + self.interface._url_allowed = False widget.on_navigation_starting = handler # test allowed URL widget._impl.simulate_navigation("https://beeware.org") From 7444b1a7426471db2498373ec7aaef65951df677 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 08:31:19 +0100 Subject: [PATCH 24/27] fixed test_navigation_starting_async --- core/tests/widgets/test_webview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index cbf396538c..03b0bcfc06 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -405,7 +405,7 @@ async def handler(widget, **kwargs): return await dialog_mock(url) widget.url = None - self.interface._url_allowed = False + widget._url_allowed = False widget.on_navigation_starting = handler # test allowed URL widget._impl.simulate_navigation("https://beeware.org") From 48e9af8a01cc9d6d9824a774e58957f576962ab7 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 08:58:45 +0100 Subject: [PATCH 25/27] fixed test_webview.py --- core/tests/widgets/test_webview.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 03b0bcfc06..e02a15abaf 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -381,10 +381,10 @@ def handler(widget, **kwargs): widget.url = None widget.on_navigation_starting = handler # test allowed URL - widget._impl.simulate_navigation("https://beeware.org") + widget._impl.simulate_navigation_starting("https://beeware.org") assert widget.url == "https://beeware.org" # test denied URL - widget._impl.simulate_navigation("https://google.com") + widget._impl.simulate_navigation_starting("https://google.com") assert widget.url == "https://beeware.org" @@ -408,14 +408,14 @@ async def handler(widget, **kwargs): widget._url_allowed = False widget.on_navigation_starting = handler # test allowed URL - widget._impl.simulate_navigation("https://beeware.org") + widget._impl.simulate_navigation_starting("https://beeware.org") # navigation is denied until user decides assert widget.url is None # wait for the user response asyncio.sleep(0.5) assert widget.url == "https://beeware.org" # test denied URL - widget._impl.simulate_navigation("https://google.com") + 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 From 5773f037be6117516d97d132a3fbbaef32e0cd23 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Thu, 27 Nov 2025 09:13:22 +0100 Subject: [PATCH 26/27] fixed test_webview.py --- core/tests/widgets/test_webview.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index e02a15abaf..721cb6a45a 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -412,12 +412,12 @@ async def handler(widget, **kwargs): # navigation is denied until user decides assert widget.url is None # wait for the user response - asyncio.sleep(0.5) + 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 - asyncio.sleep(0.5) + await asyncio.sleep(0.5) assert widget.url == "https://beeware.org" From b7a622199964eb43dc7a5f10680543434844f216 Mon Sep 17 00:00:00 2001 From: Tom Arn Date: Fri, 28 Nov 2025 13:03:08 +0100 Subject: [PATCH 27/27] * added test for None URL * added test for set_content with navigation_starting handler * removed unnecessary try block * modified set_url on dummy platform --- core/src/toga/widgets/webview.py | 17 +++++++---------- core/tests/widgets/test_webview.py | 18 ++++++++++++++++++ dummy/src/toga_dummy/widgets/webview.py | 2 ++ ~toxtest.cmd | 1 + 4 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 ~toxtest.cmd diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 4e72033bbe..8350e22a63 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -152,16 +152,13 @@ def on_navigation_starting(self, handler): def cleanup(widget, result, **kwargs): url = kwargs.get("url", None) - try: - 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 - except Exception as ex: - print(f"on_navigation_starting.cleanup exception: {str(ex)}") + 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: diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 721cb6a45a..e4cea1cfb0 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -209,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 = "

Fancy page

" + assert_action_performed_with( + widget, + "set content", + root_url="", + content="

Fancy page

", + ) + + def test_get_content_property_error(widget): """Verify that using the getter on widget.content fails.""" with pytest.raises(AttributeError): @@ -380,6 +395,9 @@ def handler(widget, **kwargs): 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" diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 677a9367b9..b2ec198692 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -26,6 +26,8 @@ def get_url(self): def set_url(self, value, future=None): self._set_value("url", value) self._set_value("loaded_future", future) + # allow the URL only once + self.interface._url_allowed = False def get_cookies(self): self._action("cookies") diff --git a/~toxtest.cmd b/~toxtest.cmd new file mode 100644 index 0000000000..7789eb0def --- /dev/null +++ b/~toxtest.cmd @@ -0,0 +1 @@ +tox -e py -- tests/widgets/test_webview.py