fix(services/spotlight): improve MPRIS artwork handling and scope keyword searches to single plugin

- mpris: native MediaArt cache lookup, dedupe play/pause emission, safe
  cleanup, artwork polling for already-playing browser players, drop
  os.system seek fallback
- spotlight: scope plugin keyword and detect() matches to a single plugin
  via search_single instead of global search; register clipboard keywords
  (clip/clipboard)
This commit is contained in:
sankalp committed 2026-07-13 22:20:19 +05:45
1 parent 1fc7edd483
commit 54ff85ffc9
43 files changed
+1227 -637

No files matched your search

+2 -2
View File
@@ -2,9 +2,9 @@ name: CodeQL
on: on:
push: push:
branches: [main] branches: [main, dev]
pull_request: pull_request:
branches: [main] branches: [main, dev]
schedule: schedule:
- cron: "0 6 * * 1" - cron: "0 6 * * 1"
+2 -2
View File
@@ -2,9 +2,9 @@ name: Pre-commit
on: on:
push: push:
branches: [main] branches: [main, dev]
pull_request: pull_request:
branches: [main] branches: [main, dev]
permissions: permissions:
contents: read contents: read
+31
View File
@@ -71,6 +71,37 @@ cd ../..
uv run start 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 ## Documentation
| Doc | What's inside | | Doc | What's inside |
-11
View File
@@ -22,7 +22,6 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pam>=0.2.0", "pam>=0.2.0",
"psutil>=7.2.2", "psutil>=7.2.2",
"pywayland>=0.4.18",
"six>=1.17.0", "six>=1.17.0",
"tomlkit>=0.15.0", "tomlkit>=0.15.0",
] ]
@@ -44,16 +43,6 @@ dev = [
start = "main:main" start = "main:main"
lock = "window.lock: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] [tool.ruff]
target-version = "py313" target-version = "py313"
line-length = 88 line-length = 88
-1
View File
@@ -3,7 +3,6 @@ fabric @ git+https://github.com/Fabric-Development/fabric.git@b7b0f9975f0cf161cc
httpx==0.28.1 httpx==0.28.1
pam==0.2.0 pam==0.2.0
psutil==7.2.2 psutil==7.2.2
pywayland==0.4.18
ruff==0.15.2 ruff==0.15.2
six==1.17.0 six==1.17.0
tomlkit==0.15.0 tomlkit==0.15.0
+233
View File
@@ -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
+80 -49
View File
@@ -14,12 +14,79 @@ ENV_VARS = {
"UBUNTU_MENUPROXY": "1", "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_BEGIN = "# >>> modus global-menu shim >>>"
_SHIM_END = "# <<< modus global-menu shim <<<" _SHIM_END = "# <<< modus global-menu shim <<<"
def _compile_shim() -> Path | None: def _remove_global_ld_preload():
"""Compile the menu-button shim if needed. Returns path to .so or None.""" """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: try:
if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime: if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime:
return SHIM_SO return SHIM_SO
@@ -48,43 +115,6 @@ def _compile_shim() -> Path | None:
return 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(): def setup_global_menu_environment():
"""Write env config files so GTK apps register with AppMenu at next login. """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. Uses session-level config files that take effect at login.
""" """
try: 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( exec_shell_command(
"dbus-update-activation-environment --systemd " "dbus-update-activation-environment --systemd "
"GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1" "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( exec_shell_command(
"flatpak override --user --talk-name=com.canonical.AppMenu.Registrar" "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: with open(env_path, "w") as f:
for k, v in ENV_VARS.items(): for k, v in ENV_VARS.items():
f.write(f"{k}={v}\n") f.write(f"{k}={v}\n")
if shim_path:
f.write(f"LD_PRELOAD={shim_path}\n")
except OSError: except OSError:
pass pass
@@ -149,8 +182,6 @@ def setup_global_menu_environment():
except OSError: except OSError:
pass pass
_write_shell_rc(shim_path)
logger.info("[GlobalMenu] GTK environment injected") logger.info("[GlobalMenu] GTK environment injected")
except Exception as e: except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}") logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+84
View File
@@ -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": <shim>}`` 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()
+111 -133
View File
@@ -3,8 +3,9 @@
import json import json
import re import re
import threading import threading
import time
from dataclasses import dataclass 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.core.service import Property, Service, Signal
from fabric.utils import ( 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.environment import setup_global_menu_environment
from globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient from globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient
_NODE_NAME_RE = re.compile(r'<node name="([^"]+)"')
_DBUS_TIMEOUT_INTROSPECT = 500 _DBUS_TIMEOUT_INTROSPECT = 500
_DBUS_TIMEOUT_PID = 200 _DBUS_TIMEOUT_PID = 200
_DBUS_TIMEOUT_FAST = 100 _DBUS_TIMEOUT_FAST = 100
@@ -36,21 +39,20 @@ _DE_NAMESPACES = [
"deepin", "deepin",
] ]
_MenuClient = Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]
class _IntrospectionCache: class _IntrospectionCache:
"""Caches DBus Introspect XML per service+path to avoid redundant round-trips.""" """Caches DBus Introspect XML per service+path to avoid redundant round-trips."""
def __init__(self): def __init__(self):
self._cache: Dict[str, str] = {} self._cache: dict[str, str] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def _key(self, service: str, path: str) -> str:
return f"{service}:{path}"
def get(self, service: str, path: str, bus, timeout: int) -> str: def get(self, service: str, path: str, bus, timeout: int) -> str:
if not path or not GLib.Variant.is_object_path(path): if not path or not GLib.Variant.is_object_path(path):
return "" return ""
key = self._key(service, path) key = f"{service}:{path}"
with self._lock: with self._lock:
cached = self._cache.get(key) cached = self._cache.get(key)
if cached is not None: if cached is not None:
@@ -61,14 +63,11 @@ class _IntrospectionCache:
return xml return xml
def invalidate(self, service: str): def invalidate(self, service: str):
prefix = f"{service}:"
with self._lock: with self._lock:
keys = [k for k in self._cache if k.startswith(f"{service}:")] self._cache = {
for k in keys: k: v for k, v in self._cache.items() if not k.startswith(prefix)
self._cache.pop(k, None) }
_exe_to_service: Dict[str, str] = {}
"""Maps executable path to last-known DBus service name. Populated during discovery."""
@dataclass @dataclass
@@ -79,11 +78,18 @@ class AppServiceInfo:
executable: str = "" executable: str = ""
wm_class: str = "" wm_class: str = ""
app_id: str = "" app_id: str = ""
importer: Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]] = None importer: Optional[_MenuClient] = None
revision: int = 0 revision: int = 0
connected_signals: bool = False 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: def _dbus_introspect(bus, service: str, path: str, timeout: int) -> str:
if not path or not GLib.Variant.is_object_path(path): if not path or not GLib.Variant.is_object_path(path):
return "" return ""
@@ -144,8 +150,6 @@ REGISTRAR_XML = """
</node> </node>
""" """
_EMPTY_SENTINEL: List[DBusMenuItem] = []
class GlobalMenuService(Service): class GlobalMenuService(Service):
"""Service that extracts and provides application menus for the global menu bar.""" """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: ... def active_app_changed(self, app_name: str, wm_class: str) -> None: ...
@Property(object, flags="read-write") @Property(object, flags="read-write")
def current_menu(self) -> Optional[List[DBusMenuItem]]: def current_menu(self) -> Optional[list[DBusMenuItem]]:
return self._current_menu return self._current_menu
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._current_menu: Optional[List[DBusMenuItem]] = None self._current_menu: Optional[list[DBusMenuItem]] = None
self._current_app: str = "" self._current_app: str = ""
self._current_wm_class: str = "" self._current_wm_class: str = ""
self._current_pid: int = 0 self._current_pid: int = 0
# Service registry: service_name -> AppServiceInfo self._service_registry: dict[str, AppServiceInfo] = {}
# This is the core event-driven data structure (single source of truth). 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._introspection_cache = _IntrospectionCache()
self._seq_lock = threading.Lock() self._seq_lock = threading.Lock()
self._extraction_seq = 0 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._registry_lock = threading.Lock()
self._state_lock = threading.Lock() self._state_lock = threading.Lock()
@@ -236,28 +238,13 @@ class GlobalMenuService(Service):
self._on_service_disappeared(name) self._on_service_disappeared(name)
def _on_service_appeared(self, service_name: str): def _on_service_appeared(self, service_name: str):
"""Discover a newly appeared DBus service and populate the registry."""
try: try:
bus = _get_bus() bus = _get_bus()
pid = _dbus_get_pid(bus, service_name, _DBUS_TIMEOUT_FAST) pid = _dbus_get_pid(bus, service_name, _DBUS_TIMEOUT_FAST)
if pid <= 0: if pid <= 0:
return return
executable = "" executable = _resolve_executable(pid)
try: self._register_new_service(service_name, pid, executable)
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
self._introspection_cache.invalidate(service_name) self._introspection_cache.invalidate(service_name)
logger.debug( logger.debug(
f"[GlobalMenuService] Registered new service: {service_name} pid={pid}" f"[GlobalMenuService] Registered new service: {service_name} pid={pid}"
@@ -274,7 +261,8 @@ class GlobalMenuService(Service):
return return
if entry.pid: if entry.pid:
self._pid_to_service.pop(entry.pid, None) 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: try:
entry.importer.disconnect_signals() entry.importer.disconnect_signals()
except Exception: except Exception:
@@ -283,7 +271,7 @@ class GlobalMenuService(Service):
self._introspection_cache.invalidate(service_name) self._introspection_cache.invalidate(service_name)
with self._state_lock: 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 self._current_menu = None
logger.debug( logger.debug(
@@ -366,6 +354,30 @@ class GlobalMenuService(Service):
def _uid(self, wm_class: str, pid: int) -> str: def _uid(self, wm_class: str, pid: int) -> str:
return f"{wm_class}:{pid}" 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): def update_active_window(self, app_name: str, wm_class: str):
target_pid = 0 target_pid = 0
try: try:
@@ -402,7 +414,6 @@ class GlobalMenuService(Service):
self.active_app_changed(app_name, wm_class) self.active_app_changed(app_name, wm_class)
# PRIORITY 1: Registry lookup by PID (persistent importers)
with self._registry_lock: with self._registry_lock:
svc_name = self._pid_to_service.get(target_pid) if target_pid else None svc_name = self._pid_to_service.get(target_pid) if target_pid else None
registered_entry = ( registered_entry = (
@@ -425,13 +436,12 @@ class GlobalMenuService(Service):
thread.start() thread.start()
return return
# No importer found in registry — must discover
with self._state_lock: with self._state_lock:
self._current_menu = None self._current_menu = None
self.menu_changed([]) self.menu_changed([])
inflight_key = self._uid(wm_class, target_pid) inflight_key = self._uid(wm_class, target_pid)
with self._lock: with self._inflight_lock:
if inflight_key in self._extracting_in_flight: if inflight_key in self._extracting_in_flight:
logger.debug( logger.debug(
f"[GlobalMenuService] Extraction in-flight for '{inflight_key}' — skipping" f"[GlobalMenuService] Extraction in-flight for '{inflight_key}' — skipping"
@@ -500,8 +510,8 @@ class GlobalMenuService(Service):
def _handle_items_updated( def _handle_items_updated(
self, self,
svc_name: str, svc_name: str,
updated_props: List[Tuple[int, dict]], updated_props: list[tuple[int, dict]],
removed_ids: List[int], removed_ids: list[int],
): ):
with self._registry_lock: with self._registry_lock:
entry = self._service_registry.get(svc_name) entry = self._service_registry.get(svc_name)
@@ -520,7 +530,7 @@ class GlobalMenuService(Service):
self, self,
wm_class: str, wm_class: str,
seq: int, seq: int,
client: Union[DBusMenuClient, GtkMenuClient, ActionMenuClient], client: _MenuClient,
inflight_key: str, inflight_key: str,
): ):
try: try:
@@ -547,7 +557,7 @@ class GlobalMenuService(Service):
) )
finally: finally:
if inflight_key: if inflight_key:
with self._lock: with self._inflight_lock:
self._extracting_in_flight.discard(inflight_key) self._extracting_in_flight.discard(inflight_key)
def _scan_initial_services(self): def _scan_initial_services(self):
@@ -573,22 +583,8 @@ class GlobalMenuService(Service):
pid = _dbus_get_pid(bus, name, _DBUS_TIMEOUT_FAST) pid = _dbus_get_pid(bus, name, _DBUS_TIMEOUT_FAST)
if pid <= 0: if pid <= 0:
continue continue
executable = "" executable = _resolve_executable(pid)
try: self._register_new_service(name, pid, executable)
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
logger.debug( logger.debug(
f"[GlobalMenuService] Initial scan: registered {len(names)} services" f"[GlobalMenuService] Initial scan: registered {len(names)} services"
) )
@@ -597,7 +593,7 @@ class GlobalMenuService(Service):
def _discover_dbus_client( def _discover_dbus_client(
self, wm_class: str, target_pid: int = 0 self, wm_class: str, target_pid: int = 0
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: ) -> Optional[_MenuClient]:
try: try:
if target_pid <= 0: if target_pid <= 0:
out = exec_shell_command("hyprctl activewindow -j") out = exec_shell_command("hyprctl activewindow -j")
@@ -621,9 +617,7 @@ class GlobalMenuService(Service):
logger.debug(f"[GlobalMenuService] Discovery failed: {e}") logger.debug(f"[GlobalMenuService] Discovery failed: {e}")
return None return None
def _find_registry( def _find_registry(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
with self._registry_lock: with self._registry_lock:
svc_name = self._pid_to_service.get(target_pid) svc_name = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc_name) if svc_name else None entry = self._service_registry.get(svc_name) if svc_name else None
@@ -632,9 +626,7 @@ class GlobalMenuService(Service):
return entry.importer return entry.importer
return None return None
def _find_registrar( def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
bus = _get_bus() bus = _get_bus()
with self._registry_lock: with self._registry_lock:
registered = dict(self._registered_menus) registered = dict(self._registered_menus)
@@ -663,7 +655,7 @@ class GlobalMenuService(Service):
def _find_fallback_scan( def _find_fallback_scan(
self, wm_class: str, target_pid: int self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]: ) -> Optional[_MenuClient]:
bus = _get_bus() bus = _get_bus()
try: try:
res = bus.call_sync( res = bus.call_sync(
@@ -712,7 +704,6 @@ class GlobalMenuService(Service):
bus, svc, dynamic_bases bus, svc, dynamic_bases
) )
# PRIORITY A: DBusMenu (com.canonical.dbusmenu)
for path in fallback_paths: for path in fallback_paths:
xml = self._introspection_cache.get( xml = self._introspection_cache.get(
svc, path, bus, _DBUS_TIMEOUT_INTROSPECT svc, path, bus, _DBUS_TIMEOUT_INTROSPECT
@@ -723,7 +714,6 @@ class GlobalMenuService(Service):
self._cache_discovery(svc, path, pid, wm_class, client) self._cache_discovery(svc, path, pid, wm_class, client)
return client return client
# PRIORITY B: org.gtk.Menus
gtk_candidates = [] gtk_candidates = []
for path in fallback_paths: for path in fallback_paths:
xml = self._introspection_cache.get( xml = self._introspection_cache.get(
@@ -732,17 +722,17 @@ class GlobalMenuService(Service):
if "org.gtk.Menus" in xml: if "org.gtk.Menus" in xml:
gtk_candidates.append((svc, path)) 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"): 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)) gtk_candidates.append((svc, dfs_path))
existing_gtk_paths.add(dfs_path)
for gsvc, gpath in gtk_candidates: for gsvc, gpath in gtk_candidates:
try: try:
test_client = GtkMenuClient(gsvc, gpath) test_client = GtkMenuClient(gsvc, gpath)
test_items = test_client.get_layout() test_items = test_client.get_layout()
if not test_items: if not test_items:
import time
for _ in range(4): for _ in range(4):
time.sleep(0.05) time.sleep(0.05)
if test_items := test_client.get_layout(): if test_items := test_client.get_layout():
@@ -758,26 +748,9 @@ class GlobalMenuService(Service):
except Exception: except Exception:
pass pass
# PRIORITY C: org.gtk.Actions (last resort) action_candidates = self._build_action_candidates(
action_candidates = ["/"] svc, _safe_cls, _safe_cap
name_path = (
"/" + svc.replace(".", "/") if not svc.startswith(":") else ""
) )
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( action_candidates.extend(
self._dfs_find_interface(svc, "org.gtk.Actions") self._dfs_find_interface(svc, "org.gtk.Actions")
) )
@@ -804,6 +777,26 @@ class GlobalMenuService(Service):
continue continue
return None 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: def _build_dynamic_bases(self, svc: str, _safe_cls: str, _safe_cap: str) -> list:
static_paths = [ static_paths = [
"/MenuBar", "/MenuBar",
@@ -841,12 +834,12 @@ class GlobalMenuService(Service):
static_paths.append(p) static_paths.append(p)
return static_paths 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) fallback_paths = list(bases)
seen_fallback = set(fallback_paths) seen_fallback = set(fallback_paths)
def _add_paths(parent_path, xml_str): def _add_paths(parent_path, xml_str):
for child in re.findall(r'<node name="([^"]+)"', xml_str): for child in _NODE_NAME_RE.findall(xml_str):
child_path = f"{parent_path}/{child}" child_path = f"{parent_path}/{child}"
if ( if (
GLib.Variant.is_object_path(child_path) GLib.Variant.is_object_path(child_path)
@@ -876,7 +869,7 @@ class GlobalMenuService(Service):
path: str, path: str,
pid: int, pid: int,
wm_class: str, wm_class: str,
client: Union[DBusMenuClient, GtkMenuClient, ActionMenuClient], client: _MenuClient,
): ):
with self._registry_lock: with self._registry_lock:
if service not in self._service_registry: if service not in self._service_registry:
@@ -890,8 +883,6 @@ class GlobalMenuService(Service):
self._service_registry[service] = entry self._service_registry[service] = entry
if pid: if pid:
self._pid_to_service[pid] = service self._pid_to_service[pid] = service
if entry.executable:
_exe_to_service[entry.executable] = service
else: else:
entry = self._service_registry[service] entry = self._service_registry[service]
entry.importer = client entry.importer = client
@@ -935,7 +926,7 @@ class GlobalMenuService(Service):
if f'interface name="{interface}"' in xml: if f'interface name="{interface}"' in xml:
results.append(path) results.append(path)
for child in re.findall(r'<node name="([^"]+)"', xml): for child in _NODE_NAME_RE.findall(xml):
child_path = ( child_path = (
path.rstrip("/") + "/" + child if path != "/" else "/" + child path.rstrip("/") + "/" + child if path != "/" else "/" + child
) )
@@ -1002,10 +993,9 @@ class GlobalMenuService(Service):
items = client.get_layout() items = client.get_layout()
if items: if items:
with self._lock: logger.info(
logger.info( f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items"
f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items" )
)
else: else:
logger.info(f"[GlobalMenuService] No DBus menu found for '{wm_class}'") logger.info(f"[GlobalMenuService] No DBus menu found for '{wm_class}'")
@@ -1027,10 +1017,10 @@ class GlobalMenuService(Service):
) )
finally: finally:
if inflight_key: if inflight_key:
with self._lock: with self._inflight_lock:
self._extracting_in_flight.discard(inflight_key) self._extracting_in_flight.discard(inflight_key)
def _emit_menu_changed(self, items: List[DBusMenuItem], seq: int = 0): def _emit_menu_changed(self, items: list[DBusMenuItem], seq: int = 0):
if seq: if seq:
with self._seq_lock: with self._seq_lock:
if seq != self._extraction_seq: if seq != self._extraction_seq:
@@ -1041,39 +1031,27 @@ class GlobalMenuService(Service):
return False return False
def click_item(self, item_id: int) -> bool: def click_item(self, item_id: int) -> bool:
with self._state_lock: entry, pid = self._resolve_current_entry()
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: 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 True
return False return False
def about_to_show(self, item_id: int) -> bool: def about_to_show(self, item_id: int) -> bool:
with self._state_lock: entry, _pid = self._resolve_current_entry()
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: if entry and entry.importer:
return entry.importer.about_to_show(item_id) return entry.importer.about_to_show(item_id)
return False 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: with self._state_lock:
target_pid = self._current_pid if pid == self._current_pid:
with self._registry_lock: self._current_menu = items
svc = self._pid_to_service.get(target_pid) return items
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 []
_global_menu_svc_instance = None _global_menu_svc_instance = None
+2 -4
View File
@@ -30,7 +30,6 @@ class CachedNotification(Service):
# Store cache metadata for cleanup # Store cache metadata for cleanup
self.cache_metadata = data.get("cache_metadata", {}) self.cache_metadata = data.get("cache_metadata", {})
self.timestamp = data.get("timestamp", int(time.time())) self.timestamp = data.get("timestamp", int(time.time()))
self._pixbuf_cache = {}
return self return self
@@ -137,7 +136,7 @@ class CachedNotification(Service):
notification_image_cache_key = ( notification_image_cache_key = (
get_notification_image_cache_key(self.id, image_pixbuf) 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 # If temp file is gone or any other error, just mark as None
pass pass
except Exception: except Exception:
@@ -175,7 +174,6 @@ class CachedNotification(Service):
self._cache_id = cache_id self._cache_id = cache_id
self.cache_metadata = {} self.cache_metadata = {}
self.timestamp = int(time.time()) self.timestamp = int(time.time())
self._pixbuf_cache: dict[str, GdkPixbuf.Pixbuf | None] = {}
class CachedNotifications(Notifications): class CachedNotifications(Notifications):
@@ -497,7 +495,7 @@ class CachedNotifications(Notifications):
logger.debug( logger.debug(
f"Cached notification image for notification {cache_id}" f"Cached notification image for notification {cache_id}"
) )
except (AttributeError, OSError, Exception) as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to cache notification image for notification {cache_id}: {e}" f"Failed to cache notification image for notification {cache_id}: {e}"
) )
+110
View File
@@ -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()
+5 -1
View File
@@ -41,6 +41,7 @@ class KeyboardLayout(Service):
self._last_layout = None self._last_layout = None
self.layouts = [] self.layouts = []
self.current_index = 0 self.current_index = 0
self._reload_pending = False
self._init_layout_config() self._init_layout_config()
self._start_file_monitor() self._start_file_monitor()
@@ -106,9 +107,12 @@ class KeyboardLayout(Service):
logger.error(f"[KeyboardLayout] Failed to start file monitor: {e}") logger.error(f"[KeyboardLayout] Failed to start file monitor: {e}")
def _on_file_changed(self, monitor, file, *args): 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): def _reload_layout(self):
self._reload_pending = False
new_layout = self._read_layout() new_layout = self._read_layout()
if new_layout != self._last_layout: if new_layout != self._last_layout:
self._last_layout = new_layout self._last_layout = new_layout
+6 -33
View File
@@ -1,14 +1,13 @@
import json import json
import os import os
import re
import subprocess
import sys import sys
from fabric.core.service import Property, Service, Signal from fabric.core.service import Property, Service, Signal
from fabric.hyprland.service import Hyprland 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.custom_notification import CachedNotifications
from services.inhibit import get_inhibit_service
from utils.app_name_resolver import app_name_resolver from utils.app_name_resolver import app_name_resolver
from utils.functions import ( from utils.functions import (
find_binary, find_binary,
@@ -432,35 +431,14 @@ def is_night_light_active() -> bool:
# --- Caffeine orchestration --- # --- Caffeine orchestration ---
_caffeine_process: subprocess.Popen | None = None
def is_caffeine_active() -> bool: def is_caffeine_active() -> bool:
if _caffeine_process is not None: return get_inhibit_service().active
return True
try:
return bool(find_process_pid("modus-inhibit", timeout=0.5))
except Exception:
return False
def toggle_caffeine() -> bool: def toggle_caffeine() -> bool:
global _caffeine_process return get_inhibit_service().toggle()
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
# --- Hyprland window management API --- # --- Hyprland window management API ---
@@ -549,14 +527,9 @@ def close_window(address: str):
def launch_app(command_line: str): def launch_app(command_line: str):
if not command_line: from globalmenu.launch import launch_command
return
cleaned = re.sub(r"%\w+", "", command_line).strip() launch_command(command_line)
exec_shell_command_async(
f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'"
)
def open_trash(): def open_trash():
+196 -64
View File
@@ -1,11 +1,12 @@
import hashlib import hashlib
import mimetypes import mimetypes
import threading
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
import gi import gi
from fabric.utils import GLib, logger from fabric.utils import GLib, exec_shell_command_async, logger
gi.require_version("Playerctl", "2.0") gi.require_version("Playerctl", "2.0")
from fabric import Fabricator # noqa: E402 from fabric import Fabricator # noqa: E402
@@ -16,6 +17,45 @@ from shared.data import CACHE_DIR # noqa: E402
TEMP_DIR = CACHE_DIR 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): class PlayerService(Service):
@Signal @Signal
@@ -95,19 +135,19 @@ class PlayerService(Service):
def play_pause(self, *_): def play_pause(self, *_):
try: try:
self._player.play_pause() self._player.play_pause()
except Exception as e: except GLib.Error as e:
logger.warning(f"play_pause failed: {e}") logger.warning(f"play_pause failed: {e}")
def next(self, *_): def next(self, *_):
try: try:
self._player.next() self._player.next()
except Exception as e: except GLib.Error as e:
logger.warning(f"next failed: {e}") logger.warning(f"next failed: {e}")
def previous(self, *_): def previous(self, *_):
try: try:
self._player.previous() self._player.previous()
except Exception as e: except GLib.Error as e:
logger.warning(f"previous failed: {e}") logger.warning(f"previous failed: {e}")
def __init__(self, player: Playerctl.Player, **kwargs): def __init__(self, player: Playerctl.Player, **kwargs):
@@ -117,6 +157,7 @@ class PlayerService(Service):
self._current_artwork_path = "" self._current_artwork_path = ""
self._is_cleaning_up = False self._is_cleaning_up = False
self._signal_ids = [] self._signal_ids = []
self._last_emitted_status = ""
self._signal_ids.append( self._signal_ids.append(
self._player.connect("playback-status", self.on_playback_status) 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("metadata", self.on_metadata))
self._signal_ids.append(self._player.connect("seeked", self.on_seeked)) 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 self._pos_polling = False # guard: Fabricator.start() blindly creates a new
# GLib timer every call — this flag prevents stacking # GLib timer every call — this flag prevents stacking
self.pos_fabricator = Fabricator( self.pos_fabricator = Fabricator(
@@ -143,11 +183,48 @@ class PlayerService(Service):
try: try:
metadata = self._player.props.metadata metadata = self._player.props.metadata
if 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) self._handle_artwork(metadata)
except Exception as e: except Exception as e:
logger.warning(f"Failed to initialize metadata: {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: def get_artwork(self) -> str:
return self._current_artwork_path return self._current_artwork_path
@@ -181,14 +258,17 @@ class PlayerService(Service):
current = self.get_position() / 1_000_000 current = self.get_position() / 1_000_000
try: try:
self._player.set_position(int(pos * 1_000_000)) self._player.set_position(int(pos * 1_000_000))
except Exception: except GLib.Error:
try: try:
offset = pos - current offset = pos - current
self._player.seek(int(offset * 1_000_000)) self._player.seek(int(offset * 1_000_000))
except Exception: except GLib.Error:
import os name = self.player_name
if name:
os.system(f"playerctl -p {self.player_name} position {pos}") # No shell: pass args as a list so player_name can't inject.
exec_shell_command_async(
["playerctl", "-p", name, "position", str(pos)]
)
finally: finally:
if self.playback_status.lower() == "playing": if self.playback_status.lower() == "playing":
self._start_pos_fabricator() self._start_pos_fabricator()
@@ -232,6 +312,11 @@ class PlayerService(Service):
dur = metadata["mpris:length"] / 1_000_000 dur = metadata["mpris:length"] / 1_000_000
except Exception: except Exception:
dur = 0 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) self.track_position(pos, dur)
def on_seeked(self, player, position): def on_seeked(self, player, position):
@@ -240,97 +325,144 @@ class PlayerService(Service):
if self.playback_status.lower() == "playing": if self.playback_status.lower() == "playing":
self._start_pos_fabricator() self._start_pos_fabricator()
def on_playback_status(self, player, status): def _notify_playback(self, status_str: str):
"""DBus signal handler - instant when it fires (e.g. Modus buttons).""" """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: if self._is_cleaning_up:
return return
self.status = status status_str = (status_str or "").lower()
self.poll_progress() if status_str == self._last_emitted_status:
if self.playback_status.lower() == "playing": return
self._last_emitted_status = status_str
if status_str == "playing":
self.play() self.play()
else: else:
self.pause() self.pause()
def _on_polled_status_change(self, new_status: str): def on_playback_status(self, player, status):
"""Fabricator-driven status change - fires reliably every second.""" """DBus signal handler - instant when it fires (e.g. Modus buttons)."""
if self._is_cleaning_up: if self._is_cleaning_up:
return return
self.poll_progress() self.poll_progress()
if new_status.lower() == "playing": self._notify_playback(self.playback_status)
self.play()
else: def _on_polled_status_change(self, new_status: str):
self.pause() """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): def on_metadata(self, player, metadata):
if self._is_cleaning_up: if self._is_cleaning_up:
return return
self.meta_change(metadata, player) self.meta_change(metadata, player)
self._handle_artwork(metadata)
self.fabricating(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): def _handle_artwork(self, metadata):
if self._is_cleaning_up: if self._is_cleaning_up:
return return
try: try:
art_url = metadata["mpris:artUrl"] art_url = metadata["mpris:artUrl"]
except Exception: except (KeyError, TypeError):
return
if not art_url:
return return
artwork_hash = hashlib.md5(art_url.encode()).hexdigest() artwork_hash = hashlib.md5(art_url.encode()).hexdigest()
if artwork_hash == self._current_artwork_hash: if artwork_hash == self._current_artwork_hash:
return return
self._current_artwork_hash = artwork_hash self._current_artwork_hash = artwork_hash
parsed = urllib.parse.urlparse(art_url)
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file": if parsed.scheme == "file":
self._set_artwork(urllib.parse.unquote(parsed.path)) local = urllib.parse.unquote(parsed.path)
elif parsed.scheme in ("http", "https"): if Path(local).exists():
GLib.Thread.new( self._set_artwork(local)
"download-artwork", return
self._download_artwork,
art_url, if parsed.scheme in ("http", "https"):
artwork_hash, 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): def _set_artwork(self, path: str):
if self._is_cleaning_up:
return
self._current_artwork_path = path self._current_artwork_path = path
self.artwork_change(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: if self._is_cleaning_up:
return return
try: try:
cache_dir = TEMP_DIR / "player-art" with urllib.request.urlopen(art_url, timeout=5) as response:
cache_dir.mkdir(parents=True, exist_ok=True) 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() if not self._is_cleaning_up:
parsed = urllib.parse.urlparse(art_url) GLib.idle_add(self._set_artwork, str(target))
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))
except Exception as e: except Exception as e:
logger.error(f"Failed to download artwork: {e}") logger.error(f"Failed to download artwork: {e}")
+41 -4
View File
@@ -25,12 +25,12 @@ class Wifi(Service):
self._ap_signal: int | None = None self._ap_signal: int | None = None
super().__init__(**kwargs) super().__init__(**kwargs)
self._client.connect( self._client_handler = self._client.connect(
"notify::wireless-enabled", "notify::wireless-enabled",
lambda *args: self.notifier("enabled", args), lambda *args: self.notifier("enabled", args),
) )
if self._device: if self._device:
bulk_connect( self._device_handlers = bulk_connect(
self._device, self._device,
{ {
"notify::active-access-point": lambda *args: self._activate_ap(), "notify::active-access-point": lambda *args: self._activate_ap(),
@@ -41,6 +41,23 @@ class Wifi(Service):
) )
self._activate_ap() 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): def ap_update(self):
self.emit("changed") self.emit("changed")
for sn in [ for sn in [
@@ -245,6 +262,7 @@ class Ethernet(Service):
self._client: NM.Client = client self._client: NM.Client = client
self._device: NM.DeviceEthernet = device self._device: NM.DeviceEthernet = device
self._handlers: list[int] = []
for pn in ( for pn in (
"active-connection", "active-connection",
"icon-name", "icon-name",
@@ -252,14 +270,26 @@ class Ethernet(Service):
"speed", "speed",
"state", "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): def notifier(self, pn):
self.notify(pn) self.notify(pn)
self.emit("changed") 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): class NetworkClient(Service):
@Signal @Signal
@@ -325,9 +355,16 @@ class NetworkClient(Service):
return return
if self.wifi_device is wifi: if self.wifi_device is wifi:
self.wifi_device = next(iter(self.wifi_devices.values()), None) self.wifi_device = next(iter(self.wifi_devices.values()), None)
wifi.close()
logger.info(f"[Network] Wifi device removed: {iface}") logger.info(f"[Network] Wifi device removed: {iface}")
self.emit("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: def _get_primary_device(self) -> Literal["wifi", "wired"] | None:
if not self._client: if not self._client:
return None return None
+53 -39
View File
@@ -11,38 +11,45 @@ from shared.data import (
WALLPAPERS_THUMBNAILS_SIZE, WALLPAPERS_THUMBNAILS_SIZE,
) )
for path in (WALLPAPER_PATH, WALLPAPER_THUMBS_PATH): os.makedirs(WALLPAPER_PATH, exist_ok=True)
os.makedirs(path, exist_ok=True) os.makedirs(WALLPAPER_THUMBS_PATH, exist_ok=True)
def get_thumbnail_filename(image_path: str) -> str: def get_thumbnail_filename(image_path: str) -> str:
key = hashlib.sha256(image_path.encode()).hexdigest() key = hashlib.sha256(image_path.encode()).hexdigest()
size = WALLPAPERS_THUMBNAILS_SIZE return f"{key}_{WALLPAPERS_THUMBNAILS_SIZE}x{WALLPAPERS_THUMBNAILS_SIZE}.webp"
return f"{key}_{size}x{size}.webp"
def create_thumbnail( 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: ) -> bool:
"""Create WebP thumbnail with proper memory cleanup to prevent leaks.""" """Create a compressed WebP thumbnail."""
pixbuf = None
try: try:
# Load image at scaled size to minimize memory usage pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(
pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(image_path, size, size, True) image_path,
# Save as WebP with quality 85 for better compression (60-80% smaller than PNG) size,
result = pixbuf.savev(thumbnail_path, "webp", ["quality"], ["85"]) size,
return bool(result) preserve_aspect_ratio=True,
)
return bool(
pixbuf.savev(
thumbnail_path,
"webp",
["quality"],
["85"],
)
)
except Exception: except Exception:
return False 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: def generate_colors_from_wallpaper(image_path: str) -> bool:
cmd = f'matugen image "{image_path}"' return bool(exec_shell_command_async(f'matugen image "{image_path}"'))
return bool(exec_shell_command_async(cmd))
class WallpaperService(Service): class WallpaperService(Service):
@@ -57,55 +64,62 @@ class WallpaperService(Service):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._processed_count = 0
self._file_cache: list[str] | None = None self._file_cache: list[str] | None = None
def get_all_wallpapers(self, use_cache: bool = True) -> list[str]: def get_all_wallpapers(self, use_cache: bool = True) -> list[str]:
if use_cache and self._file_cache is not None: if use_cache and self._file_cache is not None:
return self._file_cache return self._file_cache
all_files = [] wallpapers: list[str] = []
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)
self._file_cache = all_files with os.scandir(WALLPAPER_PATH) as entries:
return all_files 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: def invalidate_cache(self) -> None:
self._file_cache = None self._file_cache = None
def get_thumbnail_path(self, image_path: str) -> str: def get_thumbnail_path(self, image_path: str) -> str:
thumbnail_filename = get_thumbnail_filename(image_path) return os.path.join(
return WALLPAPER_THUMBS_PATH + "/" + thumbnail_filename WALLPAPER_THUMBS_PATH,
get_thumbnail_filename(image_path),
)
def has_thumbnail(self, image_path: str) -> bool: def has_thumbnail(self, image_path: str) -> bool:
thumbnail_path = self.get_thumbnail_path(image_path) return os.path.isfile(self.get_thumbnail_path(image_path))
return os.path.isfile(thumbnail_path)
def set_wallpaper(self, image_path: str): def set_wallpaper(self, image_path: str) -> None:
exec_shell_command_async( 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) self.wallpaper_set(image_path)
target_path = os.path.abspath(image_path) link_path = os.path.expanduser("~/.current.wall")
home_dir = os.path.expanduser("~")
link_path = os.path.join(home_dir, ".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.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') exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0')
self.colors_generated(image_path) self.colors_generated(image_path)
def get_wallpaper_info(self, image_path: str) -> dict: def get_wallpaper_info(self, image_path: str) -> dict:
"""Get information about a wallpaper"""
stat = os.stat(image_path) stat = os.stat(image_path)
return { return {
"path": image_path, "path": image_path,
"filename": os.path.basename(image_path), "filename": os.path.basename(image_path),
+3 -5
View File
@@ -344,7 +344,6 @@ class FlatScale(Gtk.DrawingArea, Widget):
self._anim_bubble_open.min_value = p self._anim_bubble_open.min_value = p
self._anim_bubble_open.max_value = 1.0 self._anim_bubble_open.max_value = 1.0
self._anim_bubble_open.value = p self._anim_bubble_open.value = p
self._anim_bubble_open._start_time = None
self._anim_bubble_open.play() self._anim_bubble_open.play()
def _bubble_close(self): def _bubble_close(self):
@@ -356,7 +355,6 @@ class FlatScale(Gtk.DrawingArea, Widget):
self._anim_bubble_close.min_value = start self._anim_bubble_close.min_value = start
self._anim_bubble_close.max_value = 1.0 self._anim_bubble_close.max_value = 1.0
self._anim_bubble_close.value = start self._anim_bubble_close.value = start
self._anim_bubble_close._start_time = None
self._anim_bubble_close.play() self._anim_bubble_close.play()
def _sync_bubble_progress(self): def _sync_bubble_progress(self):
@@ -578,9 +576,9 @@ class FlatScale(Gtk.DrawingArea, Widget):
) )
cr.fill() cr.fill()
# Percentage bubble (drag only) — pass slider_left_x as "slider_top_y" # Percentage bubble (drag only)
slider_left_x = slider_cx - slider_thickness / 2 max_thickness = max(self._anim_press.min_value, self._anim_press.max_value)
self._draw_bubble(cr, slider_cy, slider_left_x, styles) self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles)
return False return False
@@ -80,3 +80,7 @@ class AnimatedScrollable(ScrolledWindow):
value = self.height_animator.value value = self.height_animator.value
value = 0 if value < 0 else value value = 0 if value < 0 else value
return value, value return value, value
def destroy(self):
self.height_animator.stop()
super().destroy()
+11
View File
@@ -403,3 +403,14 @@ class BatteryControl(Box):
def on_profile_changed(self, service, *args): def on_profile_changed(self, service, *args):
self.update_energy_mode_buttons() 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()
+21 -6
View File
@@ -69,13 +69,16 @@ class PopupWindow(WaylandWindow):
if not self._pointing_widget: if not self._pointing_widget:
return 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(): 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 return
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate) self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
@@ -92,6 +95,18 @@ class PopupWindow(WaylandWindow):
return None return None
return self.do_reposition(self.do_calculate_edges()) 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): def do_calculate_edges(self):
move_axe = "x" move_axe = "x"
parent_window = self.get_parent_window() parent_window = self.get_parent_window()
-26
View File
@@ -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 { #spotlight {
margin: 8px; margin: 8px;
padding: 5px; padding: 5px;
@@ -40,10 +22,6 @@
transition: background-color apply(transition-medium) ease-in-out; transition: background-color apply(transition-medium) ease-in-out;
} }
.app-search-header.shine {
animation: header-shine 1s ease-in-out infinite alternate;
}
.app-slot { .app-slot {
padding: 8px; padding: 8px;
border: solid 1px transparent; border: solid 1px transparent;
@@ -64,10 +42,6 @@
background-color: @primary-a20; background-color: @primary-a20;
} }
.app-slot.shine {
animation: app-slot-shine 0.3s ease-in-out;
}
.app-title { .app-title {
color: @foreground; color: @foreground;
font-size: apply(font-base); font-size: apply(font-base);
+6
View File
@@ -5,14 +5,19 @@ class AppName:
def __init__(self, path="/usr/share/applications"): def __init__(self, path="/usr/share/applications"):
self.files = os.listdir(path) self.files = os.listdir(path)
self.path = path self.path = path
self._cache: dict[str, str | None] = {}
def get_app_name(self, wmclass, _format_=False): def get_app_name(self, wmclass, _format_=False):
if wmclass in self._cache:
return self._cache[wmclass]
desktop_file = "" desktop_file = ""
for f in self.files: for f in self.files:
if f.startswith(wmclass + ".desktop"): if f.startswith(wmclass + ".desktop"):
desktop_file = f desktop_file = f
if desktop_file == "": if desktop_file == "":
self._cache[wmclass] = None
return None return None
desktop_app_name = wmclass desktop_app_name = wmclass
@@ -22,6 +27,7 @@ class AppName:
if line.startswith("Name="): if line.startswith("Name="):
desktop_app_name = line.split("=")[1].strip() desktop_app_name = line.split("=")[1].strip()
break break
self._cache[wmclass] = desktop_app_name
return desktop_app_name return desktop_app_name
def format_app_name(self, title, wmclass, update=False): def format_app_name(self, title, wmclass, update=False):
+1 -6
View File
@@ -657,12 +657,6 @@ class BluetoothConnections(Box):
device_slot = BluetoothDeviceSlot(device) device_slot = BluetoothDeviceSlot(device)
self.other_devices.add(device_slot) self.other_devices.add(device_slot)
for slot in existing_paired.values():
pass
for slot in existing_other.values():
pass
if not self._destroyed: if not self._destroyed:
has_paired_devices = len(paired_devices) > 0 has_paired_devices = len(paired_devices) > 0
has_other_devices = len(other_devices) > 0 has_other_devices = len(other_devices) > 0
@@ -679,6 +673,7 @@ class BluetoothConnections(Box):
def start_device_monitoring(self): def start_device_monitoring(self):
"""Start periodic monitoring for device changes""" """Start periodic monitoring for device changes"""
self.stop_device_monitoring()
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
def stop_device_monitoring(self): def stop_device_monitoring(self):
+5 -17
View File
@@ -7,7 +7,7 @@ from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay from fabric.widgets.overlay import Overlay
from services.brightness import Brightness 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 services.network import NetworkClient
from shared.widgets.flat_scale import FlatScale from shared.widgets.flat_scale import FlatScale
from shared.window.applet_window import AppletWindow from shared.window.applet_window import AppletWindow
@@ -133,11 +133,8 @@ class ModusControlCenter(AppletWindow):
h_expand=True, h_expand=True,
) )
# Only connect brightness controls if brightness service is available # Disable brightness scale if no backlight device is available
if get_brightness_service().max_screen > 0: if get_brightness_service().max_screen <= 0:
pass
else:
# Disable brightness scale if no backlight device available
self.brightness_scale.set_sensitive(False) self.brightness_scale.set_sensitive(False)
self._mpris_manager = get_shared_mpris_manager() 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) self.per_app_volume_center_box.set_size_request(300, -1)
def _check_initial_states(self): def _check_initial_states(self):
self.caffeine_mode = is_caffeine_active() self.caffeine_mode = get_inhibit_service().active
self.flight_mode = False self.flight_mode = False
GLib.timeout_add(100, self._update_initial_labels)
def _update_initial_labels(self):
return False
def set_dont_disturb(self, *_): def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode self.focus_mode = not self.focus_mode
@@ -770,7 +763,7 @@ class ModusControlCenter(AppletWindow):
def toggle_caffeine(self, *_): def toggle_caffeine(self, *_):
try: try:
self.caffeine_mode = toggle_caffeine() self.caffeine_mode = get_inhibit_service().toggle()
self.caffeine_icon.dynamic_file( self.caffeine_icon.dynamic_file(
"applets/caffeine-on.svg" "applets/caffeine-on.svg"
if self.caffeine_mode if self.caffeine_mode
@@ -1200,11 +1193,6 @@ class ModusControlCenter(AppletWindow):
self._cleanup_processes() self._cleanup_processes()
self._disconnect_signals_when_hidden() self._disconnect_signals_when_hidden()
# Force garbage collection
import gc
gc.collect()
logger.debug("Complete cleanup finished successfully") logger.debug("Complete cleanup finished successfully")
except Exception as e: except Exception as e:
+40 -36
View File
@@ -11,6 +11,43 @@ from shared.widgets.flat_scale import FlatScale
# Local imports # Local imports
from utils.roam import audio_service 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): class PerAppVolumeControl(Box):
"""Per-application volume control widget""" """Per-application volume control widget"""
@@ -125,27 +162,7 @@ class PerAppVolumeControl(Box):
# Check description first as it's more reliable # Check description first as it's more reliable
search_text = app_description if app_description else app_name search_text = app_description if app_description else app_name
icon_mapping = { icon_mapping = _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",
}
# Check if any key in mapping is contained in search text # Check if any key in mapping is contained in search text
for key, icon in icon_mapping.items(): for key, icon in icon_mapping.items():
@@ -167,22 +184,9 @@ class PerAppVolumeControl(Box):
return "Unknown" return "Unknown"
# Handle common app names specially # 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() name_lower = name.lower()
if name_lower in special_names: if name_lower in _APP_SPECIAL_NAMES:
return special_names[name_lower] return _APP_SPECIAL_NAMES[name_lower]
# Default: capitalize first letter # Default: capitalize first letter
return name.capitalize() return name.capitalize()
+1
View File
@@ -631,6 +631,7 @@ class WifiConnections(Box):
def start_network_monitoring(self): def start_network_monitoring(self):
"""Start periodic monitoring for network changes""" """Start periodic monitoring for network changes"""
self.stop_network_monitoring()
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh) self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
def stop_network_monitoring(self): def stop_network_monitoring(self):
+8 -6
View File
@@ -56,6 +56,10 @@ from .layout import DockLayout
_PIXBUF_CACHE_MAX = 64 _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): class DockCanvas(Gtk.DrawingArea):
def __init__(self, parent_window): def __init__(self, parent_window):
@@ -136,9 +140,6 @@ class DockCanvas(Gtk.DrawingArea):
self._desktop_apps = [] self._desktop_apps = []
return self._desktop_apps return self._desktop_apps
def _refresh_desktop_apps(self) -> None:
self._desktop_apps = []
def _canvas_height(self) -> int: def _canvas_height(self) -> int:
size = self._base_icon_size() size = self._base_icon_size()
return int( return int(
@@ -921,7 +922,9 @@ class DockCanvas(Gtk.DrawingArea):
if command_line: if command_line:
launch_app(command_line) launch_app(command_line)
elif hasattr(app_info, "launch"): elif hasattr(app_info, "launch"):
app_info.launch() from globalmenu.launch import launch_desktop_app
launch_desktop_app(app_info)
else: else:
logger.error( logger.error(
f"[DockCanvas] Cannot determine launch command: {app_info}" f"[DockCanvas] Cannot determine launch command: {app_info}"
@@ -976,8 +979,7 @@ class DockCanvas(Gtk.DrawingArea):
if app.executable: if app.executable:
exe = os.path.basename(app.executable).lower() exe = os.path.basename(app.executable).lower()
if exe in expanded: if exe in expanded:
common = ["kitty", "bash", "sh", "zsh", "python", "python3"] if exe in _COMMON_EXECUTABLES:
if exe in common:
app_names = [ app_names = [
getattr(app, "name", ""), getattr(app, "name", ""),
getattr(app, "display_name", ""), getattr(app, "display_name", ""),
+2 -2
View File
@@ -257,12 +257,12 @@ def initialize():
), ),
) )
lockscreen.show() lockscreen.show()
return lock, lockscreen
def main(): def main():
set_process_name("lockscreen") set_process_name("lockscreen")
initialize() _lock, lockscreen = initialize()
lockscreen = LockScreen(GtkSessionLock.Lock())
global app global app
app = Application("lock", lockscreen) app = Application("lock", lockscreen)
+1 -19
View File
@@ -21,7 +21,7 @@ from services.modus import notification_service
from shared.widgets.clipping_box import ClippingBox from shared.widgets.clipping_box import ClippingBox
from shared.widgets.custom_image import CustomImage from shared.widgets.custom_image import CustomImage
from shared.widgets.customrevealer import SlideRevealer 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.roam import modus_service
from utils.icon_resolver import IconResolver from utils.icon_resolver import IconResolver
@@ -1071,9 +1071,6 @@ class ModusNoti(Window):
# Preload assets immediately for optimal caching and display performance # Preload assets immediately for optimal caching and display performance
preload_notification_assets(notification) 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 queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE: if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
# Remove oldest notification from queue (not current showing one) # Remove oldest notification from queue (not current showing one)
@@ -1271,21 +1268,6 @@ class ModusNoti(Window):
def update_config(self, new_config): def update_config(self, new_config):
"""Update notification configuration dynamically.""" """Update notification configuration dynamically."""
try: 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 # Update the current config
self._current_config.update(new_config) self._current_config.update(new_config)
+4 -10
View File
@@ -178,13 +178,9 @@ class ExpandableNotificationGroup(Box):
self.add(self.collapsed_eventbox) self.add(self.collapsed_eventbox)
# Create expanded state (hidden initially)
self.create_expanded_state()
def _get_notification_pixbuf_for_group(self, cached_notification): def _get_notification_pixbuf_for_group(self, cached_notification):
"""Get notification pixbuf at 64x64 - matches app icon size for consistency""" """Get notification pixbuf at 64x64 - matches app icon size for consistency"""
notification = cached_notification._notification notification = cached_notification._notification
getattr(notification, "id", None)
pixbuf = None pixbuf = None
# First try to get cached notification image using stored cache key # First try to get cached notification image using stored cache key
@@ -372,10 +368,6 @@ class ExpandableNotificationGroup(Box):
self.expanded_container.set_visible(False) self.expanded_container.set_visible(False)
return False # Don't repeat timeout 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): def close_all(self, *args):
"""Close all notifications in this group with proper cache cleanup""" """Close all notifications in this group with proper cache cleanup"""
# Close all notifications in this group # Close all notifications in this group
@@ -462,7 +454,6 @@ class NotificationCenterWidget(NotificationWidget):
def _get_notification_pixbuf(self, notification): def _get_notification_pixbuf(self, notification):
"""Get notification pixbuf at 64x64 - matches app icon size for consistency""" """Get notification pixbuf at 64x64 - matches app icon size for consistency"""
getattr(notification, "id", None)
pixbuf = None pixbuf = None
# First try to get cached notification image using stored cache key # First try to get cached notification image using stored cache key
@@ -819,8 +810,11 @@ class NotificationCenter(AppletWindow):
group_widget.remove(child) group_widget.remove(child)
child.destroy() child.destroy()
# Recreate content # Recreate content (collapsed + expanded states)
group_widget.create_collapsed_state() 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() group_widget.show_all()
except Exception as e: except Exception as e:
-6
View File
@@ -69,12 +69,6 @@ class Panel(Window):
self.tray.connect("add", self._update_tray_visibility) self.tray.connect("add", self._update_tray_visibility)
self.tray.connect("remove", 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._spotlight_toggle_cb = None
self.search = Button( self.search = Button(
name="panel-button", name="panel-button",
+27 -7
View File
@@ -334,9 +334,24 @@ class MicrophoneIndicator(Box):
self._connect_mic_signals() self._connect_mic_signals()
def _connect_mic_signals(self): def _connect_mic_signals(self):
if self._audio.microphone: mic = self._audio.microphone
self._audio.microphone.connect("changed", self._on_mic_stream_changed) if mic is None:
self._sync_state() 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, *_): def _on_mic_device_changed(self, *_):
self._connect_mic_signals() self._connect_mic_signals()
@@ -371,8 +386,13 @@ class MicrophoneIndicator(Box):
self._hide_timer_id = 0 self._hide_timer_id = 0
try: try:
self._audio.disconnect_by_func(self._on_mic_device_changed) self._audio.disconnect_by_func(self._on_mic_device_changed)
if self._audio.microphone: except Exception:
self._audio.microphone.disconnect_by_func(self._on_mic_stream_changed) pass
except Exception as e: old = getattr(self, "_mic_device", None)
logger.warning(f"[NotchIndicators] mic disconnect: {e}") 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() super().destroy()
+6
View File
@@ -188,6 +188,12 @@ class Notch(Box):
def _on_player_vanish(self, _manager, name: str): def _on_player_vanish(self, _manager, name: str):
if name in self._music_services: if name in self._music_services:
self._music_services.remove(name) 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() self._apply_stack_state()
# ── Scroll ──────────────────────────────────────────────────────────────── # ── Scroll ────────────────────────────────────────────────────────────────
+13
View File
@@ -257,6 +257,19 @@ class ScreenCaptureWindow(Window):
_handler_id[0] = screen_capture_service.connect("screenshot-taken", _on_taken) _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( ok = screen_capture_service.screenshot(
target, target,
output_dir=output_dir, output_dir=output_dir,
-2
View File
@@ -113,8 +113,6 @@ class PluginManager:
if entry.module_name: if entry.module_name:
self._loader.unload_module(entry.module_name) self._loader.unload_module(entry.module_name)
from pathlib import Path
fp = Path(entry.path) if entry.path else None fp = Path(entry.path) if entry.path else None
if fp and fp.exists(): if fp and fp.exists():
source = entry.source source = entry.source
+36 -4
View File
@@ -75,7 +75,7 @@ class SearchPipeline:
self._current_token.cancel() self._current_token.cancel()
with self._lock: with self._lock:
for t in self._pending_threads: for t in self._pending_threads:
t.join(timeout=0.5) t.join(timeout=0.1)
self._pending_threads.clear() self._pending_threads.clear()
for source_id in self._pending_idle_ids: for source_id in self._pending_idle_ids:
GLib.source_remove(source_id) GLib.source_remove(source_id)
@@ -112,13 +112,33 @@ class SearchPipeline:
results = self._safe_search(instance, query, token) results = self._safe_search(instance, query, token)
self._stamp_results(results, entry) self._stamp_results(results, entry)
if not token.is_cancelled: 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: with self._lock:
self._pending_idle_ids.append(source_id) self._pending_idle_ids.append(source_id)
except Exception as e: except Exception as e:
logger.error(f"[SearchPipeline] Error searching {entry.id}: {e}") logger.error(f"[SearchPipeline] Error searching {entry.id}: {e}")
if not token.is_cancelled: 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: with self._lock:
self._pending_idle_ids.append(source_id) self._pending_idle_ids.append(source_id)
@@ -143,6 +163,8 @@ class SearchPipeline:
remaining = len(searchable) remaining = len(searchable)
def _on_all_done(): def _on_all_done():
if token.is_cancelled:
return
merged = self._merge_results(collected) merged = self._merge_results(collected)
callback(merged) callback(merged)
@@ -151,7 +173,17 @@ class SearchPipeline:
with results_lock: with results_lock:
remaining -= 1 remaining -= 1
if remaining <= 0: 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: with self._lock:
self._pending_idle_ids.append(source_id) self._pending_idle_ids.append(source_id)
+58 -35
View File
@@ -1,4 +1,3 @@
import gc
import os import os
from fabric.core import Signal from fabric.core import Signal
@@ -98,7 +97,7 @@ class Spotlight(Box):
self.children = self.header, self.scrolled_clip self.children = self.header, self.scrolled_clip
# Search # Search
@debounce(200) @debounce(300)
def on_entry_changed(self, entry: Entry, *_): def on_entry_changed(self, entry: Entry, *_):
if getattr(entry, "_ignore_change", False): if getattr(entry, "_ignore_change", False):
entry._ignore_change = False entry._ignore_change = False
@@ -128,7 +127,11 @@ class Spotlight(Box):
self.header_icon.set_from_icon_name( self.header_icon.set_from_icon_name(
getattr(inst, "icon", "") or "system-search-symbolic" 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 return
self.header_icon.set_from_icon_name("system-search-symbolic") self.header_icon.set_from_icon_name("system-search-symbolic")
@@ -225,7 +228,27 @@ class Spotlight(Box):
self._deactivate_keyword_plugin() self._deactivate_keyword_plugin()
def _on_search_results(self, results: list[SearchResult]): 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 self._current_results = results
if not results: if not results:
@@ -234,15 +257,19 @@ class Spotlight(Box):
return return
self._auto_start_refresh_timer(results) 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() 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 # Rendering
def _render_result(self, result: SearchResult) -> Button: def _render_result(self, result: SearchResult) -> Button:
rt = result.render_type rt = result.render_type
@@ -298,7 +325,7 @@ class Spotlight(Box):
style_classes="app-slot", style_classes="app-slot",
child=Box(orientation="h", spacing=12, children=children), child=Box(orientation="h", spacing=12, children=children),
tooltip_text=r.subtitle, 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: 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: def _render_emoji(self, r: SearchResult) -> Button:
@@ -368,7 +395,7 @@ class Spotlight(Box):
], ],
), ),
tooltip_text=f"Click to copy {emoji_char}", 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: def _render_power(self, r: SearchResult) -> Button:
@@ -399,7 +426,7 @@ class Spotlight(Box):
], ],
), ),
tooltip_text=f"Click to {r.title.lower()} the system", 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: def _render_wallpaper(self, r: SearchResult) -> Button:
@@ -415,7 +442,7 @@ class Spotlight(Box):
return Button( return Button(
style_classes="app-slot wallpaper", style_classes="app-slot wallpaper",
child=child_widget, 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: def _render_clipboard_text(self, r: SearchResult) -> Button:
@@ -436,7 +463,7 @@ class Spotlight(Box):
], ],
), ),
tooltip_text=r.subtitle, 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: def _render_clipboard_image(self, r: SearchResult) -> Button:
@@ -465,7 +492,7 @@ class Spotlight(Box):
return Button( return Button(
style_classes="app-slot wallpaper clipboard-image", style_classes="app-slot wallpaper clipboard-image",
child=child_widget, 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: def _render_default(self, r: SearchResult) -> Button:
@@ -497,7 +524,7 @@ class Spotlight(Box):
return Button( return Button(
style_classes="app-slot", style_classes="app-slot",
child=Box(orientation="h", spacing=12, children=children), 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): def _activate_result(self, result: SearchResult):
@@ -505,19 +532,23 @@ class Spotlight(Box):
result.action() result.action()
self.launched() 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 # Viewport management
def _clear_viewport(self): def _clear_viewport(self):
old_results = self._current_results
self._current_results = [] self._current_results = []
self._result_widgets.clear() for child in list(self.viewport.children):
for child in self.viewport.children:
try: try:
child.disconnect_by_func(self.on_slot_enter) child.disconnect_by_func(self.on_slot_enter)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
self.viewport.remove(child) self.viewport.remove(child)
child.destroy() child.destroy()
del old_results self._result_widgets.clear()
def update_result_subtitle(self, result_id: str, text: str) -> None: def update_result_subtitle(self, result_id: str, text: str) -> None:
"""Update subtitle label of a rendered result in-place (no re-render).""" """Update subtitle label of a rendered result in-place (no re-render)."""
@@ -528,9 +559,6 @@ class Spotlight(Box):
if not child: if not child:
return return
# Walk: Button → main Box → title_box (last Box) → subtitle Label (2nd) # 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)] boxes = [c for c in child.get_children() if isinstance(c, Box)]
if not boxes: if not boxes:
return return
@@ -551,12 +579,6 @@ class Spotlight(Box):
self.scrolled_window.show() self.scrolled_window.show()
self.scrolled_window.animate_size(new_height) 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: if self.viewport.children:
self.set_selected_index(0) self.set_selected_index(0)
@@ -660,15 +682,17 @@ class SpotlightWindow(Window):
def close_spotlight(self): def close_spotlight(self):
self.spotlight_box.plugin_service.pipeline.cancel() self.spotlight_box.plugin_service.pipeline.cancel()
self.spotlight_box._stop_refresh_timer()
self.spotlight_box.header_entry.set_text("")
debounce_id = getattr( debounce_id = getattr(
self.spotlight_box, "_debounce_timer_on_entry_changed", None self.spotlight_box, "_debounce_timer_on_entry_changed", None
) )
if debounce_id: if debounce_id:
GLib.source_remove(debounce_id) GLib.source_remove(debounce_id)
self.spotlight_box._debounce_timer_on_entry_changed = 0 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._clear_viewport()
self.spotlight_box.scrolled_window.animate_size(0) self.spotlight_box.scrolled_window.animate_size(0)
self.spotlight_box.scrolled_window.hide() 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.header_icon.set_from_icon_name("system-search-symbolic")
self.spotlight_box._deactivate_keyword_plugin() self.spotlight_box._deactivate_keyword_plugin()
self.spotlight_box.plugin_service.manager.release_memory_all() self.spotlight_box.plugin_service.manager.release_memory_all()
gc.collect()
self.hide() self.hide()
def toggle( def toggle(
+16 -1
View File
@@ -1,3 +1,4 @@
import time
from typing import Any from typing import Any
from fabric.utils import DesktopApp, get_desktop_applications from fabric.utils import DesktopApp, get_desktop_applications
@@ -17,6 +18,8 @@ class ApplicationPlugin(SpotlightPlugin):
def __init__(self, context): def __init__(self, context):
super().__init__(context) super().__init__(context)
self._desktop_apps: list[DesktopApp] = [] self._desktop_apps: list[DesktopApp] = []
self._apps_loaded_at: float = 0.0
self._apps_ttl: float = 1.0
def initialize(self) -> None: def initialize(self) -> None:
self._desktop_apps = get_desktop_applications() self._desktop_apps = get_desktop_applications()
@@ -28,6 +31,16 @@ class ApplicationPlugin(SpotlightPlugin):
if not query: if not query:
return [] 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] = [] results: list[SearchResult] = []
for app in self._desktop_apps: for app in self._desktop_apps:
display_name = getattr(app, "display_name", "") or "" display_name = getattr(app, "display_name", "") or ""
@@ -60,7 +73,9 @@ class ApplicationPlugin(SpotlightPlugin):
@staticmethod @staticmethod
def _launch_app(app: DesktopApp) -> None: def _launch_app(app: DesktopApp) -> None:
app.launch() from globalmenu.launch import launch_desktop_app
launch_desktop_app(app)
PLUGIN = ApplicationPlugin PLUGIN = ApplicationPlugin
+6 -1
View File
@@ -13,6 +13,7 @@ class ClipboardPlugin(SpotlightPlugin):
id = "clipboard" id = "clipboard"
name = "Clipboard" name = "Clipboard"
icon = "edit-paste-symbolic" icon = "edit-paste-symbolic"
keywords = ["clip", "clipboard"]
searchable = True searchable = True
priority = 80 priority = 80
@@ -45,7 +46,11 @@ class ClipboardPlugin(SpotlightPlugin):
def search(self, query: str, token: Any) -> list[SearchResult]: def search(self, query: str, token: Any) -> list[SearchResult]:
q = query.strip() 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() q = q[5:].strip()
elif q.lower() == "clip": elif q.lower() == "clip":
q = "" q = ""
-1
View File
@@ -36,7 +36,6 @@ class EmojiPlugin(SpotlightPlugin):
return return
cldr = self._load_cldr_annotations() cldr = self._load_cldr_annotations()
self._emoji_data = cldr or {} self._emoji_data = cldr or {}
logger.info(f"[Emoji] Loaded {len(self._emoji_data)} emojis")
def detect(self, text: str) -> bool: def detect(self, text: str) -> bool:
s = text.strip() s = text.strip()
+1 -3
View File
@@ -1,4 +1,3 @@
import gc
import os import os
import random import random
from typing import Any from typing import Any
@@ -8,7 +7,7 @@ from shared.data import WALLPAPERS_THUMBNAILS_SIZE
from utils.functions import fuzzy_filter from utils.functions import fuzzy_filter
from window.spotlight.api import SearchResult, SpotlightPlugin from window.spotlight.api import SearchResult, SpotlightPlugin
MAX_THUMBS_PER_SEARCH = 5 MAX_THUMBS_PER_SEARCH = 3
class WallpaperPlugin(SpotlightPlugin): class WallpaperPlugin(SpotlightPlugin):
@@ -28,7 +27,6 @@ class WallpaperPlugin(SpotlightPlugin):
def cleanup(self) -> None: def cleanup(self) -> None:
self._wallpaper_service = None self._wallpaper_service = None
gc.collect()
def release_memory(self) -> None: def release_memory(self) -> None:
if self._wallpaper_service: if self._wallpaper_service:
-7
View File
@@ -612,13 +612,6 @@ class ApplicationSwitcher(Window):
if addr in self._addr_to_idx: if addr in self._addr_to_idx:
self._capture.capture_by_handle(addr, 300, 168, False) 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): def grab_keyboard(self):
try: try:
display = Gdk.Display.get_default() display = Gdk.Display.get_default()
Generated
+1 -94
View File
@@ -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" }, { 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]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.5.0" version = "3.5.0"
@@ -226,13 +153,12 @@ wheels = [
[[package]] [[package]]
name = "modus" name = "modus"
version = "2.0.0" version = "2.0.0"
source = { editable = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fabric" }, { name = "fabric" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pam" }, { name = "pam" },
{ name = "psutil" }, { name = "psutil" },
{ name = "pywayland" },
{ name = "six" }, { name = "six" },
{ name = "tomlkit" }, { name = "tomlkit" },
] ]
@@ -249,7 +175,6 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "pam", specifier = ">=0.2.0" }, { name = "pam", specifier = ">=0.2.0" },
{ name = "psutil", specifier = ">=7.2.2" }, { name = "psutil", specifier = ">=7.2.2" },
{ name = "pywayland", specifier = ">=0.4.18" },
{ name = "six", specifier = ">=1.17.0" }, { name = "six", specifier = ">=1.17.0" },
{ name = "tomlkit", specifier = ">=0.15.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" }, { 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]] [[package]]
name = "pygobject" name = "pygobject"
version = "3.50.0" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"