Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[widget audit] toga.WebView #1949

Merged
merged 31 commits into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
398415a
Rename some classes raising test warnings.
freakboy3742 May 25, 2023
0f7fde6
Update core API, docs, tests and example app.
freakboy3742 May 25, 2023
c76d55e
Cocoa to 100%.
freakboy3742 May 25, 2023
3f9b81c
Update base probe to allow for specific delays.
freakboy3742 May 25, 2023
1192ca4
iOS test probe for Webkit, tests at 100%.
freakboy3742 May 25, 2023
b0d2619
Cleanup unneeded code.
freakboy3742 May 25, 2023
7ca2cfc
Add changenote and deprecation notes.
freakboy3742 May 25, 2023
9f8ed02
Initial updates to GTK, Android and Winforms backends to adhere to ne…
freakboy3742 May 25, 2023
7443c71
GTK tests to 100% coverage.
freakboy3742 May 25, 2023
5fbdaad
Corrected doc typos and stray test failure.
freakboy3742 May 25, 2023
070cf8f
Use an adaptive delay for webview content changes.
freakboy3742 May 25, 2023
639fccc
Ensure load event is raised on GTK for cleared content.
freakboy3742 May 26, 2023
67014dd
Correct documentation typos.
freakboy3742 May 26, 2023
aa61547
Enable future annotations.
freakboy3742 May 26, 2023
01d2330
Merge branch 'main' into audit-webview
freakboy3742 May 30, 2023
a9e47e0
Merge branch 'main' into audit-webview
mhsmith Jun 7, 2023
c332b33
Documentation fixes
mhsmith Jun 7, 2023
3c8fbe0
Winforms at 100% coverage
mhsmith Jun 7, 2023
7d82bb8
Android at 100% coverage
mhsmith Jun 8, 2023
953e02b
Fix failures on Cocoa
mhsmith Jun 8, 2023
b9788dd
Improve failure logging in assert_content_change
mhsmith Jun 8, 2023
440609c
Split removal notes into individual lines.
freakboy3742 Jun 8, 2023
be3a43e
Minor docs tweaks.
freakboy3742 Jun 8, 2023
17cf05d
Merge branch 'main' into audit-webview
freakboy3742 Jun 9, 2023
2db3c13
Fix async issues on Windows
mhsmith Jun 10, 2023
521aab0
Documentation tweaks
mhsmith Jun 12, 2023
3d413c8
Make tests handle Windows user_agent being empty during initialization
mhsmith Jun 12, 2023
f89474c
Loosen timeouts, run testbed CI on Python 3.10
mhsmith Jun 13, 2023
dd733cb
Loosen timeouts again
mhsmith Jun 13, 2023
ea726e8
Loosen timeouts again
mhsmith Jun 13, 2023
ddbe67d
Fix Android failures, reduce timeouts to more reasonable levels
mhsmith Jun 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ jobs:
uses: actions/setup-python@v4.6.1
if: ${{ matrix.setup-python }}
with:
python-version: "3.X"
# We're not using 3.11 yet, because the testbed's ProxyEventLoop isn't
# compatible with it (https://github.com/beeware/toga/issues/1982).
python-version: "3.10"
- name: Install dependencies
run: |
${{ matrix.pre-command }}
Expand Down
108 changes: 54 additions & 54 deletions android/src/toga_android/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import asyncio
import base64
import json

from travertino.size import at_least

from ..libs.android.view import View__MeasureSpec
from toga.widgets.webview import JavaScriptResult

from ..libs.android.webkit import ValueCallback, WebView as A_WebView, WebViewClient
from .base import Widget


class ReceiveString(ValueCallback):
def __init__(self, fn=None):
def __init__(self, future, on_result):
super().__init__()
self._fn = fn
self.future = future
self.on_result = on_result

def onReceiveValue(self, value):
if self._fn:
if value is None:
self._fn(None)
else:
# Ensure we send a string to the function.
self._fn(value.toString())
# If the evaluation fails, a message is written to Logcat, but the value sent to
# the callback will be "null", with no way to distinguish it from an actual null
# return value.
result = json.loads(value)

# Because this method is called directly from the Android event loop, calling
# set_result on a timed-out future would crash the whole testbed with an
# InvalidStateError.
if self.future.cancelled(): # pragma: nocover
pass
else:
self.future.set_result(result)
if self.on_result:
self.on_result(result)


class WebView(Widget):
Expand All @@ -28,59 +37,50 @@ def create(self):
# Set a WebViewClient so that new links open in this activity,
# rather than triggering the phone's web browser.
self.native.setWebViewClient(WebViewClient())
# Enable JS.
self.native.getSettings().setJavaScriptEnabled(True)

def set_on_key_down(self, handler):
# Android isn't a platform that usually has a keyboard attached, so this is unimplemented for now.
self.interface.factory.not_implemented("WebView.set_on_key_down()")

def set_on_webview_load(self, handler):
# This requires subclassing WebViewClient, which is not yet possible with rubicon-java.
self.interface.factory.not_implemented("WebView.set_on_webview_load()")

def get_dom(self):
# Android has no straightforward way to get the DOM from the browser synchronously.
self.interface.factory.not_implemented("WebView.get_dom()")
self.settings = self.native.getSettings()
self.default_user_agent = self.settings.getUserAgentString()
self.settings.setJavaScriptEnabled(True)

def get_url(self):
return self.native.getUrl()

def set_url(self, value):
if value:
self.native.loadUrl(str(value))
url = self.native.getUrl()
if url == "about:blank" or url.startswith("data:"):
return None
else:
return url

def set_url(self, value, future=None):
if value is None:
value = "about:blank"
self.native.loadUrl(value)

# Detecting when the load is complete requires subclassing WebViewClient
# (https://github.com/beeware/toga/issues/1020).
if future:
future.set_result(None)

def set_content(self, root_url, content):
# Android WebView lacks an underlying set_content() primitive, so we navigate to
# a data URL. This means we ignore the root_url parameter.
data_url = "data:text/html; charset=utf-8; base64," + base64.b64encode(
content.encode("utf-8")
).decode("ascii")
self.set_url(data_url)
# There is a loadDataWithBaseURL method, but it's inconsistent about whether
# getUrl returns the given URL or a data: URL. Rather than support this feature
# intermittently, it's better to not support it at all.
self.native.loadData(content, "text/html", "utf-8")

def get_user_agent(self):
return self.settings.getUserAgentString()

def set_user_agent(self, value):
if value is not None:
self.native.getSettings().setUserAgentString(value)
self.settings.setUserAgentString(
self.default_user_agent if value is None else value
)

def evaluate_javascript(self, javascript, on_result=None):
result = JavaScriptResult()

async def evaluate_javascript(self, javascript):
js_value = asyncio.Future()
self.native.evaluateJavascript(
str(javascript), ReceiveString(js_value.set_result)
javascript, ReceiveString(result.future, on_result)
)
return await js_value

def invoke_javascript(self, javascript):
self.native.evaluateJavascript(str(javascript), ReceiveString())
return result

def rehint(self):
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
# Refuse to call measure() if widget has no container, i.e., has no LayoutParams.
# Android's measure() throws NullPointerException if the widget has no LayoutParams.
if not self.native.getLayoutParams():
return
self.native.measure(
View__MeasureSpec.UNSPECIFIED,
View__MeasureSpec.UNSPECIFIED,
)
self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth())
self.interface.intrinsic.height = self.native.getMeasuredHeight()
self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT)
7 changes: 5 additions & 2 deletions android/tests_backend/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ def assert_font_family(self, expected):
else:
assert actual == expected

async def redraw(self, message=None):
async def redraw(self, message=None, delay=None):
"""Request a redraw of the app, waiting until that redraw has completed."""
# If we're running slow, wait for a second
if self.app.run_slow:
delay = 1

if delay:
print("Waiting for redraw" if message is None else message)
await asyncio.sleep(1)
await asyncio.sleep(delay)
4 changes: 2 additions & 2 deletions android/tests_backend/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ def assert_alignment(self, expected):
else:
assert actual == expected

async def redraw(self, message=None):
async def redraw(self, message=None, delay=None):
"""Request a redraw of the app, waiting until that redraw has completed."""
self.native.requestLayout()
await self.layout_listener.event.wait()

await super().redraw(message=message)
await super().redraw(message=message, delay=delay)

@property
def enabled(self):
Expand Down
10 changes: 10 additions & 0 deletions android/tests_backend/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from android.webkit import WebView

from .base import SimpleProbe


class WebViewProbe(SimpleProbe):
native_class = WebView
content_supports_url = False
javascript_supports_exception = False
supports_on_load = False
1 change: 1 addition & 0 deletions changes/1949.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The WebView widget now has 100% test coverage, and complete API documentation.
1 change: 1 addition & 0 deletions changes/1949.removal.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``on_key_down`` handler has been removed from WebView. If you need to catch user input, either use a handler in the embedded Javascript, or create a ``Command`` with a key shortcut.
1 change: 1 addition & 0 deletions changes/1949.removal.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``get_dom()`` method on WebView has been removed. This method wasn't implemented on most platforms, and wasn't working on any of the platforms where it *was* implemented, as modern web view implementations don't provide a synchronous API for accessing web content in this way.
1 change: 1 addition & 0 deletions changes/1949.removal.3.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``evaluate_javascript()`` method on WebView has been modified to work in both synchronous and asynchronous contexts. In a synchronous context you can invoke the method and use a functional ``on_result`` callback to be notified when evaluation is complete. In an asynchronous context, you can await the result.
1 change: 1 addition & 0 deletions changes/1949.removal.4.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``invoke_javascript()`` method has been removed. All usage of ``invoke_javascript()`` can be replaced with ``evaluate_javascript()``.
1 change: 1 addition & 0 deletions changes/1949.removal.5.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The usage of local ``file://`` URLs has been explicitly prohibited. ``file://`` URLs have not been reliable for some time; their usage is now explicitly prohibited.
124 changes: 49 additions & 75 deletions cocoa/src/toga_cocoa/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
from asyncio import get_event_loop
from ctypes import c_void_p

from rubicon.objc import objc_method, objc_property, py_from_ns
from rubicon.objc.runtime import objc_id
from travertino.size import at_least

from ..keys import toga_key
from ..libs import NSURL, NSURLRequest, WKWebView, send_super
from toga.widgets.webview import JavaScriptResult

from ..libs import NSURL, NSURLRequest, WKWebView
from .base import Widget


def js_completion_handler(future, on_result=None):
def _completion_handler(res: objc_id, error: objc_id) -> None:
if error:
error = py_from_ns(error)
exc = RuntimeError(str(error))
future.set_exception(exc)
if on_result:
on_result(None, exception=exc)
else:
result = py_from_ns(res)
future.set_result(result)
if on_result:
on_result(result)

return _completion_handler


class TogaWebView(WKWebView):
interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)

@objc_method
def webView_didFinishNavigation_(self, navigation) -> None:
if self.interface.on_webview_load:
self.interface.on_webview_load(self.interface)
self.interface.on_webview_load(self.interface)

if self.impl.loaded_future:
self.impl.loaded_future.set_result(None)
self.impl.loaded_future = None

@objc_method
def acceptsFirstResponder(self) -> bool:
return True

@objc_method
def keyDown_(self, event) -> None:
if self.interface.on_key_down:
self.interface.on_key_down(self.interface, **toga_key(event))
send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p])

@objc_method
def touchBar(self):
# Disable the touchbar.
return None


class WebView(Widget):
def create(self):
Expand All @@ -44,77 +51,44 @@ def create(self):
self.native.navigationDelegate = self.native
self.native.uIDelegate = self.native

self.loaded_future = None

# Add the layout constraints
self.add_constraints()

def set_on_key_down(self, handler):
pass

def set_on_webview_load(self, handler):
pass

def get_dom(self):
# Utilises Step 2) of:
# https://developer.apple.com/library/content/documentation/
# Cocoa/Conceptual/DisplayWebContent/Tasks/SaveAndLoad.html
html = self.native.mainframe.DOMDocument.documentElement.outerHTML
return html

def get_url(self):
url = self.native.URL
if url:
return str(url)
url = str(self.native.URL)
return None if url == "about:blank" else url

def set_url(self, value):
def set_url(self, value, future=None):
if value:
request = NSURLRequest.requestWithURL(NSURL.URLWithString(value))
self.native.loadRequest(request)
else:
request = NSURLRequest.requestWithURL(NSURL.URLWithString("about:blank"))

self.loaded_future = future
self.native.loadRequest(request)

def set_content(self, root_url, content):
self.native.loadHTMLString(content, baseURL=NSURL.URLWithString(root_url))

def get_user_agent(self):
return str(self.native.valueForKey("userAgent"))

def set_user_agent(self, value):
user_agent = (
value
if value
else (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 "
"(KHTML, like Gecko) Version/10.1.2 Safari/603.3.8"
)
self.native.customUserAgent = value

def evaluate_javascript(self, javascript: str, on_result=None) -> str:
result = JavaScriptResult()
self.native.evaluateJavaScript(
javascript,
completionHandler=js_completion_handler(
future=result.future,
on_result=on_result,
),
)
self.native.customUserAgent = user_agent

async def evaluate_javascript(self, javascript):
"""Evaluate a JavaScript expression.

**This method is asynchronous**. It will return when the expression has been
evaluated and a result is available.

:param javascript: The javascript expression to evaluate
:type javascript: ``str``
"""

loop = get_event_loop()
future = loop.create_future()

def completion_handler(res: objc_id, error: objc_id) -> None:
if error:
error = py_from_ns(error)
exc = RuntimeError(str(error))
future.set_exception(exc)
else:
future.set_result(py_from_ns(res))

self.native.evaluateJavaScript(javascript, completionHandler=completion_handler)

return await future

def invoke_javascript(self, javascript):
"""Invoke a block of javascript.

:param javascript: The javascript expression to invoke
"""
self.native.evaluateJavaScript(javascript, completionHandler=None)
return result

def rehint(self):
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
Expand Down
7 changes: 5 additions & 2 deletions cocoa/tests_backend/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ def assert_font_family(self, expected):
SYSTEM: ".AppleSystemUIFont",
}.get(expected, expected)

async def redraw(self, message=None):
async def redraw(self, message=None, delay=None):
"""Request a redraw of the app, waiting until that redraw has completed."""
if self.app.run_slow:
# If we're running slow, wait for a second
print("Waiting for redraw" if message is None else message)
await asyncio.sleep(1)
delay = 1

if delay:
await asyncio.sleep(delay)
else:
# Running at "normal" speed, we need to release to the event loop
# for at least one iteration. `runUntilDate:None` does this.
Expand Down
Loading