Files
clan-core/pkgs/clan-app/clan_app/views/webview.py
2024-12-10 21:50:04 +01:00

212 lines
7.9 KiB
Python

import json
import logging
import traceback
from pathlib import Path
from typing import Any
import gi
from clan_cli.api import MethodRegistry, dataclass_to_dict, from_dict
from clan_app.api import GObjApi, GResult, ImplFunc
from clan_app.api.file import open_file
gi.require_version("WebKit", "6.0")
from gi.repository import Gio, GLib, GObject, WebKit
log = logging.getLogger(__name__)
class WebExecutor(GObject.Object):
def __init__(self, content_uri: str, jschema_api: MethodRegistry) -> None:
super().__init__()
self.jschema_api: MethodRegistry = jschema_api
self.webview: WebKit.WebView = WebKit.WebView()
settings: WebKit.Settings = self.webview.get_settings()
# settings.
settings.set_property("enable-developer-extras", True)
self.webview.set_settings(settings)
# FIXME: This filtering is incomplete, it only triggers if a user clicks a link
self.webview.connect("decide-policy", self.on_decide_policy)
# For when the page is fully loaded
self.webview.connect("load-changed", self.on_load_changed)
self.manager: WebKit.UserContentManager = (
self.webview.get_user_content_manager()
)
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...")
# Important: it seems postMessage must be given some payload, otherwise it won't trigger the event
self.manager.register_script_message_handler("gtk")
self.manager.connect("script-message-received", self.on_message_received)
self.webview.load_uri(content_uri)
self.content_uri = content_uri
self.api: GObjApi = GObjApi(self.jschema_api.functions)
self.api.overwrite_fn(open_file)
self.api.check_signature(self.jschema_api.signatures)
def on_load_changed(
self, webview: WebKit.WebView, load_event: WebKit.LoadEvent
) -> None:
if load_event == WebKit.LoadEvent.FINISHED:
if log.isEnabledFor(logging.DEBUG):
pass
# inspector = webview.get_inspector()
# inspector.show()
def on_decide_policy(
self,
webview: WebKit.WebView,
decision: WebKit.NavigationPolicyDecision,
decision_type: WebKit.PolicyDecisionType,
) -> bool:
if decision_type != WebKit.PolicyDecisionType.NAVIGATION_ACTION:
return False # Continue with the default handler
navigation_action: WebKit.NavigationAction = decision.get_navigation_action()
request: WebKit.URIRequest = navigation_action.get_request()
uri = request.get_uri()
if self.content_uri.startswith("http://") and uri.startswith(self.content_uri):
log.debug(f"Allow navigation request: {uri}")
return False
if self.content_uri.startswith("file://") and uri.startswith(self.content_uri):
log.debug(f"Allow navigation request: {uri}")
return False
log.warning(
f"Do not allow navigation request: {uri}. Current content uri: {self.content_uri}"
)
decision.ignore()
return True # Stop other handlers from being invoked
def on_message_received(
self, user_content_manager: WebKit.UserContentManager, message: Any
) -> None:
json_msg = message.to_json(4) # 4 is num of indents
log.debug(f"Webview Request: {json_msg}")
payload = json.loads(json_msg)
method_name = payload["method"]
data = payload.get("data")
# Get the function gobject from the api
if not self.api.has_obj(method_name):
self.return_data_to_js(
method_name,
json.dumps(
{
"op_key": data["op_key"],
"status": "error",
"errors": [
{
"message": "Internal API Error",
"description": f"Function '{method_name}' not found",
}
],
}
),
)
return
function_obj = self.api.get_obj(method_name)
# Create an instance of the function gobject
fn_instance = function_obj()
fn_instance.await_result(self.on_result)
# Extract the data from the payload
if data is None:
log.error(
f"JS function call '{method_name}' has no data field. Skipping execution."
)
return
if data.get("op_key") is None:
log.error(
f"JS function call '{method_name}' has no op_key field. Skipping execution."
)
return
try:
# Initialize dataclasses from the payload
reconciled_arguments = {}
for k, v in data.items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = self.jschema_api.get_method_argtype(method_name, k)
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
GLib.idle_add(fn_instance.internal_async_run, reconciled_arguments)
except Exception as e:
self.return_data_to_js(
method_name,
json.dumps(
{
"op_key": data["op_key"],
"status": "error",
"errors": [
{
"message": "Internal API Error",
"description": traceback.format_exception(e),
}
],
}
),
)
def on_result(self, source: ImplFunc, data: GResult) -> None:
result = dataclass_to_dict(data.result)
# Important:
# 2. ensure_ascii = False. non-ASCII characters are correctly handled, instead of being escaped.
try:
serialized = json.dumps(result, indent=4, ensure_ascii=False)
except TypeError:
log.exception(f"Error serializing result for {data.method_name}")
raise
log.debug(f"Result for {data.method_name}: {serialized}")
# Use idle_add to queue the response call to js on the main GTK thread
self.return_data_to_js(data.method_name, serialized)
def return_data_to_js(self, method_name: str, serialized: str) -> bool:
js = f"""
window.clan.{method_name}({serialized});
"""
def dump_failed_code() -> None:
tmp_file = Path("/tmp/clan-pyjs-bridge-error.js")
with tmp_file.open("w") as f:
f.write(js)
log.debug(f"Failed code dumped in JS file: {tmp_file}")
# Error handling if the JavaScript evaluation fails
def on_js_evaluation_finished(
webview: WebKit.WebView, task: Gio.AsyncResult
) -> None:
try:
# Get the result of the JavaScript evaluation
value = webview.evaluate_javascript_finish(task)
if not value:
log.exception("No value returned")
dump_failed_code()
except GLib.Error:
log.exception("Error evaluating JS")
dump_failed_code()
self.webview.evaluate_javascript(
js,
-1,
None,
None,
None,
on_js_evaluation_finished,
)
return GLib.SOURCE_REMOVE
def get_webview(self) -> WebKit.WebView:
return self.webview