clan ui: integrate webview ui into clan-mananger-gtk

This commit is contained in:
Johannes Kirschbauer
2024-05-18 11:56:35 +02:00
committed by hsjobeki
parent 7980f13bed
commit fe17f9e1a1
5 changed files with 75 additions and 44 deletions

View File

@@ -1,63 +1,72 @@
from dataclasses import dataclass
import dataclasses import dataclasses
import json import json
import sys import sys
from pathlib import Path
import threading import threading
from typing import Any, Callable, Union, get_type_hints from collections.abc import Callable
from pathlib import Path
from typing import Any, Union
import gi import gi
from clan_cli import machines from clan_cli import machines
gi.require_version("WebKit", "6.0") gi.require_version("WebKit", "6.0")
from gi.repository import WebKit, GLib from gi.repository import GLib, WebKit
site_index: Path = ( site_index: Path = (
Path(sys.argv[0]).absolute() / Path("../..") / Path("clan_vm_manager/.webui/index.html") Path(sys.argv[0]).absolute()
/ Path("../..")
/ Path("clan_vm_manager/.webui/index.html")
).resolve() ).resolve()
def type_to_dict(t: Any) -> dict: def type_to_dict(t: Any) -> dict:
if dataclasses.is_dataclass(t): if dataclasses.is_dataclass(t):
fields = dataclasses.fields(t) fields = dataclasses.fields(t)
return { return {
'type': 'dataclass', "type": "dataclass",
'name': t.__name__, "name": t.__name__,
'fields': {f.name: type_to_dict(f.type) for f in fields} "fields": {f.name: type_to_dict(f.type) for f in fields},
} }
if hasattr(t, '__origin__'): # Check if it's a generic type if hasattr(t, "__origin__"): # Check if it's a generic type
if t.__origin__ is None: if t.__origin__ is None:
# Non-generic user-defined or built-in type # Non-generic user-defined or built-in type
return {'type': t.__name__} return {"type": t.__name__}
if t.__origin__ is Union: if t.__origin__ is Union:
return { return {"type": "union", "of": [type_to_dict(arg) for arg in t.__args__]}
'type': 'union',
'of': [type_to_dict(arg) for arg in t.__args__]
}
elif issubclass(t.__origin__, list): elif issubclass(t.__origin__, list):
return {'type': 'list', 'item_type': type_to_dict(t.__args__[0])} return {"type": "list", "item_type": type_to_dict(t.__args__[0])}
elif issubclass(t.__origin__, dict): elif issubclass(t.__origin__, dict):
return {'type': 'dict', 'key_type': type_to_dict(t.__args__[0]), 'value_type': type_to_dict(t.__args__[1])} return {
"type": "dict",
"key_type": type_to_dict(t.__args__[0]),
"value_type": type_to_dict(t.__args__[1]),
}
elif issubclass(t.__origin__, tuple): elif issubclass(t.__origin__, tuple):
return {'type': 'tuple', 'element_types': [type_to_dict(elem) for elem in t.__args__]} return {
"type": "tuple",
"element_types": [type_to_dict(elem) for elem in t.__args__],
}
elif issubclass(t.__origin__, set): elif issubclass(t.__origin__, set):
return {'type': 'set', 'item_type': type_to_dict(t.__args__[0])} return {"type": "set", "item_type": type_to_dict(t.__args__[0])}
else: else:
# Handle other generic types (like Union, Optional) # Handle other generic types (like Union, Optional)
return {'type': str(t.__origin__.__name__), 'parameters': [type_to_dict(arg) for arg in t.__args__]} return {
"type": str(t.__origin__.__name__),
"parameters": [type_to_dict(arg) for arg in t.__args__],
}
elif isinstance(t, type): elif isinstance(t, type):
return {'type': t.__name__} return {"type": t.__name__}
else: else:
return {'type': str(t)} return {"type": str(t)}
class WebView: class WebView:
method_registry: dict[str,Callable] = {}
def __init__(self) -> None: def __init__(self) -> None:
self.webview = WebKit.WebView() self.method_registry: dict[str, Callable] = {}
self.webview = WebKit.WebView()
self.manager = self.webview.get_user_content_manager() self.manager = self.webview.get_user_content_manager()
# Can be called with: window.webkit.messageHandlers.gtk.postMessage("...") # 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 # Important: it seems postMessage must be given some payload, otherwise it won't trigger the event
@@ -66,33 +75,36 @@ class WebView:
self.webview.load_uri(f"file://{site_index}") self.webview.load_uri(f"file://{site_index}")
def method(self, function: Callable) -> Callable: def method(self, function: Callable) -> Callable:
# type_hints = get_type_hints(function) # type_hints = get_type_hints(function)
# serialized_hints = {key: type_to_dict(value) for key, value in type_hints.items()} # serialized_hints = {key: type_to_dict(value) for key, value in type_hints.items()}
self.method_registry[function.__name__] = function self.method_registry[function.__name__] = function
return function return function
def on_message_received(
def on_message_received(self, user_content_manager: WebKit.UserContentManager, message: Any) -> None: self, user_content_manager: WebKit.UserContentManager, message: Any
) -> None:
payload = json.loads(message.to_json(0)) payload = json.loads(message.to_json(0))
print(f"Received message: {payload}") print(f"Received message: {payload}")
method_name = payload["method"] method_name = payload["method"]
handler_fn = self.method_registry[method_name] handler_fn = self.method_registry[method_name]
# Start handler_fn in a new thread # Start handler_fn in a new thread
thread = threading.Thread(target=self.threaded_handler, args=(handler_fn, payload.get("data"), method_name)) thread = threading.Thread(
target=self.threaded_handler,
args=(handler_fn, payload.get("data"), method_name),
)
thread.start() thread.start()
def threaded_handler(self, handler_fn: Callable[[Any],Any], data: Any, method_name: str) -> None: def threaded_handler(
self, handler_fn: Callable[[Any], Any], data: Any, method_name: str
) -> None:
result = handler_fn(data) result = handler_fn(data)
serialized = json.dumps(result) serialized = json.dumps(result)
thread_id = threading.get_ident()
# Use idle_add to queue the response call to js on the main GTK thread # Use idle_add to queue the response call to js on the main GTK thread
GLib.idle_add(self.call_js, method_name, serialized) GLib.idle_add(self.call_js, method_name, serialized)
def call_js(self, method_name: str, serialized: str) -> bool: def call_js(self, method_name: str, serialized: str) -> bool:
# This function must be run on the main GTK thread to interact with the webview # This function must be run on the main GTK thread to interact with the webview
self.webview.evaluate_javascript( self.webview.evaluate_javascript(
@@ -112,6 +124,7 @@ class WebView:
webview = WebView() webview = WebView()
@webview.method @webview.method
def list_machines(data: None) -> list[str]: def list_machines(data: None) -> list[str]:
return machines.list.list_machines(".") return machines.list.list_machines(".")

View File

@@ -18,6 +18,8 @@
pytest-subprocess, # fake the real subprocess behavior to make your tests more independent. pytest-subprocess, # fake the real subprocess behavior to make your tests more independent.
pytest-xdist, # Run tests in parallel on multiple cores pytest-xdist, # Run tests in parallel on multiple cores
pytest-timeout, # Add timeouts to your tests pytest-timeout, # Add timeouts to your tests
webview-ui,
fontconfig,
}: }:
let let
source = ./.; source = ./.;
@@ -70,6 +72,7 @@ python3.pkgs.buildPythonApplication rec {
format = "pyproject"; format = "pyproject";
makeWrapperArgs = [ makeWrapperArgs = [
"--set FONTCONFIG_FILE ${fontconfig.out}/etc/fonts/fonts.conf"
# This prevents problems with mixed glibc versions that might occur when the # This prevents problems with mixed glibc versions that might occur when the
# cli is called through a browser built against another glibc # cli is called through a browser built against another glibc
"--unset LD_LIBRARY_PATH" "--unset LD_LIBRARY_PATH"
@@ -123,13 +126,20 @@ python3.pkgs.buildPythonApplication rec {
passthru.runtimeDependencies = runtimeDependencies; passthru.runtimeDependencies = runtimeDependencies;
passthru.testDependencies = testDependencies; passthru.testDependencies = testDependencies;
# TODO: place webui in lib/python3.11/site-packages/clan_vm_manager
postInstall = ''
mkdir -p $out/clan_vm_manager/.webui
cp -r ${webview-ui}/dist/* $out/clan_vm_manager/.webui
'';
# Don't leak python packages into a devshell. # Don't leak python packages into a devshell.
# It can be very confusing if you `nix run` than load the cli from the devshell instead. # It can be very confusing if you `nix run` than load the cli from the devshell instead.
postFixup = '' postFixup = ''
rm $out/nix-support/propagated-build-inputs rm $out/nix-support/propagated-build-inputs
''; '';
checkPhase = '' checkPhase = ''
PYTHONPATH= $out/bin/clan-vm-manager --help # TODO: figure out why the test cannot load the fonts
# PYTHONPATH= $out/bin/clan-vm-manager --help
''; '';
desktopItems = [ desktop-file ]; desktopItems = [ desktop-file ];
} }

View File

@@ -7,7 +7,7 @@
inherit (config.packages) clan-vm-manager webview-ui; inherit (config.packages) clan-vm-manager webview-ui;
}; };
packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix { packages.clan-vm-manager = pkgs.python3.pkgs.callPackage ./default.nix {
inherit (config.packages) clan-cli; inherit (config.packages) clan-cli webview-ui;
}; };
checks = config.packages.clan-vm-manager.tests; checks = config.packages.clan-vm-manager.tests;

View File

@@ -10,7 +10,7 @@
python3, python3,
gtk4, gtk4,
libadwaita, libadwaita,
nodejs_latest, webview-ui,
}: }:
let let
@@ -29,7 +29,6 @@ mkShell {
inherit (clan-vm-manager) nativeBuildInputs; inherit (clan-vm-manager) nativeBuildInputs;
buildInputs = buildInputs =
[ [
nodejs_latest
ruff ruff
gtk4.dev # has the demo called 'gtk4-widget-factory' gtk4.dev # has the demo called 'gtk4-widget-factory'
libadwaita.devdoc # has the demo called 'adwaita-1-demo' libadwaita.devdoc # has the demo called 'adwaita-1-demo'
@@ -53,5 +52,11 @@ mkShell {
# Add clan-cli to the python path so that we can import it without building it in nix first # Add clan-cli to the python path so that we can import it without building it in nix first
export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH" export PYTHONPATH="$GIT_ROOT/pkgs/clan-cli":"$PYTHONPATH"
# Add the webview-ui to the .webui directory
rm -rf ./clan_vm_manager/.webui/*
mkdir -p ./clan_vm_manager/.webui
cp -a ${webview-ui}/dist/* ./clan_vm_manager/.webui
chmod -R +w ./clan_vm_manager/.webui
''; '';
} }

View File

@@ -1,16 +1,19 @@
{ inputs, ... }: { inputs, ... }:
{ {
perSystem = perSystem =
{ system, pkgs, config, ... }: {
system,
pkgs,
config,
...
}:
let let
node_modules-dev = config.packages.webview-ui.prepared-dev; node_modules-dev = config.packages.webview-ui.prepared-dev;
in in
{ {
packages.webview-ui = inputs.dream2nix.lib.evalModules { packages.webview-ui = inputs.dream2nix.lib.evalModules {
packageSets.nixpkgs = inputs.dream2nix.inputs.nixpkgs.legacyPackages.${system}; packageSets.nixpkgs = inputs.dream2nix.inputs.nixpkgs.legacyPackages.${system};
modules = [ modules = [ ./default.nix ];
./default.nix
];
}; };
devShells.webview-ui = pkgs.mkShell { devShells.webview-ui = pkgs.mkShell {
inputsFrom = [ config.packages.webview-ui.out ]; inputsFrom = [ config.packages.webview-ui.out ];