From 0fa1b4586d08b07756af16c6379c39c104cbf5ff Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 30 Dec 2024 18:05:17 +0100 Subject: [PATCH 01/14] clan-app: packaged c webui lib --- pkgs/clan-app/default.nix | 1 + pkgs/clan-app/flake-module.nix | 2 +- pkgs/clan-app/shell.nix | 5 ++- pkgs/flake-module.nix | 1 + pkgs/webview-wrapper-py/default.nix | 56 +++++++++++++++++++++++++++++ pkgs/webview-wrapper/default.nix | 39 ++++++++++++++++++++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 pkgs/webview-wrapper-py/default.nix create mode 100644 pkgs/webview-wrapper/default.nix diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index bdbc20ad4..944ab36fd 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -40,6 +40,7 @@ let libadwaita webkitgtk_6_0 adwaita-icon-theme + ]; # Deps including python packages from the local project diff --git a/pkgs/clan-app/flake-module.nix b/pkgs/clan-app/flake-module.nix index 3cceb34a6..4ed9d50fb 100644 --- a/pkgs/clan-app/flake-module.nix +++ b/pkgs/clan-app/flake-module.nix @@ -14,7 +14,7 @@ else { devShells.clan-app = pkgs.callPackage ./shell.nix { - inherit (config.packages) clan-app; + inherit (config.packages) clan-app webview-wrapper; inherit self'; }; packages.clan-app = pkgs.python3.pkgs.callPackage ./default.nix { diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 0ee55486a..d8ea8fc34 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -12,6 +12,8 @@ python3, gtk4, libadwaita, + webview-wrapper, + clang, self', }: @@ -36,6 +38,8 @@ mkShell { glib ruff gtk4 + clang + webview-wrapper gtk4.dev # has the demo called 'gtk4-widget-factory' libadwaita.devdoc # has the demo called 'adwaita-1-demo' ] @@ -51,7 +55,6 @@ mkShell { export GIT_ROOT=$(git rev-parse --show-toplevel) export PKG_ROOT=$GIT_ROOT/pkgs/clan-app - export WEBKIT_DISABLE_COMPOSITING_MODE=1 # Add current package to PYTHONPATH export PYTHONPATH="$PKG_ROOT''${PYTHONPATH:+:$PYTHONPATH:}" diff --git a/pkgs/flake-module.nix b/pkgs/flake-module.nix index 93548a7e4..85704a7cf 100644 --- a/pkgs/flake-module.nix +++ b/pkgs/flake-module.nix @@ -33,6 +33,7 @@ editor = pkgs.callPackage ./editor/clan-edit-codium.nix { }; classgen = pkgs.callPackage ./classgen { }; zerotierone = pkgs.callPackage ./zerotierone { }; + webview-wrapper = pkgs.callPackage ./webview-wrapper { }; }; }; } diff --git a/pkgs/webview-wrapper-py/default.nix b/pkgs/webview-wrapper-py/default.nix new file mode 100644 index 000000000..9d13ca739 --- /dev/null +++ b/pkgs/webview-wrapper-py/default.nix @@ -0,0 +1,56 @@ +{ lib +, buildPythonPackage +, fetchFromGitHub + +# build-system dependencies +, setuptools +, wheel +, webview-wrapper + +}: + +buildPythonPackage rec { + pname = "python-webui"; + version = "main"; + + src = fetchFromGitHub { + owner = "webui-dev"; + repo = "python-webui"; + rev = "fa961b5ee0752c9408ac01519097f5481a0fcecf"; # Replace with specific commit hash for reproducibility + # sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # Replace with actual hash via nix-prefetch-git + }; + + sourceRoot = "PyPI/Package"; + + # Indicate this is a recent Python project with PEP 517 support (pyproject.toml) + pyproject = true; + + # Declare the build system (setuptools and wheel are common) + buildInputs = [ + setuptools + wheel + ]; + + # Declare required Python package dependencies + propagatedBuildInputs = [ + + ]; + + # Native inputs for testing, if tests are included + nativeCheckInputs = [ + + ]; + + # If tests don't work out of the box or need adjustments, patches can be applied here + postPatch = '' + # Example: Modify or patch some test files + echo "No postPatch modifications applied yet." + ''; + + meta = with lib; { + description = "A Python library for webui-dev"; + homepage = "https://github.com/webui-dev/python-webui"; + license = licenses.mit; + maintainers = [ maintainers.yourname ]; + }; +} \ No newline at end of file diff --git a/pkgs/webview-wrapper/default.nix b/pkgs/webview-wrapper/default.nix new file mode 100644 index 000000000..4adc04ed1 --- /dev/null +++ b/pkgs/webview-wrapper/default.nix @@ -0,0 +1,39 @@ +{ pkgs }: + +pkgs.stdenv.mkDerivation rec { + pname = "webui"; + version = "nigthly"; + + src = pkgs.fetchFromGitHub { + owner = "webui-dev"; + repo = "python-webui"; + rev = "0ff3b1351b9e24be4463b1baf2c26966caeae74a"; # Use a specific commit sha or tag for reproducibility + sha256 = "sha256-xSOnCkW4iZkSSLKzk6r3hewC3bPJlV7L6aoGEchyEys="; # Replace with actual sha256 + }; + + outputs = [ "out" "dev" ]; + + # Dependencies used during the build process, if any + buildInputs = [ + pkgs.gnumake + ]; + + # Commands to build and install the project + buildPhase = '' + make + ''; + + installPhase = '' + mkdir -p $out/lib + mkdir -p $out/include + cp -r dist/* $out/lib + cp -r include/* $out/include + ''; + + meta = with pkgs.lib; { + description = "Webui is a UI library for C/C++/Go/Rust to build portable desktop/web apps using WebView"; + homepage = "https://github.com/webui-dev/webui"; + license = licenses.mit; + platforms = platforms.linux ++ platforms.darwin; # Adjust if needed + }; +} \ No newline at end of file From ea5a2a9447954781245d8bd5cdfdd7f3c4ab24ff Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 00:23:41 +0100 Subject: [PATCH 02/14] clan-app: changed webui to webview lib --- .../clan_app/deps/webview/__init__.py | 0 .../clan_app/deps/webview/_dummy_holder.pyx | 0 .../clan_app/deps/webview/_webview_ffi.py | 134 ++++++++++++++++++ .../clan-app/clan_app/deps/webview/webview.py | 92 ++++++++++++ pkgs/webview-wrapper-py/default.nix | 56 -------- pkgs/webview-wrapper/default.nix | 37 ++--- 6 files changed, 241 insertions(+), 78 deletions(-) create mode 100644 pkgs/clan-app/clan_app/deps/webview/__init__.py create mode 100644 pkgs/clan-app/clan_app/deps/webview/_dummy_holder.pyx create mode 100644 pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py create mode 100644 pkgs/clan-app/clan_app/deps/webview/webview.py delete mode 100644 pkgs/webview-wrapper-py/default.nix diff --git a/pkgs/clan-app/clan_app/deps/webview/__init__.py b/pkgs/clan-app/clan_app/deps/webview/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pkgs/clan-app/clan_app/deps/webview/_dummy_holder.pyx b/pkgs/clan-app/clan_app/deps/webview/_dummy_holder.pyx new file mode 100644 index 000000000..e69de29bb diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py new file mode 100644 index 000000000..9295f5816 --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -0,0 +1,134 @@ +import ctypes +import sys +import os +import platform +import urllib.request +from pathlib import Path +from ctypes import c_int, c_char_p, c_void_p, CFUNCTYPE +import ctypes.util + +def _encode_c_string(s: str) -> bytes: + return s.encode("utf-8") + +def _get_webview_version(): + """Get webview version from environment variable or use default""" + return os.getenv("WEBVIEW_VERSION", "0.8.1") + +def _get_lib_names(): + """Get platform-specific library names.""" + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "windows": + if machine == "amd64" or machine == "x86_64": + return ["webview.dll", "WebView2Loader.dll"] + elif machine == "arm64": + raise Exception("arm64 is not supported on Windows") + elif system == "darwin": + if machine == "arm64": + return ["libwebview.aarch64.dylib"] + else: + return ["libwebview.x86_64.dylib"] + else: # linux + return ["libwebview.so"] + +def _get_download_urls(): + """Get the appropriate download URLs based on the platform.""" + version = _get_webview_version() + return [f"https://github.com/webview/webview_deno/releases/download/{version}/{lib_name}" + for lib_name in _get_lib_names()] + +def _be_sure_libraries(): + """Ensure libraries exist and return paths.""" + if getattr(sys, 'frozen', False): + if hasattr(sys, '_MEIPASS'): + base_dir = Path(sys._MEIPASS) + else: + base_dir = Path(sys.executable).parent / '_internal' + else: + base_dir = Path(__file__).parent + + lib_dir = base_dir / "lib" + lib_names = _get_lib_names() + lib_paths = [lib_dir / 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()] + if not missing_libs: + return lib_paths + + # Download missing libraries + download_urls = _get_download_urls() + system = platform.system().lower() + + lib_dir.mkdir(parents=True, exist_ok=True) + + for url, lib_path in zip(download_urls, lib_paths): + if lib_path.exists(): + continue + + print(f"Downloading library from {url}") + try: + req = urllib.request.Request( + url, + headers={'User-Agent': 'Mozilla/5.0'} + ) + with urllib.request.urlopen(req) as response, open(lib_path, 'wb') as out_file: + out_file.write(response.read()) + except Exception as e: + raise RuntimeError(f"Failed to download library: {e}") + + return lib_paths + +class _WebviewLibrary: + def __init__(self): + lib_names=_get_lib_names() + 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}") + raise + # Define FFI functions + self.webview_create = self.lib.webview_create + self.webview_create.argtypes = [c_int, c_void_p] + self.webview_create.restype = c_void_p + + self.webview_destroy = self.lib.webview_destroy + self.webview_destroy.argtypes = [c_void_p] + + self.webview_run = self.lib.webview_run + self.webview_run.argtypes = [c_void_p] + + self.webview_terminate = self.lib.webview_terminate + self.webview_terminate.argtypes = [c_void_p] + + self.webview_set_title = self.lib.webview_set_title + self.webview_set_title.argtypes = [c_void_p, c_char_p] + + self.webview_set_size = self.lib.webview_set_size + self.webview_set_size.argtypes = [c_void_p, c_int, c_int, c_int] + + self.webview_navigate = self.lib.webview_navigate + self.webview_navigate.argtypes = [c_void_p, c_char_p] + + self.webview_init = self.lib.webview_init + self.webview_init.argtypes = [c_void_p, c_char_p] + + self.webview_eval = self.lib.webview_eval + self.webview_eval.argtypes = [c_void_p, c_char_p] + + self.webview_bind = self.lib.webview_bind + self.webview_bind.argtypes = [c_void_p, c_char_p, c_void_p, c_void_p] + + self.webview_unbind = self.lib.webview_unbind + self.webview_unbind.argtypes = [c_void_p, c_char_p] + + self.webview_return = self.lib.webview_return + self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p] + + self.CFUNCTYPE = CFUNCTYPE + +_webview_lib = _WebviewLibrary() diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py new file mode 100644 index 000000000..2cebd315e --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -0,0 +1,92 @@ +from enum import IntEnum +from typing import Optional, Callable, Any +import json +import ctypes +from ._webview_ffi import _webview_lib, _encode_c_string + +class SizeHint(IntEnum): + NONE = 0 + MIN = 1 + MAX = 2 + FIXED = 3 + +class Size: + def __init__(self, width: int, height: int, hint: SizeHint): + self.width = width + self.height = height + self.hint = hint + +class Webview: + def __init__(self, debug: bool = False, size: Optional[Size] = None, window: Optional[int] = None): + self._handle = _webview_lib.webview_create(int(debug), window) + self._callbacks = {} + + if size: + self.size = size + + @property + def size(self) -> Size: + return self._size + + @size.setter + def size(self, value: Size): + _webview_lib.webview_set_size(self._handle, value.width, value.height, value.hint) + self._size = value + + @property + def title(self) -> str: + return self._title + + @title.setter + def title(self, value: str): + _webview_lib.webview_set_title(self._handle, _encode_c_string(value)) + self._title = value + + def destroy(self): + for name in list(self._callbacks.keys()): + self.unbind(name) + _webview_lib.webview_terminate(self._handle) + _webview_lib.webview_destroy(self._handle) + self._handle = None + + def navigate(self, url: str): + _webview_lib.webview_navigate(self._handle, _encode_c_string(url)) + + def run(self): + _webview_lib.webview_run(self._handle) + self.destroy() + + def bind(self, name: str, callback: Callable[..., Any]): + def wrapper(seq: bytes, req: bytes, arg: int): + args = json.loads(req.decode()) + try: + result = callback(*args) + success = True + except Exception as e: + result = str(e) + success = False + self.return_(seq.decode(), 0 if success else 1, json.dumps(result)) + + c_callback = _webview_lib.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p)(wrapper) + self._callbacks[name] = c_callback + _webview_lib.webview_bind(self._handle, _encode_c_string(name), c_callback, None) + + def unbind(self, name: str): + if name in self._callbacks: + _webview_lib.webview_unbind(self._handle, _encode_c_string(name)) + del self._callbacks[name] + + def return_(self, seq: str, status: int, result: str): + _webview_lib.webview_return(self._handle, _encode_c_string(seq), status, _encode_c_string(result)) + + def eval(self, source: str): + _webview_lib.webview_eval(self._handle, _encode_c_string(source)) + + def init(self, source: str): + _webview_lib.webview_init(self._handle, _encode_c_string(source)) + +if __name__ == "__main__": + wv = Webview() + wv.title = "Hello, World!" + wv.navigate("https://www.google.com") + wv.run() diff --git a/pkgs/webview-wrapper-py/default.nix b/pkgs/webview-wrapper-py/default.nix deleted file mode 100644 index 9d13ca739..000000000 --- a/pkgs/webview-wrapper-py/default.nix +++ /dev/null @@ -1,56 +0,0 @@ -{ lib -, buildPythonPackage -, fetchFromGitHub - -# build-system dependencies -, setuptools -, wheel -, webview-wrapper - -}: - -buildPythonPackage rec { - pname = "python-webui"; - version = "main"; - - src = fetchFromGitHub { - owner = "webui-dev"; - repo = "python-webui"; - rev = "fa961b5ee0752c9408ac01519097f5481a0fcecf"; # Replace with specific commit hash for reproducibility - # sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # Replace with actual hash via nix-prefetch-git - }; - - sourceRoot = "PyPI/Package"; - - # Indicate this is a recent Python project with PEP 517 support (pyproject.toml) - pyproject = true; - - # Declare the build system (setuptools and wheel are common) - buildInputs = [ - setuptools - wheel - ]; - - # Declare required Python package dependencies - propagatedBuildInputs = [ - - ]; - - # Native inputs for testing, if tests are included - nativeCheckInputs = [ - - ]; - - # If tests don't work out of the box or need adjustments, patches can be applied here - postPatch = '' - # Example: Modify or patch some test files - echo "No postPatch modifications applied yet." - ''; - - meta = with lib; { - description = "A Python library for webui-dev"; - homepage = "https://github.com/webui-dev/python-webui"; - license = licenses.mit; - maintainers = [ maintainers.yourname ]; - }; -} \ No newline at end of file diff --git a/pkgs/webview-wrapper/default.nix b/pkgs/webview-wrapper/default.nix index 4adc04ed1..3951ebf45 100644 --- a/pkgs/webview-wrapper/default.nix +++ b/pkgs/webview-wrapper/default.nix @@ -1,39 +1,32 @@ { pkgs }: -pkgs.stdenv.mkDerivation rec { - pname = "webui"; +pkgs.stdenv.mkDerivation { + pname = "webview"; version = "nigthly"; src = pkgs.fetchFromGitHub { - owner = "webui-dev"; - repo = "python-webui"; - rev = "0ff3b1351b9e24be4463b1baf2c26966caeae74a"; # Use a specific commit sha or tag for reproducibility - sha256 = "sha256-xSOnCkW4iZkSSLKzk6r3hewC3bPJlV7L6aoGEchyEys="; # Replace with actual sha256 + owner = "webview"; + repo = "webview"; + rev = "83a4b4a5bbcb4b0ba2ca3ee226c2da1414719106"; + sha256 = "sha256-5R8kllvP2EBuDANIl07fxv/EcbPpYgeav8Wfz7Kt13c="; }; outputs = [ "out" "dev" ]; # Dependencies used during the build process, if any - buildInputs = [ - pkgs.gnumake + buildInputs = with pkgs; [ + gnumake + cmake + pkg-config + webkitgtk_6_0 + gtk4 ]; - # Commands to build and install the project - buildPhase = '' - make - ''; - - installPhase = '' - mkdir -p $out/lib - mkdir -p $out/include - cp -r dist/* $out/lib - cp -r include/* $out/include - ''; meta = with pkgs.lib; { - description = "Webui is a UI library for C/C++/Go/Rust to build portable desktop/web apps using WebView"; - homepage = "https://github.com/webui-dev/webui"; + description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)"; + homepage = "https://github.com/webview/webview"; license = licenses.mit; - platforms = platforms.linux ++ platforms.darwin; # Adjust if needed + platforms = platforms.linux ++ platforms.darwin; }; } \ No newline at end of file From 64f58013434a858d051abe5393c1a706f0cbee34 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 00:44:52 +0100 Subject: [PATCH 03/14] clan-app: Working webview from webview lib --- pkgs/clan-app/bin/clan-app | 1 + pkgs/clan-app/clan_app/__init__.py | 18 +++++++-- .../_dummy_holder.pyx => __init__.py} | 0 .../clan_app/deps/webview/_webview_ffi.py | 38 ++++--------------- pkgs/clan-app/pyproject.toml | 2 +- pkgs/clan-app/shell.nix | 2 + 6 files changed, 25 insertions(+), 36 deletions(-) rename pkgs/clan-app/clan_app/deps/{webview/_dummy_holder.pyx => __init__.py} (100%) diff --git a/pkgs/clan-app/bin/clan-app b/pkgs/clan-app/bin/clan-app index 167616d6a..a3d8c4acf 100755 --- a/pkgs/clan-app/bin/clan-app +++ b/pkgs/clan-app/bin/clan-app @@ -4,6 +4,7 @@ from pathlib import Path module_path = Path(__file__).parent.parent.absolute() + sys.path.insert(0, str(module_path)) sys.path.insert(0, str(module_path.parent / "clan_cli")) diff --git a/pkgs/clan-app/clan_app/__init__.py b/pkgs/clan-app/clan_app/__init__.py index 03647b16b..a0dd3c33e 100644 --- a/pkgs/clan-app/clan_app/__init__.py +++ b/pkgs/clan-app/clan_app/__init__.py @@ -1,18 +1,28 @@ import logging import sys -# Remove working directory from sys.path -if "" in sys.path: - sys.path.remove("") - from clan_cli.profiler import profile from clan_app.app import MainApplication log = logging.getLogger(__name__) +from urllib.parse import quote + +from clan_app.deps.webview.webview import Webview + @profile def main(argv: list[str] = sys.argv) -> int: + html = """ + + +

Hello from Python Webview!

+ + + """ + webview = Webview() + webview.navigate(f"data:text/html,{quote(html)}") + webview.run() app = MainApplication() return app.run(argv) diff --git a/pkgs/clan-app/clan_app/deps/webview/_dummy_holder.pyx b/pkgs/clan-app/clan_app/deps/__init__.py similarity index 100% rename from pkgs/clan-app/clan_app/deps/webview/_dummy_holder.pyx rename to pkgs/clan-app/clan_app/deps/__init__.py diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py index 9295f5816..3101b38ca 100644 --- a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -32,12 +32,6 @@ def _get_lib_names(): else: # linux return ["libwebview.so"] -def _get_download_urls(): - """Get the appropriate download URLs based on the platform.""" - version = _get_webview_version() - return [f"https://github.com/webview/webview_deno/releases/download/{version}/{lib_name}" - for lib_name in _get_lib_names()] - def _be_sure_libraries(): """Ensure libraries exist and return paths.""" if getattr(sys, 'frozen', False): @@ -47,38 +41,20 @@ def _be_sure_libraries(): base_dir = Path(sys.executable).parent / '_internal' else: base_dir = Path(__file__).parent - - lib_dir = base_dir / "lib" + from ctypes.util import find_library + + lib_dir = os.environ.get("WEBVIEW_LIB_DIR") + if not lib_dir: + raise RuntimeError("WEBVIEW_LIB_DIR environment variable is not set") + lib_dir = Path(lib_dir) lib_names = _get_lib_names() lib_paths = [lib_dir / 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()] if not missing_libs: return lib_paths - # Download missing libraries - download_urls = _get_download_urls() - system = platform.system().lower() - - lib_dir.mkdir(parents=True, exist_ok=True) - - for url, lib_path in zip(download_urls, lib_paths): - if lib_path.exists(): - continue - - print(f"Downloading library from {url}") - try: - req = urllib.request.Request( - url, - headers={'User-Agent': 'Mozilla/5.0'} - ) - with urllib.request.urlopen(req) as response, open(lib_path, 'wb') as out_file: - out_file.write(response.read()) - except Exception as e: - raise RuntimeError(f"Failed to download library: {e}") - - return lib_paths class _WebviewLibrary: def __init__(self): diff --git a/pkgs/clan-app/pyproject.toml b/pkgs/clan-app/pyproject.toml index 55a04543d..33c8654cf 100644 --- a/pkgs/clan-app/pyproject.toml +++ b/pkgs/clan-app/pyproject.toml @@ -9,7 +9,7 @@ description = "clan app" dynamic = ["version"] scripts = { clan-app = "clan_app:main" } -[project.urls] +[project.urls] Homepage = "https://clan.lol/" Documentation = "https://docs.clan.lol/" Repository = "https://git.clan.lol/clan/clan-core" diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index d8ea8fc34..9f3bed655 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -66,5 +66,7 @@ mkShell { export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS + + export WEBVIEW_LIB_DIR=${webview-wrapper}/lib ''; } From 85facd1c4591faf288e5fb7cfd067fa963c66d5f Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 01:12:30 +0100 Subject: [PATCH 04/14] clan-app: added header files --- pkgs/clan-app/clan_app/__init__.py | 37 +++++++++++++++---- .../clan_app/deps/webview/_webview_ffi.py | 8 ---- pkgs/clan-app/shell.nix | 1 + pkgs/webview-wrapper/default.nix | 3 +- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/pkgs/clan-app/clan_app/__init__.py b/pkgs/clan-app/clan_app/__init__.py index a0dd3c33e..457e151c8 100644 --- a/pkgs/clan-app/clan_app/__init__.py +++ b/pkgs/clan-app/clan_app/__init__.py @@ -3,17 +3,39 @@ import sys from clan_cli.profiler import profile -from clan_app.app import MainApplication - log = logging.getLogger(__name__) -from urllib.parse import quote - +from clan_cli.custom_logger import setup_logging from clan_app.deps.webview.webview import Webview - +from pathlib import Path +import os +import argparse +from urllib.parse import quote @profile def main(argv: list[str] = sys.argv) -> int: + parser = argparse.ArgumentParser(description="Clan App") + parser.add_argument("--content-uri", type=str, help="The URI of the content to display") + parser.add_argument("--debug", action="store_true", help="Enable debug mode") + args = parser.parse_args(argv[1:]) + + if args.debug: + setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0]) + setup_logging(logging.DEBUG, root_log_name="clan_cli") + else: + setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) + + log.debug("Debug mode enabled") + + if args.content_uri: + content_uri = args.content_uri + else: + 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}" + html = """ @@ -21,8 +43,9 @@ def main(argv: list[str] = sys.argv) -> int: """ + webview = Webview() webview.navigate(f"data:text/html,{quote(html)}") + #webview.navigate(content_uri) webview.run() - app = MainApplication() - return app.run(argv) + diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py index 3101b38ca..529ff1686 100644 --- a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -34,14 +34,6 @@ def _get_lib_names(): def _be_sure_libraries(): """Ensure libraries exist and return paths.""" - if getattr(sys, 'frozen', False): - if hasattr(sys, '_MEIPASS'): - base_dir = Path(sys._MEIPASS) - else: - base_dir = Path(sys.executable).parent / '_internal' - else: - base_dir = Path(__file__).parent - from ctypes.util import find_library lib_dir = os.environ.get("WEBVIEW_LIB_DIR") if not lib_dir: diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 9f3bed655..9d91c7a3a 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -39,6 +39,7 @@ mkShell { ruff gtk4 clang + webview-wrapper.dev webview-wrapper gtk4.dev # has the demo called 'gtk4-widget-factory' libadwaita.devdoc # has the demo called 'adwaita-1-demo' diff --git a/pkgs/webview-wrapper/default.nix b/pkgs/webview-wrapper/default.nix index 3951ebf45..96d8fd2d9 100644 --- a/pkgs/webview-wrapper/default.nix +++ b/pkgs/webview-wrapper/default.nix @@ -1,4 +1,4 @@ -{ pkgs }: +{ pkgs, ... }: pkgs.stdenv.mkDerivation { pname = "webview"; @@ -22,7 +22,6 @@ pkgs.stdenv.mkDerivation { gtk4 ]; - meta = with pkgs.lib; { description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)"; homepage = "https://github.com/webview/webview"; From 93d966e48da4bd2771330f4267cf57ab0336b7d3 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 01:25:39 +0100 Subject: [PATCH 05/14] clan-app: Fix EGL error by upgrading nixpkgs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 3f8603934..abdfa8d1f 100644 --- a/flake.lock +++ b/flake.lock @@ -57,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1734435836, - "narHash": "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU=", + "lastModified": 1735821806, + "narHash": "sha256-cuNapx/uQeCgeuhUhdck3JKbgpsml259sjUQnWM7zW8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4989a246d7a390a859852baddb1013f825435cee", + "rev": "d6973081434f88088e5321f83ebafe9a1167c367", "type": "github" }, "original": { From d60cd27097714d4636a340146cc4a8faa7a6c376 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 01:34:28 +0100 Subject: [PATCH 06/14] Fix nix run .#clan-app --- pkgs/clan-app/clan_app/__init__.py | 28 ++++----- .../clan_app/deps/webview/_webview_ffi.py | 36 +++++++----- .../clan-app/clan_app/deps/webview/webview.py | 58 ++++++++++++------- pkgs/clan-app/default.nix | 6 +- pkgs/clan-app/flake-module.nix | 4 +- pkgs/clan-app/shell.nix | 8 +-- pkgs/flake-module.nix | 2 +- .../default.nix | 9 ++- 8 files changed, 85 insertions(+), 66 deletions(-) rename pkgs/{webview-wrapper => webview-lib}/default.nix (87%) diff --git a/pkgs/clan-app/clan_app/__init__.py b/pkgs/clan-app/clan_app/__init__.py index 457e151c8..b5801ff6a 100644 --- a/pkgs/clan-app/clan_app/__init__.py +++ b/pkgs/clan-app/clan_app/__init__.py @@ -5,17 +5,21 @@ from clan_cli.profiler import profile log = logging.getLogger(__name__) -from clan_cli.custom_logger import setup_logging -from clan_app.deps.webview.webview import Webview -from pathlib import Path -import os import argparse -from urllib.parse import quote +import os +from pathlib import Path + +from clan_cli.custom_logger import setup_logging + +from clan_app.deps.webview.webview import Webview + @profile def main(argv: list[str] = sys.argv) -> int: parser = argparse.ArgumentParser(description="Clan App") - parser.add_argument("--content-uri", type=str, help="The URI of the content to display") + parser.add_argument( + "--content-uri", type=str, help="The URI of the content to display" + ) parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args(argv[1:]) @@ -36,16 +40,6 @@ 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}" - html = """ - - -

Hello from Python Webview!

- - - """ - webview = Webview() - webview.navigate(f"data:text/html,{quote(html)}") - #webview.navigate(content_uri) + webview.navigate(content_uri) webview.run() - diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py index 529ff1686..25bd83453 100644 --- a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -1,43 +1,47 @@ import ctypes -import sys +import ctypes.util import os import platform -import urllib.request +from ctypes import CFUNCTYPE, c_char_p, c_int, c_void_p from pathlib import Path -from ctypes import c_int, c_char_p, c_void_p, CFUNCTYPE -import ctypes.util + def _encode_c_string(s: str) -> bytes: return s.encode("utf-8") + def _get_webview_version(): """Get webview version from environment variable or use default""" return os.getenv("WEBVIEW_VERSION", "0.8.1") + def _get_lib_names(): """Get platform-specific library names.""" system = platform.system().lower() machine = platform.machine().lower() - + if system == "windows": if machine == "amd64" or machine == "x86_64": return ["webview.dll", "WebView2Loader.dll"] - elif machine == "arm64": - raise Exception("arm64 is not supported on Windows") - elif system == "darwin": + if machine == "arm64": + msg = "arm64 is not supported on Windows" + raise Exception(msg) + return None + if system == "darwin": if machine == "arm64": return ["libwebview.aarch64.dylib"] - else: - return ["libwebview.x86_64.dylib"] - else: # linux - return ["libwebview.so"] + return ["libwebview.x86_64.dylib"] + # linux + return ["libwebview.so"] + def _be_sure_libraries(): """Ensure libraries exist and return paths.""" lib_dir = os.environ.get("WEBVIEW_LIB_DIR") if not lib_dir: - raise RuntimeError("WEBVIEW_LIB_DIR environment variable is not set") + msg = "WEBVIEW_LIB_DIR environment variable is not set" + raise RuntimeError(msg) lib_dir = Path(lib_dir) lib_names = _get_lib_names() lib_paths = [lib_dir / lib_name for lib_name in lib_names] @@ -46,11 +50,12 @@ def _be_sure_libraries(): missing_libs = [path for path in lib_paths if not path.exists()] if not missing_libs: return lib_paths + return None class _WebviewLibrary: - def __init__(self): - lib_names=_get_lib_names() + def __init__(self) -> None: + lib_names = _get_lib_names() try: library_path = ctypes.util.find_library(lib_names[0]) if not library_path: @@ -99,4 +104,5 @@ class _WebviewLibrary: self.CFUNCTYPE = CFUNCTYPE + _webview_lib = _WebviewLibrary() diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index 2cebd315e..ca0a761d8 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -1,8 +1,11 @@ -from enum import IntEnum -from typing import Optional, Callable, Any -import json import ctypes -from ._webview_ffi import _webview_lib, _encode_c_string +import json +from collections.abc import Callable +from enum import IntEnum +from typing import Any + +from ._webview_ffi import _encode_c_string, _webview_lib + class SizeHint(IntEnum): NONE = 0 @@ -10,14 +13,18 @@ class SizeHint(IntEnum): MAX = 2 FIXED = 3 + class Size: - def __init__(self, width: int, height: int, hint: SizeHint): + def __init__(self, width: int, height: int, hint: SizeHint) -> None: self.width = width self.height = height self.hint = hint + class Webview: - def __init__(self, debug: bool = False, size: Optional[Size] = None, window: Optional[int] = None): + def __init__( + self, debug: bool = False, size: Size | None = None, window: int | None = None + ) -> None: self._handle = _webview_lib.webview_create(int(debug), window) self._callbacks = {} @@ -29,8 +36,10 @@ class Webview: return self._size @size.setter - def size(self, value: Size): - _webview_lib.webview_set_size(self._handle, value.width, value.height, value.hint) + def size(self, value: Size) -> None: + _webview_lib.webview_set_size( + self._handle, value.width, value.height, value.hint + ) self._size = value @property @@ -38,26 +47,26 @@ class Webview: return self._title @title.setter - def title(self, value: str): + def title(self, value: str) -> None: _webview_lib.webview_set_title(self._handle, _encode_c_string(value)) self._title = value - def destroy(self): + def destroy(self) -> None: for name in list(self._callbacks.keys()): self.unbind(name) _webview_lib.webview_terminate(self._handle) _webview_lib.webview_destroy(self._handle) self._handle = None - def navigate(self, url: str): + def navigate(self, url: str) -> None: _webview_lib.webview_navigate(self._handle, _encode_c_string(url)) - def run(self): + def run(self) -> None: _webview_lib.webview_run(self._handle) self.destroy() - def bind(self, name: str, callback: Callable[..., Any]): - def wrapper(seq: bytes, req: bytes, arg: int): + def bind(self, name: str, callback: Callable[..., Any]) -> None: + def wrapper(seq: bytes, req: bytes, arg: int) -> None: args = json.loads(req.decode()) try: result = callback(*args) @@ -67,24 +76,31 @@ class Webview: success = False self.return_(seq.decode(), 0 if success else 1, json.dumps(result)) - c_callback = _webview_lib.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p)(wrapper) + c_callback = _webview_lib.CFUNCTYPE( + None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p + )(wrapper) self._callbacks[name] = c_callback - _webview_lib.webview_bind(self._handle, _encode_c_string(name), c_callback, None) + _webview_lib.webview_bind( + self._handle, _encode_c_string(name), c_callback, None + ) - def unbind(self, name: str): + def unbind(self, name: str) -> None: if name in self._callbacks: _webview_lib.webview_unbind(self._handle, _encode_c_string(name)) del self._callbacks[name] - def return_(self, seq: str, status: int, result: str): - _webview_lib.webview_return(self._handle, _encode_c_string(seq), status, _encode_c_string(result)) + def return_(self, seq: str, status: int, result: str) -> None: + _webview_lib.webview_return( + self._handle, _encode_c_string(seq), status, _encode_c_string(result) + ) - def eval(self, source: str): + def eval(self, source: str) -> None: _webview_lib.webview_eval(self._handle, _encode_c_string(source)) - def init(self, source: str): + def init(self, source: str) -> None: _webview_lib.webview_init(self._handle, _encode_c_string(source)) + if __name__ == "__main__": wv = Webview() wv.title = "Hello, World!" diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index 944ab36fd..8f09df6e1 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -19,6 +19,7 @@ pytest-xdist, # Run tests in parallel on multiple cores pytest-timeout, # Add timeouts to your tests webview-ui, + webview-lib, fontconfig, }: let @@ -48,7 +49,7 @@ let # Runtime binary dependencies required by the application runtimeDependencies = [ - + webview-lib ]; # Dependencies required for running tests @@ -77,10 +78,9 @@ python3.pkgs.buildPythonApplication rec { dontWrapGApps = true; preFixup = '' makeWrapperArgs+=( - # Use software rendering for webkit, mesa causes random crashes with css. - --set WEBKIT_DISABLE_COMPOSITING_MODE 1 --set FONTCONFIG_FILE ${fontconfig.out}/etc/fonts/fonts.conf --set WEBUI_PATH "$out/${python3.sitePackages}/clan_app/.webui" + --set WEBVIEW_LIB_DIR "${webview-lib}/lib" # This prevents problems with mixed glibc versions that might occur when the # cli is called through a browser built against another glibc --unset LD_LIBRARY_PATH diff --git a/pkgs/clan-app/flake-module.nix b/pkgs/clan-app/flake-module.nix index 4ed9d50fb..72c098ce7 100644 --- a/pkgs/clan-app/flake-module.nix +++ b/pkgs/clan-app/flake-module.nix @@ -14,11 +14,11 @@ else { devShells.clan-app = pkgs.callPackage ./shell.nix { - inherit (config.packages) clan-app webview-wrapper; + inherit (config.packages) clan-app webview-lib; inherit self'; }; packages.clan-app = pkgs.python3.pkgs.callPackage ./default.nix { - inherit (config.packages) clan-cli webview-ui; + inherit (config.packages) clan-cli webview-ui webview-lib; }; checks = config.packages.clan-app.tests; diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 9d91c7a3a..3904bbb8e 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -12,7 +12,7 @@ python3, gtk4, libadwaita, - webview-wrapper, + webview-lib, clang, self', }: @@ -39,8 +39,8 @@ mkShell { ruff gtk4 clang - webview-wrapper.dev - webview-wrapper + webview-lib.dev + webview-lib gtk4.dev # has the demo called 'gtk4-widget-factory' libadwaita.devdoc # has the demo called 'adwaita-1-demo' ] @@ -68,6 +68,6 @@ mkShell { export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS - export WEBVIEW_LIB_DIR=${webview-wrapper}/lib + export WEBVIEW_LIB_DIR=${webview-lib}/lib ''; } diff --git a/pkgs/flake-module.nix b/pkgs/flake-module.nix index 85704a7cf..b608c9728 100644 --- a/pkgs/flake-module.nix +++ b/pkgs/flake-module.nix @@ -33,7 +33,7 @@ editor = pkgs.callPackage ./editor/clan-edit-codium.nix { }; classgen = pkgs.callPackage ./classgen { }; zerotierone = pkgs.callPackage ./zerotierone { }; - webview-wrapper = pkgs.callPackage ./webview-wrapper { }; + webview-lib = pkgs.callPackage ./webview-lib { }; }; }; } diff --git a/pkgs/webview-wrapper/default.nix b/pkgs/webview-lib/default.nix similarity index 87% rename from pkgs/webview-wrapper/default.nix rename to pkgs/webview-lib/default.nix index 96d8fd2d9..5dd78de97 100644 --- a/pkgs/webview-wrapper/default.nix +++ b/pkgs/webview-lib/default.nix @@ -7,11 +7,14 @@ pkgs.stdenv.mkDerivation { src = pkgs.fetchFromGitHub { owner = "webview"; repo = "webview"; - rev = "83a4b4a5bbcb4b0ba2ca3ee226c2da1414719106"; + rev = "83a4b4a5bbcb4b0ba2ca3ee226c2da1414719106"; sha256 = "sha256-5R8kllvP2EBuDANIl07fxv/EcbPpYgeav8Wfz7Kt13c="; }; - outputs = [ "out" "dev" ]; + outputs = [ + "out" + "dev" + ]; # Dependencies used during the build process, if any buildInputs = with pkgs; [ @@ -28,4 +31,4 @@ pkgs.stdenv.mkDerivation { license = licenses.mit; platforms = platforms.linux ++ platforms.darwin; }; -} \ No newline at end of file +} From bed51fc32490a612cc453d37f205491411a745ff Mon Sep 17 00:00:00 2001 From: Qubasa Date: Sat, 4 Jan 2025 20:02:43 +0100 Subject: [PATCH 07/14] clan-app: working js<->python api bridge --- pkgs/clan-app/clan_app/__init__.py | 10 +- pkgs/clan-app/clan_app/app.py | 143 ------------------ pkgs/clan-app/clan_app/components/__init__.py | 0 pkgs/clan-app/clan_app/components/executor.py | 127 ---------------- .../clan_app/components/interfaces.py | 11 -- .../clan_app/deps/webview/_webview_ffi.py | 25 +-- .../clan-app/clan_app/deps/webview/webview.py | 73 ++++++++- pkgs/clan-app/clan_app/singletons/__init__.py | 0 pkgs/clan-app/clan_app/singletons/toast.py | 118 --------------- .../clan-app/clan_app/singletons/use_views.py | 37 ----- pkgs/clan-app/clan_app/windows/__init__.py | 0 pkgs/clan-app/clan_app/windows/main_window.py | 51 ------- pkgs/clan-cli/clan_cli/api/__init__.py | 7 +- pkgs/webview-ui/app/src/api/index.ts | 110 +------------- pkgs/webview-ui/app/src/routes/hosts/view.tsx | 4 +- 15 files changed, 103 insertions(+), 613 deletions(-) delete mode 100644 pkgs/clan-app/clan_app/app.py delete mode 100644 pkgs/clan-app/clan_app/components/__init__.py delete mode 100644 pkgs/clan-app/clan_app/components/executor.py delete mode 100644 pkgs/clan-app/clan_app/components/interfaces.py delete mode 100644 pkgs/clan-app/clan_app/singletons/__init__.py delete mode 100644 pkgs/clan-app/clan_app/singletons/toast.py delete mode 100644 pkgs/clan-app/clan_app/singletons/use_views.py delete mode 100644 pkgs/clan-app/clan_app/windows/__init__.py delete mode 100644 pkgs/clan-app/clan_app/windows/main_window.py diff --git a/pkgs/clan-app/clan_app/__init__.py b/pkgs/clan-app/clan_app/__init__.py index b5801ff6a..7dd5876ec 100644 --- a/pkgs/clan-app/clan_app/__init__.py +++ b/pkgs/clan-app/clan_app/__init__.py @@ -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 diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py deleted file mode 100644 index bf55d6ca8..000000000 --- a/pkgs/clan-app/clan_app/app.py +++ /dev/null @@ -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, - ) diff --git a/pkgs/clan-app/clan_app/components/__init__.py b/pkgs/clan-app/clan_app/components/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pkgs/clan-app/clan_app/components/executor.py b/pkgs/clan-app/clan_app/components/executor.py deleted file mode 100644 index 137ff8ff4..000000000 --- a/pkgs/clan-app/clan_app/components/executor.py +++ /dev/null @@ -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 diff --git a/pkgs/clan-app/clan_app/components/interfaces.py b/pkgs/clan-app/clan_app/components/interfaces.py deleted file mode 100644 index bd4866c3f..000000000 --- a/pkgs/clan-app/clan_app/components/interfaces.py +++ /dev/null @@ -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 diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py index 25bd83453..e652ba028 100644 --- a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -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}") diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index ca0a761d8..bc8c09198 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -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()) diff --git a/pkgs/clan-app/clan_app/singletons/__init__.py b/pkgs/clan-app/clan_app/singletons/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pkgs/clan-app/clan_app/singletons/toast.py b/pkgs/clan-app/clan_app/singletons/toast.py deleted file mode 100644 index 3c2785618..000000000 --- a/pkgs/clan-app/clan_app/singletons/toast.py +++ /dev/null @@ -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"⚠ Warning {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" {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" {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 {message}""" - ) - 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(), - ) diff --git a/pkgs/clan-app/clan_app/singletons/use_views.py b/pkgs/clan-app/clan_app/singletons/use_views.py deleted file mode 100644 index 36e9fa2d9..000000000 --- a/pkgs/clan-app/clan_app/singletons/use_views.py +++ /dev/null @@ -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 diff --git a/pkgs/clan-app/clan_app/windows/__init__.py b/pkgs/clan-app/clan_app/windows/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pkgs/clan-app/clan_app/windows/main_window.py b/pkgs/clan-app/clan_app/windows/main_window.py deleted file mode 100644 index a63fca01e..000000000 --- a/pkgs/clan-app/clan_app/windows/main_window.py +++ /dev/null @@ -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) diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 9f62a7fde..0f96487c3 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -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", diff --git a/pkgs/webview-ui/app/src/api/index.ts b/pkgs/webview-ui/app/src/api/index.ts index 0408430eb..83574a0f3 100644 --- a/pkgs/webview-ui/app/src/api/index.ts +++ b/pkgs/webview-ui/app/src/api/index.ts @@ -43,109 +43,15 @@ export interface GtkResponse { op_key: string; } -declare global { - interface Window { - clan: ClanOperations; - webkit: { - messageHandlers: { - gtk: { - postMessage: (message: { - method: OperationNames; - data: OperationArgs; - }) => 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) => void - >; -}; -const registry: ObserverRegistry = operationNames.reduce( - (acc, opName) => ({ - ...acc, - [opName]: {}, - }), - {} as ObserverRegistry, -); - -function createFunctions( - operationName: K, -): { - dispatch: (args: OperationArgs) => void; - receive: (fn: (response: OperationResponse) => void, id: string) => void; -} { - window.clan[operationName] = (s: string) => { - const f = (response: OperationResponse) => { - // 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) => { - // Send the data to the gtk app - window.webkit.messageHandlers.gtk.postMessage({ - method: operationName, - data: args, - }); - }, - receive: (fn: (response: OperationResponse) => 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) => void; - receive: (fn: (response: OperationResponse) => 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 = ( method: K, args: OperationArgs, ) => { - return new Promise>((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 }; diff --git a/pkgs/webview-ui/app/src/routes/hosts/view.tsx b/pkgs/webview-ui/app/src/routes/hosts/view.tsx index 8c1523e44..c9bb8396f 100644 --- a/pkgs/webview-ui/app/src/routes/hosts/view.tsx +++ b/pkgs/webview-ui/app/src/routes/hosts/view.tsx @@ -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 = () => {
From 973f8f0489a772c889d36c69e84dd963ca9c1fbb Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 14:38:37 +0100 Subject: [PATCH 08/14] clan-app: working file dialogue --- pkgs/clan-app/clan_app/__init__.py | 31 +-- pkgs/clan-app/clan_app/api/__init__.py | 158 ------------- pkgs/clan-app/clan_app/api/file.py | 213 +++++++----------- pkgs/clan-app/clan_app/app.py | 48 ++++ .../clan_app/deps/webview/_webview_ffi.py | 1 + pkgs/clan-app/clan_app/views/__init__.py | 0 pkgs/clan-app/clan_app/views/webview.py | 211 ----------------- pkgs/clan-app/default.nix | 29 +-- pkgs/clan-app/shell.nix | 3 +- pkgs/clan-cli/clan_cli/api/__init__.py | 21 +- pkgs/clan-cli/clan_cli/cmd.py | 7 +- 11 files changed, 163 insertions(+), 559 deletions(-) create mode 100644 pkgs/clan-app/clan_app/app.py delete mode 100644 pkgs/clan-app/clan_app/views/__init__.py delete mode 100644 pkgs/clan-app/clan_app/views/webview.py diff --git a/pkgs/clan-app/clan_app/__init__.py b/pkgs/clan-app/clan_app/__init__.py index 7dd5876ec..35976eb00 100644 --- a/pkgs/clan-app/clan_app/__init__.py +++ b/pkgs/clan-app/clan_app/__init__.py @@ -1,3 +1,4 @@ +import argparse import logging import sys @@ -5,14 +6,7 @@ from clan_cli.profiler import profile log = logging.getLogger(__name__) -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 Size, SizeHint, Webview +from clan_app.app import ClanAppOptions, app_run @profile @@ -24,24 +18,7 @@ def main(argv: list[str] = sys.argv) -> int: parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args(argv[1:]) - if args.debug: - setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0]) - setup_logging(logging.DEBUG, root_log_name="clan_cli") - else: - setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) + app_opts = ClanAppOptions(content_uri=args.content_uri, debug=args.debug) + app_run(app_opts) - log.debug("Debug mode enabled") - - if args.content_uri: - content_uri = args.content_uri - else: - 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.size = Size(1280, 1024, SizeHint.NONE) - webview.navigate(content_uri) - webview.run() return 0 diff --git a/pkgs/clan-app/clan_app/api/__init__.py b/pkgs/clan-app/clan_app/api/__init__.py index 5fd480501..e69de29bb 100644 --- a/pkgs/clan-app/clan_app/api/__init__.py +++ b/pkgs/clan-app/clan_app/api/__init__.py @@ -1,158 +0,0 @@ -import inspect -import logging -import threading -from collections.abc import Callable -from typing import ( - Any, - ClassVar, - Generic, - ParamSpec, - TypeVar, - cast, -) - -from clan_cli.errors import ClanError -from gi.repository import GLib, GObject - -log = logging.getLogger(__name__) - - -class GResult(GObject.Object): - result: Any - op_key: str - method_name: str - - def __init__(self, result: Any, method_name: str) -> None: - super().__init__() - self.result = result - self.method_name = method_name - - -B = TypeVar("B") -P = ParamSpec("P") - - -class ImplFunc(GObject.Object, Generic[P, B]): - op_key: str | None = None - __gsignals__: ClassVar = { - "returns": (GObject.SignalFlags.RUN_FIRST, None, [GResult]), - } - - def returns(self, result: B, *, method_name: str | None = None) -> None: - if method_name is None: - method_name = self.__class__.__name__ - - self.emit("returns", GResult(result, method_name)) - - def await_result(self, fn: Callable[["ImplFunc[..., Any]", B], None]) -> None: - self.connect("returns", fn) - - def async_run(self, *args: P.args, **kwargs: P.kwargs) -> bool: - msg = "Method 'async_run' must be implemented" - raise NotImplementedError(msg) - - def internal_async_run(self, data: Any) -> bool: - result = GLib.SOURCE_REMOVE - try: - result = self.async_run(**data) - except Exception: - log.exception("Error in async_run") - # TODO: send error to js - return result - - -# TODO: Reimplement this such that it uses a multiprocessing.Array of type ctypes.c_char -# all fn arguments are serialized to json and passed to the new process over the Array -# the new process deserializes the json and calls the function -# the result is serialized to json and passed back to the main process over another Array -class MethodExecutor(threading.Thread): - def __init__( - self, function: Callable[..., Any], *args: Any, **kwargs: dict[str, Any] - ) -> None: - super().__init__() - self.function = function - self.args = args - self.kwargs = kwargs - self.result: Any = None - self.finished = False - - def run(self) -> None: - try: - self.result = self.function(*self.args, **self.kwargs) - except Exception: - log.exception("Error in MethodExecutor") - finally: - self.finished = True - - -class GObjApi: - def __init__(self, methods: dict[str, Callable[..., Any]]) -> None: - self._methods: dict[str, Callable[..., Any]] = methods - self._obj_registry: dict[str, type[ImplFunc]] = {} - - def overwrite_fn(self, obj: type[ImplFunc]) -> None: - fn_name = obj.__name__ - - if fn_name in self._obj_registry: - msg = f"Function '{fn_name}' already registered" - raise ClanError(msg) - self._obj_registry[fn_name] = obj - - def check_signature(self, fn_signatures: dict[str, inspect.Signature]) -> None: - overwrite_fns = self._obj_registry - - # iterate over the methods and check if all are implemented - for m_name, m_signature in fn_signatures.items(): - if m_name not in overwrite_fns: - continue - # check if the signature of the overridden method matches - # the implementation signature - exp_args = [] - exp_return = m_signature.return_annotation - for param in dict(m_signature.parameters).values(): - exp_args.append(param.annotation) - exp_signature = (tuple(exp_args), exp_return) - - # implementation signature - obj = dict(overwrite_fns[m_name].__dict__) - obj_type = obj["__orig_bases__"][0] - got_signature = obj_type.__args__ - - if exp_signature != got_signature: - log.error(f"Expected signature: {exp_signature}") - log.error(f"Actual signature: {got_signature}") - msg = f"Overwritten method '{m_name}' has different signature than the implementation" - raise ClanError(msg) - - def has_obj(self, fn_name: str) -> bool: - return fn_name in self._obj_registry or fn_name in self._methods - - def get_obj(self, fn_name: str) -> type[ImplFunc]: - result = self._obj_registry.get(fn_name, None) - if result is not None: - return result - - plain_fn = self._methods.get(fn_name, None) - if plain_fn is None: - msg = f"Method '{fn_name}' not found in Api" - raise ClanError(msg) - - class GenericFnRuntime(ImplFunc[..., Any]): - def __init__(self) -> None: - super().__init__() - self.thread: MethodExecutor | None = None - - def async_run(self, *args: Any, **kwargs: dict[str, Any]) -> bool: - assert plain_fn is not None - - if self.thread is None: - self.thread = MethodExecutor(plain_fn, *args, **kwargs) - self.thread.start() - return GLib.SOURCE_CONTINUE - if self.thread.finished: - result = self.thread.result - self.returns(method_name=fn_name, result=result) - return GLib.SOURCE_REMOVE - return GLib.SOURCE_CONTINUE - - return cast(type[ImplFunc], GenericFnRuntime) diff --git a/pkgs/clan-app/clan_app/api/file.py b/pkgs/clan-app/clan_app/api/file.py index c3235f926..f5fef860a 100644 --- a/pkgs/clan-app/clan_app/api/file.py +++ b/pkgs/clan-app/clan_app/api/file.py @@ -1,146 +1,95 @@ # ruff: noqa: N801 -import gi - -gi.require_version("Gtk", "4.0") import logging -from pathlib import Path -from typing import Any +from tkinter import Tk, filedialog -from clan_cli.api import ErrorDataClass, SuccessDataClass -from clan_cli.api.directory import FileRequest -from gi.repository import Gio, GLib, Gtk - -from clan_app.api import ImplFunc +from clan_cli.api import ApiError, ErrorDataClass, SuccessDataClass +from clan_cli.api.directory import FileFilter, FileRequest log = logging.getLogger(__name__) -def remove_none(_list: list) -> list: - return [i for i in _list if i is not None] +def _apply_filters(filters: FileFilter | None) -> list[tuple[str, str]]: + if not filters: + return [] + + filter_patterns = [] + + if filters.mime_types: + # Tkinter does not directly support MIME types, so this section can be adjusted + # if you wish to handle them differently + filter_patterns.extend(filters.mime_types) + + if filters.patterns: + filter_patterns.extend(filters.patterns) + + if filters.suffixes: + suffix_patterns = [f"*.{suffix}" for suffix in filters.suffixes] + filter_patterns.extend(suffix_patterns) + + filter_title = filters.title if filters.title else "Custom Files" + + return [(filter_title, " ".join(filter_patterns))] -# This implements the abstract function open_file with one argument, file_request, -# which is a FileRequest object and returns a string or None. -class open_file( - ImplFunc[[FileRequest, str], SuccessDataClass[list[str] | None] | ErrorDataClass] -): - def __init__(self) -> None: - super().__init__() +def open_file( + file_request: FileRequest, *, op_key: str +) -> SuccessDataClass[list[str] | None] | ErrorDataClass: + try: + root = Tk() + root.withdraw() # Hide the main window + root.attributes("-topmost", True) # Bring the dialogs to the front - def async_run(self, file_request: FileRequest, op_key: str) -> bool: - def on_file_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: - try: - gfile = file_dialog.open_finish(task) - if gfile: - selected_path = remove_none([gfile.get_path()]) - self.returns( - SuccessDataClass( - op_key=op_key, data=selected_path, status="success" - ) - ) - except Exception as e: - print(f"Error getting selected file or directory: {e}") + file_paths: list[str] | None = None - def on_file_select_multiple( - file_dialog: Gtk.FileDialog, task: Gio.Task - ) -> None: - try: - gfiles: Any = file_dialog.open_multiple_finish(task) - if gfiles: - selected_paths = remove_none([gfile.get_path() for gfile in gfiles]) - self.returns( - SuccessDataClass( - op_key=op_key, data=selected_paths, status="success" - ) - ) - else: - self.returns( - SuccessDataClass(op_key=op_key, data=None, status="success") - ) - except Exception as e: - print(f"Error getting selected files: {e}") - - def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: - try: - gfile = file_dialog.select_folder_finish(task) - if gfile: - selected_path = remove_none([gfile.get_path()]) - self.returns( - SuccessDataClass( - op_key=op_key, data=selected_path, status="success" - ) - ) - else: - self.returns( - SuccessDataClass(op_key=op_key, data=None, status="success") - ) - except Exception as e: - print(f"Error getting selected directory: {e}") - - def on_save_finish(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: - try: - gfile = file_dialog.save_finish(task) - if gfile: - selected_path = remove_none([gfile.get_path()]) - self.returns( - SuccessDataClass( - op_key=op_key, data=selected_path, status="success" - ) - ) - else: - self.returns( - SuccessDataClass(op_key=op_key, data=None, status="success") - ) - except Exception as e: - print(f"Error getting selected file: {e}") - - dialog = Gtk.FileDialog() - - if file_request.title: - dialog.set_title(file_request.title) - - if file_request.filters: - filters = Gio.ListStore.new(Gtk.FileFilter) - file_filters = Gtk.FileFilter() - - if file_request.filters.title: - file_filters.set_name(file_request.filters.title) - - if file_request.filters.mime_types: - for mime in file_request.filters.mime_types: - file_filters.add_mime_type(mime) - filters.append(file_filters) - - if file_request.filters.patterns: - for pattern in file_request.filters.patterns: - file_filters.add_pattern(pattern) - - if file_request.filters.suffixes: - for suffix in file_request.filters.suffixes: - file_filters.add_suffix(suffix) - - filters.append(file_filters) - dialog.set_filters(filters) - - if file_request.initial_file: - p = Path(file_request.initial_file).expanduser() - f = Gio.File.new_for_path(str(p)) - dialog.set_initial_file(f) - - if file_request.initial_folder: - p = Path(file_request.initial_folder).expanduser() - f = Gio.File.new_for_path(str(p)) - dialog.set_initial_folder(f) - - # if select_folder - if file_request.mode == "select_folder": - dialog.select_folder(callback=on_folder_select) - if file_request.mode == "open_multiple_files": - dialog.open_multiple(callback=on_file_select_multiple) - elif file_request.mode == "open_file": - dialog.open(callback=on_file_select) + if file_request.mode == "open_file": + file_path = filedialog.askopenfilename( + title=file_request.title, + initialdir=file_request.initial_folder, + initialfile=file_request.initial_file, + filetypes=_apply_filters(file_request.filters), + ) + file_paths = [file_path] + elif file_request.mode == "select_folder": + file_path = filedialog.askdirectory( + title=file_request.title, initialdir=file_request.initial_folder + ) + file_paths = [file_path] elif file_request.mode == "save": - dialog.save(callback=on_save_finish) + file_path = filedialog.asksaveasfilename( + title=file_request.title, + initialdir=file_request.initial_folder, + initialfile=file_request.initial_file, + filetypes=_apply_filters(file_request.filters), + ) + file_paths = [file_path] + elif file_request.mode == "open_multiple_files": + file_paths = list( + filedialog.askopenfilenames( + title=file_request.title, + initialdir=file_request.initial_folder, + filetypes=_apply_filters(file_request.filters), + ) + ) - return GLib.SOURCE_REMOVE + if not file_paths: + msg = "No file selected or operation canceled by the user" + raise ValueError(msg) # noqa: TRY301 + + return SuccessDataClass(op_key, status="success", data=file_paths) + + except Exception as e: + log.exception("Error opening file") + return ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message=e.__class__.__name__, + description=str(e), + location=["open_file"], + ) + ], + ) + finally: + root.destroy() diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py new file mode 100644 index 000000000..c1f1a2554 --- /dev/null +++ b/pkgs/clan-app/clan_app/app.py @@ -0,0 +1,48 @@ +import logging + +from clan_cli.profiler import profile + +log = logging.getLogger(__name__) + + +import os +from dataclasses import dataclass +from pathlib import Path + +from clan_cli.api import API +from clan_cli.custom_logger import setup_logging + +from clan_app.api.file import open_file +from clan_app.deps.webview.webview import Size, SizeHint, Webview + + +@dataclass +class ClanAppOptions: + content_uri: str + debug: bool + + +@profile +def app_run(app_opts: ClanAppOptions) -> int: + if app_opts.debug: + setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0]) + setup_logging(logging.DEBUG, root_log_name="clan_cli") + else: + setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) + + log.debug("Debug mode enabled") + + if app_opts.content_uri: + content_uri = app_opts.content_uri + else: + site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html" + content_uri = f"file://{site_index}" + + webview = Webview(debug=app_opts.debug) + + API.overwrite_fn(open_file) + webview.bind_jsonschema_api(API) + webview.size = Size(1280, 1024, SizeHint.NONE) + webview.navigate(content_uri) + webview.run() + return 0 diff --git a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py index e652ba028..1d727c9bc 100644 --- a/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py +++ b/pkgs/clan-app/clan_app/deps/webview/_webview_ffi.py @@ -69,6 +69,7 @@ class _WebviewLibrary: except Exception as e: print(f"Failed to load webview library: {e}") raise + # Define FFI functions self.webview_create = self.lib.webview_create self.webview_create.argtypes = [c_int, c_void_p] diff --git a/pkgs/clan-app/clan_app/views/__init__.py b/pkgs/clan-app/clan_app/views/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pkgs/clan-app/clan_app/views/webview.py b/pkgs/clan-app/clan_app/views/webview.py deleted file mode 100644 index 4f931cceb..000000000 --- a/pkgs/clan-app/clan_app/views/webview.py +++ /dev/null @@ -1,211 +0,0 @@ -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 diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index 8f09df6e1..bbdae731b 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -1,18 +1,11 @@ { - python3, + python3Full, runCommand, setuptools, copyDesktopItems, - pygobject3, wrapGAppsHook4, - gtk4, - adwaita-icon-theme, - pygobject-stubs, - gobject-introspection, clan-cli, makeDesktopItem, - libadwaita, - webkitgtk_6_0, pytest, # Testing framework pytest-cov, # Generate coverage reports pytest-subprocess, # fake the real subprocess behavior to make your tests more independent. @@ -35,17 +28,11 @@ let # Dependencies that are directly used in the project but nor from internal python packages externalPythonDeps = [ - pygobject3 - pygobject-stubs - gtk4 - libadwaita - webkitgtk_6_0 - adwaita-icon-theme ]; # Deps including python packages from the local project - allPythonDeps = [ (python3.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; + allPythonDeps = [ (python3Full.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; # Runtime binary dependencies required by the application runtimeDependencies = [ @@ -68,9 +55,9 @@ let testDependencies = runtimeDependencies ++ allPythonDeps ++ externalTestDeps; # Setup Python environment with all dependencies for running tests - pythonWithTestDeps = python3.withPackages (_ps: testDependencies); + pythonWithTestDeps = python3Full.withPackages (_ps: testDependencies); in -python3.pkgs.buildPythonApplication rec { +python3Full.pkgs.buildPythonApplication rec { name = "clan-app"; src = source; format = "pyproject"; @@ -79,7 +66,7 @@ python3.pkgs.buildPythonApplication rec { preFixup = '' makeWrapperArgs+=( --set FONTCONFIG_FILE ${fontconfig.out}/etc/fonts/fonts.conf - --set WEBUI_PATH "$out/${python3.sitePackages}/clan_app/.webui" + --set WEBUI_PATH "$out/${python3Full.sitePackages}/clan_app/.webui" --set WEBVIEW_LIB_DIR "${webview-lib}/lib" # This prevents problems with mixed glibc versions that might occur when the # cli is called through a browser built against another glibc @@ -93,8 +80,6 @@ python3.pkgs.buildPythonApplication rec { setuptools copyDesktopItems wrapGAppsHook4 - - gobject-introspection ]; # The necessity of setting buildInputs and propagatedBuildInputs to the @@ -149,8 +134,8 @@ python3.pkgs.buildPythonApplication rec { passthru.testDependencies = testDependencies; postInstall = '' - mkdir -p $out/${python3.sitePackages}/clan_app/.webui - cp -r ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* $out/${python3.sitePackages}/clan_app/.webui + mkdir -p $out/${python3Full.sitePackages}/clan_app/.webui + cp -r ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/* $out/${python3Full.sitePackages}/clan_app/.webui mkdir -p $out/share/icons/hicolor cp -r ./clan_app/assets/white-favicons/* $out/share/icons/hicolor ''; diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 3904bbb8e..04c904fc2 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -68,6 +68,7 @@ mkShell { export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS - export WEBVIEW_LIB_DIR=${webview-lib}/lib + # export WEBVIEW_LIB_DIR=${webview-lib}/lib + export WEBVIEW_LIB_DIR=$HOME/Projects/webview/build/core ''; } diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 0f96487c3..7dcf8754f 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -54,9 +54,7 @@ def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None: params = list(sig.parameters.values()) # Add 'op_key' parameter - op_key_param = Parameter( - "op_key", Parameter.KEYWORD_ONLY, default=None, annotation=str - ) + op_key_param = Parameter("op_key", Parameter.KEYWORD_ONLY, annotation=str) params.append(op_key_param) # Create a new signature @@ -110,6 +108,21 @@ API.register(open_file) self.register(wrapper) return fn + def overwrite_fn(self, fn: Callable[..., Any]) -> None: + fn_name = fn.__name__ + + if fn_name not in self._registry: + msg = f"Function '{fn_name}' is not registered as an API method" + raise ClanError(msg) + + fn_signature = signature(fn) + abstract_signature = signature(self._registry[fn_name]) + if fn_signature != abstract_signature: + msg = f"Expected signature: {abstract_signature}\nActual signature: {fn_signature}" + raise ClanError(msg) + + self._registry[fn_name] = fn + F = TypeVar("F", bound=Callable[..., Any]) def register(self, fn: F) -> F: @@ -125,7 +138,7 @@ API.register(open_file) @wraps(fn) def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]: try: - data: T = fn(*args, op_key=op_key, **kwargs) + data: T = fn(*args, **kwargs) return SuccessDataClass(status="success", data=data, op_key=op_key) except ClanError as e: log.exception(f"Error calling wrapped {fn.__name__}") diff --git a/pkgs/clan-cli/clan_cli/cmd.py b/pkgs/clan-cli/clan_cli/cmd.py index 8ffb9881d..16c2d7e6c 100644 --- a/pkgs/clan-cli/clan_cli/cmd.py +++ b/pkgs/clan-cli/clan_cli/cmd.py @@ -393,10 +393,9 @@ def run( ) if options.check and process.returncode != 0: - err = ClanCmdError(cmd_out) - err.msg = str(stderr_buf) - err.description = "Command has been cancelled" - raise err + if is_async_cancelled(): + cmd_out.msg = "Command cancelled" + raise ClanCmdError(cmd_out) return cmd_out From 6f5aadcba58b88c3d44740e4bf264ebbef3e1b76 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 15:34:48 +0100 Subject: [PATCH 09/14] clan-app: working nix run .#clan-app, working open_file with tkinter --- pkgs/clan-app/clan_app/api/file.py | 22 +++++++++------- pkgs/clan-app/clan_app/app.py | 1 + pkgs/clan-cli/clan_cli/api/__init__.py | 18 ++++++++++++- pkgs/webview-ui/app/src/api/index.ts | 23 ++++------------ pkgs/webview-ui/app/src/index.tsx | 1 - pkgs/webview-ui/app/tests/types.test.ts | 35 ++----------------------- 6 files changed, 37 insertions(+), 63 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/file.py b/pkgs/clan-app/clan_app/api/file.py index f5fef860a..62100d978 100644 --- a/pkgs/clan-app/clan_app/api/file.py +++ b/pkgs/clan-app/clan_app/api/file.py @@ -40,7 +40,9 @@ def open_file( root.withdraw() # Hide the main window root.attributes("-topmost", True) # Bring the dialogs to the front - file_paths: list[str] | None = None + + file_path: str = "" + multiple_files: list[str] = [] if file_request.mode == "open_file": file_path = filedialog.askopenfilename( @@ -49,12 +51,12 @@ def open_file( initialfile=file_request.initial_file, filetypes=_apply_filters(file_request.filters), ) - file_paths = [file_path] + elif file_request.mode == "select_folder": file_path = filedialog.askdirectory( title=file_request.title, initialdir=file_request.initial_folder ) - file_paths = [file_path] + elif file_request.mode == "save": file_path = filedialog.asksaveasfilename( title=file_request.title, @@ -62,21 +64,21 @@ def open_file( initialfile=file_request.initial_file, filetypes=_apply_filters(file_request.filters), ) - file_paths = [file_path] + elif file_request.mode == "open_multiple_files": - file_paths = list( - filedialog.askopenfilenames( + tresult = filedialog.askopenfilenames( title=file_request.title, initialdir=file_request.initial_folder, filetypes=_apply_filters(file_request.filters), ) - ) + multiple_files = list(tresult) - if not file_paths: - msg = "No file selected or operation canceled by the user" + if len(file_path) == 0 and len(multiple_files) == 0: + msg = "No file selected" raise ValueError(msg) # noqa: TRY301 - return SuccessDataClass(op_key, status="success", data=file_paths) + multiple_files = [file_path] if len(multiple_files) == 0 else multiple_files + return SuccessDataClass(op_key, status="success", data=multiple_files) except Exception as e: log.exception("Error opening file") diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index c1f1a2554..4071c9e8e 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -29,6 +29,7 @@ def app_run(app_opts: ClanAppOptions) -> int: setup_logging(logging.DEBUG, root_log_name="clan_cli") else: setup_logging(logging.INFO, root_log_name=__name__.split(".")[0]) + setup_logging(logging.INFO, root_log_name="clan_cli") log.debug("Debug mode enabled") diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 7dcf8754f..4e3f18b08 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -54,7 +54,12 @@ def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None: params = list(sig.parameters.values()) # Add 'op_key' parameter - op_key_param = Parameter("op_key", Parameter.KEYWORD_ONLY, annotation=str) + op_key_param = Parameter("op_key", + Parameter.KEYWORD_ONLY, + # we add a None default value so that typescript code gen drops the parameter + # FIXME: this is a hack, we should filter out op_key in the typescript code gen + default=None, + annotation=str) params.append(op_key_param) # Create a new signature @@ -117,6 +122,17 @@ API.register(open_file) fn_signature = signature(fn) abstract_signature = signature(self._registry[fn_name]) + + # Remove the default argument of op_key from abstract_signature + # FIXME: This is a hack to make the signature comparison work + # because the other hack above where default value of op_key is None in the wrapper + abstract_params = list(abstract_signature.parameters.values()) + for i, param in enumerate(abstract_params): + if param.name == "op_key": + abstract_params[i] = param.replace(default=Parameter.empty) + break + abstract_signature = abstract_signature.replace(parameters=abstract_params) + if fn_signature != abstract_signature: msg = f"Expected signature: {abstract_signature}\nActual signature: {fn_signature}" raise ClanError(msg) diff --git a/pkgs/webview-ui/app/src/api/index.ts b/pkgs/webview-ui/app/src/api/index.ts index 83574a0f3..30ce2aa07 100644 --- a/pkgs/webview-ui/app/src/api/index.ts +++ b/pkgs/webview-ui/app/src/api/index.ts @@ -43,27 +43,14 @@ export interface GtkResponse { op_key: string; } -const operations = schema.properties; -const operationNames = Object.keys(operations) as OperationNames[]; -export const callApi = ( +export const callApi = async ( method: K, args: OperationArgs, -) => { +): Promise> => { console.log("Calling API", method, args); - return (window as any)[method](args); + const response = await (window as unknown as Record) => Promise>>)[method](args); + return response as OperationResponse; }; -const deserialize = - (fn: (response: T) => void) => - (r: unknown) => { - try { - fn(r as T); - } catch (e) { - console.error("Error parsing JSON: ", e); - window.localStorage.setItem("error", JSON.stringify(r)); - console.error(r); - console.error("See localStorage 'error'"); - alert(`Error parsing JSON: ${e}`); - } - }; + diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index 37849ee84..a172e586e 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -26,7 +26,6 @@ export const client = new QueryClient(); const root = document.getElementById("app"); -window.clan = window.clan || {}; if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( diff --git a/pkgs/webview-ui/app/tests/types.test.ts b/pkgs/webview-ui/app/tests/types.test.ts index 04e374e25..2db35d654 100644 --- a/pkgs/webview-ui/app/tests/types.test.ts +++ b/pkgs/webview-ui/app/tests/types.test.ts @@ -1,40 +1,9 @@ -import { describe, expectTypeOf, it } from "vitest"; +import { describe, it } from "vitest"; -import { OperationNames, pyApi } from "@/src/api"; describe.concurrent("API types work properly", () => { // Test some basic types it("distinct success/error unions", async () => { - const k: OperationNames = "create_clan" as OperationNames; // Just a random key, since - expectTypeOf(pyApi[k].receive).toBeFunction(); - expectTypeOf(pyApi[k].receive).parameter(0).toBeFunction(); - // receive is a function that takes a function, which takes the response parameter - expectTypeOf(pyApi[k].receive) - .parameter(0) - .parameter(0) - .toMatchTypeOf< - { status: "success"; data?: any } | { status: "error"; errors: any[] } - >(); - }); - - it("Cannot access data of error response", async () => { - const k: OperationNames = "create_clan" as OperationNames; // Just a random key, since - expectTypeOf(pyApi[k].receive).toBeFunction(); - expectTypeOf(pyApi[k].receive).parameter(0).toBeFunction(); - expectTypeOf(pyApi[k].receive).parameter(0).parameter(0).toMatchTypeOf< - // @ts-expect-error: data is not defined in error responses - | { status: "success"; data?: any } - | { status: "error"; errors: any[]; data: any } - >(); - }); - - it("Machine list receives a records of names and machine info.", async () => { - expectTypeOf(pyApi.list_inventory_machines.receive) - .parameter(0) - .parameter(0) - .toMatchTypeOf< - | { status: "success"; data: Record } - | { status: "error"; errors: any } - >(); + }); }); From 26ff5aa1e195fd076b9a257c8b02a1350db07123 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 15:41:20 +0100 Subject: [PATCH 10/14] clan-cli: Ignore new type hints in api/serde.py clan-cli: Ignore new type hints in api/serde.py clan-cli: Ignore new type hints in api/serde.py clan-cli: Ignore new type hints in api/serde.py --- pkgs/clan-app/clan_app/api/file.py | 11 +++++------ pkgs/clan-cli/clan_cli/api/__init__.py | 14 ++++++++------ pkgs/clan-cli/clan_cli/api/serde.py | 2 +- pkgs/clan-cli/clan_cli/api/util.py | 4 +++- pkgs/clan-cli/clan_cli/jsonrpc.py | 2 +- pkgs/webview-ui/app/src/api/index.ts | 12 ++++++++---- pkgs/webview-ui/app/src/index.tsx | 1 - pkgs/webview-ui/app/tests/types.test.ts | 5 +---- 8 files changed, 27 insertions(+), 24 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/file.py b/pkgs/clan-app/clan_app/api/file.py index 62100d978..5dd2b1d04 100644 --- a/pkgs/clan-app/clan_app/api/file.py +++ b/pkgs/clan-app/clan_app/api/file.py @@ -40,8 +40,7 @@ def open_file( root.withdraw() # Hide the main window root.attributes("-topmost", True) # Bring the dialogs to the front - - file_path: str = "" + file_path: str = "" multiple_files: list[str] = [] if file_request.mode == "open_file": @@ -67,10 +66,10 @@ def open_file( elif file_request.mode == "open_multiple_files": tresult = filedialog.askopenfilenames( - title=file_request.title, - initialdir=file_request.initial_folder, - filetypes=_apply_filters(file_request.filters), - ) + title=file_request.title, + initialdir=file_request.initial_folder, + filetypes=_apply_filters(file_request.filters), + ) multiple_files = list(tresult) if len(file_path) == 0 and len(multiple_files) == 0: diff --git a/pkgs/clan-cli/clan_cli/api/__init__.py b/pkgs/clan-cli/clan_cli/api/__init__.py index 4e3f18b08..c0a1ce37b 100644 --- a/pkgs/clan-cli/clan_cli/api/__init__.py +++ b/pkgs/clan-cli/clan_cli/api/__init__.py @@ -54,12 +54,14 @@ def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None: params = list(sig.parameters.values()) # Add 'op_key' parameter - op_key_param = Parameter("op_key", - Parameter.KEYWORD_ONLY, - # we add a None default value so that typescript code gen drops the parameter - # FIXME: this is a hack, we should filter out op_key in the typescript code gen - default=None, - annotation=str) + op_key_param = Parameter( + "op_key", + Parameter.KEYWORD_ONLY, + # we add a None default value so that typescript code gen drops the parameter + # FIXME: this is a hack, we should filter out op_key in the typescript code gen + default=None, + annotation=str, + ) params.append(op_key_param) # Create a new signature diff --git a/pkgs/clan-cli/clan_cli/api/serde.py b/pkgs/clan-cli/clan_cli/api/serde.py index e15be8e2d..6b1f60281 100644 --- a/pkgs/clan-cli/clan_cli/api/serde.py +++ b/pkgs/clan-cli/clan_cli/api/serde.py @@ -302,7 +302,7 @@ def construct_dataclass( field_value = data.get(data_field_name) if field_value is None and ( - field.type is None or is_type_in_union(field.type, type(None)) + field.type is None or is_type_in_union(field.type, type(None)) # type: ignore ): field_values[field.name] = None else: diff --git a/pkgs/clan-cli/clan_cli/api/util.py b/pkgs/clan-cli/clan_cli/api/util.py index 404f5e39f..073a945d2 100644 --- a/pkgs/clan-cli/clan_cli/api/util.py +++ b/pkgs/clan-cli/clan_cli/api/util.py @@ -119,7 +119,9 @@ def type_to_dict( f.type, str ), f"Expected field type to be a type, got {f.type}, Have you imported `from __future__ import annotations`?" properties[f.metadata.get("alias", f.name)] = type_to_dict( - f.type, f"{scope} {t.__name__}.{f.name}", type_map + f.type, + f"{scope} {t.__name__}.{f.name}", # type: ignore + type_map, # type: ignore ) required = set() diff --git a/pkgs/clan-cli/clan_cli/jsonrpc.py b/pkgs/clan-cli/clan_cli/jsonrpc.py index ec83050d9..8234e837d 100644 --- a/pkgs/clan-cli/clan_cli/jsonrpc.py +++ b/pkgs/clan-cli/clan_cli/jsonrpc.py @@ -10,6 +10,6 @@ class ClanJSONEncoder(json.JSONEncoder): return o.to_json() # Check if the object is a dataclass if dataclasses.is_dataclass(o): - return dataclasses.asdict(o) # type: ignore[call-overload] + return dataclasses.asdict(o) # type: ignore # Otherwise, use the default serialization return super().default(o) diff --git a/pkgs/webview-ui/app/src/api/index.ts b/pkgs/webview-ui/app/src/api/index.ts index 30ce2aa07..9bca920ec 100644 --- a/pkgs/webview-ui/app/src/api/index.ts +++ b/pkgs/webview-ui/app/src/api/index.ts @@ -43,14 +43,18 @@ export interface GtkResponse { op_key: string; } - export const callApi = async ( method: K, args: OperationArgs, ): Promise> => { console.log("Calling API", method, args); - const response = await (window as unknown as Record) => Promise>>)[method](args); + const response = await ( + window as unknown as Record< + OperationNames, + ( + args: OperationArgs, + ) => Promise> + > + )[method](args); return response as OperationResponse; }; - - diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index a172e586e..155294f66 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -26,7 +26,6 @@ export const client = new QueryClient(); const root = document.getElementById("app"); - if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", diff --git a/pkgs/webview-ui/app/tests/types.test.ts b/pkgs/webview-ui/app/tests/types.test.ts index 2db35d654..b4d7f748e 100644 --- a/pkgs/webview-ui/app/tests/types.test.ts +++ b/pkgs/webview-ui/app/tests/types.test.ts @@ -1,9 +1,6 @@ import { describe, it } from "vitest"; - describe.concurrent("API types work properly", () => { // Test some basic types - it("distinct success/error unions", async () => { - - }); + it("distinct success/error unions", async () => {}); }); From 04e644cacc533e8c0c18b65c08f10c12a0d27423 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 19:12:52 +0100 Subject: [PATCH 11/14] clan: revert flake.lock upgrade --- flake.lock | 6 +++--- pkgs/clan-app/clan_app/deps/webview/webview.py | 2 +- pkgs/clan-app/shell.nix | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index abdfa8d1f..3f8603934 100644 --- a/flake.lock +++ b/flake.lock @@ -57,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1735821806, - "narHash": "sha256-cuNapx/uQeCgeuhUhdck3JKbgpsml259sjUQnWM7zW8=", + "lastModified": 1734435836, + "narHash": "sha256-kMBQ5PRiFLagltK0sH+08aiNt3zGERC2297iB6vrvlU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d6973081434f88088e5321f83ebafe9a1167c367", + "rev": "4989a246d7a390a859852baddb1013f825435cee", "type": "github" }, "original": { diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index bc8c09198..9aa4a16cf 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -126,7 +126,7 @@ class Webview: 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) diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 04c904fc2..9015e6d4e 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -68,7 +68,7 @@ mkShell { export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS - # export WEBVIEW_LIB_DIR=${webview-lib}/lib - export WEBVIEW_LIB_DIR=$HOME/Projects/webview/build/core + export WEBVIEW_LIB_DIR=${webview-lib}/lib + # export WEBVIEW_LIB_DIR=$HOME/Projects/webview/build/core ''; } From bd9536e8f94cf9368da6c9929bf5ae06b4079f97 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 19:39:21 +0100 Subject: [PATCH 12/14] clan-app: fix webiview-lib under darwin --- pkgs/clan-app/clan-app.code-workspace | 3 +++ pkgs/webview-lib/default.nix | 18 +++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/pkgs/clan-app/clan-app.code-workspace b/pkgs/clan-app/clan-app.code-workspace index 6cf6c41e4..95f1b4ff7 100644 --- a/pkgs/clan-app/clan-app.code-workspace +++ b/pkgs/clan-app/clan-app.code-workspace @@ -17,6 +17,9 @@ }, { "path": "../webview-ui" + }, + { + "path": "../webview-lib" } ], "settings": { diff --git a/pkgs/webview-lib/default.nix b/pkgs/webview-lib/default.nix index 5dd78de97..bcb2001e8 100644 --- a/pkgs/webview-lib/default.nix +++ b/pkgs/webview-lib/default.nix @@ -17,13 +17,17 @@ pkgs.stdenv.mkDerivation { ]; # Dependencies used during the build process, if any - buildInputs = with pkgs; [ - gnumake - cmake - pkg-config - webkitgtk_6_0 - gtk4 - ]; + buildInputs = + with pkgs; + [ + gnumake + cmake + pkg-config + ] + ++ pkgs.lib.optionals stdenv.isLinux [ + webkitgtk_6_0 + gtk4 + ]; meta = with pkgs.lib; { description = "Tiny cross-platform webview library for C/C++. Uses WebKit (GTK/Cocoa) and Edge WebView2 (Windows)"; From 6a7da4ef118734f28cb30f2555b9a555c0860f0a Mon Sep 17 00:00:00 2001 From: Qubasa Date: Mon, 6 Jan 2025 20:08:45 +0100 Subject: [PATCH 13/14] clan-app: Fix clan-app-pytest not finding python3Full --- pkgs/clan-app/default.nix | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index bbdae731b..db019b595 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -31,14 +31,14 @@ let ]; - # Deps including python packages from the local project - allPythonDeps = [ (python3Full.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; - # Runtime binary dependencies required by the application runtimeDependencies = [ webview-lib ]; + # Deps including python packages from the local project + allPythonDeps = [ (python3Full.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; + # Dependencies required for running tests externalTestDeps = externalPythonDeps @@ -53,9 +53,6 @@ let # Dependencies required for running tests testDependencies = runtimeDependencies ++ allPythonDeps ++ externalTestDeps; - - # Setup Python environment with all dependencies for running tests - pythonWithTestDeps = python3Full.withPackages (_ps: testDependencies); in python3Full.pkgs.buildPythonApplication rec { name = "clan-app"; @@ -100,7 +97,12 @@ python3Full.pkgs.buildPythonApplication rec { passthru = { tests = { clan-app-pytest = - runCommand "clan-app-pytest" { inherit buildInputs propagatedBuildInputs nativeBuildInputs; } + runCommand "clan-app-pytest" + { + buildInputs = buildInputs ++ externalTestDeps; + propagatedBuildInputs = propagatedBuildInputs ++ externalTestDeps; + inherit nativeBuildInputs; + } '' cp -r ${source} ./src chmod +w -R ./src @@ -119,8 +121,9 @@ python3Full.pkgs.buildPythonApplication rec { fc-list echo "STARTING ..." + export WEBVIEW_LIB_DIR "${webview-lib}/lib" export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${pythonWithTestDeps}/bin/python -m pytest -s -m "not impure" ./tests + ${python3Full}/bin/python3 -m pytest -s -m "not impure" ./tests touch $out ''; }; From 0db994469952ef322b5d43c19911746f49203918 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 7 Jan 2025 00:10:34 +0100 Subject: [PATCH 14/14] clan-app: Fix python3Full and python3 incompatibilities. 'pytest' not found and devshell bugs --- pkgs/clan-app/default.nix | 63 +++++++++++--------------------- pkgs/clan-app/shell.nix | 51 ++++++-------------------- pkgs/clan-app/tests/test_join.py | 3 -- pkgs/clan-app/tests/wayland.py | 6 ++- 4 files changed, 38 insertions(+), 85 deletions(-) diff --git a/pkgs/clan-app/default.nix b/pkgs/clan-app/default.nix index db019b595..e518ef71d 100644 --- a/pkgs/clan-app/default.nix +++ b/pkgs/clan-app/default.nix @@ -3,14 +3,8 @@ runCommand, setuptools, copyDesktopItems, - wrapGAppsHook4, clan-cli, makeDesktopItem, - pytest, # Testing framework - pytest-cov, # Generate coverage reports - pytest-subprocess, # fake the real subprocess behavior to make your tests more independent. - pytest-xdist, # Run tests in parallel on multiple cores - pytest-timeout, # Add timeouts to your tests webview-ui, webview-lib, fontconfig, @@ -26,33 +20,27 @@ let mimeTypes = [ "x-scheme-handler/clan" ]; }; - # Dependencies that are directly used in the project but nor from internal python packages - externalPythonDeps = [ - - ]; - # Runtime binary dependencies required by the application runtimeDependencies = [ - webview-lib + ]; - # Deps including python packages from the local project - allPythonDeps = [ (python3Full.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps; - # Dependencies required for running tests - externalTestDeps = - externalPythonDeps - ++ runtimeDependencies - ++ [ - pytest # Testing framework + pyTestDeps = + ps: + with ps; + [ + (python3Full.pkgs.toPythonModule pytest) + # Testing framework pytest-cov # Generate coverage reports pytest-subprocess # fake the real subprocess behavior to make your tests more independent. pytest-xdist # Run tests in parallel on multiple cores pytest-timeout # Add timeouts to your tests - ]; + ] + ++ pytest.propagatedBuildInputs; + + clan-cli-module = [ (python3Full.pkgs.toPythonModule clan-cli) ]; - # Dependencies required for running tests - testDependencies = runtimeDependencies ++ allPythonDeps ++ externalTestDeps; in python3Full.pkgs.buildPythonApplication rec { name = "clan-app"; @@ -76,22 +64,15 @@ python3Full.pkgs.buildPythonApplication rec { nativeBuildInputs = [ setuptools copyDesktopItems - wrapGAppsHook4 + fontconfig ]; # The necessity of setting buildInputs and propagatedBuildInputs to the # same values for your Python package within Nix largely stems from ensuring # that all necessary dependencies are consistently available both # at build time and runtime, - buildInputs = allPythonDeps ++ runtimeDependencies; - propagatedBuildInputs = - allPythonDeps - ++ runtimeDependencies - ++ [ - - # TODO: see postFixup clan-cli/default.nix:L188 - clan-cli.propagatedBuildInputs - ]; + buildInputs = clan-cli-module ++ runtimeDependencies; + propagatedBuildInputs = buildInputs; # also re-expose dependencies so we test them in CI passthru = { @@ -99,9 +80,10 @@ python3Full.pkgs.buildPythonApplication rec { clan-app-pytest = runCommand "clan-app-pytest" { - buildInputs = buildInputs ++ externalTestDeps; - propagatedBuildInputs = propagatedBuildInputs ++ externalTestDeps; - inherit nativeBuildInputs; + buildInputs = runtimeDependencies ++ [ + (python3Full.withPackages (ps: clan-cli-module ++ (pyTestDeps ps))) + fontconfig + ]; } '' cp -r ${source} ./src @@ -121,9 +103,9 @@ python3Full.pkgs.buildPythonApplication rec { fc-list echo "STARTING ..." - export WEBVIEW_LIB_DIR "${webview-lib}/lib" + export WEBVIEW_LIB_DIR="${webview-lib}/lib" export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 - ${python3Full}/bin/python3 -m pytest -s -m "not impure" ./tests + python3 -m pytest -s -m "not impure" ./tests touch $out ''; }; @@ -131,10 +113,7 @@ python3Full.pkgs.buildPythonApplication rec { # Additional pass-through attributes passthru.desktop-file = desktop-file; - passthru.externalPythonDeps = externalPythonDeps; - passthru.externalTestDeps = externalTestDeps; - passthru.runtimeDependencies = runtimeDependencies; - passthru.testDependencies = testDependencies; + passthru.devshellDeps = ps: (pyTestDeps ps); postInstall = '' mkdir -p $out/${python3Full.sitePackages}/clan_app/.webui diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 9015e6d4e..9d0cac805 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -1,56 +1,29 @@ { - lib, - glib, gsettings-desktop-schemas, - stdenv, clan-app, mkShell, ruff, - desktop-file-utils, - xdg-utils, - mypy, - python3, gtk4, - libadwaita, webview-lib, - clang, + python3Full, self', }: -let - devshellTestDeps = - clan-app.externalTestDeps - ++ (with python3.pkgs; [ - rope - mypy - setuptools - wheel - pip - ]); -in mkShell { - inherit (clan-app) nativeBuildInputs propagatedBuildInputs; inputsFrom = [ self'.devShells.default ]; - buildInputs = - [ - glib - ruff - gtk4 - clang - webview-lib.dev - webview-lib - gtk4.dev # has the demo called 'gtk4-widget-factory' - libadwaita.devdoc # has the demo called 'adwaita-1-demo' - ] - ++ devshellTestDeps - - # Dependencies for testing for linux hosts - ++ (lib.optionals stdenv.isLinux [ - xdg-utils # install desktop files - desktop-file-utils # verify desktop files - ]); + buildInputs = [ + (python3Full.withPackages ( + ps: + with ps; + [ + ruff + mypy + ] + ++ (clan-app.devshellDeps ps) + )) + ]; shellHook = '' export GIT_ROOT=$(git rev-parse --show-toplevel) diff --git a/pkgs/clan-app/tests/test_join.py b/pkgs/clan-app/tests/test_join.py index fff6de20c..a41f3fe02 100644 --- a/pkgs/clan-app/tests/test_join.py +++ b/pkgs/clan-app/tests/test_join.py @@ -1,8 +1,5 @@ -import time - from wayland import GtkProc def test_open(app: GtkProc) -> None: - time.sleep(0.5) assert app.poll() is None diff --git a/pkgs/clan-app/tests/wayland.py b/pkgs/clan-app/tests/wayland.py index 2e96688a5..9fa73961c 100644 --- a/pkgs/clan-app/tests/wayland.py +++ b/pkgs/clan-app/tests/wayland.py @@ -21,7 +21,11 @@ GtkProc = NewType("GtkProc", Popen) @pytest.fixture def app() -> Generator[GtkProc, None, None]: - rapp = Popen([sys.executable, "-m", "clan_app"], text=True) + cmd = [sys.executable, "-m", "clan_app"] + print(f"Running: {cmd}") + rapp = Popen( + cmd, text=True, stdout=sys.stdout, stderr=sys.stderr, start_new_session=True + ) yield GtkProc(rapp) # Cleanup: Terminate your application rapp.terminate()