clan-app: working js<->python api bridge
This commit is contained in:
@@ -9,9 +9,10 @@ import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from clan_cli.api import API
|
||||
from clan_cli.custom_logger import setup_logging
|
||||
|
||||
from clan_app.deps.webview.webview import Webview
|
||||
from clan_app.deps.webview.webview import Size, SizeHint, Webview
|
||||
|
||||
|
||||
@profile
|
||||
@@ -37,9 +38,10 @@ def main(argv: list[str] = sys.argv) -> int:
|
||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
||||
content_uri = f"file://{site_index}"
|
||||
|
||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
||||
content_uri = f"file://{site_index}"
|
||||
webview = Webview(debug=args.debug)
|
||||
webview.bind_jsonschema_api(API)
|
||||
|
||||
webview = Webview()
|
||||
webview.size = Size(1280, 1024, SizeHint.NONE)
|
||||
webview.navigate(content_uri)
|
||||
webview.run()
|
||||
return 0
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import gi
|
||||
|
||||
from clan_app import assets
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from clan_cli.custom_logger import setup_logging
|
||||
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk
|
||||
|
||||
from clan_app.components.interfaces import ClanConfig
|
||||
|
||||
from .windows.main_window import MainWindow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MainApplication(Adw.Application):
|
||||
"""
|
||||
This class is initialized every time the app is started
|
||||
Only the Adw.ApplicationWindow is a singleton.
|
||||
So don't use any singletons in the Adw.Application class.
|
||||
"""
|
||||
|
||||
__gsignals__: ClassVar = {
|
||||
"join_request": (GObject.SignalFlags.RUN_FIRST, None, [str]),
|
||||
}
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(
|
||||
application_id="org.clan.app",
|
||||
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
|
||||
)
|
||||
|
||||
self.add_main_option(
|
||||
"debug",
|
||||
ord("d"),
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
"enable debug mode",
|
||||
None,
|
||||
)
|
||||
|
||||
self.add_main_option(
|
||||
"content-uri",
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
"set the webview content uri",
|
||||
None,
|
||||
)
|
||||
|
||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
||||
self.content_uri = f"file://{site_index}"
|
||||
self.window: MainWindow | None = None
|
||||
self.connect("activate", self.on_activate)
|
||||
self.connect("shutdown", self.on_shutdown)
|
||||
|
||||
def on_shutdown(self, source: "MainApplication") -> None:
|
||||
log.debug("Shutting down Adw.Application")
|
||||
|
||||
if self.get_windows() == []:
|
||||
log.debug("No windows to destroy")
|
||||
if self.window:
|
||||
# TODO: Doesn't seem to raise the destroy signal. Need to investigate
|
||||
# self.get_windows() returns an empty list. Desync between window and application?
|
||||
self.window.close()
|
||||
|
||||
else:
|
||||
log.error("No window to destroy")
|
||||
|
||||
def do_command_line(self, command_line: Any) -> int:
|
||||
options = command_line.get_options_dict()
|
||||
# convert GVariantDict -> GVariant -> dict
|
||||
options = options.end().unpack()
|
||||
|
||||
if "debug" in options and self.window is None:
|
||||
setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0])
|
||||
setup_logging(logging.DEBUG, root_log_name="clan_cli")
|
||||
elif self.window is None:
|
||||
setup_logging(logging.INFO, root_log_name=__name__.split(".")[0])
|
||||
log.debug("Debug logging enabled")
|
||||
|
||||
if "content-uri" in options:
|
||||
self.content_uri = options["content-uri"]
|
||||
log.debug(f"Setting content uri to {self.content_uri}")
|
||||
|
||||
args = command_line.get_arguments()
|
||||
|
||||
self.activate()
|
||||
|
||||
# Check if there are arguments that are not inside the options
|
||||
if len(args) > 1:
|
||||
non_option_args = [arg for arg in args[1:] if arg not in options.values()]
|
||||
if non_option_args:
|
||||
uri = non_option_args[0]
|
||||
self.emit("join_request", uri)
|
||||
|
||||
return 0
|
||||
|
||||
def on_window_hide_unhide(self, *_args: Any) -> None:
|
||||
if not self.window:
|
||||
log.error("No window to hide/unhide")
|
||||
return
|
||||
if self.window.is_visible():
|
||||
self.window.hide()
|
||||
else:
|
||||
self.window.present()
|
||||
|
||||
def dummy_menu_entry(self) -> None:
|
||||
log.info("Dummy menu entry called")
|
||||
|
||||
def on_activate(self, source: "MainApplication") -> None:
|
||||
if not self.window:
|
||||
self.init_style()
|
||||
self.window = MainWindow(
|
||||
config=ClanConfig(initial_view="webview", content_uri=self.content_uri)
|
||||
)
|
||||
self.window.set_application(self)
|
||||
|
||||
self.window.show()
|
||||
|
||||
# TODO: For css styling
|
||||
def init_style(self) -> None:
|
||||
resource_path = assets.loc / "style.css"
|
||||
|
||||
log.debug(f"Style css path: {resource_path}")
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path(str(resource_path))
|
||||
display = Gdk.Display.get_default()
|
||||
assert display is not None
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
display,
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Kill the new process and all its children by sending a SIGTERM signal to the process group
|
||||
def _kill_group(proc: mp.Process) -> None:
|
||||
pid = proc.pid
|
||||
if proc.is_alive() and pid:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
else:
|
||||
log.warning(f"Process '{proc.name}' with pid '{pid}' is already dead")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MPProcess:
|
||||
name: str
|
||||
proc: mp.Process
|
||||
out_file: Path
|
||||
|
||||
# Kill the new process and all its children by sending a SIGTERM signal to the process group
|
||||
def kill_group(self) -> None:
|
||||
_kill_group(proc=self.proc)
|
||||
|
||||
|
||||
def _set_proc_name(name: str) -> None:
|
||||
if sys.platform != "linux":
|
||||
return
|
||||
import ctypes
|
||||
|
||||
# Define the prctl function with the appropriate arguments and return type
|
||||
libc = ctypes.CDLL("libc.so.6")
|
||||
prctl = libc.prctl
|
||||
prctl.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
prctl.restype = ctypes.c_int
|
||||
|
||||
# Set the process name to "my_process"
|
||||
prctl(15, name.encode(), 0, 0, 0)
|
||||
|
||||
|
||||
def _init_proc(
|
||||
func: Callable,
|
||||
out_file: Path,
|
||||
proc_name: str,
|
||||
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# Create a new process group
|
||||
os.setsid()
|
||||
|
||||
# Open stdout and stderr
|
||||
with out_file.open("w") as out_fd:
|
||||
os.dup2(out_fd.fileno(), sys.stdout.fileno())
|
||||
os.dup2(out_fd.fileno(), sys.stderr.fileno())
|
||||
|
||||
# Print some information
|
||||
pid = os.getpid()
|
||||
gpid = os.getpgid(pid=pid)
|
||||
|
||||
# Set the process name
|
||||
_set_proc_name(proc_name)
|
||||
|
||||
# Close stdin
|
||||
sys.stdin.close()
|
||||
|
||||
linebreak = "=" * 5
|
||||
# Execute the main function
|
||||
print(linebreak + f" {func.__name__}:{pid} " + linebreak, file=sys.stderr)
|
||||
try:
|
||||
func(**kwargs)
|
||||
except Exception as ex:
|
||||
traceback.print_exc()
|
||||
if on_except is not None:
|
||||
on_except(ex, mp.current_process())
|
||||
|
||||
# Kill the new process and all its children by sending a SIGTERM signal to the process group
|
||||
pid = os.getpid()
|
||||
gpid = os.getpgid(pid=pid)
|
||||
print(f"Killing process group pid={pid} gpid={gpid}", file=sys.stderr)
|
||||
os.killpg(gpid, signal.SIGTERM)
|
||||
sys.exit(1)
|
||||
# Don't use a finally block here, because we want the exitcode to be set to
|
||||
# 0 if the function returns normally
|
||||
|
||||
|
||||
def spawn(
|
||||
*,
|
||||
out_file: Path,
|
||||
on_except: Callable[[Exception, mp.process.BaseProcess], None] | None,
|
||||
func: Callable,
|
||||
**kwargs: Any,
|
||||
) -> MPProcess:
|
||||
# Decouple the process from the parent
|
||||
if mp.get_start_method(allow_none=True) is None:
|
||||
mp.set_start_method(method="forkserver")
|
||||
|
||||
# Set names
|
||||
proc_name = f"MPExec:{func.__name__}"
|
||||
|
||||
# Start the process
|
||||
proc = mp.Process(
|
||||
target=_init_proc,
|
||||
args=(func, out_file, proc_name, on_except),
|
||||
name=proc_name,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
proc.start()
|
||||
|
||||
# Return the process
|
||||
mp_proc = MPProcess(name=proc_name, proc=proc, out_file=out_file)
|
||||
|
||||
return mp_proc
|
||||
@@ -1,11 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClanConfig:
|
||||
initial_view: str
|
||||
content_uri: str
|
||||
@@ -10,12 +10,12 @@ def _encode_c_string(s: str) -> bytes:
|
||||
return s.encode("utf-8")
|
||||
|
||||
|
||||
def _get_webview_version():
|
||||
def _get_webview_version() -> str:
|
||||
"""Get webview version from environment variable or use default"""
|
||||
return os.getenv("WEBVIEW_VERSION", "0.8.1")
|
||||
|
||||
|
||||
def _get_lib_names():
|
||||
def _get_lib_names() -> list[str]:
|
||||
"""Get platform-specific library names."""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
@@ -25,8 +25,9 @@ def _get_lib_names():
|
||||
return ["webview.dll", "WebView2Loader.dll"]
|
||||
if machine == "arm64":
|
||||
msg = "arm64 is not supported on Windows"
|
||||
raise Exception(msg)
|
||||
return None
|
||||
raise RuntimeError(msg)
|
||||
msg = f"Unsupported architecture: {machine}"
|
||||
raise RuntimeError(msg)
|
||||
if system == "darwin":
|
||||
if machine == "arm64":
|
||||
return ["libwebview.aarch64.dylib"]
|
||||
@@ -35,16 +36,16 @@ def _get_lib_names():
|
||||
return ["libwebview.so"]
|
||||
|
||||
|
||||
def _be_sure_libraries():
|
||||
def _be_sure_libraries() -> list[Path] | None:
|
||||
"""Ensure libraries exist and return paths."""
|
||||
|
||||
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
|
||||
if not lib_dir:
|
||||
msg = "WEBVIEW_LIB_DIR environment variable is not set"
|
||||
raise RuntimeError(msg)
|
||||
lib_dir = Path(lib_dir)
|
||||
lib_dir_p = Path(lib_dir)
|
||||
lib_names = _get_lib_names()
|
||||
lib_paths = [lib_dir / lib_name for lib_name in lib_names]
|
||||
lib_paths = [lib_dir_p / lib_name for lib_name in lib_names]
|
||||
|
||||
# Check if any library is missing
|
||||
missing_libs = [path for path in lib_paths if not path.exists()]
|
||||
@@ -56,10 +57,14 @@ def _be_sure_libraries():
|
||||
class _WebviewLibrary:
|
||||
def __init__(self) -> None:
|
||||
lib_names = _get_lib_names()
|
||||
|
||||
library_path = ctypes.util.find_library(lib_names[0])
|
||||
if not library_path:
|
||||
library_paths = _be_sure_libraries()
|
||||
if not library_paths:
|
||||
msg = f"Failed to find required library: {lib_names}"
|
||||
raise RuntimeError(msg)
|
||||
try:
|
||||
library_path = ctypes.util.find_library(lib_names[0])
|
||||
if not library_path:
|
||||
library_paths = _be_sure_libraries()
|
||||
self.lib = ctypes.cdll.LoadLibrary(str(library_paths[0]))
|
||||
except Exception as e:
|
||||
print(f"Failed to load webview library: {e}")
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from clan_cli.api import MethodRegistry, dataclass_to_dict, from_dict
|
||||
|
||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SizeHint(IntEnum):
|
||||
NONE = 0
|
||||
@@ -26,7 +32,7 @@ class Webview:
|
||||
self, debug: bool = False, size: Size | None = None, window: int | None = None
|
||||
) -> None:
|
||||
self._handle = _webview_lib.webview_create(int(debug), window)
|
||||
self._callbacks = {}
|
||||
self._callbacks: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
if size:
|
||||
self.size = size
|
||||
@@ -65,6 +71,71 @@ class Webview:
|
||||
_webview_lib.webview_run(self._handle)
|
||||
self.destroy()
|
||||
|
||||
def bind_jsonschema_api(self, api: MethodRegistry) -> None:
|
||||
for name, method in api.functions.items():
|
||||
|
||||
def wrapper(
|
||||
seq: bytes,
|
||||
req: bytes,
|
||||
arg: int,
|
||||
wrap_method: Callable[..., Any] = method,
|
||||
method_name: str = name,
|
||||
) -> None:
|
||||
def thread_task() -> None:
|
||||
args = json.loads(req.decode())
|
||||
|
||||
try:
|
||||
log.debug(f"Calling {method_name}({args[0]})")
|
||||
# Initialize dataclasses from the payload
|
||||
reconciled_arguments = {}
|
||||
for k, v in args[0].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 = 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)
|
||||
|
||||
reconciled_arguments["op_key"] = seq.decode()
|
||||
# TODO: We could remove the wrapper in the MethodRegistry
|
||||
# and just call the method directly
|
||||
result = wrap_method(**reconciled_arguments)
|
||||
success = True
|
||||
except Exception as e:
|
||||
log.exception(f"Error calling {method_name}")
|
||||
result = str(e)
|
||||
success = False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(result), indent=4, ensure_ascii=False
|
||||
)
|
||||
except TypeError:
|
||||
log.exception(f"Error serializing result for {method_name}")
|
||||
raise
|
||||
|
||||
log.debug(f"Result for {method_name}: {serialized}")
|
||||
self.return_(seq.decode(), 0 if success else 1, serialized)
|
||||
|
||||
thread = threading.Thread(target=thread_task)
|
||||
thread.start()
|
||||
|
||||
c_callback = _webview_lib.CFUNCTYPE(
|
||||
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p
|
||||
)(wrapper)
|
||||
log.debug(f"Binding {name} to {method}")
|
||||
if name in self._callbacks:
|
||||
msg = f"Callback {name} already exists. Skipping binding."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
self._callbacks[name] = c_callback
|
||||
_webview_lib.webview_bind(
|
||||
self._handle, _encode_c_string(name), c_callback, None
|
||||
)
|
||||
|
||||
def bind(self, name: str, callback: Callable[..., Any]) -> None:
|
||||
def wrapper(seq: bytes, req: bytes, arg: int) -> None:
|
||||
args = json.loads(req.decode())
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToastOverlay:
|
||||
"""
|
||||
The ToastOverlay is a class that manages the display of toasts
|
||||
It should be used as a singleton in your application to prevent duplicate toasts
|
||||
Usage
|
||||
"""
|
||||
|
||||
# For some reason, the adw toast overlay cannot be subclassed
|
||||
# Thats why it is added as a class property
|
||||
overlay: Adw.ToastOverlay
|
||||
active_toasts: set[str]
|
||||
|
||||
_instance: "None | ToastOverlay" = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
msg = "Call use() instead"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@classmethod
|
||||
def use(cls: Any) -> "ToastOverlay":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls.__new__(cls)
|
||||
cls.overlay = Adw.ToastOverlay()
|
||||
cls.active_toasts = set()
|
||||
|
||||
return cls._instance
|
||||
|
||||
def add_toast_unique(self, toast: Adw.Toast, key: str) -> None:
|
||||
if key not in self.active_toasts:
|
||||
self.active_toasts.add(key)
|
||||
self.overlay.add_toast(toast)
|
||||
toast.connect("dismissed", lambda toast: self.active_toasts.remove(key))
|
||||
|
||||
|
||||
class WarningToast:
|
||||
toast: Adw.Toast
|
||||
|
||||
def __init__(self, message: str, persistent: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.toast = Adw.Toast.new(
|
||||
f"<span foreground='orange'>⚠ Warning </span> {message}"
|
||||
)
|
||||
self.toast.set_use_markup(True)
|
||||
|
||||
self.toast.set_priority(Adw.ToastPriority.NORMAL)
|
||||
|
||||
if persistent:
|
||||
self.toast.set_timeout(0)
|
||||
|
||||
|
||||
class InfoToast:
|
||||
toast: Adw.Toast
|
||||
|
||||
def __init__(self, message: str, persistent: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.toast = Adw.Toast.new(f"<span>❕</span> {message}")
|
||||
self.toast.set_use_markup(True)
|
||||
|
||||
self.toast.set_priority(Adw.ToastPriority.NORMAL)
|
||||
|
||||
if persistent:
|
||||
self.toast.set_timeout(0)
|
||||
|
||||
|
||||
class SuccessToast:
|
||||
toast: Adw.Toast
|
||||
|
||||
def __init__(self, message: str, persistent: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.toast = Adw.Toast.new(f"<span foreground='green'>✅</span> {message}")
|
||||
self.toast.set_use_markup(True)
|
||||
|
||||
self.toast.set_priority(Adw.ToastPriority.NORMAL)
|
||||
|
||||
if persistent:
|
||||
self.toast.set_timeout(0)
|
||||
|
||||
|
||||
class LogToast:
|
||||
toast: Adw.Toast
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
on_button_click: Callable[[], None],
|
||||
button_label: str = "More",
|
||||
persistent: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.toast = Adw.Toast.new(
|
||||
f"""Logs are available <span weight="regular">{message}</span>"""
|
||||
)
|
||||
self.toast.set_use_markup(True)
|
||||
|
||||
self.toast.set_priority(Adw.ToastPriority.NORMAL)
|
||||
|
||||
if persistent:
|
||||
self.toast.set_timeout(0)
|
||||
|
||||
self.toast.set_button_label(button_label)
|
||||
self.toast.connect(
|
||||
"button-clicked",
|
||||
lambda _: on_button_click(),
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw
|
||||
|
||||
|
||||
class ViewStack:
|
||||
"""
|
||||
This is a singleton.
|
||||
It is initialized with the first call of use()
|
||||
|
||||
Usage:
|
||||
|
||||
ViewStack.use().set_visible()
|
||||
|
||||
ViewStack.use() can also be called before the data is needed. e.g. to eliminate/reduce waiting time.
|
||||
|
||||
"""
|
||||
|
||||
_instance: "None | ViewStack" = None
|
||||
view: Adw.ViewStack
|
||||
|
||||
# Make sure the VMS class is used as a singleton
|
||||
def __init__(self) -> None:
|
||||
msg = "Call use() instead"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@classmethod
|
||||
def use(cls: Any) -> "ViewStack":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls.__new__(cls)
|
||||
cls.view = Adw.ViewStack()
|
||||
|
||||
return cls._instance
|
||||
@@ -1,51 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import gi
|
||||
from clan_cli.api import API
|
||||
|
||||
from clan_app.components.interfaces import ClanConfig
|
||||
from clan_app.singletons.toast import ToastOverlay
|
||||
from clan_app.singletons.use_views import ViewStack
|
||||
from clan_app.views.webview import WebExecutor
|
||||
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gio
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MainWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, config: ClanConfig) -> None:
|
||||
super().__init__()
|
||||
self.set_title("Clan App")
|
||||
self.set_default_size(1280, 1024)
|
||||
|
||||
# Overlay for GTK side exclusive toasts
|
||||
overlay = ToastOverlay.use().overlay
|
||||
view = Adw.ToolbarView()
|
||||
overlay.set_child(view)
|
||||
|
||||
self.set_content(overlay)
|
||||
|
||||
header = Adw.HeaderBar()
|
||||
view.add_top_bar(header)
|
||||
|
||||
app = Gio.Application.get_default()
|
||||
assert app is not None
|
||||
|
||||
stack_view = ViewStack.use().view
|
||||
|
||||
webexec = WebExecutor(jschema_api=API, content_uri=config.content_uri)
|
||||
|
||||
stack_view.add_named(webexec.get_webview(), "webview")
|
||||
stack_view.set_visible_child_name(config.initial_view)
|
||||
|
||||
view.set_content(stack_view)
|
||||
|
||||
self.connect("destroy", self.on_destroy)
|
||||
|
||||
def on_destroy(self, source: "Adw.ApplicationWindow") -> None:
|
||||
log.debug("Destroying Adw.ApplicationWindow")
|
||||
os._exit(0)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
@@ -11,6 +12,8 @@ from typing import (
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from .serde import dataclass_to_dict, from_dict, sanitize_string
|
||||
|
||||
__all__ = ["dataclass_to_dict", "from_dict", "sanitize_string"]
|
||||
@@ -122,9 +125,10 @@ API.register(open_file)
|
||||
@wraps(fn)
|
||||
def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]:
|
||||
try:
|
||||
data: T = fn(*args, **kwargs)
|
||||
data: T = fn(*args, op_key=op_key, **kwargs)
|
||||
return SuccessDataClass(status="success", data=data, op_key=op_key)
|
||||
except ClanError as e:
|
||||
log.exception(f"Error calling wrapped {fn.__name__}")
|
||||
return ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
@@ -137,6 +141,7 @@ API.register(open_file)
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error calling wrapped {fn.__name__}")
|
||||
return ErrorDataClass(
|
||||
op_key=op_key,
|
||||
status="error",
|
||||
|
||||
@@ -43,109 +43,15 @@ export interface GtkResponse<T> {
|
||||
op_key: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
clan: ClanOperations;
|
||||
webkit: {
|
||||
messageHandlers: {
|
||||
gtk: {
|
||||
postMessage: (message: {
|
||||
method: OperationNames;
|
||||
data: OperationArgs<OperationNames>;
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
// Make sure window.webkit is defined although the type is not correctly filled yet.
|
||||
window.clan = {} as ClanOperations;
|
||||
|
||||
const operations = schema.properties;
|
||||
const operationNames = Object.keys(operations) as OperationNames[];
|
||||
|
||||
type ObserverRegistry = {
|
||||
[K in OperationNames]: Record<
|
||||
string,
|
||||
(response: OperationResponse<K>) => void
|
||||
>;
|
||||
};
|
||||
const registry: ObserverRegistry = operationNames.reduce(
|
||||
(acc, opName) => ({
|
||||
...acc,
|
||||
[opName]: {},
|
||||
}),
|
||||
{} as ObserverRegistry,
|
||||
);
|
||||
|
||||
function createFunctions<K extends OperationNames>(
|
||||
operationName: K,
|
||||
): {
|
||||
dispatch: (args: OperationArgs<K>) => void;
|
||||
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
|
||||
} {
|
||||
window.clan[operationName] = (s: string) => {
|
||||
const f = (response: OperationResponse<K>) => {
|
||||
// Get the correct receiver function for the op_key
|
||||
const receiver = registry[operationName][response.op_key];
|
||||
if (receiver) {
|
||||
receiver(response);
|
||||
}
|
||||
};
|
||||
deserialize(f)(s);
|
||||
};
|
||||
|
||||
return {
|
||||
dispatch: (args: OperationArgs<K>) => {
|
||||
// Send the data to the gtk app
|
||||
window.webkit.messageHandlers.gtk.postMessage({
|
||||
method: operationName,
|
||||
data: args,
|
||||
});
|
||||
},
|
||||
receive: (fn: (response: OperationResponse<K>) => void, id: string) => {
|
||||
// @ts-expect-error: This should work although typescript doesn't let us write
|
||||
registry[operationName][id] = fn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type PyApi = {
|
||||
[K in OperationNames]: {
|
||||
dispatch: (args: OperationArgs<K>) => void;
|
||||
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function download(filename: string, text: string) {
|
||||
const element = document.createElement("a");
|
||||
element.setAttribute(
|
||||
"href",
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
|
||||
);
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
export const callApi = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
) => {
|
||||
return new Promise<OperationResponse<K>>((resolve) => {
|
||||
const id = nanoid();
|
||||
pyApi[method].receive((response) => {
|
||||
console.log(method, "Received response: ", { response });
|
||||
resolve(response);
|
||||
}, id);
|
||||
|
||||
pyApi[method].dispatch({ ...args, op_key: id });
|
||||
});
|
||||
console.log("Calling API", method, args);
|
||||
return (window as any)[method](args);
|
||||
};
|
||||
|
||||
const deserialize =
|
||||
@@ -161,15 +67,3 @@ const deserialize =
|
||||
alert(`Error parsing JSON: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Create the API object
|
||||
|
||||
const pyApi: PyApi = {} as PyApi;
|
||||
|
||||
operationNames.forEach((opName) => {
|
||||
const name = opName as OperationNames;
|
||||
// @ts-expect-error - TODO: Fix this. Typescript is not recognizing the receive function correctly
|
||||
pyApi[name] = createFunctions(name);
|
||||
});
|
||||
|
||||
export { pyApi };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { OperationResponse, pyApi } from "@/src/api";
|
||||
import { OperationResponse, callApi } from "@/src/api";
|
||||
import { Button } from "@/src/components/button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const HostList: Component = () => {
|
||||
<div class="tooltip tooltip-bottom" data-tip="Refresh install targets">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => pyApi.show_mdns.dispatch({})}
|
||||
onClick={() => callApi("show_mdns", {})}
|
||||
startIcon={<Icon icon="Update" />}
|
||||
></Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user