From 476366fcfa4dc6c5289c5acae7e8d30551816936 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 29 Dec 2024 13:56:09 +0100 Subject: [PATCH 01/17] Working cookie retrieval for windows --- core/src/toga/widgets/webview.py | 7 +++ iOS/src/toga_iOS/widgets/webview.py | 25 ++++++++- winforms/src/toga_winforms/libs/extensions.py | 1 + winforms/src/toga_winforms/widgets/webview.py | 54 +++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index a64a32fc05..3428309041 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -134,6 +134,13 @@ def set_content(self, root_url: str, content: str) -> None: :param content: The HTML content for the WebView """ self._impl.set_content(root_url, content) + + def get_cookies(self, on_result: OnResultT): + """Retrieve cookies from the WebView. + + :param on_result: A callback function to process the cookies once retrieved. + """ + self._impl.get_cookies(on_result) def evaluate_javascript( self, diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 3bc19a4516..601f80d8fe 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -1,4 +1,4 @@ -from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns +from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns, ObjCClass from travertino.size import at_least from toga.widgets.webview import JavaScriptResult @@ -78,6 +78,29 @@ def evaluate_javascript(self, javascript, on_result=None): return result + def get_cookies(self, on_cookies): + """ + Retrieve cookies asynchronously from the WebView's cookie store. + + :param on_cookies: A callback function to handle the retrieved cookies. + """ + cookie_store = self.native.configuration.websiteDataStore.httpCookieStore + cookies = [] + + def completion_handler(cookie): + # Add the cookie to the list + cookies.append(py_from_ns(cookie)) + + def finalize_cookies(): + # Pass the list of cookies to the callback + on_cookies(cookies) + + # Enumerate all cookies in the cookie store + cookie_store.getAllCookiesWithCompletionHandler_(completion_handler) + + # Call the final callback after fetching cookies + self.interface.app.interface.set_timeout(finalize_cookies, delay=0.1) + def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/libs/extensions.py b/winforms/src/toga_winforms/libs/extensions.py index 79397fbddc..e708c95420 100644 --- a/winforms/src/toga_winforms/libs/extensions.py +++ b/winforms/src/toga_winforms/libs/extensions.py @@ -28,6 +28,7 @@ clr.AddReference(str(WEBVIEW2_DIR / "Microsoft.Web.WebView2.WinForms.dll")) from Microsoft.Web.WebView2.Core import ( # noqa: F401, E402 + CoreWebView2Cookie, WebView2RuntimeNotFoundException, ) from Microsoft.Web.WebView2.WinForms import ( # noqa: F401, E402 diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index d84b6b52aa..fa0583c54d 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -7,12 +7,14 @@ String, Uri, ) +from System.Collections.Generic import List # Import List for generics from System.Drawing import Color from System.Threading.Tasks import Task, TaskScheduler import toga from toga.widgets.webview import JavaScriptResult from toga_winforms.libs.extensions import ( + CoreWebView2Cookie, CoreWebView2CreationProperties, WebView2, WebView2RuntimeNotFoundException, @@ -75,6 +77,10 @@ def winforms_initialization_completed(self, sender, args): settings = self.native.CoreWebView2.Settings self.default_user_agent = settings.UserAgent + # Initialize cookie manager + self.cookie_manager = self.native.CoreWebView2.CookieManager + print("CookieManager initialized:", self.cookie_manager is not None) + debug = True settings.AreBrowserAcceleratorKeysEnabled = debug settings.AreDefaultContextMenusEnabled = debug @@ -180,3 +186,51 @@ def execute(): self.run_after_initialization(execute) return result + + def get_cookies(self, on_result): + """ + Retrieve all cookies asynchronously from the WebView. + + :param on_result: Callback to handle the cookies. + """ + if not hasattr(self, "cookie_manager") or self.cookie_manager is None: + raise RuntimeError( + "CookieManager is not initialized. Ensure the WebView is fully " + "loaded before calling get_cookies()." + ) + + cookies = [] + + def handle_cookie(cookie): + cookies.append( + { + "name": cookie.Name, + "value": cookie.Value, + "domain": cookie.Domain, + "path": cookie.Path, + "secure": cookie.IsSecure, + "http_only": cookie.IsHttpOnly, + "expiration": ( + cookie.Expires.ToString("o") + if cookie.IsSession is False + else None + ), + } + ) + + def completion_handler(task): + try: + # Process the cookies when the task is complete + cookie_list = task.Result + for cookie in cookie_list: + handle_cookie(cookie) + # Call the provided callback with the collected cookies + on_result(cookies) + except Exception as e: + print("Error retrieving cookies:", e) + + # Enumerate all cookies asynchronously + task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() + self.cookie_manager.GetCookiesAsync(None).ContinueWith( + Action[Task[List[CoreWebView2Cookie]]](completion_handler), task_scheduler + ) From e43f452797c85318ea7bac04cca47b4e549163fb Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 29 Dec 2024 16:49:15 +0100 Subject: [PATCH 02/17] Add support for MacOS and iOS update ios code add the funtctionality for macos as well --- cocoa/src/toga_cocoa/widgets/webview.py | 38 ++++++++++++++++ iOS/src/toga_iOS/widgets/webview.py | 60 +++++++++++++++---------- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index d1fa1183ce..10f58759d5 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -90,3 +90,41 @@ def evaluate_javascript(self, javascript: str, on_result=None) -> str: def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + + + def get_cookies(self, on_result): + """ + Retrieve all cookies asynchronously from the WebView. + + :param on_result: Callback to handle the cookies. + """ + cookie_store = self.native.configuration.websiteDataStore.httpCookieStore + + def cookies_callback(cookies: objc_id) -> None: + # Convert the cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Structure the cookies as a list of dictionaries + structured_cookies = [] + for cookie in cookies_array: + structured_cookies.append( + { + "name": str(cookie.name), + "value": str(cookie.value), + "domain": str(cookie.domain), + "path": str(cookie.path), + "secure": bool(cookie.isSecure), + "http_only": bool(cookie.isHTTPOnly), + "expiration": ( + cookie.expiresDate.description + if cookie.expiresDate + else None + ), + } + ) + + # Pass the structured cookies to the provided callback + on_result(structured_cookies) + + # Call the method to retrieve all cookies + cookie_store.getAllCookies_(cookies_callback) diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 601f80d8fe..0bfac10c02 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -1,4 +1,4 @@ -from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns, ObjCClass +from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least from toga.widgets.webview import JavaScriptResult @@ -78,29 +78,43 @@ def evaluate_javascript(self, javascript, on_result=None): return result - def get_cookies(self, on_cookies): + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + + def get_cookies(self, on_result): """ - Retrieve cookies asynchronously from the WebView's cookie store. - - :param on_cookies: A callback function to handle the retrieved cookies. + Retrieve all cookies asynchronously from the WebView. + + :param on_result: Callback to handle the cookies. """ cookie_store = self.native.configuration.websiteDataStore.httpCookieStore - cookies = [] - - def completion_handler(cookie): - # Add the cookie to the list - cookies.append(py_from_ns(cookie)) - def finalize_cookies(): - # Pass the list of cookies to the callback - on_cookies(cookies) - - # Enumerate all cookies in the cookie store - cookie_store.getAllCookiesWithCompletionHandler_(completion_handler) - - # Call the final callback after fetching cookies - self.interface.app.interface.set_timeout(finalize_cookies, delay=0.1) - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + def cookies_callback(cookies: objc_id) -> None: + # Convert the cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Structure the cookies as a list of dictionaries + structured_cookies = [] + for cookie in cookies_array: + structured_cookies.append( + { + "name": str(cookie.name), + "value": str(cookie.value), + "domain": str(cookie.domain), + "path": str(cookie.path), + "secure": bool(cookie.isSecure), + "http_only": bool(cookie.isHTTPOnly), + "expiration": ( + cookie.expiresDate.description + if cookie.expiresDate + else None + ), + } + ) + + # Pass the structured cookies to the provided callback + on_result(structured_cookies) + + # Call the method to retrieve all cookies + cookie_store.getAllCookies_(cookies_callback) From c384e2a4f2736793caeb969107c44457fdad0006 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Wed, 1 Jan 2025 16:25:17 +0100 Subject: [PATCH 03/17] making uniform with evaluate javascript --- cocoa/src/toga_cocoa/widgets/webview.py | 97 ++++++++++--------- core/src/toga/widgets/webview.py | 8 +- core/tests/test_platform.py | 45 ++++++++- core/tests/widgets/test_webview.py | 28 ++++++ dummy/src/toga_dummy/widgets/webview.py | 9 ++ iOS/src/toga_iOS/widgets/webview.py | 75 ++++++++------ winforms/src/toga_winforms/widgets/webview.py | 86 ++++++++-------- 7 files changed, 229 insertions(+), 119 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 10f58759d5..91996a17b3 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -1,10 +1,9 @@ from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least -from toga.widgets.webview import JavaScriptResult - -from ..libs import NSURL, NSURLRequest, WKWebView -from .base import Widget +from toga.widgets.webview import CookiesResult, JavaScriptResult +from toga_iOS.libs import NSURL, NSURLRequest, WKWebView +from toga_iOS.widgets.base import Widget def js_completion_handler(result): @@ -19,6 +18,40 @@ def _completion_handler(res: objc_id, error: objc_id) -> None: return _completion_handler +def cookies_completion_handler(result): + def _completion_handler(cookies: objc_id) -> None: + try: + # Convert cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Structure the cookies as a list of dictionaries + structured_cookies = [] + for cookie in cookies_array: + structured_cookies.append( + { + "name": str(cookie.name), + "value": str(cookie.value), + "domain": str(cookie.domain), + "path": str(cookie.path), + "secure": bool(cookie.isSecure), + "http_only": bool(cookie.isHTTPOnly), + "expiration": ( + cookie.expiresDate.description + if cookie.expiresDate + else None + ), + } + ) + + # Set the result in the AsyncResult + result.set_result(structured_cookies) + except Exception as exc: + # Set an exception in the AsyncResult if something goes wrong + result.set_exception(exc) + + return _completion_handler + + class TogaWebView(WKWebView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -31,10 +64,6 @@ def webView_didFinishNavigation_(self, navigation) -> None: self.impl.loaded_future.set_result(None) self.impl.loaded_future = None - @objc_method - def acceptsFirstResponder(self) -> bool: - return True - class WebView(Widget): def create(self): @@ -42,12 +71,8 @@ def create(self): self.native.interface = self.interface self.native.impl = self - # Enable the content inspector. This was added in macOS 13.3 (Ventura). It will - # be a no-op on newer versions of macOS; you need to package the app, then run: - # - # defaults write com.example.appname WebKitDeveloperExtras -bool true - # - # from the command line. + # Enable the content inspector. This was added in iOS 16.4. + # It is a no-op on earlier versions. self.native.inspectable = True self.native.navigationDelegate = self.native @@ -78,8 +103,8 @@ def get_user_agent(self): def set_user_agent(self, value): self.native.customUserAgent = value - def evaluate_javascript(self, javascript: str, on_result=None) -> str: - result = JavaScriptResult(on_result=on_result) + def evaluate_javascript(self, javascript, on_result=None): + result = JavaScriptResult(on_result) self.native.evaluateJavaScript( javascript, completionHandler=js_completion_handler(result), @@ -91,40 +116,20 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - def get_cookies(self, on_result): + def get_cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Callback to handle the cookies. + :param on_result: Optional callback to handle the cookies. + :return: An AsyncResult object that can be awaited. """ - cookie_store = self.native.configuration.websiteDataStore.httpCookieStore + # Create an AsyncResult to manage the cookies + result = CookiesResult(on_result) - def cookies_callback(cookies: objc_id) -> None: - # Convert the cookies from Objective-C to Python objects - cookies_array = py_from_ns(cookies) - - # Structure the cookies as a list of dictionaries - structured_cookies = [] - for cookie in cookies_array: - structured_cookies.append( - { - "name": str(cookie.name), - "value": str(cookie.value), - "domain": str(cookie.domain), - "path": str(cookie.path), - "secure": bool(cookie.isSecure), - "http_only": bool(cookie.isHTTPOnly), - "expiration": ( - cookie.expiresDate.description - if cookie.expiresDate - else None - ), - } - ) + # Retrieve the cookie store from the WebView + cookie_store = self.native.configuration.websiteDataStore.httpCookieStore - # Pass the structured cookies to the provided callback - on_result(structured_cookies) + # Call the method to retrieve all cookies and pass the completion handler + cookie_store.getAllCookies_(cookies_completion_handler(result)) - # Call the method to retrieve all cookies - cookie_store.getAllCookies_(cookies_callback) + return result diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 3428309041..4740b48fd2 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -12,6 +12,10 @@ class JavaScriptResult(AsyncResult): RESULT_TYPE = "JavaScript" +class CookiesResult(AsyncResult): + RESULT_TYPE = "Cookies" + + class OnWebViewLoadHandler(Protocol): def __call__(self, widget: WebView, **kwargs: Any) -> object: """A handler to invoke when the WebView is loaded. @@ -134,10 +138,10 @@ def set_content(self, root_url: str, content: str) -> None: :param content: The HTML content for the WebView """ self._impl.set_content(root_url, content) - + def get_cookies(self, on_result: OnResultT): """Retrieve cookies from the WebView. - + :param on_result: A callback function to process the cookies once retrieved. """ self._impl.get_cookies(on_result) diff --git a/core/tests/test_platform.py b/core/tests/test_platform.py index aaf4dd7ecb..03015d3715 100644 --- a/core/tests/test_platform.py +++ b/core/tests/test_platform.py @@ -1,11 +1,16 @@ import importlib.metadata import sys -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest import toga_dummy -from toga.platform import current_platform, get_current_platform, get_platform_factory +from toga.platform import ( + current_platform, + entry_points, + get_current_platform, + get_platform_factory, +) @pytest.fixture @@ -199,3 +204,39 @@ def test_environment_variable_fail(monkeypatch): r"\('fake_platform_module'\) could not be loaded.", ): _get_platform_factory() + + +def test_entry_points_pre_310(monkeypatch): + """Test entry_points function for Python < 3.10.""" + with patch.object(sys, "version_info", (3, 9)): + with patch("toga.platform.metadata.entry_points") as mock_entry_points: + mock_entry_points.return_value = {"group1": "entry1", "group2": "entry2"} + result = entry_points(group="group1") + assert result == "entry1" + mock_entry_points.assert_called_once_with() + + +def test_entry_points_post_310(monkeypatch): + """Test entry_points function for Python >= 3.10.""" + with patch.object(sys, "version_info", (3, 10, 0)): # Correctly mock Python >= 3.10 + # Mock importlib.metadata.entry_points to return the correct format + with patch("importlib.metadata.entry_points") as mock_entry_points: + mock_entry_points.return_value = [ + importlib.metadata.EntryPoint( + name="group1", value="entry1", group="group1" + ), + importlib.metadata.EntryPoint( + name="group2", value="entry2", group="group2" + ), + ] + + # Test the entry_points function + result = entry_points(group="group1") + # Extract the value of the entry point with the correct group + entry_point_value = next( + ep.value + for ep in mock_entry_points.return_value + if ep.group == "group1" + ) + assert result == entry_point_value # Expecting the value for group1 + mock_entry_points.assert_called_once_with(group="group1") diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 07a6594185..0ce4cb04ef 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -248,3 +248,31 @@ async def delayed_page_load(): # The async handler was invoked on_result_handler.assert_called_once_with(42) + + +async def test_get_cookies_async(widget): + """Cookies can be retrieved asynchronously from the WebView.""" + + # An async task that simulates retrieval of cookies after a delay + async def delayed_cookie_retrieval(): + await asyncio.sleep(0.1) + + # Simulate cookies being retrieved + cookies = {"session_id": "abc123", "user_id": "42"} + widget._impl.simulate_cookie_retrieval(cookies) + + asyncio.create_task(delayed_cookie_retrieval()) + + on_result_handler = Mock() + + # Call the get_cookies method with the on_result handler + widget.get_cookies(on_result_handler) + + # Verify that the action was performed + assert_action_performed(widget, "get_cookies") + + # Allow the async delay to complete + await asyncio.sleep(0.2) + + # Verify that the callback was invoked with the correct cookies + on_result_handler.assert_called_once_with({"session_id": "abc123", "user_id": "42"}) diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 12541790df..343bcf4124 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -39,3 +39,12 @@ def simulate_page_loaded(self): def simulate_javascript_result(self, value): self._js_result.set_result(42) + + def get_cookies(self, on_result=None): + """Simulate retrieving cookies.""" + self._action("get_cookies") + + def simulate_cookie_retrieval(self, cookies): + """Simulate completion of cookie retrieval.""" + if self._on_result: + self._on_result(cookies) diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 0bfac10c02..91996a17b3 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -1,7 +1,7 @@ from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least -from toga.widgets.webview import JavaScriptResult +from toga.widgets.webview import CookiesResult, JavaScriptResult from toga_iOS.libs import NSURL, NSURLRequest, WKWebView from toga_iOS.widgets.base import Widget @@ -18,6 +18,40 @@ def _completion_handler(res: objc_id, error: objc_id) -> None: return _completion_handler +def cookies_completion_handler(result): + def _completion_handler(cookies: objc_id) -> None: + try: + # Convert cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Structure the cookies as a list of dictionaries + structured_cookies = [] + for cookie in cookies_array: + structured_cookies.append( + { + "name": str(cookie.name), + "value": str(cookie.value), + "domain": str(cookie.domain), + "path": str(cookie.path), + "secure": bool(cookie.isSecure), + "http_only": bool(cookie.isHTTPOnly), + "expiration": ( + cookie.expiresDate.description + if cookie.expiresDate + else None + ), + } + ) + + # Set the result in the AsyncResult + result.set_result(structured_cookies) + except Exception as exc: + # Set an exception in the AsyncResult if something goes wrong + result.set_exception(exc) + + return _completion_handler + + class TogaWebView(WKWebView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -82,39 +116,20 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def get_cookies(self, on_result): + def get_cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Callback to handle the cookies. + :param on_result: Optional callback to handle the cookies. + :return: An AsyncResult object that can be awaited. """ - cookie_store = self.native.configuration.websiteDataStore.httpCookieStore - - def cookies_callback(cookies: objc_id) -> None: - # Convert the cookies from Objective-C to Python objects - cookies_array = py_from_ns(cookies) + # Create an AsyncResult to manage the cookies + result = CookiesResult(on_result) - # Structure the cookies as a list of dictionaries - structured_cookies = [] - for cookie in cookies_array: - structured_cookies.append( - { - "name": str(cookie.name), - "value": str(cookie.value), - "domain": str(cookie.domain), - "path": str(cookie.path), - "secure": bool(cookie.isSecure), - "http_only": bool(cookie.isHTTPOnly), - "expiration": ( - cookie.expiresDate.description - if cookie.expiresDate - else None - ), - } - ) + # Retrieve the cookie store from the WebView + cookie_store = self.native.configuration.websiteDataStore.httpCookieStore - # Pass the structured cookies to the provided callback - on_result(structured_cookies) + # Call the method to retrieve all cookies and pass the completion handler + cookie_store.getAllCookies_(cookies_completion_handler(result)) - # Call the method to retrieve all cookies - cookie_store.getAllCookies_(cookies_callback) + return result diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index fa0583c54d..91713ee18e 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -12,7 +12,7 @@ from System.Threading.Tasks import Task, TaskScheduler import toga -from toga.widgets.webview import JavaScriptResult +from toga.widgets.webview import CookiesResult, JavaScriptResult from toga_winforms.libs.extensions import ( CoreWebView2Cookie, CoreWebView2CreationProperties, @@ -34,6 +34,39 @@ def task(): return wrapper +def cookies_completion_handler(result): + """ + Generalized completion handler for processing cookies. + """ + + def _completion_handler(task): + try: + # Retrieve and structure cookies from the task result + cookie_list = task.Result + structured_cookies = [ + { + "name": cookie.Name, + "value": cookie.Value, + "domain": cookie.Domain, + "path": cookie.Path, + "secure": cookie.IsSecure, + "http_only": cookie.IsHttpOnly, + "expiration": ( + cookie.Expires.ToString("o") if not cookie.IsSession else None + ), + } + for cookie in cookie_list + ] + + # Set the result in the CookiesResult object + result.set_result(structured_cookies) + except Exception as exc: + # Handle exceptions and set them in the CookiesResult object + result.set_exception(exc) + + return _completion_handler + + class WebView(Widget): def create(self): self.native = WebView2() @@ -187,50 +220,25 @@ def execute(): self.run_after_initialization(execute) return result - def get_cookies(self, on_result): + def get_cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Callback to handle the cookies. + :param on_result: Optional callback to handle the cookies. + :return: A CookiesResult object that can be awaited. """ - if not hasattr(self, "cookie_manager") or self.cookie_manager is None: - raise RuntimeError( - "CookieManager is not initialized. Ensure the WebView is fully " - "loaded before calling get_cookies()." - ) + # Create an AsyncResult to manage the cookies + result = CookiesResult(on_result) - cookies = [] - - def handle_cookie(cookie): - cookies.append( - { - "name": cookie.Name, - "value": cookie.Value, - "domain": cookie.Domain, - "path": cookie.Path, - "secure": cookie.IsSecure, - "http_only": cookie.IsHttpOnly, - "expiration": ( - cookie.Expires.ToString("o") - if cookie.IsSession is False - else None - ), - } - ) + # Wrap the Python completion handler in a .NET Action delegate + completion_handler_delegate = Action[Task[List[CoreWebView2Cookie]]]( + cookies_completion_handler(result) + ) - def completion_handler(task): - try: - # Process the cookies when the task is complete - cookie_list = task.Result - for cookie in cookie_list: - handle_cookie(cookie) - # Call the provided callback with the collected cookies - on_result(cookies) - except Exception as e: - print("Error retrieving cookies:", e) - - # Enumerate all cookies asynchronously + # Call the method to retrieve cookies asynchronously task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() self.cookie_manager.GetCookiesAsync(None).ContinueWith( - Action[Task[List[CoreWebView2Cookie]]](completion_handler), task_scheduler + completion_handler_delegate, task_scheduler ) + + return result From 41a96afc0273fada6f7313415c82130cd7516867 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Thu, 2 Jan 2025 10:10:58 +0100 Subject: [PATCH 04/17] Creating tests for cookie retrieval --- cocoa/src/toga_cocoa/widgets/webview.py | 21 ++++++++---- core/src/toga/widgets/webview.py | 7 ++-- core/tests/test_platform.py | 45 ++----------------------- core/tests/widgets/test_webview.py | 36 ++++++++++++++++---- dummy/src/toga_dummy/widgets/webview.py | 17 ++++++++-- 5 files changed, 65 insertions(+), 61 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 91996a17b3..3e04b16e78 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -2,8 +2,9 @@ from travertino.size import at_least from toga.widgets.webview import CookiesResult, JavaScriptResult -from toga_iOS.libs import NSURL, NSURLRequest, WKWebView -from toga_iOS.widgets.base import Widget + +from ..libs import NSURL, NSURLRequest, WKWebView +from .base import Widget def js_completion_handler(result): @@ -64,6 +65,10 @@ def webView_didFinishNavigation_(self, navigation) -> None: self.impl.loaded_future.set_result(None) self.impl.loaded_future = None + @objc_method + def acceptsFirstResponder(self) -> bool: + return True + class WebView(Widget): def create(self): @@ -71,8 +76,12 @@ def create(self): self.native.interface = self.interface self.native.impl = self - # Enable the content inspector. This was added in iOS 16.4. - # It is a no-op on earlier versions. + # Enable the content inspector. This was added in macOS 13.3 (Ventura). It will + # be a no-op on newer versions of macOS; you need to package the app, then run: + # + # defaults write com.example.appname WebKitDeveloperExtras -bool true + # + # from the command line. self.native.inspectable = True self.native.navigationDelegate = self.native @@ -103,8 +112,8 @@ def get_user_agent(self): def set_user_agent(self, value): self.native.customUserAgent = value - def evaluate_javascript(self, javascript, on_result=None): - result = JavaScriptResult(on_result) + def evaluate_javascript(self, javascript: str, on_result=None) -> str: + result = JavaScriptResult(on_result=on_result) self.native.evaluateJavaScript( javascript, completionHandler=js_completion_handler(result), diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 4740b48fd2..63786d7d53 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -139,12 +139,15 @@ def set_content(self, root_url: str, content: str) -> None: """ self._impl.set_content(root_url, content) - def get_cookies(self, on_result: OnResultT): + def get_cookies( + self, + on_result: OnResultT | None = None, + ) -> CookiesResult: """Retrieve cookies from the WebView. :param on_result: A callback function to process the cookies once retrieved. """ - self._impl.get_cookies(on_result) + return self._impl.get_cookies(on_result=on_result) def evaluate_javascript( self, diff --git a/core/tests/test_platform.py b/core/tests/test_platform.py index 03015d3715..aaf4dd7ecb 100644 --- a/core/tests/test_platform.py +++ b/core/tests/test_platform.py @@ -1,16 +1,11 @@ import importlib.metadata import sys -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest import toga_dummy -from toga.platform import ( - current_platform, - entry_points, - get_current_platform, - get_platform_factory, -) +from toga.platform import current_platform, get_current_platform, get_platform_factory @pytest.fixture @@ -204,39 +199,3 @@ def test_environment_variable_fail(monkeypatch): r"\('fake_platform_module'\) could not be loaded.", ): _get_platform_factory() - - -def test_entry_points_pre_310(monkeypatch): - """Test entry_points function for Python < 3.10.""" - with patch.object(sys, "version_info", (3, 9)): - with patch("toga.platform.metadata.entry_points") as mock_entry_points: - mock_entry_points.return_value = {"group1": "entry1", "group2": "entry2"} - result = entry_points(group="group1") - assert result == "entry1" - mock_entry_points.assert_called_once_with() - - -def test_entry_points_post_310(monkeypatch): - """Test entry_points function for Python >= 3.10.""" - with patch.object(sys, "version_info", (3, 10, 0)): # Correctly mock Python >= 3.10 - # Mock importlib.metadata.entry_points to return the correct format - with patch("importlib.metadata.entry_points") as mock_entry_points: - mock_entry_points.return_value = [ - importlib.metadata.EntryPoint( - name="group1", value="entry1", group="group1" - ), - importlib.metadata.EntryPoint( - name="group2", value="entry2", group="group2" - ), - ] - - # Test the entry_points function - result = entry_points(group="group1") - # Extract the value of the entry point with the correct group - entry_point_value = next( - ep.value - for ep in mock_entry_points.return_value - if ep.group == "group1" - ) - assert result == entry_point_value # Expecting the value for group1 - mock_entry_points.assert_called_once_with(group="group1") diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 0ce4cb04ef..b44f4e8c5d 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -258,21 +258,43 @@ async def delayed_cookie_retrieval(): await asyncio.sleep(0.1) # Simulate cookies being retrieved - cookies = {"session_id": "abc123", "user_id": "42"} + cookies = { + "name": "test", + "value": "test", + "domain": "example.com", + "path": "/", + "secure": True, + "http_only": True, + "expiration": None, + } widget._impl.simulate_cookie_retrieval(cookies) asyncio.create_task(delayed_cookie_retrieval()) on_result_handler = Mock() - # Call the get_cookies method with the on_result handler - widget.get_cookies(on_result_handler) + # Ensure that the `get_cookies` method raises a DeprecationWarning + with pytest.warns( + DeprecationWarning, + match=r"Synchronous `on_result` handlers have been deprecated;", + ): + # Correctly pass the on_result handler as a keyword argument + result = await widget.get_cookies(on_result=on_result_handler) # Verify that the action was performed assert_action_performed(widget, "get_cookies") - # Allow the async delay to complete - await asyncio.sleep(0.2) + # Verify the result and handler invocation + expected_cookies = { + "name": "test", + "value": "test", + "domain": "example.com", + "path": "/", + "secure": True, + "http_only": True, + "expiration": None, + } + + assert result == expected_cookies - # Verify that the callback was invoked with the correct cookies - on_result_handler.assert_called_once_with({"session_id": "abc123", "user_id": "42"}) + on_result_handler.assert_called_once_with(expected_cookies) diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index 343bcf4124..da183185e9 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -1,4 +1,4 @@ -from toga.widgets.webview import JavaScriptResult +from toga.widgets.webview import CookiesResult, JavaScriptResult from .base import Widget @@ -43,8 +43,19 @@ def simulate_javascript_result(self, value): def get_cookies(self, on_result=None): """Simulate retrieving cookies.""" self._action("get_cookies") + self._cookie_result = CookiesResult(on_result) + return self._cookie_result def simulate_cookie_retrieval(self, cookies): """Simulate completion of cookie retrieval.""" - if self._on_result: - self._on_result(cookies) + self._cookie_result.set_result( + { + "name": "test", + "value": "test", + "domain": "example.com", + "path": "/", + "secure": True, + "http_only": True, + "expiration": None, + } + ) From 147bec125c7997af568edd027fdd3265436687b0 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Thu, 2 Jan 2025 10:49:55 +0100 Subject: [PATCH 05/17] made documentation more extensive --- core/src/toga/widgets/webview.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 63786d7d53..b370fece52 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -145,6 +145,12 @@ def get_cookies( ) -> CookiesResult: """Retrieve cookies from the WebView. + **This is an asynchronous method**. There is no guarantee that the function + has finished evaluating when this method returns. The object returned by this + method can be awaited to obtain the value of the expression. + + **Note:** This is not yet currently supported on Android or Linux. + :param on_result: A callback function to process the cookies once retrieved. """ return self._impl.get_cookies(on_result=on_result) From 196c58ffcaaaf4d07614472d47dc5429cedfd74c Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Thu, 2 Jan 2025 10:57:22 +0100 Subject: [PATCH 06/17] added changes file --- changes/3068.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3068.feature.rst diff --git a/changes/3068.feature.rst b/changes/3068.feature.rst new file mode 100644 index 0000000000..9f0bcdc01a --- /dev/null +++ b/changes/3068.feature.rst @@ -0,0 +1 @@ +Added support for cookie retrieval from a webview From 17f284105e25630f478b04c6673dff2d374ac9ab Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Thu, 2 Jan 2025 20:03:45 +0100 Subject: [PATCH 07/17] Remove print statement on Windows --- winforms/src/toga_winforms/widgets/webview.py | 1 - 1 file changed, 1 deletion(-) diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 91713ee18e..a621edd995 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -112,7 +112,6 @@ def winforms_initialization_completed(self, sender, args): # Initialize cookie manager self.cookie_manager = self.native.CoreWebView2.CookieManager - print("CookieManager initialized:", self.cookie_manager is not None) debug = True settings.AreBrowserAcceleratorKeysEnabled = debug From 5fe0de7671df9381c97e731e96a7d460cd2e3ef2 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Fri, 3 Jan 2025 13:38:46 +0100 Subject: [PATCH 08/17] Update changes/3068.feature.rst Co-authored-by: Russell Keith-Magee --- changes/3068.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/3068.feature.rst b/changes/3068.feature.rst index 9f0bcdc01a..fd588ab7a3 100644 --- a/changes/3068.feature.rst +++ b/changes/3068.feature.rst @@ -1 +1 @@ -Added support for cookie retrieval from a webview +Webviews now support the retrieval of cookies. From 4b67b9af6ea48d8efebab34ef9dacf1c85976213 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 12:42:47 +0100 Subject: [PATCH 09/17] Using Cookiejar as return as suggested --- cocoa/src/toga_cocoa/widgets/webview.py | 41 +++++++++----- iOS/src/toga_iOS/widgets/webview.py | 41 +++++++++----- winforms/src/toga_winforms/widgets/webview.py | 56 +++++++++++++------ 3 files changed, 95 insertions(+), 43 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 3e04b16e78..b704154210 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -1,3 +1,5 @@ +from http.cookiejar import Cookie, CookieJar + from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least @@ -25,27 +27,40 @@ def _completion_handler(cookies: objc_id) -> None: # Convert cookies from Objective-C to Python objects cookies_array = py_from_ns(cookies) - # Structure the cookies as a list of dictionaries - structured_cookies = [] + # Initialize a CookieJar + cookie_jar = CookieJar() + + # Add each cookie from the array into the CookieJar for cookie in cookies_array: - structured_cookies.append( - { - "name": str(cookie.name), - "value": str(cookie.value), - "domain": str(cookie.domain), - "path": str(cookie.path), - "secure": bool(cookie.isSecure), - "http_only": bool(cookie.isHTTPOnly), - "expiration": ( + cookie_obj = Cookie( + version=0, + name=str(cookie.name), + value=str(cookie.value), + port=None, + port_specified=False, + domain=str(cookie.domain), + domain_specified=True, + domain_initial_dot=False, + path=str(cookie.path), + path_specified=True, + secure=bool(cookie.isSecure), + expires=None, + discard=cookie.IsSession, + comment=None, + comment_url=None, + rest={ + "HttpOnly": bool(cookie.isHTTPOnly), + "Expires": ( cookie.expiresDate.description if cookie.expiresDate else None ), - } + }, ) + cookie_jar.set_cookie(cookie_obj) # Set the result in the AsyncResult - result.set_result(structured_cookies) + result.set_result(cookie_jar) except Exception as exc: # Set an exception in the AsyncResult if something goes wrong result.set_exception(exc) diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 91996a17b3..d5edce1549 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -1,3 +1,5 @@ +from http.cookiejar import Cookie, CookieJar + from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns from travertino.size import at_least @@ -24,27 +26,40 @@ def _completion_handler(cookies: objc_id) -> None: # Convert cookies from Objective-C to Python objects cookies_array = py_from_ns(cookies) - # Structure the cookies as a list of dictionaries - structured_cookies = [] + # Initialize a CookieJar + cookie_jar = CookieJar() + + # Add each cookie from the array into the CookieJar for cookie in cookies_array: - structured_cookies.append( - { - "name": str(cookie.name), - "value": str(cookie.value), - "domain": str(cookie.domain), - "path": str(cookie.path), - "secure": bool(cookie.isSecure), - "http_only": bool(cookie.isHTTPOnly), - "expiration": ( + cookie_obj = Cookie( + version=0, + name=str(cookie.name), + value=str(cookie.value), + port=None, + port_specified=False, + domain=str(cookie.domain), + domain_specified=True, + domain_initial_dot=False, + path=str(cookie.path), + path_specified=True, + secure=bool(cookie.isSecure), + expires=None, + discard=cookie.IsSession, + comment=None, + comment_url=None, + rest={ + "HttpOnly": bool(cookie.isHTTPOnly), + "Expires": ( cookie.expiresDate.description if cookie.expiresDate else None ), - } + }, ) + cookie_jar.set_cookie(cookie_obj) # Set the result in the AsyncResult - result.set_result(structured_cookies) + result.set_result(cookie_jar) except Exception as exc: # Set an exception in the AsyncResult if something goes wrong result.set_exception(exc) diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index a621edd995..dbe630da82 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -1,5 +1,6 @@ import json import webbrowser +from http.cookiejar import Cookie, CookieJar import System.Windows.Forms as WinForms from System import ( @@ -41,25 +42,46 @@ def cookies_completion_handler(result): def _completion_handler(task): try: - # Retrieve and structure cookies from the task result - cookie_list = task.Result - structured_cookies = [ - { - "name": cookie.Name, - "value": cookie.Value, - "domain": cookie.Domain, - "path": cookie.Path, - "secure": cookie.IsSecure, - "http_only": cookie.IsHttpOnly, - "expiration": ( - cookie.Expires.ToString("o") if not cookie.IsSession else None - ), - } - for cookie in cookie_list + # Initialize a CookieJar to store cookies + cookie_jar = CookieJar() + + # Use a list comprehension to convert each cookie into a Cookie + # object and add it to the CookieJar + [ + cookie_jar.set_cookie( + Cookie( + version=0, + name=cookie.Name, + value=cookie.Value, + port=None, + port_specified=False, + domain=cookie.Domain, + domain_specified=True, + domain_initial_dot=False, + path=cookie.Path, + path_specified=True, + secure=cookie.IsSecure, + expires=None, + discard=cookie.IsSession, + comment=None, + comment_url=None, + rest={ + "HttpOnly": cookie.IsHttpOnly, # Store the HttpOnly flag + "Expires": ( + cookie.Expires.ToString("o") + if not cookie.IsSession + else None + ), + }, + rfc2109=False, # Whether the cookie follows RFC 2109 + ) + ) + for cookie in task.Result ] - # Set the result in the CookiesResult object - result.set_result(structured_cookies) + # Set the CookieJar in the CookiesResult object + result.set_result(cookie_jar) + except Exception as exc: # Handle exceptions and set them in the CookiesResult object result.set_exception(exc) From 9702a73ea454458b3d20f50de986a996cc0e3fcf Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 13:02:09 +0100 Subject: [PATCH 10/17] fixing tests making call more pythonic as suggested --- cocoa/src/toga_cocoa/widgets/webview.py | 9 +- core/src/toga/widgets/webview.py | 5 +- core/tests/widgets/test_webview.py | 125 +++++++++++++----- dummy/src/toga_dummy/widgets/webview.py | 25 ++-- iOS/src/toga_iOS/widgets/webview.py | 9 +- winforms/src/toga_winforms/widgets/webview.py | 9 +- 6 files changed, 108 insertions(+), 74 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index b704154210..86f479b829 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -48,14 +48,7 @@ def _completion_handler(cookies: objc_id) -> None: discard=cookie.IsSession, comment=None, comment_url=None, - rest={ - "HttpOnly": bool(cookie.isHTTPOnly), - "Expires": ( - cookie.expiresDate.description - if cookie.expiresDate - else None - ), - }, + rest={}, ) cookie_jar.set_cookie(cookie_obj) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index b370fece52..ffc6c74c98 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -139,7 +139,7 @@ def set_content(self, root_url: str, content: str) -> None: """ self._impl.set_content(root_url, content) - def get_cookies( + def cookies( self, on_result: OnResultT | None = None, ) -> CookiesResult: @@ -148,12 +148,13 @@ def get_cookies( **This is an asynchronous method**. There is no guarantee that the function has finished evaluating when this method returns. The object returned by this method can be awaited to obtain the value of the expression. + An http.cookiejar.CookieJar will be returned **Note:** This is not yet currently supported on Android or Linux. :param on_result: A callback function to process the cookies once retrieved. """ - return self._impl.get_cookies(on_result=on_result) + return self._impl.cookies(on_result=on_result) def evaluate_javascript( self, diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index b44f4e8c5d..7097bd95d5 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -1,4 +1,5 @@ import asyncio +from http.cookiejar import Cookie from unittest.mock import Mock import pytest @@ -250,51 +251,107 @@ async def delayed_page_load(): on_result_handler.assert_called_once_with(42) -async def test_get_cookies_async(widget): - """Cookies can be retrieved asynchronously from the WebView.""" +async def test_retrieve_cookies_async(widget): + """Cookies can be retrieved asynchronously.""" + + # Simulate backend cookie retrieval + cookies = [ + Cookie( + version=0, + name="test", + value="test_value", + port=None, + port_specified=False, + domain="example.com", + domain_specified=True, + domain_initial_dot=False, + path="/", + path_specified=True, + secure=True, + expires=None, # Simulating a session cookie + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, + ) + ] - # An async task that simulates retrieval of cookies after a delay async def delayed_cookie_retrieval(): await asyncio.sleep(0.1) + widget._impl.simulate_cookie_retrieval(cookies) + + asyncio.create_task(delayed_cookie_retrieval()) - # Simulate cookies being retrieved - cookies = { - "name": "test", - "value": "test", - "domain": "example.com", - "path": "/", - "secure": True, - "http_only": True, - "expiration": None, - } + # Retrieve the result from widget.cookies + result = widget.cookies() + + # Get the cookie jar from the future + cookie_jar = await result.future # Await the future to get the CookieJar + + # Validate the cookies in the CookieJar + cookie = next(iter(cookie_jar)) # Get the first (and only) cookie + assert cookie.name == "test" + assert cookie.value == "test_value" + assert cookie.domain == "example.com" + assert cookie.path == "/" + assert cookie.secure is True + assert cookie.expires is None + + +async def test_retrieve_cookies_sync(widget): + """Deprecated sync handlers can be used for cookie retrieval.""" + + # Simulate backend cookie retrieval + cookies = [ + Cookie( + version=0, + name="test", + value="test_value", + port=None, + port_specified=False, + domain="example.com", + domain_specified=True, + domain_initial_dot=False, + path="/", + path_specified=True, + secure=True, + expires=None, # Simulating a session cookie + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, + ) + ] + + # Simulate the cookie retrieval with a delay + async def delayed_cookie_retrieval(): + await asyncio.sleep(0.1) widget._impl.simulate_cookie_retrieval(cookies) asyncio.create_task(delayed_cookie_retrieval()) + # Mock the result handler on_result_handler = Mock() - # Ensure that the `get_cookies` method raises a DeprecationWarning with pytest.warns( DeprecationWarning, match=r"Synchronous `on_result` handlers have been deprecated;", ): - # Correctly pass the on_result handler as a keyword argument - result = await widget.get_cookies(on_result=on_result_handler) - - # Verify that the action was performed - assert_action_performed(widget, "get_cookies") - - # Verify the result and handler invocation - expected_cookies = { - "name": "test", - "value": "test", - "domain": "example.com", - "path": "/", - "secure": True, - "http_only": True, - "expiration": None, - } - - assert result == expected_cookies - - on_result_handler.assert_called_once_with(expected_cookies) + # Retrieve the cookies with the deprecated synchronous handler + result = await widget.cookies(on_result=on_result_handler) + + assert_action_performed(widget, "cookies") + + # Validate the retrieved cookies + cookie = next(iter(result)) # Assuming `result` is iterable + assert cookie.name == "test" + assert cookie.value == "test_value" + assert cookie.domain == "example.com" + assert cookie.path == "/" + assert cookie.secure is True + assert cookie.expires is None + + # Verify that the on_result handler was invoked + on_result_handler.assert_called_once_with(result) diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index da183185e9..ef96eafc10 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -1,3 +1,5 @@ +from http.cookiejar import CookieJar + from toga.widgets.webview import CookiesResult, JavaScriptResult from .base import Widget @@ -40,22 +42,17 @@ def simulate_page_loaded(self): def simulate_javascript_result(self, value): self._js_result.set_result(42) - def get_cookies(self, on_result=None): - """Simulate retrieving cookies.""" - self._action("get_cookies") + def cookies(self, on_result=None): + """Simulate retrieving cookies asynchronously.""" + self._action("cookies") self._cookie_result = CookiesResult(on_result) return self._cookie_result def simulate_cookie_retrieval(self, cookies): """Simulate completion of cookie retrieval.""" - self._cookie_result.set_result( - { - "name": "test", - "value": "test", - "domain": "example.com", - "path": "/", - "secure": True, - "http_only": True, - "expiration": None, - } - ) + print("kaas") + print(cookies) + cookie_jar = CookieJar() + for cookie in cookies: + cookie_jar.set_cookie(cookie) + self._cookie_result.set_result(cookie_jar) diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index d5edce1549..283e334d69 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -47,14 +47,7 @@ def _completion_handler(cookies: objc_id) -> None: discard=cookie.IsSession, comment=None, comment_url=None, - rest={ - "HttpOnly": bool(cookie.isHTTPOnly), - "Expires": ( - cookie.expiresDate.description - if cookie.expiresDate - else None - ), - }, + rest={}, ) cookie_jar.set_cookie(cookie_obj) diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index dbe630da82..65503db343 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -65,14 +65,7 @@ def _completion_handler(task): discard=cookie.IsSession, comment=None, comment_url=None, - rest={ - "HttpOnly": cookie.IsHttpOnly, # Store the HttpOnly flag - "Expires": ( - cookie.Expires.ToString("o") - if not cookie.IsSession - else None - ), - }, + rest={}, rfc2109=False, # Whether the cookie follows RFC 2109 ) ) From 4c53dc8cce450fc919216f0ac636b23169ceaede Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 20:26:24 +0100 Subject: [PATCH 11/17] fixing doc linting issue --- 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 ffc6c74c98..c5b9b45017 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -148,7 +148,7 @@ def cookies( **This is an asynchronous method**. There is no guarantee that the function has finished evaluating when this method returns. The object returned by this method can be awaited to obtain the value of the expression. - An http.cookiejar.CookieJar will be returned + An CookieJar object will be returned **Note:** This is not yet currently supported on Android or Linux. From 932c5e820022a26a5238db0bd8613327d18c0c2b Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 22:19:58 +0100 Subject: [PATCH 12/17] fixing testbed test --- cocoa/src/toga_cocoa/widgets/webview.py | 2 +- iOS/src/toga_iOS/widgets/webview.py | 2 +- testbed/tests/widgets/test_webview.py | 36 +++++++++++++++++++ winforms/src/toga_winforms/widgets/webview.py | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 86f479b829..9eaa177dcc 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -133,7 +133,7 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def get_cookies(self, on_result=None): + def cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 283e334d69..43395a3592 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -124,7 +124,7 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def get_cookies(self, on_result=None): + def cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 6591e47b9f..0c58b29da5 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -318,3 +318,39 @@ async def test_dom_storage_enabled(widget, probe, on_load): }})()""" result = await wait_for(widget.evaluate_javascript(expression), JS_TIMEOUT) assert result == expected_value + + +async def test_retrieve_cookies_async(widget, probe, on_load): + """Cookies can be retrieved asynchronously.""" + # A page must be loaded to set cookies + await wait_for( + widget.load_url("https://example.com/"), + LOAD_TIMEOUT, + ) + # Small pause to ensure JavaScript can run without security errors + await asyncio.sleep(1) + + # JavaScript expression to set a cookie and return the current cookies + expression = """ + (function setCookie() { + document.cookie = "test=test_value; path=/; Secure"; + return document.cookie; + })()""" + + await wait_for(widget.evaluate_javascript(expression), JS_TIMEOUT) + + # Retrieve cookies using widget.cookies() + result = widget.cookies() # Call the cookies method + cookie_jar = await result.future # Await the future to get the CookieJar + + # Find the test cookie in the CookieJar + cookie = next((c for c in cookie_jar if c.name == "test"), None) + assert cookie is not None, "Test cookie not found in CookieJar" + + # Validate the test cookie + assert cookie.name == "test" + assert cookie.value == "test_value" + assert cookie.domain == "example.com" + assert cookie.path == "/" + assert cookie.secure is True + assert cookie.expires is None diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 65503db343..6cd598289d 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -234,7 +234,7 @@ def execute(): self.run_after_initialization(execute) return result - def get_cookies(self, on_result=None): + def cookies(self, on_result=None): """ Retrieve all cookies asynchronously from the WebView. From c360cee0493caa90f019ead5e537b017cc4e68b5 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 22:33:58 +0100 Subject: [PATCH 13/17] adding stub implementation for not yet supported platforms --- android/src/toga_android/widgets/webview.py | 3 +++ gtk/src/toga_gtk/widgets/webview.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index f70f0d7f70..2629b278b5 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -75,3 +75,6 @@ def evaluate_javascript(self, javascript, on_result=None): self.native.evaluateJavascript(javascript, ReceiveString(result)) return result + + def cookies(self, on_result=None): + self.interface.factory.not_implemented("WebView.cookies()") diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 2e01b8a57b..8f46677afd 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -115,3 +115,6 @@ def gtk_js_finished(webview, task, *user_data): def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + + def cookies(self, on_result=None): + self.interface.factory.not_implemented("WebView.cookies()") From b476de3c81da480a6fc416953df8eaf240c17903 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sat, 4 Jan 2025 22:52:44 +0100 Subject: [PATCH 14/17] testbed fixes use the ocrrect NSHTTPCookie parameters --- cocoa/src/toga_cocoa/widgets/webview.py | 64 +++++++++--------- iOS/src/toga_iOS/widgets/webview.py | 65 +++++++++--------- winforms/src/toga_winforms/widgets/webview.py | 66 +++++++++---------- 3 files changed, 92 insertions(+), 103 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 9eaa177dcc..956099f720 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -23,40 +23,36 @@ def _completion_handler(res: objc_id, error: objc_id) -> None: def cookies_completion_handler(result): def _completion_handler(cookies: objc_id) -> None: - try: - # Convert cookies from Objective-C to Python objects - cookies_array = py_from_ns(cookies) - - # Initialize a CookieJar - cookie_jar = CookieJar() - - # Add each cookie from the array into the CookieJar - for cookie in cookies_array: - cookie_obj = Cookie( - version=0, - name=str(cookie.name), - value=str(cookie.value), - port=None, - port_specified=False, - domain=str(cookie.domain), - domain_specified=True, - domain_initial_dot=False, - path=str(cookie.path), - path_specified=True, - secure=bool(cookie.isSecure), - expires=None, - discard=cookie.IsSession, - comment=None, - comment_url=None, - rest={}, - ) - cookie_jar.set_cookie(cookie_obj) - - # Set the result in the AsyncResult - result.set_result(cookie_jar) - except Exception as exc: - # Set an exception in the AsyncResult if something goes wrong - result.set_exception(exc) + # Convert cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Initialize a CookieJar + cookie_jar = CookieJar() + + # Add each cookie from the array into the CookieJar + for cookie in cookies_array: + cookie_obj = Cookie( + version=0, + name=str(cookie.name), + value=str(cookie.value), + port=None, + port_specified=False, + domain=str(cookie.domain), + domain_specified=True, + domain_initial_dot=False, + path=str(cookie.path), + path_specified=True, + secure=bool(cookie.Secure), + expires=None, + discard=cookie.sessionOnly, + comment=None, + comment_url=None, + rest={}, + ) + cookie_jar.set_cookie(cookie_obj) + + # Set the result in the AsyncResult + result.set_result(cookie_jar) return _completion_handler diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 43395a3592..9f7968460f 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -22,40 +22,37 @@ def _completion_handler(res: objc_id, error: objc_id) -> None: def cookies_completion_handler(result): def _completion_handler(cookies: objc_id) -> None: - try: - # Convert cookies from Objective-C to Python objects - cookies_array = py_from_ns(cookies) - - # Initialize a CookieJar - cookie_jar = CookieJar() - - # Add each cookie from the array into the CookieJar - for cookie in cookies_array: - cookie_obj = Cookie( - version=0, - name=str(cookie.name), - value=str(cookie.value), - port=None, - port_specified=False, - domain=str(cookie.domain), - domain_specified=True, - domain_initial_dot=False, - path=str(cookie.path), - path_specified=True, - secure=bool(cookie.isSecure), - expires=None, - discard=cookie.IsSession, - comment=None, - comment_url=None, - rest={}, - ) - cookie_jar.set_cookie(cookie_obj) - - # Set the result in the AsyncResult - result.set_result(cookie_jar) - except Exception as exc: - # Set an exception in the AsyncResult if something goes wrong - result.set_exception(exc) + + # Convert cookies from Objective-C to Python objects + cookies_array = py_from_ns(cookies) + + # Initialize a CookieJar + cookie_jar = CookieJar() + + # Add each cookie from the array into the CookieJar + for cookie in cookies_array: + cookie_obj = Cookie( + version=0, + name=str(cookie.name), + value=str(cookie.value), + port=None, + port_specified=False, + domain=str(cookie.domain), + domain_specified=True, + domain_initial_dot=False, + path=str(cookie.path), + path_specified=True, + secure=bool(cookie.Secure), + expires=None, + discard=cookie.sessionOnly, + comment=None, + comment_url=None, + rest={}, + ) + cookie_jar.set_cookie(cookie_obj) + + # Set the result in the AsyncResult + result.set_result(cookie_jar) return _completion_handler diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 6cd598289d..58c6ef725a 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -41,43 +41,39 @@ def cookies_completion_handler(result): """ def _completion_handler(task): - try: - # Initialize a CookieJar to store cookies - cookie_jar = CookieJar() - - # Use a list comprehension to convert each cookie into a Cookie - # object and add it to the CookieJar - [ - cookie_jar.set_cookie( - Cookie( - version=0, - name=cookie.Name, - value=cookie.Value, - port=None, - port_specified=False, - domain=cookie.Domain, - domain_specified=True, - domain_initial_dot=False, - path=cookie.Path, - path_specified=True, - secure=cookie.IsSecure, - expires=None, - discard=cookie.IsSession, - comment=None, - comment_url=None, - rest={}, - rfc2109=False, # Whether the cookie follows RFC 2109 - ) - ) - for cookie in task.Result - ] - # Set the CookieJar in the CookiesResult object - result.set_result(cookie_jar) + # Initialize a CookieJar to store cookies + cookie_jar = CookieJar() + + # Use a list comprehension to convert each cookie into a Cookie + # object and add it to the CookieJar + [ + cookie_jar.set_cookie( + Cookie( + version=0, + name=cookie.Name, + value=cookie.Value, + port=None, + port_specified=False, + domain=cookie.Domain, + domain_specified=True, + domain_initial_dot=False, + path=cookie.Path, + path_specified=True, + secure=cookie.IsSecure, + expires=None, + discard=cookie.IsSession, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, # Whether the cookie follows RFC 2109 + ) + ) + for cookie in task.Result + ] - except Exception as exc: - # Handle exceptions and set them in the CookiesResult object - result.set_exception(exc) + # Set the CookieJar in the CookiesResult object + result.set_result(cookie_jar) return _completion_handler From d1778c935c4c311d243be7e9bb92715ea74112df Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Tue, 7 Jan 2025 08:28:21 +0100 Subject: [PATCH 15/17] Fix MacOS and iOS tests and cookie session only return --- cocoa/src/toga_cocoa/widgets/webview.py | 2 +- iOS/src/toga_iOS/widgets/webview.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 956099f720..0375d247fb 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -44,7 +44,7 @@ def _completion_handler(cookies: objc_id) -> None: path_specified=True, secure=bool(cookie.Secure), expires=None, - discard=cookie.sessionOnly, + discard=bool(cookie.isSessionOnly()), comment=None, comment_url=None, rest={}, diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 9f7968460f..7344b313ba 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -44,7 +44,7 @@ def _completion_handler(cookies: objc_id) -> None: path_specified=True, secure=bool(cookie.Secure), expires=None, - discard=cookie.sessionOnly, + discard=bool(cookie.isSessionOnly()), comment=None, comment_url=None, rest={}, From 2b5f5c49d39acf4f277a0c7081a3daa10e28abcc Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Tue, 7 Jan 2025 09:48:30 +0100 Subject: [PATCH 16/17] Adding stub implementation unsupported platforms --- android/src/toga_android/widgets/webview.py | 11 +++++++-- gtk/src/toga_gtk/widgets/webview.py | 11 +++++++-- testbed/tests/widgets/test_webview.py | 26 ++++++++++++--------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 2629b278b5..9b910e1055 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -3,7 +3,7 @@ from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy -from toga.widgets.webview import JavaScriptResult +from toga.widgets.webview import CookiesResult, JavaScriptResult from .base import Widget @@ -77,4 +77,11 @@ def evaluate_javascript(self, javascript, on_result=None): return result def cookies(self, on_result=None): - self.interface.factory.not_implemented("WebView.cookies()") + # Create the result object + result = CookiesResult(on_result=on_result) + result.set_result(None) + + # Signal that this feature is not implemented on the current platform + self.interface.factory.not_implemented("webview.cookies") + + return result diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 8f46677afd..49918e5f17 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from toga.widgets.webview import JavaScriptResult +from toga.widgets.webview import CookiesResult, JavaScriptResult from ..libs import GLib, WebKit2 from .base import Widget @@ -117,4 +117,11 @@ def rehint(self): self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def cookies(self, on_result=None): - self.interface.factory.not_implemented("WebView.cookies()") + # Create the result object + result = CookiesResult(on_result=on_result) + result.set_result(None) + + # Signal that this feature is not implemented on the current platform + self.interface.factory.not_implemented("webview.cookies") + + return result diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 0c58b29da5..7b85e0c264 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -341,16 +341,20 @@ async def test_retrieve_cookies_async(widget, probe, on_load): # Retrieve cookies using widget.cookies() result = widget.cookies() # Call the cookies method + cookie_jar = await result.future # Await the future to get the CookieJar - # Find the test cookie in the CookieJar - cookie = next((c for c in cookie_jar if c.name == "test"), None) - assert cookie is not None, "Test cookie not found in CookieJar" - - # Validate the test cookie - assert cookie.name == "test" - assert cookie.value == "test_value" - assert cookie.domain == "example.com" - assert cookie.path == "/" - assert cookie.secure is True - assert cookie.expires is None + if toga.platform.current_platform not in {"windows", "iOS", "macOS"}: + assert cookie_jar is None + else: + # Find the test cookie in the CookieJar + cookie = next((c for c in cookie_jar if c.name == "test"), None) + assert cookie is not None, "Test cookie not found in CookieJar" + + # Validate the test cookie + assert cookie.name == "test" + assert cookie.value == "test_value" + assert cookie.domain == "example.com" + assert cookie.path == "/" + assert cookie.secure is True + assert cookie.expires is None From 248b6e2aba5271a64f4bcabd0fd97307afe6e304 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 8 Jan 2025 13:57:07 +0800 Subject: [PATCH 17/17] Minor cleanups and consistency fixes. --- android/src/toga_android/widgets/webview.py | 19 ++--- android/tests_backend/widgets/webview.py | 7 ++ cocoa/src/toga_cocoa/widgets/webview.py | 35 +++++---- cocoa/tests_backend/widgets/webview.py | 3 + core/src/toga/widgets/webview.py | 18 ++--- core/tests/widgets/test_webview.py | 72 ++----------------- dummy/src/toga_dummy/widgets/webview.py | 13 ++-- gtk/src/toga_gtk/widgets/webview.py | 22 +++--- gtk/tests_backend/widgets/webview.py | 8 +++ iOS/src/toga_iOS/widgets/webview.py | 35 +++++---- iOS/tests_backend/widgets/webview.py | 3 + testbed/tests/widgets/test_webview.py | 53 ++++++++------ winforms/src/toga_winforms/widgets/webview.py | 41 ++++++----- winforms/tests_backend/widgets/webview.py | 3 + 14 files changed, 150 insertions(+), 182 deletions(-) diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index 9b910e1055..7699e8f56f 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -1,4 +1,5 @@ import json +from http.cookiejar import CookieJar from android.webkit import ValueCallback, WebView as A_WebView, WebViewClient from java import dynamic_proxy @@ -70,18 +71,18 @@ def set_user_agent(self, value): self.default_user_agent if value is None else value ) - def evaluate_javascript(self, javascript, on_result=None): - result = JavaScriptResult(on_result) - - self.native.evaluateJavascript(javascript, ReceiveString(result)) - return result - - def cookies(self, on_result=None): + def get_cookies(self): # Create the result object - result = CookiesResult(on_result=on_result) - result.set_result(None) + result = CookiesResult() + result.set_result(CookieJar()) # Signal that this feature is not implemented on the current platform self.interface.factory.not_implemented("webview.cookies") return result + + def evaluate_javascript(self, javascript, on_result=None): + result = JavaScriptResult(on_result) + + self.native.evaluateJavascript(javascript, ReceiveString(result)) + return result diff --git a/android/tests_backend/widgets/webview.py b/android/tests_backend/widgets/webview.py index 0a90e38339..cf5df88fb8 100644 --- a/android/tests_backend/widgets/webview.py +++ b/android/tests_backend/widgets/webview.py @@ -1,4 +1,7 @@ +from http.cookiejar import CookieJar + from android.webkit import WebView +from pytest import skip from .base import SimpleProbe @@ -8,3 +11,7 @@ class WebViewProbe(SimpleProbe): content_supports_url = False javascript_supports_exception = False supports_on_load = False + + def extract_cookie(self, cookie_jar, name): + assert isinstance(cookie_jar, CookieJar) + skip("Cookie retrieval not implemented on Android") diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 0375d247fb..eb58ede63f 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -116,33 +116,32 @@ def get_user_agent(self): def set_user_agent(self, value): self.native.customUserAgent = value - def evaluate_javascript(self, javascript: str, on_result=None) -> str: - result = JavaScriptResult(on_result=on_result) - self.native.evaluateJavaScript( - javascript, - completionHandler=js_completion_handler(result), - ) - - return result - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - def cookies(self, on_result=None): + def get_cookies(self): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Optional callback to handle the cookies. - :return: An AsyncResult object that can be awaited. + :returns: An AsyncResult object that can be awaited. """ # Create an AsyncResult to manage the cookies - result = CookiesResult(on_result) + result = CookiesResult() # Retrieve the cookie store from the WebView cookie_store = self.native.configuration.websiteDataStore.httpCookieStore # Call the method to retrieve all cookies and pass the completion handler - cookie_store.getAllCookies_(cookies_completion_handler(result)) + cookie_store.getAllCookies(cookies_completion_handler(result)) return result + + def evaluate_javascript(self, javascript: str, on_result=None) -> str: + result = JavaScriptResult(on_result=on_result) + self.native.evaluateJavaScript( + javascript, + completionHandler=js_completion_handler(result), + ) + + return result + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/tests_backend/widgets/webview.py b/cocoa/tests_backend/widgets/webview.py index 2560622aa7..89462f262e 100644 --- a/cocoa/tests_backend/widgets/webview.py +++ b/cocoa/tests_backend/widgets/webview.py @@ -8,3 +8,6 @@ class WebViewProbe(SimpleProbe): content_supports_url = True javascript_supports_exception = True supports_on_load = True + + def extract_cookie(self, cookie_jar, name): + return next((c for c in cookie_jar if c.name == name), None) diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index c5b9b45017..1b20942ba7 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -139,22 +139,18 @@ def set_content(self, root_url: str, content: str) -> None: """ self._impl.set_content(root_url, content) - def cookies( - self, - on_result: OnResultT | None = None, - ) -> CookiesResult: + @property + def cookies(self) -> CookiesResult: """Retrieve cookies from the WebView. - **This is an asynchronous method**. There is no guarantee that the function - has finished evaluating when this method returns. The object returned by this - method can be awaited to obtain the value of the expression. - An CookieJar object will be returned + **This is an asynchronous property**. The value returned by this method must be + awaited to obtain the cookies that are currently set. - **Note:** This is not yet currently supported on Android or Linux. + **Note:** This property is not currently supported on Android or Linux. - :param on_result: A callback function to process the cookies once retrieved. + :returns: An object that returns a CookieJar when awaited. """ - return self._impl.cookies(on_result=on_result) + return self._impl.get_cookies() def evaluate_javascript( self, diff --git a/core/tests/widgets/test_webview.py b/core/tests/widgets/test_webview.py index 7097bd95d5..0e5d967bdb 100644 --- a/core/tests/widgets/test_webview.py +++ b/core/tests/widgets/test_webview.py @@ -1,5 +1,5 @@ import asyncio -from http.cookiejar import Cookie +from http.cookiejar import Cookie, CookieJar from unittest.mock import Mock import pytest @@ -251,8 +251,8 @@ async def delayed_page_load(): on_result_handler.assert_called_once_with(42) -async def test_retrieve_cookies_async(widget): - """Cookies can be retrieved asynchronously.""" +async def test_retrieve_cookies(widget): + """Cookies can be retrieved.""" # Simulate backend cookie retrieval cookies = [ @@ -283,11 +283,11 @@ async def delayed_cookie_retrieval(): asyncio.create_task(delayed_cookie_retrieval()) - # Retrieve the result from widget.cookies - result = widget.cookies() - # Get the cookie jar from the future - cookie_jar = await result.future # Await the future to get the CookieJar + cookie_jar = await widget.cookies + + # The result returned is a cookiejar + assert isinstance(cookie_jar, CookieJar) # Validate the cookies in the CookieJar cookie = next(iter(cookie_jar)) # Get the first (and only) cookie @@ -297,61 +297,3 @@ async def delayed_cookie_retrieval(): assert cookie.path == "/" assert cookie.secure is True assert cookie.expires is None - - -async def test_retrieve_cookies_sync(widget): - """Deprecated sync handlers can be used for cookie retrieval.""" - - # Simulate backend cookie retrieval - cookies = [ - Cookie( - version=0, - name="test", - value="test_value", - port=None, - port_specified=False, - domain="example.com", - domain_specified=True, - domain_initial_dot=False, - path="/", - path_specified=True, - secure=True, - expires=None, # Simulating a session cookie - discard=True, - comment=None, - comment_url=None, - rest={}, - rfc2109=False, - ) - ] - - # Simulate the cookie retrieval with a delay - async def delayed_cookie_retrieval(): - await asyncio.sleep(0.1) - widget._impl.simulate_cookie_retrieval(cookies) - - asyncio.create_task(delayed_cookie_retrieval()) - - # Mock the result handler - on_result_handler = Mock() - - with pytest.warns( - DeprecationWarning, - match=r"Synchronous `on_result` handlers have been deprecated;", - ): - # Retrieve the cookies with the deprecated synchronous handler - result = await widget.cookies(on_result=on_result_handler) - - assert_action_performed(widget, "cookies") - - # Validate the retrieved cookies - cookie = next(iter(result)) # Assuming `result` is iterable - assert cookie.name == "test" - assert cookie.value == "test_value" - assert cookie.domain == "example.com" - assert cookie.path == "/" - assert cookie.secure is True - assert cookie.expires is None - - # Verify that the on_result handler was invoked - on_result_handler.assert_called_once_with(result) diff --git a/dummy/src/toga_dummy/widgets/webview.py b/dummy/src/toga_dummy/widgets/webview.py index ef96eafc10..4b6363aa07 100644 --- a/dummy/src/toga_dummy/widgets/webview.py +++ b/dummy/src/toga_dummy/widgets/webview.py @@ -26,6 +26,11 @@ def set_url(self, value, future=None): self._set_value("url", value) self._set_value("loaded_future", future) + def get_cookies(self): + self._action("cookies") + self._cookie_result = CookiesResult() + return self._cookie_result + def evaluate_javascript(self, javascript, on_result=None): self._action("evaluate_javascript", javascript=javascript) self._js_result = JavaScriptResult(on_result) @@ -42,16 +47,8 @@ def simulate_page_loaded(self): def simulate_javascript_result(self, value): self._js_result.set_result(42) - def cookies(self, on_result=None): - """Simulate retrieving cookies asynchronously.""" - self._action("cookies") - self._cookie_result = CookiesResult(on_result) - return self._cookie_result - def simulate_cookie_retrieval(self, cookies): """Simulate completion of cookie retrieval.""" - print("kaas") - print(cookies) cookie_jar = CookieJar() for cookie in cookies: cookie_jar.set_cookie(cookie) diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 49918e5f17..94c066621e 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -1,3 +1,5 @@ +from http.cookiejar import CookieJar + from travertino.size import at_least from toga.widgets.webview import CookiesResult, JavaScriptResult @@ -75,6 +77,16 @@ def set_user_agent(self, value): def set_content(self, root_url, content): self.native.load_html(content, root_url) + def get_cookies(self): + # Create the result object + result = CookiesResult() + result.set_result(CookieJar()) + + # Signal that this feature is not implemented on the current platform + self.interface.factory.not_implemented("webview.cookies") + + return result + def evaluate_javascript(self, javascript, on_result=None): # Construct a future on the event loop result = JavaScriptResult(on_result) @@ -115,13 +127,3 @@ def gtk_js_finished(webview, task, *user_data): def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - def cookies(self, on_result=None): - # Create the result object - result = CookiesResult(on_result=on_result) - result.set_result(None) - - # Signal that this feature is not implemented on the current platform - self.interface.factory.not_implemented("webview.cookies") - - return result diff --git a/gtk/tests_backend/widgets/webview.py b/gtk/tests_backend/widgets/webview.py index fc45b0d58d..a9f8aea021 100644 --- a/gtk/tests_backend/widgets/webview.py +++ b/gtk/tests_backend/widgets/webview.py @@ -1,3 +1,7 @@ +from http.cookiejar import CookieJar + +from pytest import skip + from toga_gtk.libs import WebKit2 from .base import SimpleProbe @@ -8,3 +12,7 @@ class WebViewProbe(SimpleProbe): content_supports_url = True javascript_supports_exception = True supports_on_load = True + + def extract_cookie(self, cookie_jar, name): + assert isinstance(cookie_jar, CookieJar) + skip("Cookie retrieval not implemented on GTK") diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index 7344b313ba..0f4e7cfa9b 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -108,33 +108,32 @@ def get_user_agent(self): def set_user_agent(self, value): self.native.customUserAgent = value - def evaluate_javascript(self, javascript, on_result=None): - result = JavaScriptResult(on_result) - self.native.evaluateJavaScript( - javascript, - completionHandler=js_completion_handler(result), - ) - - return result - - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - - def cookies(self, on_result=None): + def get_cookies(self): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Optional callback to handle the cookies. - :return: An AsyncResult object that can be awaited. + :returns: An AsyncResult object that can be awaited. """ # Create an AsyncResult to manage the cookies - result = CookiesResult(on_result) + result = CookiesResult() # Retrieve the cookie store from the WebView cookie_store = self.native.configuration.websiteDataStore.httpCookieStore # Call the method to retrieve all cookies and pass the completion handler - cookie_store.getAllCookies_(cookies_completion_handler(result)) + cookie_store.getAllCookies(cookies_completion_handler(result)) return result + + def evaluate_javascript(self, javascript, on_result=None): + result = JavaScriptResult(on_result) + self.native.evaluateJavaScript( + javascript, + completionHandler=js_completion_handler(result), + ) + + return result + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/tests_backend/widgets/webview.py b/iOS/tests_backend/widgets/webview.py index 7a8403e124..93ea03fc34 100644 --- a/iOS/tests_backend/widgets/webview.py +++ b/iOS/tests_backend/widgets/webview.py @@ -19,3 +19,6 @@ def has_focus(self): # put 2 WKContentViews on the page at once. current = self.widget.window._impl.native.firstResponder() return current.objc_class.name == "WKContentView" + + def extract_cookie(self, cookie_jar, name): + return next((c for c in cookie_jar if c.name == name), None) diff --git a/testbed/tests/widgets/test_webview.py b/testbed/tests/widgets/test_webview.py index 7b85e0c264..22f46b615a 100644 --- a/testbed/tests/widgets/test_webview.py +++ b/testbed/tests/widgets/test_webview.py @@ -1,6 +1,7 @@ import asyncio from asyncio import wait_for from contextlib import nullcontext +from http.cookiejar import CookieJar from time import time from unittest.mock import ANY, Mock @@ -320,15 +321,23 @@ async def test_dom_storage_enabled(widget, probe, on_load): assert result == expected_value -async def test_retrieve_cookies_async(widget, probe, on_load): - """Cookies can be retrieved asynchronously.""" +async def test_retrieve_cookies(widget, probe, on_load): + """Cookies can be retrieved.""" # A page must be loaded to set cookies await wait_for( - widget.load_url("https://example.com/"), + widget.load_url("https://github.com/beeware"), LOAD_TIMEOUT, ) - # Small pause to ensure JavaScript can run without security errors - await asyncio.sleep(1) + # DOM loads aren't instantaneous; wait for the URL to appear + await assert_content_change( + widget, + probe, + message="Page has been loaded", + url="https://github.com/beeware", + content=ANY, + on_load=on_load, + ) + await probe.redraw("Wait for Javascript completion", delay=0.1) # JavaScript expression to set a cookie and return the current cookies expression = """ @@ -339,22 +348,22 @@ async def test_retrieve_cookies_async(widget, probe, on_load): await wait_for(widget.evaluate_javascript(expression), JS_TIMEOUT) - # Retrieve cookies using widget.cookies() - result = widget.cookies() # Call the cookies method + # Retrieve cookies. + cookie_jar = await widget.cookies - cookie_jar = await result.future # Await the future to get the CookieJar + assert isinstance(cookie_jar, CookieJar) - if toga.platform.current_platform not in {"windows", "iOS", "macOS"}: - assert cookie_jar is None - else: - # Find the test cookie in the CookieJar - cookie = next((c for c in cookie_jar if c.name == "test"), None) - assert cookie is not None, "Test cookie not found in CookieJar" - - # Validate the test cookie - assert cookie.name == "test" - assert cookie.value == "test_value" - assert cookie.domain == "example.com" - assert cookie.path == "/" - assert cookie.secure is True - assert cookie.expires is None + # Cookie retrieval isn't implemented on every backend (yet), so we implement the + # retrieval in the probe to provide an opportunity to skip the test. + cookie = probe.extract_cookie(cookie_jar, "test") + + # Find the test cookie in the CookieJar + assert cookie is not None, "Test cookie not found in CookieJar" + + # Validate the test cookie + assert cookie.name == "test" + assert cookie.value == "test_value" + assert cookie.domain == "github.com" + assert cookie.path == "/" + assert cookie.secure is True + assert cookie.expires is None diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 58c6ef725a..71d71698d2 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -212,33 +212,14 @@ def set_user_agent(self, value): self.default_user_agent if value is None else value ) - def evaluate_javascript(self, javascript, on_result=None): - result = JavaScriptResult(on_result) - task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() - - def callback(task): - # If the evaluation fails, task.Result will be "null", with no way to - # distinguish it from an actual null return value. - value = json.loads(task.Result) - result.set_result(value) - - def execute(): - self.native.ExecuteScriptAsync(javascript).ContinueWith( - Action[Task[String]](callback), task_scheduler - ) - - self.run_after_initialization(execute) - return result - - def cookies(self, on_result=None): + def get_cookies(self): """ Retrieve all cookies asynchronously from the WebView. - :param on_result: Optional callback to handle the cookies. :return: A CookiesResult object that can be awaited. """ # Create an AsyncResult to manage the cookies - result = CookiesResult(on_result) + result = CookiesResult() # Wrap the Python completion handler in a .NET Action delegate completion_handler_delegate = Action[Task[List[CoreWebView2Cookie]]]( @@ -252,3 +233,21 @@ def cookies(self, on_result=None): ) return result + + def evaluate_javascript(self, javascript, on_result=None): + result = JavaScriptResult(on_result) + task_scheduler = TaskScheduler.FromCurrentSynchronizationContext() + + def callback(task): + # If the evaluation fails, task.Result will be "null", with no way to + # distinguish it from an actual null return value. + value = json.loads(task.Result) + result.set_result(value) + + def execute(): + self.native.ExecuteScriptAsync(javascript).ContinueWith( + Action[Task[String]](callback), task_scheduler + ) + + self.run_after_initialization(execute) + return result diff --git a/winforms/tests_backend/widgets/webview.py b/winforms/tests_backend/widgets/webview.py index 55fccb2445..301dfb6ea3 100644 --- a/winforms/tests_backend/widgets/webview.py +++ b/winforms/tests_backend/widgets/webview.py @@ -13,3 +13,6 @@ class WebViewProbe(SimpleProbe): javascript_supports_exception = False supports_on_load = True + + def extract_cookie(self, cookie_jar, name): + return next((c for c in cookie_jar if c.name == name), None)