diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7c79dc0..67c6533 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] schedule: - cron: "0 6 * * 1" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index b39746a..af38932 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -2,9 +2,9 @@ name: Pre-commit on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] permissions: contents: read diff --git a/README.md b/README.md index 68421f7..451af36 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,37 @@ cd ../.. uv run start ``` +## Global Menu Compatibility + +MODUS injects the GTK3 menu-button shim (`libmenu_button_shim.so`) **only into +GTK3 apps**, and only when launched through MODUS (dock, spotlight, panel). +GTK4 apps are never touched, so they launch normally — a global `LD_PRELOAD` +of this shim would otherwise crash every GTK4 process. + +### Terminal-launched GTK3 apps + +Apps you start directly from a terminal don't go through MODUS, so they don't +get the shim. They still work fine; they just miss the synthetic +button-release fix the shim provides. To get it back without a dangerous +system-wide `LD_PRELOAD`, use a thin wrapper that detects GTK3 and injects the +shim only for that process: + +```bash +#!/usr/bin/env bash +# ~/.local/bin/modus-launch — GTK3-aware launcher for the global menu shim +SHIM=~/.config/Modus/src/globalmenu/libmenu_button_shim.so +BIN="$(command -v "$1")" +if [[ -n "$BIN" ]] && readelf -d "$BIN" 2>/dev/null | grep -q 'libgtk-3\.so\.0'; then + LD_PRELOAD="$SHIM" "$@" +else + "$@" +fi +``` + +Make it executable (`chmod +x ~/.local/bin/modus-launch`) and run GTK3 apps as +`modus-launch gedit` from the terminal. GTK4 apps fall through to a normal +launch. + ## Documentation | Doc | What's inside | diff --git a/pyproject.toml b/pyproject.toml index 9c33d70..1545de4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ "httpx>=0.28.1", "pam>=0.2.0", "psutil>=7.2.2", - "pywayland>=0.4.18", "six>=1.17.0", "tomlkit>=0.15.0", ] @@ -44,16 +43,6 @@ dev = [ start = "main:main" lock = "window.lock:main" -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/shared", "src/utils", "src/window", "src/services", "src/globalmenu", "src/main.py"] - -[tool.hatch.build.targets.wheel.sources] -"src" = "" - [tool.ruff] target-version = "py313" line-length = 88 diff --git a/requirements.txt b/requirements.txt index 0466de1..60c2e34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ fabric @ git+https://github.com/Fabric-Development/fabric.git@b7b0f9975f0cf161cc httpx==0.28.1 pam==0.2.0 psutil==7.2.2 -pywayland==0.4.18 ruff==0.15.2 six==1.17.0 tomlkit==0.15.0 diff --git a/src/globalmenu/detection.py b/src/globalmenu/detection.py new file mode 100644 index 0000000..1b526e0 --- /dev/null +++ b/src/globalmenu/detection.py @@ -0,0 +1,233 @@ +"""Detect whether an executable is built against GTK3 or GTK4. + +The global menu compat shim (libmenu_button_shim.so) links against GTK3 and +must only be loaded into GTK3 processes. Loading it into a GTK4 process pulls +GTK 2/3 symbols into the same process and GTK4 aborts with: + + GTK-ERROR: "GTK 2/3 symbols detected. Using GTK 2/3 and GTK 4 in the same + process is not supported" + +We therefore inspect an executable's ELF ``DT_NEEDED`` entries to learn which +GTK it depends on, and only inject the shim for GTK3 binaries. Results are +cached on disk (keyed by resolved path + mtime) so the lookup is cheap and is +never run on every launch for unchanged binaries. +""" + +import json +import os +import shutil +from collections import deque +from pathlib import Path + +from fabric.utils import logger + +# SONAMEs we care about. +GTK3_SONAME = "libgtk-3.so.0" +GTK4_SONAME = "libgtk-4.so.1" + +_CACHE_DIR = Path.home() / ".cache" / "modus" +_CACHE_FILE = _CACHE_DIR / "gtk_class_cache.json" + +# Directories searched when resolving a NEEDED SONAME to a file. The binary's +# own directory is always tried first, then these (LD_LIBRARY_PATH wins). +_LIB_DIRS = [ + *os.environ.get("LD_LIBRARY_PATH", "").split(os.pathsep), + "/usr/lib", + "/usr/lib64", + "/lib", + "/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/i386-linux-gnu", +] + + +def _parse_dynamic(path: Path) -> tuple[set[str], list[Path]]: + """Parse an ELF's dynamic section. + + Returns ``(needed_sonames, runpath_dirs)``. ``runpath_dirs`` are the + directories named by DT_RUNPATH/DT_RPATH with ``$ORIGIN`` expanded to the + directory containing ``path``. Private libraries (e.g. gedit's + libgedit-50.so) live there and must be searched to follow the dependency + tree correctly. + """ + try: + with open(path, "rb") as f: + ident = f.read(20) + if len(ident) < 20 or ident[:4] != b"\x7fELF": + return set(), [] + is_64 = ident[4] == 2 + data = path.read_bytes() + except OSError: + return set(), [] + + try: + if is_64: + e_phoff = int.from_bytes(data[0x20:0x28], "little") + e_phentsize = int.from_bytes(data[0x36:0x38], "little") + e_phnum = int.from_bytes(data[0x38:0x3A], "little") + else: + e_phoff = int.from_bytes(data[0x1C:0x20], "little") + e_phentsize = int.from_bytes(data[0x2A:0x2C], "little") + e_phnum = int.from_bytes(data[0x2C:0x2E], "little") + except (IndexError, ValueError): + return set(), [] + + PT_DYNAMIC = 2 + word_size = 8 if is_64 else 4 + + def unpack(b: bytes) -> int: + return int.from_bytes(b[:word_size], "little") + + dynamic_off = None + for i in range(e_phnum): + base = e_phoff + i * e_phentsize + if unpack(data[base : base + 4]) == PT_DYNAMIC: + dynamic_off = unpack(data[base + word_size : base + word_size * 2]) + dyn_strtab = unpack(data[base + word_size * 2 : base + word_size * 3]) + break + + if dynamic_off is None: + return set(), [] + + DT_NEEDED = 1 + DT_STRTAB = 5 + DT_RUNPATH = 0x1D + DT_RPATH = 0x0F + + needed_offsets: list[int] = [] + strtab_off = dyn_strtab + runpath_str = "" + step = word_size * 2 + + pos = dynamic_off + while pos + step <= len(data): + d_tag = unpack(data[pos : pos + word_size]) + d_val = unpack(data[pos + word_size : pos + step]) + if d_tag == 0: # DT_NULL + break + if d_tag == DT_NEEDED: + needed_offsets.append(d_val) + elif d_tag == DT_STRTAB: + strtab_off = d_val + elif d_tag in (DT_RUNPATH, DT_RPATH): + end = data.find(b"\x00", strtab_off + d_val) + if end != -1: + runpath_str = data[strtab_off + d_val : end].decode("utf-8", "replace") + pos += step + + needed: set[str] = set() + for off in needed_offsets: + end = data.find(b"\x00", strtab_off + off) + if end == -1: + continue + needed.add(data[strtab_off + off : end].decode("utf-8", "replace")) + + origin = path.parent + runpath_dirs: list[Path] = [] + for entry in runpath_str.split(":"): + entry = entry.strip() + if not entry: + continue + entry = entry.replace("$ORIGIN", str(origin)).replace("${ORIGIN}", str(origin)) + runpath_dirs.append(Path(entry)) + + return needed, runpath_dirs + + +def _find_lib(soname: str, search_dirs: list[Path]) -> Path | None: + """Resolve a NEEDED SONAME to a file within ``search_dirs``.""" + for directory in search_dirs: + candidate = directory / soname + if candidate.is_file(): + return candidate + return None + + +def _transitive_needed(root: Path) -> set[str]: + """Collect every DT_NEEDED SONAME in the dependency tree of ``root``. + + Follows NEEDED entries transitively (including private libs reached via + RUNPATH) so that GTK pulled in indirectly is still detected. + """ + all_needed: set[str] = set() + seen_paths: set[Path] = set() + queue: deque[Path] = deque([root]) + + while queue: + lib = queue.popleft() + if lib in seen_paths: + continue + seen_paths.add(lib) + + needed, runpath_dirs = _parse_dynamic(lib) + search_dirs = [lib.parent, *runpath_dirs, *[Path(d) for d in _LIB_DIRS if d]] + for soname in needed: + all_needed.add(soname) + if soname in (GTK3_SONAME, GTK4_SONAME): + continue + dep = _find_lib(soname, search_dirs) + if dep and dep not in seen_paths: + queue.append(dep) + + return all_needed + + +def _load_cache() -> dict: + try: + return json.loads(_CACHE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def _save_cache(cache: dict) -> None: + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text(json.dumps(cache)) + except OSError as e: + logger.debug(f"[GlobalMenu] Could not write gtk class cache: {e}") + + +def executable_gtk_class(executable: str | None) -> str | None: + """Classify an executable as ``"gtk3"``, ``"gtk4"`` or ``None``. + + ``None`` means "unable to determine" (e.g. a shell script, a wrapper, or a + non-GTK binary). In that case the caller should NOT inject the GTK3 shim, + which keeps GTK4 applications safe. + """ + if not executable: + return None + + token = executable.strip().split()[0] if executable.strip() else "" + if not token: + return None + + resolved = shutil.which(token) or token + path = Path(resolved).expanduser() + if not path.is_absolute() or not path.exists(): + # Try as a direct path too. + path = Path(token) + if not path.exists(): + return None + + try: + mtime = path.stat().st_mtime_ns + except OSError: + return None + + cache = _load_cache() + key = f"{path}" + cached = cache.get(key) + if cached and cached.get("mtime") == mtime: + return cached.get("class") + + needed = _transitive_needed(path) + if GTK4_SONAME in needed: + klass = "gtk4" + elif GTK3_SONAME in needed: + klass = "gtk3" + else: + klass = None + + cache[key] = {"mtime": mtime, "class": klass} + _save_cache(cache) + return klass diff --git a/src/globalmenu/environment.py b/src/globalmenu/environment.py index dc42275..28273d0 100644 --- a/src/globalmenu/environment.py +++ b/src/globalmenu/environment.py @@ -14,12 +14,79 @@ ENV_VARS = { "UBUNTU_MENUPROXY": "1", } +# Marker lines previously written around the global LD_PRELOAD export in shell +# rc files. Kept so we can strip the stale block from already-configured systems. _SHIM_BEGIN = "# >>> modus global-menu shim >>>" _SHIM_END = "# <<< modus global-menu shim <<<" -def _compile_shim() -> Path | None: - """Compile the menu-button shim if needed. Returns path to .so or None.""" +def _remove_global_ld_preload(): + """Strip the legacy global LD_PRELOAD export from previously configured systems. + + The shim is now injected per-launch into GTK3 apps only (see + globalmenu.launch). Any globally exported LD_PRELOAD must be removed so it + no longer breaks GTK4 processes. + """ + home = Path.home() + shell = os.environ.get("SHELL", "") + rc_files = [ + home / ".zshrc", + home / ".bashrc", + home / ".profile", + home / ".config" / "fish" / "config.fish", + ] + rc_path = { + "zsh": home / ".zshrc", + "bash": home / ".bashrc", + "fish": home / ".config" / "fish" / "config.fish", + }.get(Path(shell).name) + if rc_path: + rc_files.append(rc_path) + rc_files = list(dict.fromkeys(rc_files)) # de-dupe, keep order + + for rc in rc_files: + try: + if not rc.is_file(): + continue + lines = rc.read_text().splitlines(keepends=True) + if not any(_SHIM_BEGIN in ln for ln in lines): + continue + out = [] + drop = False + for ln in lines: + if _SHIM_BEGIN in ln: + drop = True + continue + if _SHIM_END in ln: + drop = False + continue + if drop: + continue + out.append(ln) + rc.write_text("".join(out)) + logger.info(f"[GlobalMenu] Removed stale LD_PRELOAD block from {rc}") + except OSError: + pass + + # Strip the LD_PRELOAD line from environment.d/appmenu.conf. + env_path = home / ".config" / "environment.d" / "appmenu.conf" + try: + if env_path.is_file(): + kept = [ + ln + for ln in env_path.read_text().splitlines(keepends=True) + if not ln.strip().startswith("LD_PRELOAD=") + ] + env_path.write_text("".join(kept)) + except OSError: + pass + + # Clear it from the activation environment for future launches. + exec_shell_command("dbus-update-activation-environment --systemd LD_PRELOAD=") + + +def get_compiled_shim() -> Path | None: + """Compile the GTK3 menu-button shim if needed. Returns path to .so or None.""" try: if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime: return SHIM_SO @@ -48,43 +115,6 @@ def _compile_shim() -> Path | None: return None -def _write_shell_rc(shim_path: Path | None): - """Append LD_PRELOAD export to the user's shell rc file.""" - if not shim_path: - return - - shell = os.environ.get("SHELL", "") - home = Path.home() - - rc_map = { - "zsh": home / ".zshrc", - "bash": home / ".bashrc", - "fish": home / ".config" / "fish" / "config.fish", - } - - rc_path = rc_map.get(Path(shell).name) - if rc_path is None: - rc_path = home / ".profile" - - try: - if not rc_path.exists(): - return - existing = rc_path.read_text() - if _SHIM_BEGIN in existing: - return - - is_fish = rc_path.name == "config.fish" - with open(rc_path, "a") as f: - f.write(f"\n{_SHIM_BEGIN}\n") - if is_fish: - f.write(f"set -gx LD_PRELOAD {shim_path}\n") - else: - f.write(f"export LD_PRELOAD={shim_path}\n") - f.write(f"{_SHIM_END}\n") - except OSError: - pass - - def setup_global_menu_environment(): """Write env config files so GTK apps register with AppMenu at next login. @@ -93,17 +123,22 @@ def setup_global_menu_environment(): Uses session-level config files that take effect at login. """ try: - shim_path = _compile_shim() + shim_path = get_compiled_shim() + + # Remove any legacy global LD_PRELOAD export from older installs. + _remove_global_ld_preload() + + # NOTE: The menu-button shim is compiled against GTK3 and must NOT be + # exported globally. Injecting it into a GTK4 process aborts the app + # ("GTK 2/3 symbols detected"). It is injected per-launch into GTK3 + # apps only — see globalmenu.launch. + if shim_path: + logger.debug(f"[GlobalMenu] GTK3 shim ready at {shim_path}") exec_shell_command( "dbus-update-activation-environment --systemd " "GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1" ) - if shim_path: - exec_shell_command( - f"dbus-update-activation-environment --systemd LD_PRELOAD={shim_path}" - ) - exec_shell_command("systemctl --user import-environment LD_PRELOAD") exec_shell_command( "flatpak override --user --talk-name=com.canonical.AppMenu.Registrar" @@ -116,8 +151,6 @@ def setup_global_menu_environment(): with open(env_path, "w") as f: for k, v in ENV_VARS.items(): f.write(f"{k}={v}\n") - if shim_path: - f.write(f"LD_PRELOAD={shim_path}\n") except OSError: pass @@ -149,8 +182,6 @@ def setup_global_menu_environment(): except OSError: pass - _write_shell_rc(shim_path) - logger.info("[GlobalMenu] GTK environment injected") except Exception as e: logger.warning(f"[GlobalMenu] Environment setup failed: {e}") diff --git a/src/globalmenu/launch.py b/src/globalmenu/launch.py new file mode 100644 index 0000000..4bad620 --- /dev/null +++ b/src/globalmenu/launch.py @@ -0,0 +1,84 @@ +"""GTK3-aware application launching for the global menu compatibility shim. + +The legacy LD_PRELOAD approach exported ``libmenu_button_shim.so`` globally. +That shim links against GTK3, so loading it into a GTK4 process crashes the +app. Instead we detect the toolkit of the target executable and inject the +shim only into GTK3 processes, leaving GTK4 (and other) apps untouched. + +Two launch paths are supported: + +* :func:`launch_command` — for raw command lines (used by the dock launcher + and forwarded through ``uwsm app``). The shim is prepended via ``env`` so it + is set only for the spawned process. +* :func:`launch_desktop_app` — for :class:`fabric.utils.DesktopApp` objects, + which launch through GIO and inherit the current process environment. We + temporarily set ``LD_PRELOAD`` around the launch call and restore it + immediately afterwards. +""" + +import os +import re + +from fabric.utils import exec_shell_command_async, logger + +from globalmenu.detection import executable_gtk_class +from globalmenu.environment import get_compiled_shim + +_PERCENT_RE = re.compile(r"%\w+") + + +def _gtk3_shim() -> str | None: + """Return the shim path if it is compiled, else ``None``.""" + shim = get_compiled_shim() + return str(shim) if shim else None + + +def shim_env_for_executable(executable: str | None) -> dict[str, str] | None: + """Return ``{"LD_PRELOAD": }`` for a GTK3 executable, else ``None``.""" + if executable_gtk_class(executable) != "gtk3": + return None + shim = _gtk3_shim() + if not shim: + return None + return {"LD_PRELOAD": shim} + + +def launch_command(command_line: str) -> None: + """Launch a command through ``uwsm app``, injecting the GTK3 shim if needed.""" + if not command_line: + return + + cleaned = _PERCENT_RE.sub("", command_line).strip() + if not cleaned: + return + + shim_env = shim_env_for_executable(cleaned) + if shim_env: + # Scope the shim to the launched process only. + cleaned = f"env LD_PRELOAD={shim_env['LD_PRELOAD']} {cleaned}" + logger.debug(f"[GlobalMenu] Injecting GTK3 shim for: {cleaned}") + + exec_shell_command_async( + f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'" + ) + + +def launch_desktop_app(app) -> None: + """Launch a :class:`fabric.utils.DesktopApp`, injecting the GTK3 shim if needed.""" + shim_env = shim_env_for_executable(getattr(app, "executable", None)) + if shim_env: + prev = os.environ.get("LD_PRELOAD") + os.environ["LD_PRELOAD"] = shim_env["LD_PRELOAD"] + logger.debug( + f"[GlobalMenu] Injecting GTK3 shim for desktop app: " + f"{getattr(app, 'display_name', app)}" + ) + try: + app.launch() + finally: + if prev is None: + os.environ.pop("LD_PRELOAD", None) + else: + os.environ["LD_PRELOAD"] = prev + else: + app.launch() diff --git a/src/globalmenu/service.py b/src/globalmenu/service.py index 4706626..024bd4d 100644 --- a/src/globalmenu/service.py +++ b/src/globalmenu/service.py @@ -3,8 +3,9 @@ import json import re import threading +import time from dataclasses import dataclass -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Optional, Union from fabric.core.service import Property, Service, Signal from fabric.utils import ( @@ -20,6 +21,8 @@ from globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus from globalmenu.environment import setup_global_menu_environment from globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient +_NODE_NAME_RE = re.compile(r' str: - return f"{service}:{path}" - def get(self, service: str, path: str, bus, timeout: int) -> str: if not path or not GLib.Variant.is_object_path(path): return "" - key = self._key(service, path) + key = f"{service}:{path}" with self._lock: cached = self._cache.get(key) if cached is not None: @@ -61,14 +63,11 @@ class _IntrospectionCache: return xml def invalidate(self, service: str): + prefix = f"{service}:" with self._lock: - keys = [k for k in self._cache if k.startswith(f"{service}:")] - for k in keys: - self._cache.pop(k, None) - - -_exe_to_service: Dict[str, str] = {} -"""Maps executable path to last-known DBus service name. Populated during discovery.""" + self._cache = { + k: v for k, v in self._cache.items() if not k.startswith(prefix) + } @dataclass @@ -79,11 +78,18 @@ class AppServiceInfo: executable: str = "" wm_class: str = "" app_id: str = "" - importer: Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]] = None + importer: Optional[_MenuClient] = None revision: int = 0 connected_signals: bool = False +def _resolve_executable(pid: int) -> str: + try: + return os.path.realpath(f"/proc/{pid}/exe") + except Exception: + return "" + + def _dbus_introspect(bus, service: str, path: str, timeout: int) -> str: if not path or not GLib.Variant.is_object_path(path): return "" @@ -144,8 +150,6 @@ REGISTRAR_XML = """ """ -_EMPTY_SENTINEL: List[DBusMenuItem] = [] - class GlobalMenuService(Service): """Service that extracts and provides application menus for the global menu bar.""" @@ -157,28 +161,26 @@ class GlobalMenuService(Service): def active_app_changed(self, app_name: str, wm_class: str) -> None: ... @Property(object, flags="read-write") - def current_menu(self) -> Optional[List[DBusMenuItem]]: + def current_menu(self) -> Optional[list[DBusMenuItem]]: return self._current_menu def __init__(self): super().__init__() - self._current_menu: Optional[List[DBusMenuItem]] = None + self._current_menu: Optional[list[DBusMenuItem]] = None self._current_app: str = "" self._current_wm_class: str = "" self._current_pid: int = 0 - # Service registry: service_name -> AppServiceInfo - # This is the core event-driven data structure (single source of truth). - self._service_registry: Dict[str, AppServiceInfo] = {} - self._pid_to_service: Dict[int, str] = {} + self._service_registry: dict[str, AppServiceInfo] = {} + self._pid_to_service: dict[int, str] = {} self._introspection_cache = _IntrospectionCache() self._seq_lock = threading.Lock() self._extraction_seq = 0 - self._extracting_in_flight: Set[str] = set() + self._extracting_in_flight: set[str] = set() - self._lock = threading.Lock() + self._inflight_lock = threading.Lock() self._registry_lock = threading.Lock() self._state_lock = threading.Lock() @@ -236,28 +238,13 @@ class GlobalMenuService(Service): self._on_service_disappeared(name) def _on_service_appeared(self, service_name: str): - """Discover a newly appeared DBus service and populate the registry.""" try: bus = _get_bus() pid = _dbus_get_pid(bus, service_name, _DBUS_TIMEOUT_FAST) if pid <= 0: return - executable = "" - try: - executable = os.path.realpath(f"/proc/{pid}/exe") - except Exception: - pass - with self._registry_lock: - if service_name not in self._service_registry: - self._service_registry[service_name] = AppServiceInfo( - service_name=service_name, - pid=pid, - executable=executable, - ) - if pid: - self._pid_to_service[pid] = service_name - if executable: - _exe_to_service[executable] = service_name + executable = _resolve_executable(pid) + self._register_new_service(service_name, pid, executable) self._introspection_cache.invalidate(service_name) logger.debug( f"[GlobalMenuService] Registered new service: {service_name} pid={pid}" @@ -274,7 +261,8 @@ class GlobalMenuService(Service): return if entry.pid: self._pid_to_service.pop(entry.pid, None) - if entry.importer and isinstance(entry.importer, DBusMenuClient): + self._registered_menus.pop(service_name, None) + if isinstance(entry.importer, DBusMenuClient): try: entry.importer.disconnect_signals() except Exception: @@ -283,7 +271,7 @@ class GlobalMenuService(Service): self._introspection_cache.invalidate(service_name) with self._state_lock: - if entry and entry.wm_class and entry.wm_class == self._current_wm_class: + if entry.wm_class and entry.wm_class == self._current_wm_class: self._current_menu = None logger.debug( @@ -366,6 +354,30 @@ class GlobalMenuService(Service): def _uid(self, wm_class: str, pid: int) -> str: return f"{wm_class}:{pid}" + def _resolve_current_entry(self) -> tuple[Optional[AppServiceInfo], int]: + """Return the registry entry and PID for the currently active window.""" + with self._state_lock: + pid = self._current_pid + with self._registry_lock: + svc = self._pid_to_service.get(pid) + entry = self._service_registry.get(svc) if svc else None + return entry, pid + + def _register_new_service( + self, service_name: str, pid: int, executable: str = "" + ) -> None: + """Add a newly discovered service to the registry if not already present.""" + with self._registry_lock: + if service_name in self._service_registry: + return + self._service_registry[service_name] = AppServiceInfo( + service_name=service_name, + pid=pid, + executable=executable, + ) + if pid: + self._pid_to_service[pid] = service_name + def update_active_window(self, app_name: str, wm_class: str): target_pid = 0 try: @@ -402,7 +414,6 @@ class GlobalMenuService(Service): self.active_app_changed(app_name, wm_class) - # PRIORITY 1: Registry lookup by PID (persistent importers) with self._registry_lock: svc_name = self._pid_to_service.get(target_pid) if target_pid else None registered_entry = ( @@ -425,13 +436,12 @@ class GlobalMenuService(Service): thread.start() return - # No importer found in registry — must discover with self._state_lock: self._current_menu = None self.menu_changed([]) inflight_key = self._uid(wm_class, target_pid) - with self._lock: + with self._inflight_lock: if inflight_key in self._extracting_in_flight: logger.debug( f"[GlobalMenuService] Extraction in-flight for '{inflight_key}' — skipping" @@ -500,8 +510,8 @@ class GlobalMenuService(Service): def _handle_items_updated( self, svc_name: str, - updated_props: List[Tuple[int, dict]], - removed_ids: List[int], + updated_props: list[tuple[int, dict]], + removed_ids: list[int], ): with self._registry_lock: entry = self._service_registry.get(svc_name) @@ -520,7 +530,7 @@ class GlobalMenuService(Service): self, wm_class: str, seq: int, - client: Union[DBusMenuClient, GtkMenuClient, ActionMenuClient], + client: _MenuClient, inflight_key: str, ): try: @@ -547,7 +557,7 @@ class GlobalMenuService(Service): ) finally: if inflight_key: - with self._lock: + with self._inflight_lock: self._extracting_in_flight.discard(inflight_key) def _scan_initial_services(self): @@ -573,22 +583,8 @@ class GlobalMenuService(Service): pid = _dbus_get_pid(bus, name, _DBUS_TIMEOUT_FAST) if pid <= 0: continue - executable = "" - try: - executable = os.path.realpath(f"/proc/{pid}/exe") - except Exception: - pass - with self._registry_lock: - if name not in self._service_registry: - self._service_registry[name] = AppServiceInfo( - service_name=name, - pid=pid, - executable=executable, - ) - if pid: - self._pid_to_service[pid] = name - if executable: - _exe_to_service[executable] = name + executable = _resolve_executable(pid) + self._register_new_service(name, pid, executable) logger.debug( f"[GlobalMenuService] Initial scan: registered {len(names)} services" ) @@ -597,7 +593,7 @@ class GlobalMenuService(Service): def _discover_dbus_client( self, wm_class: str, target_pid: int = 0 - ) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: + ) -> Optional[_MenuClient]: try: if target_pid <= 0: out = exec_shell_command("hyprctl activewindow -j") @@ -621,9 +617,7 @@ class GlobalMenuService(Service): logger.debug(f"[GlobalMenuService] Discovery failed: {e}") return None - def _find_registry( - self, wm_class: str, target_pid: int - ) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: + def _find_registry(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]: with self._registry_lock: svc_name = self._pid_to_service.get(target_pid) entry = self._service_registry.get(svc_name) if svc_name else None @@ -632,9 +626,7 @@ class GlobalMenuService(Service): return entry.importer return None - def _find_registrar( - self, wm_class: str, target_pid: int - ) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: + def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]: bus = _get_bus() with self._registry_lock: registered = dict(self._registered_menus) @@ -663,7 +655,7 @@ class GlobalMenuService(Service): def _find_fallback_scan( self, wm_class: str, target_pid: int - ) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: + ) -> Optional[_MenuClient]: bus = _get_bus() try: res = bus.call_sync( @@ -712,7 +704,6 @@ class GlobalMenuService(Service): bus, svc, dynamic_bases ) - # PRIORITY A: DBusMenu (com.canonical.dbusmenu) for path in fallback_paths: xml = self._introspection_cache.get( svc, path, bus, _DBUS_TIMEOUT_INTROSPECT @@ -723,7 +714,6 @@ class GlobalMenuService(Service): self._cache_discovery(svc, path, pid, wm_class, client) return client - # PRIORITY B: org.gtk.Menus gtk_candidates = [] for path in fallback_paths: xml = self._introspection_cache.get( @@ -732,17 +722,17 @@ class GlobalMenuService(Service): if "org.gtk.Menus" in xml: gtk_candidates.append((svc, path)) + existing_gtk_paths = {p for _, p in gtk_candidates} for dfs_path in self._dfs_find_interface(svc, "org.gtk.Menus"): - if dfs_path not in {p for _, p in gtk_candidates}: + if dfs_path not in existing_gtk_paths: gtk_candidates.append((svc, dfs_path)) + existing_gtk_paths.add(dfs_path) for gsvc, gpath in gtk_candidates: try: test_client = GtkMenuClient(gsvc, gpath) test_items = test_client.get_layout() if not test_items: - import time - for _ in range(4): time.sleep(0.05) if test_items := test_client.get_layout(): @@ -758,26 +748,9 @@ class GlobalMenuService(Service): except Exception: pass - # PRIORITY C: org.gtk.Actions (last resort) - action_candidates = ["/"] - name_path = ( - "/" + svc.replace(".", "/") if not svc.startswith(":") else "" + action_candidates = self._build_action_candidates( + svc, _safe_cls, _safe_cap ) - if name_path and GLib.Variant.is_object_path(name_path): - action_candidates.append(name_path) - action_candidates.append("/org/gtk/Application") - - if _safe_cls: - cls_lower = _safe_cls.lower() - action_candidates.extend( - [f"/org/{cls_lower}/{_safe_cap}", f"/org/{cls_lower}"] - ) - for ns in [*_DE_NAMESPACES, cls_lower]: - for app_part in (_safe_cap, _safe_cls): - p = f"/org/{ns}/{app_part}" - if GLib.Variant.is_object_path(p): - action_candidates.append(p) - action_candidates.extend( self._dfs_find_interface(svc, "org.gtk.Actions") ) @@ -804,6 +777,26 @@ class GlobalMenuService(Service): continue return None + def _build_action_candidates( + self, svc: str, safe_cls: str, safe_cap: str + ) -> list[str]: + candidates = ["/"] + name_path = "/" + svc.replace(".", "/") if not svc.startswith(":") else "" + if name_path and GLib.Variant.is_object_path(name_path): + candidates.append(name_path) + candidates.append("/org/gtk/Application") + + if safe_cls: + cls_lower = safe_cls.lower() + candidates.extend([f"/org/{cls_lower}/{safe_cap}", f"/org/{cls_lower}"]) + for ns in [*_DE_NAMESPACES, cls_lower]: + for app_part in (safe_cap, safe_cls): + p = f"/org/{ns}/{app_part}" + if GLib.Variant.is_object_path(p): + candidates.append(p) + + return candidates + def _build_dynamic_bases(self, svc: str, _safe_cls: str, _safe_cap: str) -> list: static_paths = [ "/MenuBar", @@ -841,12 +834,12 @@ class GlobalMenuService(Service): static_paths.append(p) return static_paths - def _introspect_paths(self, bus, svc: str, bases: list) -> Tuple[list, set]: + def _introspect_paths(self, bus, svc: str, bases: list) -> tuple[list, set]: fallback_paths = list(bases) seen_fallback = set(fallback_paths) def _add_paths(parent_path, xml_str): - for child in re.findall(r' bool: - with self._state_lock: - target_pid = self._current_pid - with self._registry_lock: - svc = self._pid_to_service.get(target_pid) - entry = self._service_registry.get(svc) if svc else None + entry, pid = self._resolve_current_entry() if entry and entry.importer: - entry.importer.click_item(item_id, pid=target_pid) + entry.importer.click_item(item_id, pid=pid) return True return False def about_to_show(self, item_id: int) -> bool: - with self._state_lock: - target_pid = self._current_pid - with self._registry_lock: - svc = self._pid_to_service.get(target_pid) - entry = self._service_registry.get(svc) if svc else None + entry, _pid = self._resolve_current_entry() if entry and entry.importer: return entry.importer.about_to_show(item_id) return False - def refresh_menu_sync(self) -> List[DBusMenuItem]: + def refresh_menu_sync(self) -> list[DBusMenuItem]: + entry, pid = self._resolve_current_entry() + if not (entry and entry.importer): + return [] + items = entry.importer.get_layout(force_refresh=True) with self._state_lock: - target_pid = self._current_pid - with self._registry_lock: - svc = self._pid_to_service.get(target_pid) - entry = self._service_registry.get(svc) if svc else None - if entry and entry.importer: - items = entry.importer.get_layout(force_refresh=True) - with self._state_lock: - if target_pid == self._current_pid: - self._current_menu = items - return items - return [] + if pid == self._current_pid: + self._current_menu = items + return items _global_menu_svc_instance = None diff --git a/src/services/custom_notification.py b/src/services/custom_notification.py index 223de0b..b90496c 100644 --- a/src/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -30,7 +30,6 @@ class CachedNotification(Service): # Store cache metadata for cleanup self.cache_metadata = data.get("cache_metadata", {}) self.timestamp = data.get("timestamp", int(time.time())) - self._pixbuf_cache = {} return self @@ -137,7 +136,7 @@ class CachedNotification(Service): notification_image_cache_key = ( get_notification_image_cache_key(self.id, image_pixbuf) ) - except (AttributeError, OSError, Exception): + except Exception: # If temp file is gone or any other error, just mark as None pass except Exception: @@ -175,7 +174,6 @@ class CachedNotification(Service): self._cache_id = cache_id self.cache_metadata = {} self.timestamp = int(time.time()) - self._pixbuf_cache: dict[str, GdkPixbuf.Pixbuf | None] = {} class CachedNotifications(Notifications): @@ -497,7 +495,7 @@ class CachedNotifications(Notifications): logger.debug( f"Cached notification image for notification {cache_id}" ) - except (AttributeError, OSError, Exception) as e: + except Exception as e: logger.warning( f"Failed to cache notification image for notification {cache_id}: {e}" ) diff --git a/src/services/inhibit.py b/src/services/inhibit.py new file mode 100644 index 0000000..bf3a19c --- /dev/null +++ b/src/services/inhibit.py @@ -0,0 +1,110 @@ +from fabric.core.service import Property, Service, Signal +from fabric.utils import Gio, GLib, logger + +_BUS_NAME = "org.freedesktop.ScreenSaver" +_OBJECT_PATH = "/org/freedesktop/ScreenSaver" +_INTERFACE = "org.freedesktop.ScreenSaver" + + +class InhibitService(Service): + """Service for managing idle inhibition via org.freedesktop.ScreenSaver D-Bus.""" + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @Signal + def inhibit_changed(self, active: bool) -> None: + """Signal emitted when inhibit state changes.""" + + def __init__(self, **kwargs): + if getattr(self, "_initialized", False): + return + self._initialized = True + super().__init__(**kwargs) + self._cookie: int | None = None + self._proxy: Gio.DBusProxy | None = None + + @Property(bool, "readable", default_value=False) + def active(self) -> bool: + return self._cookie is not None + + def _get_proxy(self) -> Gio.DBusProxy | None: + if self._proxy is not None: + return self._proxy + try: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + if bus is None: + return None + self._proxy = Gio.DBusProxy.new_sync( + bus, + Gio.DBusProxyFlags.NONE, + None, + _BUS_NAME, + _OBJECT_PATH, + _INTERFACE, + None, + ) + return self._proxy + except Exception as e: + logger.error(f"[InhibitService] Failed to create D-Bus proxy: {e}") + return None + + def enable(self) -> bool: + if self.active: + return True + proxy = self._get_proxy() + if proxy is None: + return False + try: + result = proxy.call_sync( + "Inhibit", + GLib.Variant("(ss)", ("Modus", "Caffeine mode")), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + if result is not None: + self._cookie = result.unpack()[0] + self.emit("inhibit_changed", True) + return True + return False + except Exception as e: + logger.error(f"[InhibitService] Failed to inhibit: {e}") + return False + + def disable(self) -> bool: + if not self.active: + return True + proxy = self._get_proxy() + if proxy is None or self._cookie is None: + return False + try: + proxy.call_sync( + "Uninhibit", + GLib.Variant("(u)", (self._cookie,)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + self._cookie = None + self.emit("inhibit_changed", False) + return True + except Exception as e: + logger.error(f"[InhibitService] Failed to uninhibit: {e}") + return False + + def toggle(self) -> bool: + if self.active: + self.disable() + return False + else: + self.enable() + return True + + +def get_inhibit_service() -> InhibitService: + return InhibitService() diff --git a/src/services/keyboard_layout.py b/src/services/keyboard_layout.py index b046065..5e6c5e0 100644 --- a/src/services/keyboard_layout.py +++ b/src/services/keyboard_layout.py @@ -41,6 +41,7 @@ class KeyboardLayout(Service): self._last_layout = None self.layouts = [] self.current_index = 0 + self._reload_pending = False self._init_layout_config() self._start_file_monitor() @@ -106,9 +107,12 @@ class KeyboardLayout(Service): logger.error(f"[KeyboardLayout] Failed to start file monitor: {e}") def _on_file_changed(self, monitor, file, *args): - GLib.timeout_add(50, self._reload_layout) + if not self._reload_pending: + self._reload_pending = True + GLib.timeout_add(50, self._reload_layout) def _reload_layout(self): + self._reload_pending = False new_layout = self._read_layout() if new_layout != self._last_layout: self._last_layout = new_layout diff --git a/src/services/modus.py b/src/services/modus.py index d837cce..760549d 100644 --- a/src/services/modus.py +++ b/src/services/modus.py @@ -1,14 +1,13 @@ import json import os -import re -import subprocess import sys from fabric.core.service import Property, Service, Signal from fabric.hyprland.service import Hyprland -from fabric.utils import exec_shell_command_async, get_relative_path, logger +from fabric.utils import exec_shell_command_async, logger from services.custom_notification import CachedNotifications +from services.inhibit import get_inhibit_service from utils.app_name_resolver import app_name_resolver from utils.functions import ( find_binary, @@ -432,35 +431,14 @@ def is_night_light_active() -> bool: # --- Caffeine orchestration --- -_caffeine_process: subprocess.Popen | None = None def is_caffeine_active() -> bool: - if _caffeine_process is not None: - return True - try: - return bool(find_process_pid("modus-inhibit", timeout=0.5)) - except Exception: - return False + return get_inhibit_service().active def toggle_caffeine() -> bool: - global _caffeine_process - try: - if is_caffeine_active(): - inhibit_script = get_relative_path("utils/inhibit.py") - spawn_detached(["python3", inhibit_script, "off"]) - if _caffeine_process: - _caffeine_process.terminate() - _caffeine_process = None - return False - else: - inhibit_script = get_relative_path("utils/inhibit.py") - _caffeine_process = spawn_detached(["python3", inhibit_script, "on"]) - return True - except Exception as e: - logger.error(f"[Modus] Failed to toggle caffeine: {e}") - return False + return get_inhibit_service().toggle() # --- Hyprland window management API --- @@ -549,14 +527,9 @@ def close_window(address: str): def launch_app(command_line: str): - if not command_line: - return + from globalmenu.launch import launch_command - cleaned = re.sub(r"%\w+", "", command_line).strip() - - exec_shell_command_async( - f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'" - ) + launch_command(command_line) def open_trash(): diff --git a/src/services/mpris.py b/src/services/mpris.py index 2050bde..00d5ead 100644 --- a/src/services/mpris.py +++ b/src/services/mpris.py @@ -1,11 +1,12 @@ import hashlib import mimetypes +import threading import urllib.parse import urllib.request from pathlib import Path import gi -from fabric.utils import GLib, logger +from fabric.utils import GLib, exec_shell_command_async, logger gi.require_version("Playerctl", "2.0") from fabric import Fabricator # noqa: E402 @@ -16,6 +17,45 @@ from shared.data import CACHE_DIR # noqa: E402 TEMP_DIR = CACHE_DIR +# Native Linux media artwork cache (libmediaart). Optional: when the GI +# namespace is unavailable we transparently fall back to our own cache dir, +# so the service keeps working without adding a hard dependency. +try: + gi.require_version("MediaArt", "2.0") + from gi.repository import MediaArt + + _MEDIAART_AVAILABLE = True +except (ValueError, ImportError): + MediaArt = None + _MEDIAART_AVAILABLE = False + + +def _mediaart_cache_path(artist: str | None, album: str | None, title: str | None): + """Standard media-art cache path for a track, or None when unavailable. + + Does NOT download; merely derives the canonical cache location so we can + both look artwork up and store new downloads following the same rules. + """ + if not _MEDIAART_AVAILABLE: + return None + try: + result = MediaArt.get_path( + artist or None, album or None, title or None, MediaArt.Type.ALBUM + ) + except Exception as e: + logger.debug(f"MediaArt.get_path failed: {e}") + return None + # PyGObject return shape varies by version: (found, path, uri) or (path, uri). + if isinstance(result, tuple): + if len(result) == 3: + found, path, _uri = result + elif len(result) == 2: + found, path = True, result[0] + else: + return None + return path if found else None + return result + class PlayerService(Service): @Signal @@ -95,19 +135,19 @@ class PlayerService(Service): def play_pause(self, *_): try: self._player.play_pause() - except Exception as e: + except GLib.Error as e: logger.warning(f"play_pause failed: {e}") def next(self, *_): try: self._player.next() - except Exception as e: + except GLib.Error as e: logger.warning(f"next failed: {e}") def previous(self, *_): try: self._player.previous() - except Exception as e: + except GLib.Error as e: logger.warning(f"previous failed: {e}") def __init__(self, player: Playerctl.Player, **kwargs): @@ -117,6 +157,7 @@ class PlayerService(Service): self._current_artwork_path = "" self._is_cleaning_up = False self._signal_ids = [] + self._last_emitted_status = "" self._signal_ids.append( self._player.connect("playback-status", self.on_playback_status) @@ -124,7 +165,6 @@ class PlayerService(Service): self._signal_ids.append(self._player.connect("metadata", self.on_metadata)) self._signal_ids.append(self._player.connect("seeked", self.on_seeked)) - self.status = self._player.props.playback_status self._pos_polling = False # guard: Fabricator.start() blindly creates a new # GLib timer every call — this flag prevents stacking self.pos_fabricator = Fabricator( @@ -143,11 +183,48 @@ class PlayerService(Service): try: metadata = self._player.props.metadata if metadata: - self.meta_change(metadata, self._player) + # meta_change emission must not block artwork discovery. + try: + self.meta_change(metadata, self._player) + except Exception as e: + logger.debug(f"meta_change emit failed: {e}") self._handle_artwork(metadata) except Exception as e: logger.warning(f"Failed to initialize metadata: {e}") + # Playerctl loads player properties asynchronously. For a player that + # was already playing before Modus started, the initial metadata read + # above can be empty, and the `metadata` signal only fires on *change* + # — so it would never re-fire and artwork would stay missing. Browser + # players (YouTube in Firefox/Chromium) in particular populate + # `mpris:artUrl` lazily, seconds after playback begins. Re-read metadata + # repeatedly for a short window so the artwork is eventually picked up. + GLib.timeout_add(500, self._refresh_initial_state, 0) + + def _refresh_initial_state(self, attempt: int = 0): + if self._is_cleaning_up: + return False + try: + metadata = self._player.props.metadata + except Exception: + return False + if metadata: + # Push full metadata only once to avoid UI churn on every retry. + if attempt == 0: + try: + self.meta_change(metadata, self._player) + except Exception as e: + logger.debug(f"meta_change emit failed: {e}") + self._handle_artwork(metadata) + self.fabricating(metadata) + # Stop as soon as artwork was found/queued. + if self._current_artwork_hash: + return False + # Retry for ~10s (browser players can be very slow to expose artUrl). + if attempt < 12: + GLib.timeout_add(800, self._refresh_initial_state, attempt + 1) + return False + def get_artwork(self) -> str: return self._current_artwork_path @@ -181,14 +258,17 @@ class PlayerService(Service): current = self.get_position() / 1_000_000 try: self._player.set_position(int(pos * 1_000_000)) - except Exception: + except GLib.Error: try: offset = pos - current self._player.seek(int(offset * 1_000_000)) - except Exception: - import os - - os.system(f"playerctl -p {self.player_name} position {pos}") + except GLib.Error: + name = self.player_name + if name: + # No shell: pass args as a list so player_name can't inject. + exec_shell_command_async( + ["playerctl", "-p", name, "position", str(pos)] + ) finally: if self.playback_status.lower() == "playing": self._start_pos_fabricator() @@ -232,6 +312,11 @@ class PlayerService(Service): dur = metadata["mpris:length"] / 1_000_000 except Exception: dur = 0 + # Browser players (YouTube in Firefox/Chromium) populate + # `mpris:artUrl` lazily and don't reliably emit the `metadata` + # signal. Since fabricating runs every 2s while playing, this also + # polls the artwork so it shows up even for an already-playing tab. + self._handle_artwork(metadata) self.track_position(pos, dur) def on_seeked(self, player, position): @@ -240,97 +325,144 @@ class PlayerService(Service): if self.playback_status.lower() == "playing": self._start_pos_fabricator() - def on_playback_status(self, player, status): - """DBus signal handler - instant when it fires (e.g. Modus buttons).""" + def _notify_playback(self, status_str: str): + """Emit play/pause once per actual state change (dedupes the + Playerctl signal and the status Fabricator so listeners don't churn).""" if self._is_cleaning_up: return - self.status = status - self.poll_progress() - if self.playback_status.lower() == "playing": + status_str = (status_str or "").lower() + if status_str == self._last_emitted_status: + return + self._last_emitted_status = status_str + if status_str == "playing": self.play() else: self.pause() - def _on_polled_status_change(self, new_status: str): - """Fabricator-driven status change - fires reliably every second.""" + def on_playback_status(self, player, status): + """DBus signal handler - instant when it fires (e.g. Modus buttons).""" if self._is_cleaning_up: return self.poll_progress() - if new_status.lower() == "playing": - self.play() - else: - self.pause() + self._notify_playback(self.playback_status) + + def _on_polled_status_change(self, new_status: str): + """Fabricator-driven status change - fires reliably for browser players + that don't emit playback-status signals.""" + if self._is_cleaning_up: + return + self.poll_progress() + self._notify_playback(new_status) def on_metadata(self, player, metadata): if self._is_cleaning_up: return self.meta_change(metadata, player) - self._handle_artwork(metadata) self.fabricating(metadata) + def _meta_str(self, metadata, key: str) -> str | None: + try: + value = metadata[key] + except (KeyError, TypeError, GLib.Error): + return None + if isinstance(value, (list, tuple)): + return " ".join(str(v) for v in value) if value else None + return str(value) if value else None + + def _find_cached_artwork(self, metadata) -> str | None: + """Locate already-cached artwork for this track (native MediaArt cache).""" + if not _MEDIAART_AVAILABLE: + return None + artist = self._meta_str(metadata, "xesam:artist") + album = self._meta_str(metadata, "xesam:album") + title = self._meta_str(metadata, "xesam:title") + if not (artist or album or title): + return None + base = _mediaart_cache_path(artist, album, title) + if not base: + return None + base = Path(base) + if base.exists(): + return str(base) + # MediaArt files usually carry an extension; look for any sibling. + matches = list(base.parent.glob(base.stem + ".*")) + return str(matches[0]) if matches else None + + def _existing_local_artwork(self, artwork_hash: str, metadata) -> str | None: + cached = self._find_cached_artwork(metadata) + if cached: + return cached + cache_dir = TEMP_DIR / "player-art" + matches = list(cache_dir.glob(f"{artwork_hash}.*")) + return str(matches[0]) if matches else None + def _handle_artwork(self, metadata): if self._is_cleaning_up: return try: art_url = metadata["mpris:artUrl"] - except Exception: + except (KeyError, TypeError): + return + if not art_url: return artwork_hash = hashlib.md5(art_url.encode()).hexdigest() if artwork_hash == self._current_artwork_hash: return - self._current_artwork_hash = artwork_hash - parsed = urllib.parse.urlparse(art_url) + parsed = urllib.parse.urlparse(art_url) if parsed.scheme == "file": - self._set_artwork(urllib.parse.unquote(parsed.path)) - elif parsed.scheme in ("http", "https"): - GLib.Thread.new( - "download-artwork", - self._download_artwork, - art_url, - artwork_hash, - ) + local = urllib.parse.unquote(parsed.path) + if Path(local).exists(): + self._set_artwork(local) + return + + if parsed.scheme in ("http", "https"): + existing = self._existing_local_artwork(artwork_hash, metadata) + if existing and Path(existing).exists(): + self._set_artwork(existing) + return + threading.Thread( + target=self._download_artwork, + args=(art_url, artwork_hash, metadata), + daemon=True, + ).start() def _set_artwork(self, path: str): + if self._is_cleaning_up: + return self._current_artwork_path = path self.artwork_change(path) - def _download_artwork(self, art_url: str, artwork_hash: str): + def _artwork_target_path(self, artwork_hash: str, metadata, suffix: str) -> Path: + """Where to store a freshly downloaded cover: the standard MediaArt + cache location when available, otherwise our own fallback cache.""" + ma_base = self._find_cached_artwork(metadata) + if ma_base: + base = Path(ma_base) + return base.parent / (base.stem + suffix) + cache_dir = TEMP_DIR / "player-art" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / f"{artwork_hash}{suffix}" + + def _download_artwork(self, art_url: str, artwork_hash: str, metadata): if self._is_cleaning_up: return try: - cache_dir = TEMP_DIR / "player-art" - cache_dir.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(art_url, timeout=5) as response: + data = response.read() + suffix = ( + mimetypes.guess_extension(response.info().get_content_type()) + or ".png" + ) + target = self._artwork_target_path(artwork_hash, metadata, suffix) + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_bytes(data) + tmp.replace(target) - filename_hash = hashlib.md5(art_url.encode()).hexdigest() - parsed = urllib.parse.urlparse(art_url) - - local_arturl = None - url_suffix = Path(parsed.path).suffix - if url_suffix: - test_path = cache_dir / f"{filename_hash}{url_suffix}" - if test_path.exists(): - local_arturl = test_path - else: - existing = list(cache_dir.glob(f"{filename_hash}.*")) - if existing: - local_arturl = existing[0] - - if not local_arturl: - with urllib.request.urlopen(art_url, timeout=5) as response: - data = response.read() - suffix = ( - mimetypes.guess_extension(response.info().get_content_type()) - or ".png" - ) - local_arturl = cache_dir / f"{filename_hash}{suffix}" - tmp = local_arturl.with_suffix(".tmp") - tmp.write_bytes(data) - tmp.replace(local_arturl) - - GLib.idle_add(self._set_artwork, str(local_arturl)) + if not self._is_cleaning_up: + GLib.idle_add(self._set_artwork, str(target)) except Exception as e: logger.error(f"Failed to download artwork: {e}") diff --git a/src/services/network.py b/src/services/network.py index 8561b0b..fc7ff12 100644 --- a/src/services/network.py +++ b/src/services/network.py @@ -25,12 +25,12 @@ class Wifi(Service): self._ap_signal: int | None = None super().__init__(**kwargs) - self._client.connect( + self._client_handler = self._client.connect( "notify::wireless-enabled", lambda *args: self.notifier("enabled", args), ) if self._device: - bulk_connect( + self._device_handlers = bulk_connect( self._device, { "notify::active-access-point": lambda *args: self._activate_ap(), @@ -41,6 +41,23 @@ class Wifi(Service): ) self._activate_ap() + def close(self): + """Disconnect all signal handlers so the service can be garbage collected.""" + try: + self._client.disconnect(self._client_handler) + except Exception: + pass + for hid in getattr(self, "_device_handlers", ()): + try: + self._device.disconnect(hid) + except Exception: + pass + if getattr(self, "_ap", None) and getattr(self, "_ap_signal", None): + try: + self._ap.disconnect(self._ap_signal) + except Exception: + pass + def ap_update(self): self.emit("changed") for sn in [ @@ -245,6 +262,7 @@ class Ethernet(Service): self._client: NM.Client = client self._device: NM.DeviceEthernet = device + self._handlers: list[int] = [] for pn in ( "active-connection", "icon-name", @@ -252,14 +270,26 @@ class Ethernet(Service): "speed", "state", ): - self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn)) + self._handlers.append( + self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn)) + ) - self._device.connect("notify::speed", lambda *_: self.notifier("speed")) + self._handlers.append( + self._device.connect("notify::speed", lambda *_: self.notifier("speed")) + ) def notifier(self, pn): self.notify(pn) self.emit("changed") + def close(self): + """Disconnect all signal handlers so the service can be garbage collected.""" + for hid in getattr(self, "_handlers", ()): + try: + self._device.disconnect(hid) + except Exception: + pass + class NetworkClient(Service): @Signal @@ -325,9 +355,16 @@ class NetworkClient(Service): return if self.wifi_device is wifi: self.wifi_device = next(iter(self.wifi_devices.values()), None) + wifi.close() logger.info(f"[Network] Wifi device removed: {iface}") self.emit("device-removed", iface) + elif dtype == NM.DeviceType.ETHERNET: + if self.ethernet_device is not None: + self.ethernet_device.close() + self.ethernet_device = None + self.emit("device-removed", nm_device.get_iface()) + def _get_primary_device(self) -> Literal["wifi", "wired"] | None: if not self._client: return None diff --git a/src/services/wallpaper.py b/src/services/wallpaper.py index 381a999..33afc2d 100644 --- a/src/services/wallpaper.py +++ b/src/services/wallpaper.py @@ -11,38 +11,45 @@ from shared.data import ( WALLPAPERS_THUMBNAILS_SIZE, ) -for path in (WALLPAPER_PATH, WALLPAPER_THUMBS_PATH): - os.makedirs(path, exist_ok=True) +os.makedirs(WALLPAPER_PATH, exist_ok=True) +os.makedirs(WALLPAPER_THUMBS_PATH, exist_ok=True) def get_thumbnail_filename(image_path: str) -> str: key = hashlib.sha256(image_path.encode()).hexdigest() - size = WALLPAPERS_THUMBNAILS_SIZE - return f"{key}_{size}x{size}.webp" + return f"{key}_{WALLPAPERS_THUMBNAILS_SIZE}x{WALLPAPERS_THUMBNAILS_SIZE}.webp" def create_thumbnail( - image_path: str, thumbnail_path: str, size: int = WALLPAPERS_THUMBNAILS_SIZE + image_path: str, + thumbnail_path: str, + size: int = WALLPAPERS_THUMBNAILS_SIZE, ) -> bool: - """Create WebP thumbnail with proper memory cleanup to prevent leaks.""" - pixbuf = None + """Create a compressed WebP thumbnail.""" + try: - # Load image at scaled size to minimize memory usage - pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(image_path, size, size, True) - # Save as WebP with quality 85 for better compression (60-80% smaller than PNG) - result = pixbuf.savev(thumbnail_path, "webp", ["quality"], ["85"]) - return bool(result) + pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale( + image_path, + size, + size, + preserve_aspect_ratio=True, + ) + + return bool( + pixbuf.savev( + thumbnail_path, + "webp", + ["quality"], + ["85"], + ) + ) + except Exception: return False - finally: - # Explicitly clear pixbuf reference to free memory - if pixbuf is not None: - del pixbuf def generate_colors_from_wallpaper(image_path: str) -> bool: - cmd = f'matugen image "{image_path}"' - return bool(exec_shell_command_async(cmd)) + return bool(exec_shell_command_async(f'matugen image "{image_path}"')) class WallpaperService(Service): @@ -57,55 +64,62 @@ class WallpaperService(Service): def __init__(self, **kwargs): super().__init__(**kwargs) - self._processed_count = 0 self._file_cache: list[str] | None = None def get_all_wallpapers(self, use_cache: bool = True) -> list[str]: if use_cache and self._file_cache is not None: return self._file_cache - all_files = [] - for file in os.listdir(WALLPAPER_PATH): - file_full = WALLPAPER_PATH + "/" + file - mt = mimetypes.guess_type(file_full)[0] - if mt and mt.startswith("image/"): - all_files.append(file_full) + wallpapers: list[str] = [] - self._file_cache = all_files - return all_files + with os.scandir(WALLPAPER_PATH) as entries: + for entry in entries: + if not entry.is_file(): + continue + + mime, _ = mimetypes.guess_type(entry.path) + if mime and mime.startswith("image/"): + wallpapers.append(entry.path) + + self._file_cache = wallpapers + return wallpapers def invalidate_cache(self) -> None: self._file_cache = None def get_thumbnail_path(self, image_path: str) -> str: - thumbnail_filename = get_thumbnail_filename(image_path) - return WALLPAPER_THUMBS_PATH + "/" + thumbnail_filename + return os.path.join( + WALLPAPER_THUMBS_PATH, + get_thumbnail_filename(image_path), + ) def has_thumbnail(self, image_path: str) -> bool: - thumbnail_path = self.get_thumbnail_path(image_path) - return os.path.isfile(thumbnail_path) + return os.path.isfile(self.get_thumbnail_path(image_path)) - def set_wallpaper(self, image_path: str): + def set_wallpaper(self, image_path: str) -> None: exec_shell_command_async( - f"awww img {image_path} --transition-type none --transition-duration 0 --transition-fps 60" + f'awww img "{image_path}" ' + "--transition-type none " + "--transition-duration 0 " + "--transition-fps 60" ) + self.wallpaper_set(image_path) - target_path = os.path.abspath(image_path) - home_dir = os.path.expanduser("~") - link_path = os.path.join(home_dir, ".current.wall") + link_path = os.path.expanduser("~/.current.wall") - if os.path.islink(link_path) or os.path.exists(link_path): + if os.path.lexists(link_path): os.remove(link_path) - os.symlink(target_path, link_path) + os.symlink(os.path.abspath(image_path), link_path) exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0') + self.colors_generated(image_path) def get_wallpaper_info(self, image_path: str) -> dict: - """Get information about a wallpaper""" stat = os.stat(image_path) + return { "path": image_path, "filename": os.path.basename(image_path), diff --git a/src/shared/widgets/flat_scale.py b/src/shared/widgets/flat_scale.py index 40ff5c1..dbe1aa2 100644 --- a/src/shared/widgets/flat_scale.py +++ b/src/shared/widgets/flat_scale.py @@ -344,7 +344,6 @@ class FlatScale(Gtk.DrawingArea, Widget): self._anim_bubble_open.min_value = p self._anim_bubble_open.max_value = 1.0 self._anim_bubble_open.value = p - self._anim_bubble_open._start_time = None self._anim_bubble_open.play() def _bubble_close(self): @@ -356,7 +355,6 @@ class FlatScale(Gtk.DrawingArea, Widget): self._anim_bubble_close.min_value = start self._anim_bubble_close.max_value = 1.0 self._anim_bubble_close.value = start - self._anim_bubble_close._start_time = None self._anim_bubble_close.play() def _sync_bubble_progress(self): @@ -578,9 +576,9 @@ class FlatScale(Gtk.DrawingArea, Widget): ) cr.fill() - # Percentage bubble (drag only) — pass slider_left_x as "slider_top_y" - slider_left_x = slider_cx - slider_thickness / 2 - self._draw_bubble(cr, slider_cy, slider_left_x, styles) + # Percentage bubble (drag only) + max_thickness = max(self._anim_press.min_value, self._anim_press.max_value) + self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles) return False diff --git a/src/shared/window/animated_scrollwindow.py b/src/shared/window/animated_scrollwindow.py index 6d1c95d..ac1c8b7 100644 --- a/src/shared/window/animated_scrollwindow.py +++ b/src/shared/window/animated_scrollwindow.py @@ -80,3 +80,7 @@ class AnimatedScrollable(ScrolledWindow): value = self.height_animator.value value = 0 if value < 0 else value return value, value + + def destroy(self): + self.height_animator.stop() + super().destroy() diff --git a/src/shared/window/battery_widget.py b/src/shared/window/battery_widget.py index 6cdf10a..9b62c7f 100644 --- a/src/shared/window/battery_widget.py +++ b/src/shared/window/battery_widget.py @@ -403,3 +403,14 @@ class BatteryControl(Box): def on_profile_changed(self, service, *args): self.update_energy_mode_buttons() + + def destroy(self): + try: + self.battery_service.disconnect_by_func(self.on_battery_changed) + except Exception: + pass + try: + self.battery_service.disconnect_by_func(self.on_profile_changed) + except Exception: + pass + super().destroy() diff --git a/src/shared/window/popup_win.py b/src/shared/window/popup_win.py index 17c360a..1e87b94 100644 --- a/src/shared/window/popup_win.py +++ b/src/shared/window/popup_win.py @@ -69,13 +69,16 @@ class PopupWindow(WaylandWindow): if not self._pointing_widget: return + # Cleanly (re)connect: always drop any existing handlers first so that + # repeated calls while visible do not stack duplicate connections. + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + self.disconnect_by_func(self.do_handle_size_allocate) + except Exception as e: + if "nothing connected" not in str(e): + logger.error(f"Error disconnecting signal: {e}") + if not self.get_visible(): - try: - self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) - self.disconnect_by_func(self.do_handle_size_allocate) - except Exception as e: - if "nothing connected" not in str(e): - logger.error(f"Error disconnecting signal: {e}") return self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate) @@ -92,6 +95,18 @@ class PopupWindow(WaylandWindow): return None return self.do_reposition(self.do_calculate_edges()) + def destroy(self): + if self._pointing_widget: + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + except Exception: + pass + try: + self.disconnect_by_func(self.do_handle_size_allocate) + except Exception: + pass + super().destroy() + def do_calculate_edges(self): move_axe = "x" parent_window = self.get_parent_window() diff --git a/src/styles/window/spotlight.css b/src/styles/window/spotlight.css index 540e757..b4e799c 100644 --- a/src/styles/window/spotlight.css +++ b/src/styles/window/spotlight.css @@ -1,21 +1,3 @@ -@keyframes app-slot-shine { - from { - background-color: @fg-a12; - } - to { - background-color: alpha(@surface-bright, 0.5); - } -} - -@keyframes header-shine { - from { - background-color: @fg-a12; - } - to { - background-color: alpha(@surface-bright, 0.3); - } -} - #spotlight { margin: 8px; padding: 5px; @@ -40,10 +22,6 @@ transition: background-color apply(transition-medium) ease-in-out; } -.app-search-header.shine { - animation: header-shine 1s ease-in-out infinite alternate; -} - .app-slot { padding: 8px; border: solid 1px transparent; @@ -64,10 +42,6 @@ background-color: @primary-a20; } -.app-slot.shine { - animation: app-slot-shine 0.3s ease-in-out; -} - .app-title { color: @foreground; font-size: apply(font-base); diff --git a/src/utils/app_name_resolver.py b/src/utils/app_name_resolver.py index cb4046e..65e0e05 100644 --- a/src/utils/app_name_resolver.py +++ b/src/utils/app_name_resolver.py @@ -5,14 +5,19 @@ class AppName: def __init__(self, path="/usr/share/applications"): self.files = os.listdir(path) self.path = path + self._cache: dict[str, str | None] = {} def get_app_name(self, wmclass, _format_=False): + if wmclass in self._cache: + return self._cache[wmclass] + desktop_file = "" for f in self.files: if f.startswith(wmclass + ".desktop"): desktop_file = f if desktop_file == "": + self._cache[wmclass] = None return None desktop_app_name = wmclass @@ -22,6 +27,7 @@ class AppName: if line.startswith("Name="): desktop_app_name = line.split("=")[1].strip() break + self._cache[wmclass] = desktop_app_name return desktop_app_name def format_app_name(self, title, wmclass, update=False): diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py index b344679..c30729e 100644 --- a/src/window/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -657,12 +657,6 @@ class BluetoothConnections(Box): device_slot = BluetoothDeviceSlot(device) self.other_devices.add(device_slot) - for slot in existing_paired.values(): - pass - - for slot in existing_other.values(): - pass - if not self._destroyed: has_paired_devices = len(paired_devices) > 0 has_other_devices = len(other_devices) > 0 @@ -679,6 +673,7 @@ class BluetoothConnections(Box): def start_device_monitoring(self): """Start periodic monitoring for device changes""" + self.stop_device_monitoring() self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) def stop_device_monitoring(self): diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py index b53cf79..0acf95d 100644 --- a/src/window/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -7,7 +7,7 @@ from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from services.brightness import Brightness -from services.modus import is_caffeine_active, toggle_caffeine +from services.inhibit import get_inhibit_service from services.network import NetworkClient from shared.widgets.flat_scale import FlatScale from shared.window.applet_window import AppletWindow @@ -133,11 +133,8 @@ class ModusControlCenter(AppletWindow): h_expand=True, ) - # Only connect brightness controls if brightness service is available - if get_brightness_service().max_screen > 0: - pass - else: - # Disable brightness scale if no backlight device available + # Disable brightness scale if no backlight device is available + if get_brightness_service().max_screen <= 0: self.brightness_scale.set_sensitive(False) self._mpris_manager = get_shared_mpris_manager() @@ -722,12 +719,8 @@ class ModusControlCenter(AppletWindow): self.per_app_volume_center_box.set_size_request(300, -1) def _check_initial_states(self): - self.caffeine_mode = is_caffeine_active() + self.caffeine_mode = get_inhibit_service().active self.flight_mode = False - GLib.timeout_add(100, self._update_initial_labels) - - def _update_initial_labels(self): - return False def set_dont_disturb(self, *_): self.focus_mode = not self.focus_mode @@ -770,7 +763,7 @@ class ModusControlCenter(AppletWindow): def toggle_caffeine(self, *_): try: - self.caffeine_mode = toggle_caffeine() + self.caffeine_mode = get_inhibit_service().toggle() self.caffeine_icon.dynamic_file( "applets/caffeine-on.svg" if self.caffeine_mode @@ -1200,11 +1193,6 @@ class ModusControlCenter(AppletWindow): self._cleanup_processes() self._disconnect_signals_when_hidden() - # Force garbage collection - import gc - - gc.collect() - logger.debug("Complete cleanup finished successfully") except Exception as e: diff --git a/src/window/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py index 7cf38cd..d9e270d 100644 --- a/src/window/controlcenter/per_app_volume.py +++ b/src/window/controlcenter/per_app_volume.py @@ -11,6 +11,43 @@ from shared.widgets.flat_scale import FlatScale # Local imports from utils.roam import audio_service +# Mappings used to resolve an app's icon/display name from its description or +# name. Defined once at module level to avoid rebuilding on every refresh. +_APP_ICON_MAPPING = { + "spotify": "spotify", + "firefox": "firefox", + "chromium": "chromium-browser", + "chrome": "google-chrome", + "vlc": "vlc", + "discord": "discord", + "steam": "steam", + "zen": "zen-browser", + "code": "vscode", + "visual studio code": "vscode", + "telegram": "telegram-desktop", + "pulse": "audio-card", + "pipewire": "audio-card", + "alsa": "audio-card", + "sink": "audio-speakers", + "source": "audio-input-microphone", + "youtube": "youtube", + "music": "rhythmbox", + "media": "multimedia-player", +} + +_APP_SPECIAL_NAMES = { + "spotify": "Spotify", + "firefox": "Firefox", + "chromium": "Chromium", + "chrome": "Chrome", + "vlc": "VLC", + "discord": "Discord", + "steam": "Steam", + "zen": "Zen Browser", + "code": "VS Code", + "telegram": "Telegram", +} + class PerAppVolumeControl(Box): """Per-application volume control widget""" @@ -125,27 +162,7 @@ class PerAppVolumeControl(Box): # Check description first as it's more reliable search_text = app_description if app_description else app_name - icon_mapping = { - "spotify": "spotify", - "firefox": "firefox", - "chromium": "chromium-browser", - "chrome": "google-chrome", - "vlc": "vlc", - "discord": "discord", - "steam": "steam", - "zen": "zen-browser", - "code": "vscode", - "visual studio code": "vscode", - "telegram": "telegram-desktop", - "pulse": "audio-card", - "pipewire": "audio-card", - "alsa": "audio-card", - "sink": "audio-speakers", - "source": "audio-input-microphone", - "youtube": "youtube", - "music": "rhythmbox", - "media": "multimedia-player", - } + icon_mapping = _APP_ICON_MAPPING # Check if any key in mapping is contained in search text for key, icon in icon_mapping.items(): @@ -167,22 +184,9 @@ class PerAppVolumeControl(Box): return "Unknown" # Handle common app names specially - special_names = { - "spotify": "Spotify", - "firefox": "Firefox", - "chromium": "Chromium", - "chrome": "Chrome", - "vlc": "VLC", - "discord": "Discord", - "steam": "Steam", - "zen": "Zen Browser", - "code": "VS Code", - "telegram": "Telegram", - } - name_lower = name.lower() - if name_lower in special_names: - return special_names[name_lower] + if name_lower in _APP_SPECIAL_NAMES: + return _APP_SPECIAL_NAMES[name_lower] # Default: capitalize first letter return name.capitalize() diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py index 1a5dce9..07f5cd8 100644 --- a/src/window/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -631,6 +631,7 @@ class WifiConnections(Box): def start_network_monitoring(self): """Start periodic monitoring for network changes""" + self.stop_network_monitoring() self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh) def stop_network_monitoring(self): diff --git a/src/window/dock/canvas.py b/src/window/dock/canvas.py index 7cb10c3..4b781d1 100644 --- a/src/window/dock/canvas.py +++ b/src/window/dock/canvas.py @@ -56,6 +56,10 @@ from .layout import DockLayout _PIXBUF_CACHE_MAX = 64 +# Terminal/interpreter executables whose name alone should not force a match +# against unrelated desktop apps during dock item resolution. +_COMMON_EXECUTABLES = ("kitty", "bash", "sh", "zsh", "python", "python3") + class DockCanvas(Gtk.DrawingArea): def __init__(self, parent_window): @@ -136,9 +140,6 @@ class DockCanvas(Gtk.DrawingArea): self._desktop_apps = [] return self._desktop_apps - def _refresh_desktop_apps(self) -> None: - self._desktop_apps = [] - def _canvas_height(self) -> int: size = self._base_icon_size() return int( @@ -921,7 +922,9 @@ class DockCanvas(Gtk.DrawingArea): if command_line: launch_app(command_line) elif hasattr(app_info, "launch"): - app_info.launch() + from globalmenu.launch import launch_desktop_app + + launch_desktop_app(app_info) else: logger.error( f"[DockCanvas] Cannot determine launch command: {app_info}" @@ -976,8 +979,7 @@ class DockCanvas(Gtk.DrawingArea): if app.executable: exe = os.path.basename(app.executable).lower() if exe in expanded: - common = ["kitty", "bash", "sh", "zsh", "python", "python3"] - if exe in common: + if exe in _COMMON_EXECUTABLES: app_names = [ getattr(app, "name", ""), getattr(app, "display_name", ""), diff --git a/src/window/lock.py b/src/window/lock.py index b95c63f..dd95e1a 100644 --- a/src/window/lock.py +++ b/src/window/lock.py @@ -257,12 +257,12 @@ def initialize(): ), ) lockscreen.show() + return lock, lockscreen def main(): set_process_name("lockscreen") - initialize() - lockscreen = LockScreen(GtkSessionLock.Lock()) + _lock, lockscreen = initialize() global app app = Application("lock", lockscreen) diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index 4c0d200..718f210 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -21,7 +21,7 @@ from services.modus import notification_service from shared.widgets.clipping_box import ClippingBox from shared.widgets.custom_image import CustomImage from shared.widgets.customrevealer import SlideRevealer -from utils.functions import escape_markup_text, parse_timeout_string +from utils.functions import escape_markup_text from utils.roam import modus_service from utils.icon_resolver import IconResolver @@ -1071,9 +1071,6 @@ class ModusNoti(Window): # Preload assets immediately for optimal caching and display performance preload_notification_assets(notification) - # Implement smart queue management for smooth transitions - GLib.get_monotonic_time() / 1000 - # If queue is getting full, remove oldest notifications smoothly if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: # Remove oldest notification from queue (not current showing one) @@ -1271,21 +1268,6 @@ class ModusNoti(Window): def update_config(self, new_config): """Update notification configuration dynamically.""" try: - # Handle notification timeout changes - if "notification_timeout" in new_config: - timeout_str = new_config["notification_timeout"] - # Parse the timeout string to milliseconds - - parse_timeout_string(timeout_str) - - # Handle ignored apps changes - if "notification_ignored_apps" in new_config: - new_config["notification_ignored_apps"] - - # Handle limited apps history changes - if "notification_limited_apps_history" in new_config: - new_config["notification_limited_apps_history"] - # Update the current config self._current_config.update(new_config) diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py index 8b52b33..135531b 100644 --- a/src/window/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -178,13 +178,9 @@ class ExpandableNotificationGroup(Box): self.add(self.collapsed_eventbox) - # Create expanded state (hidden initially) - self.create_expanded_state() - def _get_notification_pixbuf_for_group(self, cached_notification): """Get notification pixbuf at 64x64 - matches app icon size for consistency""" notification = cached_notification._notification - getattr(notification, "id", None) pixbuf = None # First try to get cached notification image using stored cache key @@ -372,10 +368,6 @@ class ExpandableNotificationGroup(Box): self.expanded_container.set_visible(False) return False # Don't repeat timeout - def _complete_collapse(self): - """Complete the collapse animation - no longer needed but kept for compatibility""" - return False # Don't repeat timeout - def close_all(self, *args): """Close all notifications in this group with proper cache cleanup""" # Close all notifications in this group @@ -462,7 +454,6 @@ class NotificationCenterWidget(NotificationWidget): def _get_notification_pixbuf(self, notification): """Get notification pixbuf at 64x64 - matches app icon size for consistency""" - getattr(notification, "id", None) pixbuf = None # First try to get cached notification image using stored cache key @@ -819,8 +810,11 @@ class NotificationCenter(AppletWindow): group_widget.remove(child) child.destroy() - # Recreate content + # Recreate content (collapsed + expanded states) group_widget.create_collapsed_state() + group_widget.create_expanded_state() + group_widget.collapsed_eventbox.set_visible(True) + group_widget.expanded_container.set_visible(False) group_widget.show_all() except Exception as e: diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 8a39d83..9def74a 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -69,12 +69,6 @@ class Panel(Window): self.tray.connect("add", self._update_tray_visibility) self.tray.connect("remove", self._update_tray_visibility) - self.indicators = Box( - name="indicators", - orientation="h", - spacing=4, - ) - self._spotlight_toggle_cb = None self.search = Button( name="panel-button", diff --git a/src/window/panel/notch/indicators.py b/src/window/panel/notch/indicators.py index 28036be..d3dafbb 100644 --- a/src/window/panel/notch/indicators.py +++ b/src/window/panel/notch/indicators.py @@ -334,9 +334,24 @@ class MicrophoneIndicator(Box): self._connect_mic_signals() def _connect_mic_signals(self): - if self._audio.microphone: - self._audio.microphone.connect("changed", self._on_mic_stream_changed) - self._sync_state() + mic = self._audio.microphone + if mic is None: + return + # Disconnect from a previously-connected device if it changed, and + # avoid stacking duplicate handlers on the same device. + old = getattr(self, "_mic_device", None) + if old is not None and old is not mic: + try: + old.disconnect_by_func(self._on_mic_stream_changed) + except Exception: + pass + try: + mic.disconnect_by_func(self._on_mic_stream_changed) + except Exception: + pass + mic.connect("changed", self._on_mic_stream_changed) + self._mic_device = mic + self._sync_state() def _on_mic_device_changed(self, *_): self._connect_mic_signals() @@ -371,8 +386,13 @@ class MicrophoneIndicator(Box): self._hide_timer_id = 0 try: self._audio.disconnect_by_func(self._on_mic_device_changed) - if self._audio.microphone: - self._audio.microphone.disconnect_by_func(self._on_mic_stream_changed) - except Exception as e: - logger.warning(f"[NotchIndicators] mic disconnect: {e}") + except Exception: + pass + old = getattr(self, "_mic_device", None) + target = old if old is not None else self._audio.microphone + if target is not None: + try: + target.disconnect_by_func(self._on_mic_stream_changed) + except Exception as e: + logger.warning(f"[NotchIndicators] mic disconnect: {e}") super().destroy() diff --git a/src/window/panel/notch/notch.py b/src/window/panel/notch/notch.py index 4e27c46..6919b2b 100644 --- a/src/window/panel/notch/notch.py +++ b/src/window/panel/notch/notch.py @@ -188,6 +188,12 @@ class Notch(Box): def _on_player_vanish(self, _manager, name: str): if name in self._music_services: self._music_services.remove(name) + try: + svc = self._mpris.get_player_service(name) + if svc is not None: + svc.disconnect_by_func(self._on_any_artwork) + except Exception: + pass self._apply_stack_state() # ── Scroll ──────────────────────────────────────────────────────────────── diff --git a/src/window/screencapture/main.py b/src/window/screencapture/main.py index b001ea5..1dc8517 100644 --- a/src/window/screencapture/main.py +++ b/src/window/screencapture/main.py @@ -257,6 +257,19 @@ class ScreenCaptureWindow(Window): _handler_id[0] = screen_capture_service.connect("screenshot-taken", _on_taken) + def _safe_disconnect(): + if _handler_id[0] is not None: + try: + screen_capture_service.disconnect(_handler_id[0]) + except Exception: + pass + _handler_id[0] = None + return False + + # Safety net: disconnect if the service never emits (e.g. capture failed + # silently), otherwise the one-shot handler would leak on every shot. + GLib.timeout_add_seconds(10, _safe_disconnect) + ok = screen_capture_service.screenshot( target, output_dir=output_dir, diff --git a/src/window/spotlight/core/manager.py b/src/window/spotlight/core/manager.py index b018a65..98d9b89 100644 --- a/src/window/spotlight/core/manager.py +++ b/src/window/spotlight/core/manager.py @@ -113,8 +113,6 @@ class PluginManager: if entry.module_name: self._loader.unload_module(entry.module_name) - from pathlib import Path - fp = Path(entry.path) if entry.path else None if fp and fp.exists(): source = entry.source diff --git a/src/window/spotlight/core/search.py b/src/window/spotlight/core/search.py index d9b69e9..9b6fe8f 100644 --- a/src/window/spotlight/core/search.py +++ b/src/window/spotlight/core/search.py @@ -75,7 +75,7 @@ class SearchPipeline: self._current_token.cancel() with self._lock: for t in self._pending_threads: - t.join(timeout=0.5) + t.join(timeout=0.1) self._pending_threads.clear() for source_id in self._pending_idle_ids: GLib.source_remove(source_id) @@ -112,13 +112,33 @@ class SearchPipeline: results = self._safe_search(instance, query, token) self._stamp_results(results, entry) if not token.is_cancelled: - source_id = GLib.idle_add(lambda: callback(results)) + + def _fire(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError: + pass + callback(results) + return False + + source_id = GLib.idle_add(_fire) with self._lock: self._pending_idle_ids.append(source_id) except Exception as e: logger.error(f"[SearchPipeline] Error searching {entry.id}: {e}") if not token.is_cancelled: - source_id = GLib.idle_add(lambda: callback([])) + + def _fire_err(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError: + pass + callback([]) + return False + + source_id = GLib.idle_add(_fire_err) with self._lock: self._pending_idle_ids.append(source_id) @@ -143,6 +163,8 @@ class SearchPipeline: remaining = len(searchable) def _on_all_done(): + if token.is_cancelled: + return merged = self._merge_results(collected) callback(merged) @@ -151,7 +173,17 @@ class SearchPipeline: with results_lock: remaining -= 1 if remaining <= 0: - source_id = GLib.idle_add(_on_all_done) + + def _fire_all_done(): + with self._lock: + try: + self._pending_idle_ids.remove(source_id) + except ValueError: + pass + _on_all_done() + return False + + source_id = GLib.idle_add(_fire_all_done) with self._lock: self._pending_idle_ids.append(source_id) diff --git a/src/window/spotlight/main.py b/src/window/spotlight/main.py index 73edd9b..fb80167 100644 --- a/src/window/spotlight/main.py +++ b/src/window/spotlight/main.py @@ -1,4 +1,3 @@ -import gc import os from fabric.core import Signal @@ -98,7 +97,7 @@ class Spotlight(Box): self.children = self.header, self.scrolled_clip # Search - @debounce(200) + @debounce(300) def on_entry_changed(self, entry: Entry, *_): if getattr(entry, "_ignore_change", False): entry._ignore_change = False @@ -128,7 +127,11 @@ class Spotlight(Box): self.header_icon.set_from_icon_name( getattr(inst, "icon", "") or "system-search-symbolic" ) - self.plugin_service.pipeline.search(text, self._on_search_results) + # Scope to the detected plugin only — a global search would + # mix in unrelated plugins' results. + self.plugin_service.pipeline.search_single( + pe, text, self._on_search_results + ) return self.header_icon.set_from_icon_name("system-search-symbolic") @@ -225,7 +228,27 @@ class Spotlight(Box): self._deactivate_keyword_plugin() def _on_search_results(self, results: list[SearchResult]): - self._clear_viewport() + new_ids = [r.id for r in results] + old_ids = list(self._result_widgets.keys()) + + if new_ids == old_ids: + self._current_results = results + return + + for rid in set(old_ids) - set(new_ids): + self._remove_result_widget(rid) + + for child in list(self.viewport.children): + self.viewport.remove(child) + + for result in results: + widget = self._result_widgets.get(result.id) + if not widget: + widget = self._render_result(result) + self._result_widgets[result.id] = widget + widget.connect("enter-notify-event", self.on_slot_enter) + self.viewport.add(widget) + self._current_results = results if not results: @@ -234,15 +257,19 @@ class Spotlight(Box): return self._auto_start_refresh_timer(results) - - for result in results: - widget = self._render_result(result) - self._result_widgets[result.id] = widget - self.viewport.add(widget) - widget.connect("enter-notify-event", self.on_slot_enter) - self._post_arrange() + def _remove_result_widget(self, result_id: str): + widget = self._result_widgets.pop(result_id, None) + if not widget: + return + try: + widget.disconnect_by_func(self.on_slot_enter) + except (TypeError, ValueError): + pass + self.viewport.remove(widget) + widget.destroy() + # Rendering def _render_result(self, result: SearchResult) -> Button: rt = result.render_type @@ -298,7 +325,7 @@ class Spotlight(Box): style_classes="app-slot", child=Box(orientation="h", spacing=12, children=children), tooltip_text=r.subtitle, - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_calc(self, r: SearchResult) -> Button: @@ -330,7 +357,7 @@ class Spotlight(Box): ), ], ), - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_emoji(self, r: SearchResult) -> Button: @@ -368,7 +395,7 @@ class Spotlight(Box): ], ), tooltip_text=f"Click to copy {emoji_char}", - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_power(self, r: SearchResult) -> Button: @@ -399,7 +426,7 @@ class Spotlight(Box): ], ), tooltip_text=f"Click to {r.title.lower()} the system", - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_wallpaper(self, r: SearchResult) -> Button: @@ -415,7 +442,7 @@ class Spotlight(Box): return Button( style_classes="app-slot wallpaper", child=child_widget, - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_clipboard_text(self, r: SearchResult) -> Button: @@ -436,7 +463,7 @@ class Spotlight(Box): ], ), tooltip_text=r.subtitle, - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_clipboard_image(self, r: SearchResult) -> Button: @@ -465,7 +492,7 @@ class Spotlight(Box): return Button( style_classes="app-slot wallpaper clipboard-image", child=child_widget, - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _render_default(self, r: SearchResult) -> Button: @@ -497,7 +524,7 @@ class Spotlight(Box): return Button( style_classes="app-slot", child=Box(orientation="h", spacing=12, children=children), - on_clicked=lambda *_: self._activate_result(r), + on_clicked=lambda *_: self._activate_result_by_id(r.id), ) def _activate_result(self, result: SearchResult): @@ -505,19 +532,23 @@ class Spotlight(Box): result.action() self.launched() + def _activate_result_by_id(self, result_id: str): + for r in self._current_results: + if r.id == result_id: + self._activate_result(r) + return + # Viewport management def _clear_viewport(self): - old_results = self._current_results self._current_results = [] - self._result_widgets.clear() - for child in self.viewport.children: + for child in list(self.viewport.children): try: child.disconnect_by_func(self.on_slot_enter) except (TypeError, ValueError): pass self.viewport.remove(child) child.destroy() - del old_results + self._result_widgets.clear() def update_result_subtitle(self, result_id: str, text: str) -> None: """Update subtitle label of a rendered result in-place (no re-render).""" @@ -528,9 +559,6 @@ class Spotlight(Box): if not child: return # Walk: Button → main Box → title_box (last Box) → subtitle Label (2nd) - from fabric.widgets.box import Box - from fabric.widgets.label import Label - boxes = [c for c in child.get_children() if isinstance(c, Box)] if not boxes: return @@ -551,12 +579,6 @@ class Spotlight(Box): self.scrolled_window.show() self.scrolled_window.animate_size(new_height) - for i, slot in enumerate(self.viewport.children, start=1): - if i > 8: - break - slot.set_style(f"animation-duration: {round(i * 300)}ms;") - slot.add_style_class("shine") - if self.viewport.children: self.set_selected_index(0) @@ -660,15 +682,17 @@ class SpotlightWindow(Window): def close_spotlight(self): self.spotlight_box.plugin_service.pipeline.cancel() + self.spotlight_box._stop_refresh_timer() + + self.spotlight_box.header_entry.set_text("") + debounce_id = getattr( self.spotlight_box, "_debounce_timer_on_entry_changed", None ) if debounce_id: GLib.source_remove(debounce_id) self.spotlight_box._debounce_timer_on_entry_changed = 0 - self.spotlight_box._stop_refresh_timer() - self.spotlight_box.header_entry.set_text("") self.spotlight_box._clear_viewport() self.spotlight_box.scrolled_window.animate_size(0) self.spotlight_box.scrolled_window.hide() @@ -676,7 +700,6 @@ class SpotlightWindow(Window): self.spotlight_box.header_icon.set_from_icon_name("system-search-symbolic") self.spotlight_box._deactivate_keyword_plugin() self.spotlight_box.plugin_service.manager.release_memory_all() - gc.collect() self.hide() def toggle( diff --git a/src/window/spotlight/plugins/application.py b/src/window/spotlight/plugins/application.py index cc75f51..f0e543f 100644 --- a/src/window/spotlight/plugins/application.py +++ b/src/window/spotlight/plugins/application.py @@ -1,3 +1,4 @@ +import time from typing import Any from fabric.utils import DesktopApp, get_desktop_applications @@ -17,6 +18,8 @@ class ApplicationPlugin(SpotlightPlugin): def __init__(self, context): super().__init__(context) self._desktop_apps: list[DesktopApp] = [] + self._apps_loaded_at: float = 0.0 + self._apps_ttl: float = 1.0 def initialize(self) -> None: self._desktop_apps = get_desktop_applications() @@ -28,6 +31,16 @@ class ApplicationPlugin(SpotlightPlugin): if not query: return [] + # Re-scan (throttled) so newly installed .desktop files appear + # without a restart, while avoiding a disk scan on every keystroke. + now = time.monotonic() + if now - self._apps_loaded_at > self._apps_ttl: + try: + self._desktop_apps = get_desktop_applications() + self._apps_loaded_at = now + except Exception: + pass + results: list[SearchResult] = [] for app in self._desktop_apps: display_name = getattr(app, "display_name", "") or "" @@ -60,7 +73,9 @@ class ApplicationPlugin(SpotlightPlugin): @staticmethod def _launch_app(app: DesktopApp) -> None: - app.launch() + from globalmenu.launch import launch_desktop_app + + launch_desktop_app(app) PLUGIN = ApplicationPlugin diff --git a/src/window/spotlight/plugins/clipboard.py b/src/window/spotlight/plugins/clipboard.py index 3cae302..ffc0005 100644 --- a/src/window/spotlight/plugins/clipboard.py +++ b/src/window/spotlight/plugins/clipboard.py @@ -13,6 +13,7 @@ class ClipboardPlugin(SpotlightPlugin): id = "clipboard" name = "Clipboard" icon = "edit-paste-symbolic" + keywords = ["clip", "clipboard"] searchable = True priority = 80 @@ -45,7 +46,11 @@ class ClipboardPlugin(SpotlightPlugin): def search(self, query: str, token: Any) -> list[SearchResult]: q = query.strip() - if q.lower().startswith("clip "): + if q.lower().startswith("clipboard "): + q = q[10:].strip() + elif q.lower() == "clipboard": + q = "" + elif q.lower().startswith("clip "): q = q[5:].strip() elif q.lower() == "clip": q = "" diff --git a/src/window/spotlight/plugins/emoji.py b/src/window/spotlight/plugins/emoji.py index f746026..f4074a9 100644 --- a/src/window/spotlight/plugins/emoji.py +++ b/src/window/spotlight/plugins/emoji.py @@ -36,7 +36,6 @@ class EmojiPlugin(SpotlightPlugin): return cldr = self._load_cldr_annotations() self._emoji_data = cldr or {} - logger.info(f"[Emoji] Loaded {len(self._emoji_data)} emojis") def detect(self, text: str) -> bool: s = text.strip() diff --git a/src/window/spotlight/plugins/wallpapers.py b/src/window/spotlight/plugins/wallpapers.py index e290de0..022a67c 100644 --- a/src/window/spotlight/plugins/wallpapers.py +++ b/src/window/spotlight/plugins/wallpapers.py @@ -1,4 +1,3 @@ -import gc import os import random from typing import Any @@ -8,7 +7,7 @@ from shared.data import WALLPAPERS_THUMBNAILS_SIZE from utils.functions import fuzzy_filter from window.spotlight.api import SearchResult, SpotlightPlugin -MAX_THUMBS_PER_SEARCH = 5 +MAX_THUMBS_PER_SEARCH = 3 class WallpaperPlugin(SpotlightPlugin): @@ -28,7 +27,6 @@ class WallpaperPlugin(SpotlightPlugin): def cleanup(self) -> None: self._wallpaper_service = None - gc.collect() def release_memory(self) -> None: if self._wallpaper_service: diff --git a/src/window/switcher/main.py b/src/window/switcher/main.py index b393d99..1f4249b 100644 --- a/src/window/switcher/main.py +++ b/src/window/switcher/main.py @@ -612,13 +612,6 @@ class ApplicationSwitcher(Window): if addr in self._addr_to_idx: self._capture.capture_by_handle(addr, 300, 168, False) - def _refill_and_pump(self): - # Called when the switcher becomes visible or windows change - if not self.get_visible(): - return False - self._pump_capture_queue() - return False - def grab_keyboard(self): try: display = Gdk.Display.get_default() diff --git a/uv.lock b/uv.lock index 76d0106..fc6ecef 100644 --- a/uv.lock +++ b/uv.lock @@ -23,79 +23,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] -[[package]] -name = "cffi" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, -] - [[package]] name = "cfgv" version = "3.5.0" @@ -226,13 +153,12 @@ wheels = [ [[package]] name = "modus" version = "2.0.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "fabric" }, { name = "httpx" }, { name = "pam" }, { name = "psutil" }, - { name = "pywayland" }, { name = "six" }, { name = "tomlkit" }, ] @@ -249,7 +175,6 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pam", specifier = ">=0.2.0" }, { name = "psutil", specifier = ">=7.2.2" }, - { name = "pywayland", specifier = ">=0.4.18" }, { name = "six", specifier = ">=1.17.0" }, { name = "tomlkit", specifier = ">=0.15.0" }, ] @@ -349,15 +274,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pygobject" version = "3.50.0" @@ -389,15 +305,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/2d/9fbb3bd686a474d76fbd0b79abdcc016f3da760b1d1c2048bf4c611a4939/python_pam-2.0.2-py3-none-any.whl", hash = "sha256:4ac51dd8953ac59aa45505882b565eef6a22e0423dcf25d63369902080416c20", size = 10658, upload-time = "2022-03-18T00:32:07.802Z" }, ] -[[package]] -name = "pywayland" -version = "0.4.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/93/bc9b258dcb46aafc587438355a9170c3be05a6abb5cd55bf1d600d848fa0/pywayland-0.4.18.tar.gz", hash = "sha256:598ade02783aad05a328f663b51b694c5ab68bd5d1e0926c0da3c5212c566533", size = 247348, upload-time = "2024-07-27T19:24:38.008Z" } - [[package]] name = "pyyaml" version = "6.0.3"