Skip to content

Conversation

t-arn
Copy link
Contributor

@t-arn t-arn commented May 13, 2025

This PR aims to implement URL filtering for WebView on Windows and Android.
Possible use cases are:

  • preventing users from navigating off the site intended to be handled by your application's WebView
  • preventing users from navigating to known malicious web sites

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

t-arn added 4 commits May 13, 2025 07:34
* implented on_navigation_started for android
* extended the webview example
* applied black
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

Thanks for the PR; I've flagged a few issues inline, and the test suite is obviously failing on most platforms (although it looks like the reason for the failure is something that won't be an issue once you've addressed one of the comments inline).

@Override(jboolean, [A_WebView, WebResourceRequest])
def shouldOverrideUrlLoading(self, webview, webresourcerequest):
if self.webview_impl.interface.on_navigation_starting:
event = TogaNavigationEvent(webresourcerequest)
Copy link
Member

Choose a reason for hiding this comment

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

I'm a little confused about what is going on here. TogaNavigationEvent is a Python class, wrapping a request and a cancel attribute. It's never assigned to anything, or passed as an argument to anything - then it's cleared. Why is it required at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the TogaNavigationEvent was a left-over of some old code - I removed it.

@@ -86,3 +117,7 @@ def evaluate_javascript(self, javascript, on_result=None):

self.native.evaluateJavascript(javascript, ReceiveString(result))
return result

def set_on_navigation_starting(self, handler):
Copy link
Member

Choose a reason for hiding this comment

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

At this point, I'm pretty sure we've removed all the "set_on_..." methods; unless there's a specific use case (and it doesn't look like there is), this can be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I removed it.

@@ -33,6 +33,7 @@ def __init__(
url: str | None = None,
content: str | None = None,
user_agent: str | None = None,
on_navigation_starting=None,
Copy link
Member

Choose a reason for hiding this comment

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

This needs a type declaration (if only for documentation purposes)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, added type declaration

@@ -53,6 +53,10 @@ build_gradle_dependencies = [
"com.google.android.material:material:1.12.0",
]

build_gradle_extra_content="""
chaquopy.defaultConfig.staticProxy("toga_android.widgets.webview")
"""
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Member

Choose a reason for hiding this comment

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

It depends how essential the change is.

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

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

allow = False
message = f"Navigation not allowed to: {url}"
dialog = toga.InfoDialog("on_navigation_starting()", message)
asyncio.create_task(self.dialog(dialog))
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

event = TogaNavigationEvent(webresourcerequest)
allow = self.webview_impl.interface.on_navigation_starting(
webresourcerequest.getUrl().toString()
)
Copy link
Member

Choose a reason for hiding this comment

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

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

@@ -92,6 +106,9 @@ def startup(self):
on_webview_load=self.on_webview_load,
style=Pack(flex=1),
)
# activate web navigation filtering on supported platforms
if getattr(self.webview._impl, "SUPPORTS_ON_NAVIGATION_STARTING", True):
Copy link
Member

Choose a reason for hiding this comment

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

Good instinct, but it's fine for the example to raise a warning if a feature isn't available.

def winforms_navigation_starting(self, sender, event):
# print(f"winforms_navigation_starting: {event.Uri}")
if self.interface.on_navigation_starting:
allow = self.interface.on_navigation_starting(event.Uri)
Copy link
Member

Choose a reason for hiding this comment

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

As with the Android version, this needs to handle a Future being returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not quite sure, how to do this. Is there a similar case in the code base where I could get some inspiration?

@t-arn
Copy link
Contributor Author

t-arn commented May 13, 2025

@freakboy3742 I wonder if it is possible to make the on_navigation_starting handler async when it is being called by a native event. Aren't those native events always synchronous?

@freakboy3742
Copy link
Member

@freakboy3742 I wonder if it is possible to make the on_navigation_starting handler async when it is being called by a native event. Aren't those native events always synchronous?

Yes, native event handlers are always synchronous - but Toga events must be able to be defined asynchronously. That's why they return a future; we need to make sure that if a future is returned, we wait for that future.

t-arn added 2 commits May 26, 2025 08:12
* added type declaration for handler
* added WeakrefCallable wrapper
@t-arn
Copy link
Contributor Author

t-arn commented Jun 11, 2025

@freakboy3742 I implemented support for synchronous and asynchronous on_navigation_starting handlers for Windows. The code still contains a lot of print statements for easier debugging. If you approve of this approach, I'll implement something similar for Android and clean up the code.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

I'm not sure I understand the approach you've taken here.

Firstly, I'm not sure why the cached "allowed" and "not allowed" URL lists is required. It seems to undermine the ability to make per-request based decisions on whether a navigation is required, which is the point of having a callback.

Secondly, the general approach of "if async, cancel the request, and force a URL load if the async result succeeds" makes sense, but the way it gets to the actual resolution seems... convoluted.

wrapped_handler takes a function or co-routine, and guarantees that it will be invoked on the event loop. It already has a an existing cleanup method that can be used to attach "on completion" logic. There's no need to have a whole standalone "attach completion callback to result" handler - it can be baked into the cleanup method when the handler is installed.

@t-arn
Copy link
Contributor Author

t-arn commented Jun 12, 2025

@freakboy3742 The "allowed" and "not allowed" lists are meant to cache the user's decision whether to allow the URL or not. I think, the user will not want to be asked for the same URL again and again.

As for setting the callback method: I didn't realize that wrapped_handler already has the "on completion" functionality. I'll rewrite the code and use the cleanup method.

@t-arn
Copy link
Contributor Author

t-arn commented Jun 12, 2025

We could make it configurable whether the user's decisions should be saved, for example by setting WebView.save_user_url_permissions=True before setting the on_navigation_starting handler. What do you think? Should the default be True or False?

@freakboy3742
Copy link
Member

We could make it configurable whether the user's decisions should be saved, for example by setting WebView.save_user_url_permissions=True before setting the on_navigation_starting handler. What do you think? Should the default be True or False?

I see no practical use for that API. A method that returns False for https://foobar.com will always return False for that URL. There's no need to cache it.

If the intention is to allow user interaction on specific URLs, and cache answers - that's a decision the developer can make if that feature makes sense to have in an app. If nothing else, any caching scheme like that would need to have stored user preferences, a user-clearable cache, and more - so having it baked into Toga seems like massive overreach.

@t-arn
Copy link
Contributor Author

t-arn commented Jun 13, 2025

@freakboy3742 I refactored and simplified the Winforms code by using the cleanup method of wrapped_handler

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

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

This looks a lot better. There's obviously still a lot of debug to clean up, and I've flagged one way to clean up the cleanup handler even more - but the bones of this are lot closer to something I can see being merged.

The one big outstanding question is the need for the staticProxy on WebView, and whether there's any way to make this opt-in if you want navigation callbacks.


def on_navigation_starting_callback(self, widget, result):
try:
url = widget._requested_url
Copy link
Member

Choose a reason for hiding this comment

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

I'm pretty sure this wouldn't be needed if this callback method was an inner method inside on_navigation_starting. That way, it could; be a closure over the url argument. See how Window.on_close is implemented - that inner cleanup method is a closure over handler.

@t-arn
Copy link
Contributor Author

t-arn commented Jun 16, 2025

@freakboy3742 I defined on_navigation_starting cleanup method as an inner method, but the url parameter is always None inside cleanup. Is there something wrong with how I changed the code?

The webview._requested_url is still there and set, but not used anymore. I will remove it when the url parameter is working, but currently I don't see how to make it work without webview._requested_url

@freakboy3742
Copy link
Member

@freakboy3742 I defined on_navigation_starting cleanup method as an inner method, but the url parameter is always None inside cleanup. Is there something wrong with how I changed the code?

The webview._requested_url is still there and set, but not used anymore. I will remove it when the url parameter is working, but currently I don't see how to make it work without webview._requested_url

Ah - on closer inspection - the url argument to on_navigation_started will alway be None - because the method is a setter. It's not invoked when the navigation starts; it's invoked when the handler is installed - so url will always be None (and, in fact, there's no way to pass in anything to that argument at all).

So - I suspect what is needed here is a modification to wrapped_handler so that the cleanup method also accepts the *args and **kwargs passed to the event handler (in this case, url). AFAICT, all the existing uses of cleanup don't use any additional kwargs on their events - this would be the first usage on a handler that has arguments in addition to the widget.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants