clan-app: working js<->python api bridge
This commit is contained in:
@@ -9,9 +9,10 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from clan_cli.api import API
|
||||||
from clan_cli.custom_logger import setup_logging
|
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
|
@profile
|
||||||
@@ -37,9 +38,10 @@ def main(argv: list[str] = sys.argv) -> int:
|
|||||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
||||||
content_uri = f"file://{site_index}"
|
content_uri = f"file://{site_index}"
|
||||||
|
|
||||||
site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html"
|
webview = Webview(debug=args.debug)
|
||||||
content_uri = f"file://{site_index}"
|
webview.bind_jsonschema_api(API)
|
||||||
|
|
||||||
webview = Webview()
|
webview.size = Size(1280, 1024, SizeHint.NONE)
|
||||||
webview.navigate(content_uri)
|
webview.navigate(content_uri)
|
||||||
webview.run()
|
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")
|
return s.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _get_webview_version():
|
def _get_webview_version() -> str:
|
||||||
"""Get webview version from environment variable or use default"""
|
"""Get webview version from environment variable or use default"""
|
||||||
return os.getenv("WEBVIEW_VERSION", "0.8.1")
|
return os.getenv("WEBVIEW_VERSION", "0.8.1")
|
||||||
|
|
||||||
|
|
||||||
def _get_lib_names():
|
def _get_lib_names() -> list[str]:
|
||||||
"""Get platform-specific library names."""
|
"""Get platform-specific library names."""
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
@@ -25,8 +25,9 @@ def _get_lib_names():
|
|||||||
return ["webview.dll", "WebView2Loader.dll"]
|
return ["webview.dll", "WebView2Loader.dll"]
|
||||||
if machine == "arm64":
|
if machine == "arm64":
|
||||||
msg = "arm64 is not supported on Windows"
|
msg = "arm64 is not supported on Windows"
|
||||||
raise Exception(msg)
|
raise RuntimeError(msg)
|
||||||
return None
|
msg = f"Unsupported architecture: {machine}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
if system == "darwin":
|
if system == "darwin":
|
||||||
if machine == "arm64":
|
if machine == "arm64":
|
||||||
return ["libwebview.aarch64.dylib"]
|
return ["libwebview.aarch64.dylib"]
|
||||||
@@ -35,16 +36,16 @@ def _get_lib_names():
|
|||||||
return ["libwebview.so"]
|
return ["libwebview.so"]
|
||||||
|
|
||||||
|
|
||||||
def _be_sure_libraries():
|
def _be_sure_libraries() -> list[Path] | None:
|
||||||
"""Ensure libraries exist and return paths."""
|
"""Ensure libraries exist and return paths."""
|
||||||
|
|
||||||
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
|
lib_dir = os.environ.get("WEBVIEW_LIB_DIR")
|
||||||
if not lib_dir:
|
if not lib_dir:
|
||||||
msg = "WEBVIEW_LIB_DIR environment variable is not set"
|
msg = "WEBVIEW_LIB_DIR environment variable is not set"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
lib_dir = Path(lib_dir)
|
lib_dir_p = Path(lib_dir)
|
||||||
lib_names = _get_lib_names()
|
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
|
# Check if any library is missing
|
||||||
missing_libs = [path for path in lib_paths if not path.exists()]
|
missing_libs = [path for path in lib_paths if not path.exists()]
|
||||||
@@ -56,10 +57,14 @@ def _be_sure_libraries():
|
|||||||
class _WebviewLibrary:
|
class _WebviewLibrary:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
lib_names = _get_lib_names()
|
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:
|
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]))
|
self.lib = ctypes.cdll.LoadLibrary(str(library_paths[0]))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to load webview library: {e}")
|
print(f"Failed to load webview library: {e}")
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from clan_cli.api import MethodRegistry, dataclass_to_dict, from_dict
|
||||||
|
|
||||||
from ._webview_ffi import _encode_c_string, _webview_lib
|
from ._webview_ffi import _encode_c_string, _webview_lib
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SizeHint(IntEnum):
|
class SizeHint(IntEnum):
|
||||||
NONE = 0
|
NONE = 0
|
||||||
@@ -26,7 +32,7 @@ class Webview:
|
|||||||
self, debug: bool = False, size: Size | None = None, window: int | None = None
|
self, debug: bool = False, size: Size | None = None, window: int | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
self._handle = _webview_lib.webview_create(int(debug), window)
|
self._handle = _webview_lib.webview_create(int(debug), window)
|
||||||
self._callbacks = {}
|
self._callbacks: dict[str, Callable[..., Any]] = {}
|
||||||
|
|
||||||
if size:
|
if size:
|
||||||
self.size = size
|
self.size = size
|
||||||
@@ -65,6 +71,71 @@ class Webview:
|
|||||||
_webview_lib.webview_run(self._handle)
|
_webview_lib.webview_run(self._handle)
|
||||||
self.destroy()
|
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 bind(self, name: str, callback: Callable[..., Any]) -> None:
|
||||||
def wrapper(seq: bytes, req: bytes, arg: int) -> None:
|
def wrapper(seq: bytes, req: bytes, arg: int) -> None:
|
||||||
args = json.loads(req.decode())
|
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 collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -11,6 +12,8 @@ from typing import (
|
|||||||
get_type_hints,
|
get_type_hints,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
from .serde import dataclass_to_dict, from_dict, sanitize_string
|
from .serde import dataclass_to_dict, from_dict, sanitize_string
|
||||||
|
|
||||||
__all__ = ["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)
|
@wraps(fn)
|
||||||
def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]:
|
def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]:
|
||||||
try:
|
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)
|
return SuccessDataClass(status="success", data=data, op_key=op_key)
|
||||||
except ClanError as e:
|
except ClanError as e:
|
||||||
|
log.exception(f"Error calling wrapped {fn.__name__}")
|
||||||
return ErrorDataClass(
|
return ErrorDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key,
|
||||||
status="error",
|
status="error",
|
||||||
@@ -137,6 +141,7 @@ API.register(open_file)
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log.exception(f"Error calling wrapped {fn.__name__}")
|
||||||
return ErrorDataClass(
|
return ErrorDataClass(
|
||||||
op_key=op_key,
|
op_key=op_key,
|
||||||
status="error",
|
status="error",
|
||||||
|
|||||||
@@ -43,109 +43,15 @@ export interface GtkResponse<T> {
|
|||||||
op_key: string;
|
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 operations = schema.properties;
|
||||||
const operationNames = Object.keys(operations) as OperationNames[];
|
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>(
|
export const callApi = <K extends OperationNames>(
|
||||||
method: K,
|
method: K,
|
||||||
args: OperationArgs<K>,
|
args: OperationArgs<K>,
|
||||||
) => {
|
) => {
|
||||||
return new Promise<OperationResponse<K>>((resolve) => {
|
console.log("Calling API", method, args);
|
||||||
const id = nanoid();
|
return (window as any)[method](args);
|
||||||
pyApi[method].receive((response) => {
|
|
||||||
console.log(method, "Received response: ", { response });
|
|
||||||
resolve(response);
|
|
||||||
}, id);
|
|
||||||
|
|
||||||
pyApi[method].dispatch({ ...args, op_key: id });
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deserialize =
|
const deserialize =
|
||||||
@@ -161,15 +67,3 @@ const deserialize =
|
|||||||
alert(`Error parsing JSON: ${e}`);
|
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 { 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 { Button } from "@/src/components/button";
|
||||||
import Icon from "@/src/components/icon";
|
import Icon from "@/src/components/icon";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export const HostList: Component = () => {
|
|||||||
<div class="tooltip tooltip-bottom" data-tip="Refresh install targets">
|
<div class="tooltip tooltip-bottom" data-tip="Refresh install targets">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => pyApi.show_mdns.dispatch({})}
|
onClick={() => callApi("show_mdns", {})}
|
||||||
startIcon={<Icon icon="Update" />}
|
startIcon={<Icon icon="Update" />}
|
||||||
></Button>
|
></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user