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

+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",
}
# Marker lines previously written around the global LD_PRELOAD export in shell
# rc files. Kept so we can strip the stale block from already-configured systems.
_SHIM_BEGIN = "# >>> modus global-menu shim >>>"
_SHIM_END = "# <<< modus global-menu shim <<<"
def _compile_shim() -> Path | None:
"""Compile the menu-button shim if needed. Returns path to .so or None."""
def _remove_global_ld_preload():
"""Strip the legacy global LD_PRELOAD export from previously configured systems.
The shim is now injected per-launch into GTK3 apps only (see
globalmenu.launch). Any globally exported LD_PRELOAD must be removed so it
no longer breaks GTK4 processes.
"""
home = Path.home()
shell = os.environ.get("SHELL", "")
rc_files = [
home / ".zshrc",
home / ".bashrc",
home / ".profile",
home / ".config" / "fish" / "config.fish",
]
rc_path = {
"zsh": home / ".zshrc",
"bash": home / ".bashrc",
"fish": home / ".config" / "fish" / "config.fish",
}.get(Path(shell).name)
if rc_path:
rc_files.append(rc_path)
rc_files = list(dict.fromkeys(rc_files)) # de-dupe, keep order
for rc in rc_files:
try:
if not rc.is_file():
continue
lines = rc.read_text().splitlines(keepends=True)
if not any(_SHIM_BEGIN in ln for ln in lines):
continue
out = []
drop = False
for ln in lines:
if _SHIM_BEGIN in ln:
drop = True
continue
if _SHIM_END in ln:
drop = False
continue
if drop:
continue
out.append(ln)
rc.write_text("".join(out))
logger.info(f"[GlobalMenu] Removed stale LD_PRELOAD block from {rc}")
except OSError:
pass
# Strip the LD_PRELOAD line from environment.d/appmenu.conf.
env_path = home / ".config" / "environment.d" / "appmenu.conf"
try:
if env_path.is_file():
kept = [
ln
for ln in env_path.read_text().splitlines(keepends=True)
if not ln.strip().startswith("LD_PRELOAD=")
]
env_path.write_text("".join(kept))
except OSError:
pass
# Clear it from the activation environment for future launches.
exec_shell_command("dbus-update-activation-environment --systemd LD_PRELOAD=")
def get_compiled_shim() -> Path | None:
"""Compile the GTK3 menu-button shim if needed. Returns path to .so or None."""
try:
if SHIM_SO.exists() and SHIM_SO.stat().st_mtime >= SHIM_SRC.stat().st_mtime:
return SHIM_SO
@@ -48,43 +115,6 @@ def _compile_shim() -> Path | None:
return None
def _write_shell_rc(shim_path: Path | None):
"""Append LD_PRELOAD export to the user's shell rc file."""
if not shim_path:
return
shell = os.environ.get("SHELL", "")
home = Path.home()
rc_map = {
"zsh": home / ".zshrc",
"bash": home / ".bashrc",
"fish": home / ".config" / "fish" / "config.fish",
}
rc_path = rc_map.get(Path(shell).name)
if rc_path is None:
rc_path = home / ".profile"
try:
if not rc_path.exists():
return
existing = rc_path.read_text()
if _SHIM_BEGIN in existing:
return
is_fish = rc_path.name == "config.fish"
with open(rc_path, "a") as f:
f.write(f"\n{_SHIM_BEGIN}\n")
if is_fish:
f.write(f"set -gx LD_PRELOAD {shim_path}\n")
else:
f.write(f"export LD_PRELOAD={shim_path}\n")
f.write(f"{_SHIM_END}\n")
except OSError:
pass
def setup_global_menu_environment():
"""Write env config files so GTK apps register with AppMenu at next login.
@@ -93,17 +123,22 @@ def setup_global_menu_environment():
Uses session-level config files that take effect at login.
"""
try:
shim_path = _compile_shim()
shim_path = get_compiled_shim()
# Remove any legacy global LD_PRELOAD export from older installs.
_remove_global_ld_preload()
# NOTE: The menu-button shim is compiled against GTK3 and must NOT be
# exported globally. Injecting it into a GTK4 process aborts the app
# ("GTK 2/3 symbols detected"). It is injected per-launch into GTK3
# apps only — see globalmenu.launch.
if shim_path:
logger.debug(f"[GlobalMenu] GTK3 shim ready at {shim_path}")
exec_shell_command(
"dbus-update-activation-environment --systemd "
"GTK_MODULES=appmenu-gtk-module UBUNTU_MENUPROXY=1"
)
if shim_path:
exec_shell_command(
f"dbus-update-activation-environment --systemd LD_PRELOAD={shim_path}"
)
exec_shell_command("systemctl --user import-environment LD_PRELOAD")
exec_shell_command(
"flatpak override --user --talk-name=com.canonical.AppMenu.Registrar"
@@ -116,8 +151,6 @@ def setup_global_menu_environment():
with open(env_path, "w") as f:
for k, v in ENV_VARS.items():
f.write(f"{k}={v}\n")
if shim_path:
f.write(f"LD_PRELOAD={shim_path}\n")
except OSError:
pass
@@ -149,8 +182,6 @@ def setup_global_menu_environment():
except OSError:
pass
_write_shell_rc(shim_path)
logger.info("[GlobalMenu] GTK environment injected")
except Exception as e:
logger.warning(f"[GlobalMenu] Environment setup failed: {e}")
+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 re
import threading
import time
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple, Union
from typing import Optional, Union
from fabric.core.service import Property, Service, Signal
from fabric.utils import (
@@ -20,6 +21,8 @@ from globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
from globalmenu.environment import setup_global_menu_environment
from globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient
_NODE_NAME_RE = re.compile(r'<node name="([^"]+)"')
_DBUS_TIMEOUT_INTROSPECT = 500
_DBUS_TIMEOUT_PID = 200
_DBUS_TIMEOUT_FAST = 100
@@ -36,21 +39,20 @@ _DE_NAMESPACES = [
"deepin",
]
_MenuClient = Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]
class _IntrospectionCache:
"""Caches DBus Introspect XML per service+path to avoid redundant round-trips."""
def __init__(self):
self._cache: Dict[str, str] = {}
self._cache: dict[str, str] = {}
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:
if not path or not GLib.Variant.is_object_path(path):
return ""
key = self._key(service, path)
key = f"{service}:{path}"
with self._lock:
cached = self._cache.get(key)
if cached is not None:
@@ -61,14 +63,11 @@ class _IntrospectionCache:
return xml
def invalidate(self, service: str):
prefix = f"{service}:"
with self._lock:
keys = [k for k in self._cache if k.startswith(f"{service}:")]
for k in keys:
self._cache.pop(k, None)
_exe_to_service: Dict[str, str] = {}
"""Maps executable path to last-known DBus service name. Populated during discovery."""
self._cache = {
k: v for k, v in self._cache.items() if not k.startswith(prefix)
}
@dataclass
@@ -79,11 +78,18 @@ class AppServiceInfo:
executable: str = ""
wm_class: str = ""
app_id: str = ""
importer: Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]] = None
importer: Optional[_MenuClient] = None
revision: int = 0
connected_signals: bool = False
def _resolve_executable(pid: int) -> str:
try:
return os.path.realpath(f"/proc/{pid}/exe")
except Exception:
return ""
def _dbus_introspect(bus, service: str, path: str, timeout: int) -> str:
if not path or not GLib.Variant.is_object_path(path):
return ""
@@ -144,8 +150,6 @@ REGISTRAR_XML = """
</node>
"""
_EMPTY_SENTINEL: List[DBusMenuItem] = []
class GlobalMenuService(Service):
"""Service that extracts and provides application menus for the global menu bar."""
@@ -157,28 +161,26 @@ class GlobalMenuService(Service):
def active_app_changed(self, app_name: str, wm_class: str) -> None: ...
@Property(object, flags="read-write")
def current_menu(self) -> Optional[List[DBusMenuItem]]:
def current_menu(self) -> Optional[list[DBusMenuItem]]:
return self._current_menu
def __init__(self):
super().__init__()
self._current_menu: Optional[List[DBusMenuItem]] = None
self._current_menu: Optional[list[DBusMenuItem]] = None
self._current_app: str = ""
self._current_wm_class: str = ""
self._current_pid: int = 0
# Service registry: service_name -> AppServiceInfo
# This is the core event-driven data structure (single source of truth).
self._service_registry: Dict[str, AppServiceInfo] = {}
self._pid_to_service: Dict[int, str] = {}
self._service_registry: dict[str, AppServiceInfo] = {}
self._pid_to_service: dict[int, str] = {}
self._introspection_cache = _IntrospectionCache()
self._seq_lock = threading.Lock()
self._extraction_seq = 0
self._extracting_in_flight: Set[str] = set()
self._extracting_in_flight: set[str] = set()
self._lock = threading.Lock()
self._inflight_lock = threading.Lock()
self._registry_lock = threading.Lock()
self._state_lock = threading.Lock()
@@ -236,28 +238,13 @@ class GlobalMenuService(Service):
self._on_service_disappeared(name)
def _on_service_appeared(self, service_name: str):
"""Discover a newly appeared DBus service and populate the registry."""
try:
bus = _get_bus()
pid = _dbus_get_pid(bus, service_name, _DBUS_TIMEOUT_FAST)
if pid <= 0:
return
executable = ""
try:
executable = os.path.realpath(f"/proc/{pid}/exe")
except Exception:
pass
with self._registry_lock:
if service_name not in self._service_registry:
self._service_registry[service_name] = AppServiceInfo(
service_name=service_name,
pid=pid,
executable=executable,
)
if pid:
self._pid_to_service[pid] = service_name
if executable:
_exe_to_service[executable] = service_name
executable = _resolve_executable(pid)
self._register_new_service(service_name, pid, executable)
self._introspection_cache.invalidate(service_name)
logger.debug(
f"[GlobalMenuService] Registered new service: {service_name} pid={pid}"
@@ -274,7 +261,8 @@ class GlobalMenuService(Service):
return
if entry.pid:
self._pid_to_service.pop(entry.pid, None)
if entry.importer and isinstance(entry.importer, DBusMenuClient):
self._registered_menus.pop(service_name, None)
if isinstance(entry.importer, DBusMenuClient):
try:
entry.importer.disconnect_signals()
except Exception:
@@ -283,7 +271,7 @@ class GlobalMenuService(Service):
self._introspection_cache.invalidate(service_name)
with self._state_lock:
if entry and entry.wm_class and entry.wm_class == self._current_wm_class:
if entry.wm_class and entry.wm_class == self._current_wm_class:
self._current_menu = None
logger.debug(
@@ -366,6 +354,30 @@ class GlobalMenuService(Service):
def _uid(self, wm_class: str, pid: int) -> str:
return f"{wm_class}:{pid}"
def _resolve_current_entry(self) -> tuple[Optional[AppServiceInfo], int]:
"""Return the registry entry and PID for the currently active window."""
with self._state_lock:
pid = self._current_pid
with self._registry_lock:
svc = self._pid_to_service.get(pid)
entry = self._service_registry.get(svc) if svc else None
return entry, pid
def _register_new_service(
self, service_name: str, pid: int, executable: str = ""
) -> None:
"""Add a newly discovered service to the registry if not already present."""
with self._registry_lock:
if service_name in self._service_registry:
return
self._service_registry[service_name] = AppServiceInfo(
service_name=service_name,
pid=pid,
executable=executable,
)
if pid:
self._pid_to_service[pid] = service_name
def update_active_window(self, app_name: str, wm_class: str):
target_pid = 0
try:
@@ -402,7 +414,6 @@ class GlobalMenuService(Service):
self.active_app_changed(app_name, wm_class)
# PRIORITY 1: Registry lookup by PID (persistent importers)
with self._registry_lock:
svc_name = self._pid_to_service.get(target_pid) if target_pid else None
registered_entry = (
@@ -425,13 +436,12 @@ class GlobalMenuService(Service):
thread.start()
return
# No importer found in registry — must discover
with self._state_lock:
self._current_menu = None
self.menu_changed([])
inflight_key = self._uid(wm_class, target_pid)
with self._lock:
with self._inflight_lock:
if inflight_key in self._extracting_in_flight:
logger.debug(
f"[GlobalMenuService] Extraction in-flight for '{inflight_key}' — skipping"
@@ -500,8 +510,8 @@ class GlobalMenuService(Service):
def _handle_items_updated(
self,
svc_name: str,
updated_props: List[Tuple[int, dict]],
removed_ids: List[int],
updated_props: list[tuple[int, dict]],
removed_ids: list[int],
):
with self._registry_lock:
entry = self._service_registry.get(svc_name)
@@ -520,7 +530,7 @@ class GlobalMenuService(Service):
self,
wm_class: str,
seq: int,
client: Union[DBusMenuClient, GtkMenuClient, ActionMenuClient],
client: _MenuClient,
inflight_key: str,
):
try:
@@ -547,7 +557,7 @@ class GlobalMenuService(Service):
)
finally:
if inflight_key:
with self._lock:
with self._inflight_lock:
self._extracting_in_flight.discard(inflight_key)
def _scan_initial_services(self):
@@ -573,22 +583,8 @@ class GlobalMenuService(Service):
pid = _dbus_get_pid(bus, name, _DBUS_TIMEOUT_FAST)
if pid <= 0:
continue
executable = ""
try:
executable = os.path.realpath(f"/proc/{pid}/exe")
except Exception:
pass
with self._registry_lock:
if name not in self._service_registry:
self._service_registry[name] = AppServiceInfo(
service_name=name,
pid=pid,
executable=executable,
)
if pid:
self._pid_to_service[pid] = name
if executable:
_exe_to_service[executable] = name
executable = _resolve_executable(pid)
self._register_new_service(name, pid, executable)
logger.debug(
f"[GlobalMenuService] Initial scan: registered {len(names)} services"
)
@@ -597,7 +593,7 @@ class GlobalMenuService(Service):
def _discover_dbus_client(
self, wm_class: str, target_pid: int = 0
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
) -> Optional[_MenuClient]:
try:
if target_pid <= 0:
out = exec_shell_command("hyprctl activewindow -j")
@@ -621,9 +617,7 @@ class GlobalMenuService(Service):
logger.debug(f"[GlobalMenuService] Discovery failed: {e}")
return None
def _find_registry(
self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
def _find_registry(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
with self._registry_lock:
svc_name = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc_name) if svc_name else None
@@ -632,9 +626,7 @@ class GlobalMenuService(Service):
return entry.importer
return None
def _find_registrar(
self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
bus = _get_bus()
with self._registry_lock:
registered = dict(self._registered_menus)
@@ -663,7 +655,7 @@ class GlobalMenuService(Service):
def _find_fallback_scan(
self, wm_class: str, target_pid: int
) -> Optional[Union[DBusMenuClient, GtkMenuClient, ActionMenuClient]]:
) -> Optional[_MenuClient]:
bus = _get_bus()
try:
res = bus.call_sync(
@@ -712,7 +704,6 @@ class GlobalMenuService(Service):
bus, svc, dynamic_bases
)
# PRIORITY A: DBusMenu (com.canonical.dbusmenu)
for path in fallback_paths:
xml = self._introspection_cache.get(
svc, path, bus, _DBUS_TIMEOUT_INTROSPECT
@@ -723,7 +714,6 @@ class GlobalMenuService(Service):
self._cache_discovery(svc, path, pid, wm_class, client)
return client
# PRIORITY B: org.gtk.Menus
gtk_candidates = []
for path in fallback_paths:
xml = self._introspection_cache.get(
@@ -732,17 +722,17 @@ class GlobalMenuService(Service):
if "org.gtk.Menus" in xml:
gtk_candidates.append((svc, path))
existing_gtk_paths = {p for _, p in gtk_candidates}
for dfs_path in self._dfs_find_interface(svc, "org.gtk.Menus"):
if dfs_path not in {p for _, p in gtk_candidates}:
if dfs_path not in existing_gtk_paths:
gtk_candidates.append((svc, dfs_path))
existing_gtk_paths.add(dfs_path)
for gsvc, gpath in gtk_candidates:
try:
test_client = GtkMenuClient(gsvc, gpath)
test_items = test_client.get_layout()
if not test_items:
import time
for _ in range(4):
time.sleep(0.05)
if test_items := test_client.get_layout():
@@ -758,26 +748,9 @@ class GlobalMenuService(Service):
except Exception:
pass
# PRIORITY C: org.gtk.Actions (last resort)
action_candidates = ["/"]
name_path = (
"/" + svc.replace(".", "/") if not svc.startswith(":") else ""
action_candidates = self._build_action_candidates(
svc, _safe_cls, _safe_cap
)
if name_path and GLib.Variant.is_object_path(name_path):
action_candidates.append(name_path)
action_candidates.append("/org/gtk/Application")
if _safe_cls:
cls_lower = _safe_cls.lower()
action_candidates.extend(
[f"/org/{cls_lower}/{_safe_cap}", f"/org/{cls_lower}"]
)
for ns in [*_DE_NAMESPACES, cls_lower]:
for app_part in (_safe_cap, _safe_cls):
p = f"/org/{ns}/{app_part}"
if GLib.Variant.is_object_path(p):
action_candidates.append(p)
action_candidates.extend(
self._dfs_find_interface(svc, "org.gtk.Actions")
)
@@ -804,6 +777,26 @@ class GlobalMenuService(Service):
continue
return None
def _build_action_candidates(
self, svc: str, safe_cls: str, safe_cap: str
) -> list[str]:
candidates = ["/"]
name_path = "/" + svc.replace(".", "/") if not svc.startswith(":") else ""
if name_path and GLib.Variant.is_object_path(name_path):
candidates.append(name_path)
candidates.append("/org/gtk/Application")
if safe_cls:
cls_lower = safe_cls.lower()
candidates.extend([f"/org/{cls_lower}/{safe_cap}", f"/org/{cls_lower}"])
for ns in [*_DE_NAMESPACES, cls_lower]:
for app_part in (safe_cap, safe_cls):
p = f"/org/{ns}/{app_part}"
if GLib.Variant.is_object_path(p):
candidates.append(p)
return candidates
def _build_dynamic_bases(self, svc: str, _safe_cls: str, _safe_cap: str) -> list:
static_paths = [
"/MenuBar",
@@ -841,12 +834,12 @@ class GlobalMenuService(Service):
static_paths.append(p)
return static_paths
def _introspect_paths(self, bus, svc: str, bases: list) -> Tuple[list, set]:
def _introspect_paths(self, bus, svc: str, bases: list) -> tuple[list, set]:
fallback_paths = list(bases)
seen_fallback = set(fallback_paths)
def _add_paths(parent_path, xml_str):
for child in re.findall(r'<node name="([^"]+)"', xml_str):
for child in _NODE_NAME_RE.findall(xml_str):
child_path = f"{parent_path}/{child}"
if (
GLib.Variant.is_object_path(child_path)
@@ -876,7 +869,7 @@ class GlobalMenuService(Service):
path: str,
pid: int,
wm_class: str,
client: Union[DBusMenuClient, GtkMenuClient, ActionMenuClient],
client: _MenuClient,
):
with self._registry_lock:
if service not in self._service_registry:
@@ -890,8 +883,6 @@ class GlobalMenuService(Service):
self._service_registry[service] = entry
if pid:
self._pid_to_service[pid] = service
if entry.executable:
_exe_to_service[entry.executable] = service
else:
entry = self._service_registry[service]
entry.importer = client
@@ -935,7 +926,7 @@ class GlobalMenuService(Service):
if f'interface name="{interface}"' in xml:
results.append(path)
for child in re.findall(r'<node name="([^"]+)"', xml):
for child in _NODE_NAME_RE.findall(xml):
child_path = (
path.rstrip("/") + "/" + child if path != "/" else "/" + child
)
@@ -1002,10 +993,9 @@ class GlobalMenuService(Service):
items = client.get_layout()
if items:
with self._lock:
logger.info(
f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items"
)
logger.info(
f"[GlobalMenuService] Found menu for '{wm_class}': {len(items)} items"
)
else:
logger.info(f"[GlobalMenuService] No DBus menu found for '{wm_class}'")
@@ -1027,10 +1017,10 @@ class GlobalMenuService(Service):
)
finally:
if inflight_key:
with self._lock:
with self._inflight_lock:
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:
with self._seq_lock:
if seq != self._extraction_seq:
@@ -1041,39 +1031,27 @@ class GlobalMenuService(Service):
return False
def click_item(self, item_id: int) -> bool:
with self._state_lock:
target_pid = self._current_pid
with self._registry_lock:
svc = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc) if svc else None
entry, pid = self._resolve_current_entry()
if entry and entry.importer:
entry.importer.click_item(item_id, pid=target_pid)
entry.importer.click_item(item_id, pid=pid)
return True
return False
def about_to_show(self, item_id: int) -> bool:
with self._state_lock:
target_pid = self._current_pid
with self._registry_lock:
svc = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc) if svc else None
entry, _pid = self._resolve_current_entry()
if entry and entry.importer:
return entry.importer.about_to_show(item_id)
return False
def refresh_menu_sync(self) -> List[DBusMenuItem]:
def refresh_menu_sync(self) -> list[DBusMenuItem]:
entry, pid = self._resolve_current_entry()
if not (entry and entry.importer):
return []
items = entry.importer.get_layout(force_refresh=True)
with self._state_lock:
target_pid = self._current_pid
with self._registry_lock:
svc = self._pid_to_service.get(target_pid)
entry = self._service_registry.get(svc) if svc else None
if entry and entry.importer:
items = entry.importer.get_layout(force_refresh=True)
with self._state_lock:
if target_pid == self._current_pid:
self._current_menu = items
return items
return []
if pid == self._current_pid:
self._current_menu = items
return items
_global_menu_svc_instance = None
+2 -4
View File
@@ -30,7 +30,6 @@ class CachedNotification(Service):
# Store cache metadata for cleanup
self.cache_metadata = data.get("cache_metadata", {})
self.timestamp = data.get("timestamp", int(time.time()))
self._pixbuf_cache = {}
return self
@@ -137,7 +136,7 @@ class CachedNotification(Service):
notification_image_cache_key = (
get_notification_image_cache_key(self.id, image_pixbuf)
)
except (AttributeError, OSError, Exception):
except Exception:
# If temp file is gone or any other error, just mark as None
pass
except Exception:
@@ -175,7 +174,6 @@ class CachedNotification(Service):
self._cache_id = cache_id
self.cache_metadata = {}
self.timestamp = int(time.time())
self._pixbuf_cache: dict[str, GdkPixbuf.Pixbuf | None] = {}
class CachedNotifications(Notifications):
@@ -497,7 +495,7 @@ class CachedNotifications(Notifications):
logger.debug(
f"Cached notification image for notification {cache_id}"
)
except (AttributeError, OSError, Exception) as e:
except Exception as e:
logger.warning(
f"Failed to cache notification image for notification {cache_id}: {e}"
)
+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.layouts = []
self.current_index = 0
self._reload_pending = False
self._init_layout_config()
self._start_file_monitor()
@@ -106,9 +107,12 @@ class KeyboardLayout(Service):
logger.error(f"[KeyboardLayout] Failed to start file monitor: {e}")
def _on_file_changed(self, monitor, file, *args):
GLib.timeout_add(50, self._reload_layout)
if not self._reload_pending:
self._reload_pending = True
GLib.timeout_add(50, self._reload_layout)
def _reload_layout(self):
self._reload_pending = False
new_layout = self._read_layout()
if new_layout != self._last_layout:
self._last_layout = new_layout
+6 -33
View File
@@ -1,14 +1,13 @@
import json
import os
import re
import subprocess
import sys
from fabric.core.service import Property, Service, Signal
from fabric.hyprland.service import Hyprland
from fabric.utils import exec_shell_command_async, get_relative_path, logger
from fabric.utils import exec_shell_command_async, logger
from services.custom_notification import CachedNotifications
from services.inhibit import get_inhibit_service
from utils.app_name_resolver import app_name_resolver
from utils.functions import (
find_binary,
@@ -432,35 +431,14 @@ def is_night_light_active() -> bool:
# --- Caffeine orchestration ---
_caffeine_process: subprocess.Popen | None = None
def is_caffeine_active() -> bool:
if _caffeine_process is not None:
return True
try:
return bool(find_process_pid("modus-inhibit", timeout=0.5))
except Exception:
return False
return get_inhibit_service().active
def toggle_caffeine() -> bool:
global _caffeine_process
try:
if is_caffeine_active():
inhibit_script = get_relative_path("utils/inhibit.py")
spawn_detached(["python3", inhibit_script, "off"])
if _caffeine_process:
_caffeine_process.terminate()
_caffeine_process = None
return False
else:
inhibit_script = get_relative_path("utils/inhibit.py")
_caffeine_process = spawn_detached(["python3", inhibit_script, "on"])
return True
except Exception as e:
logger.error(f"[Modus] Failed to toggle caffeine: {e}")
return False
return get_inhibit_service().toggle()
# --- Hyprland window management API ---
@@ -549,14 +527,9 @@ def close_window(address: str):
def launch_app(command_line: str):
if not command_line:
return
from globalmenu.launch import launch_command
cleaned = re.sub(r"%\w+", "", command_line).strip()
exec_shell_command_async(
f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'"
)
launch_command(command_line)
def open_trash():
+196 -64
View File
@@ -1,11 +1,12 @@
import hashlib
import mimetypes
import threading
import urllib.parse
import urllib.request
from pathlib import Path
import gi
from fabric.utils import GLib, logger
from fabric.utils import GLib, exec_shell_command_async, logger
gi.require_version("Playerctl", "2.0")
from fabric import Fabricator # noqa: E402
@@ -16,6 +17,45 @@ from shared.data import CACHE_DIR # noqa: E402
TEMP_DIR = CACHE_DIR
# Native Linux media artwork cache (libmediaart). Optional: when the GI
# namespace is unavailable we transparently fall back to our own cache dir,
# so the service keeps working without adding a hard dependency.
try:
gi.require_version("MediaArt", "2.0")
from gi.repository import MediaArt
_MEDIAART_AVAILABLE = True
except (ValueError, ImportError):
MediaArt = None
_MEDIAART_AVAILABLE = False
def _mediaart_cache_path(artist: str | None, album: str | None, title: str | None):
"""Standard media-art cache path for a track, or None when unavailable.
Does NOT download; merely derives the canonical cache location so we can
both look artwork up and store new downloads following the same rules.
"""
if not _MEDIAART_AVAILABLE:
return None
try:
result = MediaArt.get_path(
artist or None, album or None, title or None, MediaArt.Type.ALBUM
)
except Exception as e:
logger.debug(f"MediaArt.get_path failed: {e}")
return None
# PyGObject return shape varies by version: (found, path, uri) or (path, uri).
if isinstance(result, tuple):
if len(result) == 3:
found, path, _uri = result
elif len(result) == 2:
found, path = True, result[0]
else:
return None
return path if found else None
return result
class PlayerService(Service):
@Signal
@@ -95,19 +135,19 @@ class PlayerService(Service):
def play_pause(self, *_):
try:
self._player.play_pause()
except Exception as e:
except GLib.Error as e:
logger.warning(f"play_pause failed: {e}")
def next(self, *_):
try:
self._player.next()
except Exception as e:
except GLib.Error as e:
logger.warning(f"next failed: {e}")
def previous(self, *_):
try:
self._player.previous()
except Exception as e:
except GLib.Error as e:
logger.warning(f"previous failed: {e}")
def __init__(self, player: Playerctl.Player, **kwargs):
@@ -117,6 +157,7 @@ class PlayerService(Service):
self._current_artwork_path = ""
self._is_cleaning_up = False
self._signal_ids = []
self._last_emitted_status = ""
self._signal_ids.append(
self._player.connect("playback-status", self.on_playback_status)
@@ -124,7 +165,6 @@ class PlayerService(Service):
self._signal_ids.append(self._player.connect("metadata", self.on_metadata))
self._signal_ids.append(self._player.connect("seeked", self.on_seeked))
self.status = self._player.props.playback_status
self._pos_polling = False # guard: Fabricator.start() blindly creates a new
# GLib timer every call — this flag prevents stacking
self.pos_fabricator = Fabricator(
@@ -143,11 +183,48 @@ class PlayerService(Service):
try:
metadata = self._player.props.metadata
if metadata:
self.meta_change(metadata, self._player)
# meta_change emission must not block artwork discovery.
try:
self.meta_change(metadata, self._player)
except Exception as e:
logger.debug(f"meta_change emit failed: {e}")
self._handle_artwork(metadata)
except Exception as e:
logger.warning(f"Failed to initialize metadata: {e}")
# Playerctl loads player properties asynchronously. For a player that
# was already playing before Modus started, the initial metadata read
# above can be empty, and the `metadata` signal only fires on *change*
# — so it would never re-fire and artwork would stay missing. Browser
# players (YouTube in Firefox/Chromium) in particular populate
# `mpris:artUrl` lazily, seconds after playback begins. Re-read metadata
# repeatedly for a short window so the artwork is eventually picked up.
GLib.timeout_add(500, self._refresh_initial_state, 0)
def _refresh_initial_state(self, attempt: int = 0):
if self._is_cleaning_up:
return False
try:
metadata = self._player.props.metadata
except Exception:
return False
if metadata:
# Push full metadata only once to avoid UI churn on every retry.
if attempt == 0:
try:
self.meta_change(metadata, self._player)
except Exception as e:
logger.debug(f"meta_change emit failed: {e}")
self._handle_artwork(metadata)
self.fabricating(metadata)
# Stop as soon as artwork was found/queued.
if self._current_artwork_hash:
return False
# Retry for ~10s (browser players can be very slow to expose artUrl).
if attempt < 12:
GLib.timeout_add(800, self._refresh_initial_state, attempt + 1)
return False
def get_artwork(self) -> str:
return self._current_artwork_path
@@ -181,14 +258,17 @@ class PlayerService(Service):
current = self.get_position() / 1_000_000
try:
self._player.set_position(int(pos * 1_000_000))
except Exception:
except GLib.Error:
try:
offset = pos - current
self._player.seek(int(offset * 1_000_000))
except Exception:
import os
os.system(f"playerctl -p {self.player_name} position {pos}")
except GLib.Error:
name = self.player_name
if name:
# No shell: pass args as a list so player_name can't inject.
exec_shell_command_async(
["playerctl", "-p", name, "position", str(pos)]
)
finally:
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
@@ -232,6 +312,11 @@ class PlayerService(Service):
dur = metadata["mpris:length"] / 1_000_000
except Exception:
dur = 0
# Browser players (YouTube in Firefox/Chromium) populate
# `mpris:artUrl` lazily and don't reliably emit the `metadata`
# signal. Since fabricating runs every 2s while playing, this also
# polls the artwork so it shows up even for an already-playing tab.
self._handle_artwork(metadata)
self.track_position(pos, dur)
def on_seeked(self, player, position):
@@ -240,97 +325,144 @@ class PlayerService(Service):
if self.playback_status.lower() == "playing":
self._start_pos_fabricator()
def on_playback_status(self, player, status):
"""DBus signal handler - instant when it fires (e.g. Modus buttons)."""
def _notify_playback(self, status_str: str):
"""Emit play/pause once per actual state change (dedupes the
Playerctl signal and the status Fabricator so listeners don't churn)."""
if self._is_cleaning_up:
return
self.status = status
self.poll_progress()
if self.playback_status.lower() == "playing":
status_str = (status_str or "").lower()
if status_str == self._last_emitted_status:
return
self._last_emitted_status = status_str
if status_str == "playing":
self.play()
else:
self.pause()
def _on_polled_status_change(self, new_status: str):
"""Fabricator-driven status change - fires reliably every second."""
def on_playback_status(self, player, status):
"""DBus signal handler - instant when it fires (e.g. Modus buttons)."""
if self._is_cleaning_up:
return
self.poll_progress()
if new_status.lower() == "playing":
self.play()
else:
self.pause()
self._notify_playback(self.playback_status)
def _on_polled_status_change(self, new_status: str):
"""Fabricator-driven status change - fires reliably for browser players
that don't emit playback-status signals."""
if self._is_cleaning_up:
return
self.poll_progress()
self._notify_playback(new_status)
def on_metadata(self, player, metadata):
if self._is_cleaning_up:
return
self.meta_change(metadata, player)
self._handle_artwork(metadata)
self.fabricating(metadata)
def _meta_str(self, metadata, key: str) -> str | None:
try:
value = metadata[key]
except (KeyError, TypeError, GLib.Error):
return None
if isinstance(value, (list, tuple)):
return " ".join(str(v) for v in value) if value else None
return str(value) if value else None
def _find_cached_artwork(self, metadata) -> str | None:
"""Locate already-cached artwork for this track (native MediaArt cache)."""
if not _MEDIAART_AVAILABLE:
return None
artist = self._meta_str(metadata, "xesam:artist")
album = self._meta_str(metadata, "xesam:album")
title = self._meta_str(metadata, "xesam:title")
if not (artist or album or title):
return None
base = _mediaart_cache_path(artist, album, title)
if not base:
return None
base = Path(base)
if base.exists():
return str(base)
# MediaArt files usually carry an extension; look for any sibling.
matches = list(base.parent.glob(base.stem + ".*"))
return str(matches[0]) if matches else None
def _existing_local_artwork(self, artwork_hash: str, metadata) -> str | None:
cached = self._find_cached_artwork(metadata)
if cached:
return cached
cache_dir = TEMP_DIR / "player-art"
matches = list(cache_dir.glob(f"{artwork_hash}.*"))
return str(matches[0]) if matches else None
def _handle_artwork(self, metadata):
if self._is_cleaning_up:
return
try:
art_url = metadata["mpris:artUrl"]
except Exception:
except (KeyError, TypeError):
return
if not art_url:
return
artwork_hash = hashlib.md5(art_url.encode()).hexdigest()
if artwork_hash == self._current_artwork_hash:
return
self._current_artwork_hash = artwork_hash
parsed = urllib.parse.urlparse(art_url)
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
self._set_artwork(urllib.parse.unquote(parsed.path))
elif parsed.scheme in ("http", "https"):
GLib.Thread.new(
"download-artwork",
self._download_artwork,
art_url,
artwork_hash,
)
local = urllib.parse.unquote(parsed.path)
if Path(local).exists():
self._set_artwork(local)
return
if parsed.scheme in ("http", "https"):
existing = self._existing_local_artwork(artwork_hash, metadata)
if existing and Path(existing).exists():
self._set_artwork(existing)
return
threading.Thread(
target=self._download_artwork,
args=(art_url, artwork_hash, metadata),
daemon=True,
).start()
def _set_artwork(self, path: str):
if self._is_cleaning_up:
return
self._current_artwork_path = path
self.artwork_change(path)
def _download_artwork(self, art_url: str, artwork_hash: str):
def _artwork_target_path(self, artwork_hash: str, metadata, suffix: str) -> Path:
"""Where to store a freshly downloaded cover: the standard MediaArt
cache location when available, otherwise our own fallback cache."""
ma_base = self._find_cached_artwork(metadata)
if ma_base:
base = Path(ma_base)
return base.parent / (base.stem + suffix)
cache_dir = TEMP_DIR / "player-art"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / f"{artwork_hash}{suffix}"
def _download_artwork(self, art_url: str, artwork_hash: str, metadata):
if self._is_cleaning_up:
return
try:
cache_dir = TEMP_DIR / "player-art"
cache_dir.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(art_url, timeout=5) as response:
data = response.read()
suffix = (
mimetypes.guess_extension(response.info().get_content_type())
or ".png"
)
target = self._artwork_target_path(artwork_hash, metadata, suffix)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_bytes(data)
tmp.replace(target)
filename_hash = hashlib.md5(art_url.encode()).hexdigest()
parsed = urllib.parse.urlparse(art_url)
local_arturl = None
url_suffix = Path(parsed.path).suffix
if url_suffix:
test_path = cache_dir / f"{filename_hash}{url_suffix}"
if test_path.exists():
local_arturl = test_path
else:
existing = list(cache_dir.glob(f"{filename_hash}.*"))
if existing:
local_arturl = existing[0]
if not local_arturl:
with urllib.request.urlopen(art_url, timeout=5) as response:
data = response.read()
suffix = (
mimetypes.guess_extension(response.info().get_content_type())
or ".png"
)
local_arturl = cache_dir / f"{filename_hash}{suffix}"
tmp = local_arturl.with_suffix(".tmp")
tmp.write_bytes(data)
tmp.replace(local_arturl)
GLib.idle_add(self._set_artwork, str(local_arturl))
if not self._is_cleaning_up:
GLib.idle_add(self._set_artwork, str(target))
except Exception as e:
logger.error(f"Failed to download artwork: {e}")
+41 -4
View File
@@ -25,12 +25,12 @@ class Wifi(Service):
self._ap_signal: int | None = None
super().__init__(**kwargs)
self._client.connect(
self._client_handler = self._client.connect(
"notify::wireless-enabled",
lambda *args: self.notifier("enabled", args),
)
if self._device:
bulk_connect(
self._device_handlers = bulk_connect(
self._device,
{
"notify::active-access-point": lambda *args: self._activate_ap(),
@@ -41,6 +41,23 @@ class Wifi(Service):
)
self._activate_ap()
def close(self):
"""Disconnect all signal handlers so the service can be garbage collected."""
try:
self._client.disconnect(self._client_handler)
except Exception:
pass
for hid in getattr(self, "_device_handlers", ()):
try:
self._device.disconnect(hid)
except Exception:
pass
if getattr(self, "_ap", None) and getattr(self, "_ap_signal", None):
try:
self._ap.disconnect(self._ap_signal)
except Exception:
pass
def ap_update(self):
self.emit("changed")
for sn in [
@@ -245,6 +262,7 @@ class Ethernet(Service):
self._client: NM.Client = client
self._device: NM.DeviceEthernet = device
self._handlers: list[int] = []
for pn in (
"active-connection",
"icon-name",
@@ -252,14 +270,26 @@ class Ethernet(Service):
"speed",
"state",
):
self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn))
self._handlers.append(
self._device.connect(f"notify::{pn}", lambda *_: self.notifier(pn))
)
self._device.connect("notify::speed", lambda *_: self.notifier("speed"))
self._handlers.append(
self._device.connect("notify::speed", lambda *_: self.notifier("speed"))
)
def notifier(self, pn):
self.notify(pn)
self.emit("changed")
def close(self):
"""Disconnect all signal handlers so the service can be garbage collected."""
for hid in getattr(self, "_handlers", ()):
try:
self._device.disconnect(hid)
except Exception:
pass
class NetworkClient(Service):
@Signal
@@ -325,9 +355,16 @@ class NetworkClient(Service):
return
if self.wifi_device is wifi:
self.wifi_device = next(iter(self.wifi_devices.values()), None)
wifi.close()
logger.info(f"[Network] Wifi device removed: {iface}")
self.emit("device-removed", iface)
elif dtype == NM.DeviceType.ETHERNET:
if self.ethernet_device is not None:
self.ethernet_device.close()
self.ethernet_device = None
self.emit("device-removed", nm_device.get_iface())
def _get_primary_device(self) -> Literal["wifi", "wired"] | None:
if not self._client:
return None
+53 -39
View File
@@ -11,38 +11,45 @@ from shared.data import (
WALLPAPERS_THUMBNAILS_SIZE,
)
for path in (WALLPAPER_PATH, WALLPAPER_THUMBS_PATH):
os.makedirs(path, exist_ok=True)
os.makedirs(WALLPAPER_PATH, exist_ok=True)
os.makedirs(WALLPAPER_THUMBS_PATH, exist_ok=True)
def get_thumbnail_filename(image_path: str) -> str:
key = hashlib.sha256(image_path.encode()).hexdigest()
size = WALLPAPERS_THUMBNAILS_SIZE
return f"{key}_{size}x{size}.webp"
return f"{key}_{WALLPAPERS_THUMBNAILS_SIZE}x{WALLPAPERS_THUMBNAILS_SIZE}.webp"
def create_thumbnail(
image_path: str, thumbnail_path: str, size: int = WALLPAPERS_THUMBNAILS_SIZE
image_path: str,
thumbnail_path: str,
size: int = WALLPAPERS_THUMBNAILS_SIZE,
) -> bool:
"""Create WebP thumbnail with proper memory cleanup to prevent leaks."""
pixbuf = None
"""Create a compressed WebP thumbnail."""
try:
# Load image at scaled size to minimize memory usage
pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(image_path, size, size, True)
# Save as WebP with quality 85 for better compression (60-80% smaller than PNG)
result = pixbuf.savev(thumbnail_path, "webp", ["quality"], ["85"])
return bool(result)
pixbuf = gdk_pixbuf.Pixbuf.new_from_file_at_scale(
image_path,
size,
size,
preserve_aspect_ratio=True,
)
return bool(
pixbuf.savev(
thumbnail_path,
"webp",
["quality"],
["85"],
)
)
except Exception:
return False
finally:
# Explicitly clear pixbuf reference to free memory
if pixbuf is not None:
del pixbuf
def generate_colors_from_wallpaper(image_path: str) -> bool:
cmd = f'matugen image "{image_path}"'
return bool(exec_shell_command_async(cmd))
return bool(exec_shell_command_async(f'matugen image "{image_path}"'))
class WallpaperService(Service):
@@ -57,55 +64,62 @@ class WallpaperService(Service):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._processed_count = 0
self._file_cache: list[str] | None = None
def get_all_wallpapers(self, use_cache: bool = True) -> list[str]:
if use_cache and self._file_cache is not None:
return self._file_cache
all_files = []
for file in os.listdir(WALLPAPER_PATH):
file_full = WALLPAPER_PATH + "/" + file
mt = mimetypes.guess_type(file_full)[0]
if mt and mt.startswith("image/"):
all_files.append(file_full)
wallpapers: list[str] = []
self._file_cache = all_files
return all_files
with os.scandir(WALLPAPER_PATH) as entries:
for entry in entries:
if not entry.is_file():
continue
mime, _ = mimetypes.guess_type(entry.path)
if mime and mime.startswith("image/"):
wallpapers.append(entry.path)
self._file_cache = wallpapers
return wallpapers
def invalidate_cache(self) -> None:
self._file_cache = None
def get_thumbnail_path(self, image_path: str) -> str:
thumbnail_filename = get_thumbnail_filename(image_path)
return WALLPAPER_THUMBS_PATH + "/" + thumbnail_filename
return os.path.join(
WALLPAPER_THUMBS_PATH,
get_thumbnail_filename(image_path),
)
def has_thumbnail(self, image_path: str) -> bool:
thumbnail_path = self.get_thumbnail_path(image_path)
return os.path.isfile(thumbnail_path)
return os.path.isfile(self.get_thumbnail_path(image_path))
def set_wallpaper(self, image_path: str):
def set_wallpaper(self, image_path: str) -> None:
exec_shell_command_async(
f"awww img {image_path} --transition-type none --transition-duration 0 --transition-fps 60"
f'awww img "{image_path}" '
"--transition-type none "
"--transition-duration 0 "
"--transition-fps 60"
)
self.wallpaper_set(image_path)
target_path = os.path.abspath(image_path)
home_dir = os.path.expanduser("~")
link_path = os.path.join(home_dir, ".current.wall")
link_path = os.path.expanduser("~/.current.wall")
if os.path.islink(link_path) or os.path.exists(link_path):
if os.path.lexists(link_path):
os.remove(link_path)
os.symlink(target_path, link_path)
os.symlink(os.path.abspath(image_path), link_path)
exec_shell_command_async(f'matugen image "{image_path}" --source-color-index 0')
self.colors_generated(image_path)
def get_wallpaper_info(self, image_path: str) -> dict:
"""Get information about a wallpaper"""
stat = os.stat(image_path)
return {
"path": image_path,
"filename": os.path.basename(image_path),
+3 -5
View File
@@ -344,7 +344,6 @@ class FlatScale(Gtk.DrawingArea, Widget):
self._anim_bubble_open.min_value = p
self._anim_bubble_open.max_value = 1.0
self._anim_bubble_open.value = p
self._anim_bubble_open._start_time = None
self._anim_bubble_open.play()
def _bubble_close(self):
@@ -356,7 +355,6 @@ class FlatScale(Gtk.DrawingArea, Widget):
self._anim_bubble_close.min_value = start
self._anim_bubble_close.max_value = 1.0
self._anim_bubble_close.value = start
self._anim_bubble_close._start_time = None
self._anim_bubble_close.play()
def _sync_bubble_progress(self):
@@ -578,9 +576,9 @@ class FlatScale(Gtk.DrawingArea, Widget):
)
cr.fill()
# Percentage bubble (drag only) — pass slider_left_x as "slider_top_y"
slider_left_x = slider_cx - slider_thickness / 2
self._draw_bubble(cr, slider_cy, slider_left_x, styles)
# Percentage bubble (drag only)
max_thickness = max(self._anim_press.min_value, self._anim_press.max_value)
self._draw_bubble(cr, slider_cx, slider_cy, max_thickness, styles)
return False
@@ -80,3 +80,7 @@ class AnimatedScrollable(ScrolledWindow):
value = self.height_animator.value
value = 0 if value < 0 else value
return value, value
def destroy(self):
self.height_animator.stop()
super().destroy()
+11
View File
@@ -403,3 +403,14 @@ class BatteryControl(Box):
def on_profile_changed(self, service, *args):
self.update_energy_mode_buttons()
def destroy(self):
try:
self.battery_service.disconnect_by_func(self.on_battery_changed)
except Exception:
pass
try:
self.battery_service.disconnect_by_func(self.on_profile_changed)
except Exception:
pass
super().destroy()
+21 -6
View File
@@ -69,13 +69,16 @@ class PopupWindow(WaylandWindow):
if not self._pointing_widget:
return
# Cleanly (re)connect: always drop any existing handlers first so that
# repeated calls while visible do not stack duplicate connections.
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception as e:
if "nothing connected" not in str(e):
logger.error(f"Error disconnecting signal: {e}")
if not self.get_visible():
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception as e:
if "nothing connected" not in str(e):
logger.error(f"Error disconnecting signal: {e}")
return
self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate)
@@ -92,6 +95,18 @@ class PopupWindow(WaylandWindow):
return None
return self.do_reposition(self.do_calculate_edges())
def destroy(self):
if self._pointing_widget:
try:
self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
try:
self.disconnect_by_func(self.do_handle_size_allocate)
except Exception:
pass
super().destroy()
def do_calculate_edges(self):
move_axe = "x"
parent_window = self.get_parent_window()
-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 {
margin: 8px;
padding: 5px;
@@ -40,10 +22,6 @@
transition: background-color apply(transition-medium) ease-in-out;
}
.app-search-header.shine {
animation: header-shine 1s ease-in-out infinite alternate;
}
.app-slot {
padding: 8px;
border: solid 1px transparent;
@@ -64,10 +42,6 @@
background-color: @primary-a20;
}
.app-slot.shine {
animation: app-slot-shine 0.3s ease-in-out;
}
.app-title {
color: @foreground;
font-size: apply(font-base);
+6
View File
@@ -5,14 +5,19 @@ class AppName:
def __init__(self, path="/usr/share/applications"):
self.files = os.listdir(path)
self.path = path
self._cache: dict[str, str | None] = {}
def get_app_name(self, wmclass, _format_=False):
if wmclass in self._cache:
return self._cache[wmclass]
desktop_file = ""
for f in self.files:
if f.startswith(wmclass + ".desktop"):
desktop_file = f
if desktop_file == "":
self._cache[wmclass] = None
return None
desktop_app_name = wmclass
@@ -22,6 +27,7 @@ class AppName:
if line.startswith("Name="):
desktop_app_name = line.split("=")[1].strip()
break
self._cache[wmclass] = desktop_app_name
return desktop_app_name
def format_app_name(self, title, wmclass, update=False):
+1 -6
View File
@@ -657,12 +657,6 @@ class BluetoothConnections(Box):
device_slot = BluetoothDeviceSlot(device)
self.other_devices.add(device_slot)
for slot in existing_paired.values():
pass
for slot in existing_other.values():
pass
if not self._destroyed:
has_paired_devices = len(paired_devices) > 0
has_other_devices = len(other_devices) > 0
@@ -679,6 +673,7 @@ class BluetoothConnections(Box):
def start_device_monitoring(self):
"""Start periodic monitoring for device changes"""
self.stop_device_monitoring()
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh)
def stop_device_monitoring(self):
+5 -17
View File
@@ -7,7 +7,7 @@ from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from services.brightness import Brightness
from services.modus import is_caffeine_active, toggle_caffeine
from services.inhibit import get_inhibit_service
from services.network import NetworkClient
from shared.widgets.flat_scale import FlatScale
from shared.window.applet_window import AppletWindow
@@ -133,11 +133,8 @@ class ModusControlCenter(AppletWindow):
h_expand=True,
)
# Only connect brightness controls if brightness service is available
if get_brightness_service().max_screen > 0:
pass
else:
# Disable brightness scale if no backlight device available
# Disable brightness scale if no backlight device is available
if get_brightness_service().max_screen <= 0:
self.brightness_scale.set_sensitive(False)
self._mpris_manager = get_shared_mpris_manager()
@@ -722,12 +719,8 @@ class ModusControlCenter(AppletWindow):
self.per_app_volume_center_box.set_size_request(300, -1)
def _check_initial_states(self):
self.caffeine_mode = is_caffeine_active()
self.caffeine_mode = get_inhibit_service().active
self.flight_mode = False
GLib.timeout_add(100, self._update_initial_labels)
def _update_initial_labels(self):
return False
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
@@ -770,7 +763,7 @@ class ModusControlCenter(AppletWindow):
def toggle_caffeine(self, *_):
try:
self.caffeine_mode = toggle_caffeine()
self.caffeine_mode = get_inhibit_service().toggle()
self.caffeine_icon.dynamic_file(
"applets/caffeine-on.svg"
if self.caffeine_mode
@@ -1200,11 +1193,6 @@ class ModusControlCenter(AppletWindow):
self._cleanup_processes()
self._disconnect_signals_when_hidden()
# Force garbage collection
import gc
gc.collect()
logger.debug("Complete cleanup finished successfully")
except Exception as e:
+40 -36
View File
@@ -11,6 +11,43 @@ from shared.widgets.flat_scale import FlatScale
# Local imports
from utils.roam import audio_service
# Mappings used to resolve an app's icon/display name from its description or
# name. Defined once at module level to avoid rebuilding on every refresh.
_APP_ICON_MAPPING = {
"spotify": "spotify",
"firefox": "firefox",
"chromium": "chromium-browser",
"chrome": "google-chrome",
"vlc": "vlc",
"discord": "discord",
"steam": "steam",
"zen": "zen-browser",
"code": "vscode",
"visual studio code": "vscode",
"telegram": "telegram-desktop",
"pulse": "audio-card",
"pipewire": "audio-card",
"alsa": "audio-card",
"sink": "audio-speakers",
"source": "audio-input-microphone",
"youtube": "youtube",
"music": "rhythmbox",
"media": "multimedia-player",
}
_APP_SPECIAL_NAMES = {
"spotify": "Spotify",
"firefox": "Firefox",
"chromium": "Chromium",
"chrome": "Chrome",
"vlc": "VLC",
"discord": "Discord",
"steam": "Steam",
"zen": "Zen Browser",
"code": "VS Code",
"telegram": "Telegram",
}
class PerAppVolumeControl(Box):
"""Per-application volume control widget"""
@@ -125,27 +162,7 @@ class PerAppVolumeControl(Box):
# Check description first as it's more reliable
search_text = app_description if app_description else app_name
icon_mapping = {
"spotify": "spotify",
"firefox": "firefox",
"chromium": "chromium-browser",
"chrome": "google-chrome",
"vlc": "vlc",
"discord": "discord",
"steam": "steam",
"zen": "zen-browser",
"code": "vscode",
"visual studio code": "vscode",
"telegram": "telegram-desktop",
"pulse": "audio-card",
"pipewire": "audio-card",
"alsa": "audio-card",
"sink": "audio-speakers",
"source": "audio-input-microphone",
"youtube": "youtube",
"music": "rhythmbox",
"media": "multimedia-player",
}
icon_mapping = _APP_ICON_MAPPING
# Check if any key in mapping is contained in search text
for key, icon in icon_mapping.items():
@@ -167,22 +184,9 @@ class PerAppVolumeControl(Box):
return "Unknown"
# Handle common app names specially
special_names = {
"spotify": "Spotify",
"firefox": "Firefox",
"chromium": "Chromium",
"chrome": "Chrome",
"vlc": "VLC",
"discord": "Discord",
"steam": "Steam",
"zen": "Zen Browser",
"code": "VS Code",
"telegram": "Telegram",
}
name_lower = name.lower()
if name_lower in special_names:
return special_names[name_lower]
if name_lower in _APP_SPECIAL_NAMES:
return _APP_SPECIAL_NAMES[name_lower]
# Default: capitalize first letter
return name.capitalize()
+1
View File
@@ -631,6 +631,7 @@ class WifiConnections(Box):
def start_network_monitoring(self):
"""Start periodic monitoring for network changes"""
self.stop_network_monitoring()
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
def stop_network_monitoring(self):
+8 -6
View File
@@ -56,6 +56,10 @@ from .layout import DockLayout
_PIXBUF_CACHE_MAX = 64
# Terminal/interpreter executables whose name alone should not force a match
# against unrelated desktop apps during dock item resolution.
_COMMON_EXECUTABLES = ("kitty", "bash", "sh", "zsh", "python", "python3")
class DockCanvas(Gtk.DrawingArea):
def __init__(self, parent_window):
@@ -136,9 +140,6 @@ class DockCanvas(Gtk.DrawingArea):
self._desktop_apps = []
return self._desktop_apps
def _refresh_desktop_apps(self) -> None:
self._desktop_apps = []
def _canvas_height(self) -> int:
size = self._base_icon_size()
return int(
@@ -921,7 +922,9 @@ class DockCanvas(Gtk.DrawingArea):
if command_line:
launch_app(command_line)
elif hasattr(app_info, "launch"):
app_info.launch()
from globalmenu.launch import launch_desktop_app
launch_desktop_app(app_info)
else:
logger.error(
f"[DockCanvas] Cannot determine launch command: {app_info}"
@@ -976,8 +979,7 @@ class DockCanvas(Gtk.DrawingArea):
if app.executable:
exe = os.path.basename(app.executable).lower()
if exe in expanded:
common = ["kitty", "bash", "sh", "zsh", "python", "python3"]
if exe in common:
if exe in _COMMON_EXECUTABLES:
app_names = [
getattr(app, "name", ""),
getattr(app, "display_name", ""),
+2 -2
View File
@@ -257,12 +257,12 @@ def initialize():
),
)
lockscreen.show()
return lock, lockscreen
def main():
set_process_name("lockscreen")
initialize()
lockscreen = LockScreen(GtkSessionLock.Lock())
_lock, lockscreen = initialize()
global app
app = Application("lock", lockscreen)
+1 -19
View File
@@ -21,7 +21,7 @@ from services.modus import notification_service
from shared.widgets.clipping_box import ClippingBox
from shared.widgets.custom_image import CustomImage
from shared.widgets.customrevealer import SlideRevealer
from utils.functions import escape_markup_text, parse_timeout_string
from utils.functions import escape_markup_text
from utils.roam import modus_service
from utils.icon_resolver import IconResolver
@@ -1071,9 +1071,6 @@ class ModusNoti(Window):
# Preload assets immediately for optimal caching and display performance
preload_notification_assets(notification)
# Implement smart queue management for smooth transitions
GLib.get_monotonic_time() / 1000
# If queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
# Remove oldest notification from queue (not current showing one)
@@ -1271,21 +1268,6 @@ class ModusNoti(Window):
def update_config(self, new_config):
"""Update notification configuration dynamically."""
try:
# Handle notification timeout changes
if "notification_timeout" in new_config:
timeout_str = new_config["notification_timeout"]
# Parse the timeout string to milliseconds
parse_timeout_string(timeout_str)
# Handle ignored apps changes
if "notification_ignored_apps" in new_config:
new_config["notification_ignored_apps"]
# Handle limited apps history changes
if "notification_limited_apps_history" in new_config:
new_config["notification_limited_apps_history"]
# Update the current config
self._current_config.update(new_config)
+4 -10
View File
@@ -178,13 +178,9 @@ class ExpandableNotificationGroup(Box):
self.add(self.collapsed_eventbox)
# Create expanded state (hidden initially)
self.create_expanded_state()
def _get_notification_pixbuf_for_group(self, cached_notification):
"""Get notification pixbuf at 64x64 - matches app icon size for consistency"""
notification = cached_notification._notification
getattr(notification, "id", None)
pixbuf = None
# First try to get cached notification image using stored cache key
@@ -372,10 +368,6 @@ class ExpandableNotificationGroup(Box):
self.expanded_container.set_visible(False)
return False # Don't repeat timeout
def _complete_collapse(self):
"""Complete the collapse animation - no longer needed but kept for compatibility"""
return False # Don't repeat timeout
def close_all(self, *args):
"""Close all notifications in this group with proper cache cleanup"""
# Close all notifications in this group
@@ -462,7 +454,6 @@ class NotificationCenterWidget(NotificationWidget):
def _get_notification_pixbuf(self, notification):
"""Get notification pixbuf at 64x64 - matches app icon size for consistency"""
getattr(notification, "id", None)
pixbuf = None
# First try to get cached notification image using stored cache key
@@ -819,8 +810,11 @@ class NotificationCenter(AppletWindow):
group_widget.remove(child)
child.destroy()
# Recreate content
# Recreate content (collapsed + expanded states)
group_widget.create_collapsed_state()
group_widget.create_expanded_state()
group_widget.collapsed_eventbox.set_visible(True)
group_widget.expanded_container.set_visible(False)
group_widget.show_all()
except Exception as e:
-6
View File
@@ -69,12 +69,6 @@ class Panel(Window):
self.tray.connect("add", self._update_tray_visibility)
self.tray.connect("remove", self._update_tray_visibility)
self.indicators = Box(
name="indicators",
orientation="h",
spacing=4,
)
self._spotlight_toggle_cb = None
self.search = Button(
name="panel-button",
+27 -7
View File
@@ -334,9 +334,24 @@ class MicrophoneIndicator(Box):
self._connect_mic_signals()
def _connect_mic_signals(self):
if self._audio.microphone:
self._audio.microphone.connect("changed", self._on_mic_stream_changed)
self._sync_state()
mic = self._audio.microphone
if mic is None:
return
# Disconnect from a previously-connected device if it changed, and
# avoid stacking duplicate handlers on the same device.
old = getattr(self, "_mic_device", None)
if old is not None and old is not mic:
try:
old.disconnect_by_func(self._on_mic_stream_changed)
except Exception:
pass
try:
mic.disconnect_by_func(self._on_mic_stream_changed)
except Exception:
pass
mic.connect("changed", self._on_mic_stream_changed)
self._mic_device = mic
self._sync_state()
def _on_mic_device_changed(self, *_):
self._connect_mic_signals()
@@ -371,8 +386,13 @@ class MicrophoneIndicator(Box):
self._hide_timer_id = 0
try:
self._audio.disconnect_by_func(self._on_mic_device_changed)
if self._audio.microphone:
self._audio.microphone.disconnect_by_func(self._on_mic_stream_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] mic disconnect: {e}")
except Exception:
pass
old = getattr(self, "_mic_device", None)
target = old if old is not None else self._audio.microphone
if target is not None:
try:
target.disconnect_by_func(self._on_mic_stream_changed)
except Exception as e:
logger.warning(f"[NotchIndicators] mic disconnect: {e}")
super().destroy()
+6
View File
@@ -188,6 +188,12 @@ class Notch(Box):
def _on_player_vanish(self, _manager, name: str):
if name in self._music_services:
self._music_services.remove(name)
try:
svc = self._mpris.get_player_service(name)
if svc is not None:
svc.disconnect_by_func(self._on_any_artwork)
except Exception:
pass
self._apply_stack_state()
# ── Scroll ────────────────────────────────────────────────────────────────
+13
View File
@@ -257,6 +257,19 @@ class ScreenCaptureWindow(Window):
_handler_id[0] = screen_capture_service.connect("screenshot-taken", _on_taken)
def _safe_disconnect():
if _handler_id[0] is not None:
try:
screen_capture_service.disconnect(_handler_id[0])
except Exception:
pass
_handler_id[0] = None
return False
# Safety net: disconnect if the service never emits (e.g. capture failed
# silently), otherwise the one-shot handler would leak on every shot.
GLib.timeout_add_seconds(10, _safe_disconnect)
ok = screen_capture_service.screenshot(
target,
output_dir=output_dir,
-2
View File
@@ -113,8 +113,6 @@ class PluginManager:
if entry.module_name:
self._loader.unload_module(entry.module_name)
from pathlib import Path
fp = Path(entry.path) if entry.path else None
if fp and fp.exists():
source = entry.source
+36 -4
View File
@@ -75,7 +75,7 @@ class SearchPipeline:
self._current_token.cancel()
with self._lock:
for t in self._pending_threads:
t.join(timeout=0.5)
t.join(timeout=0.1)
self._pending_threads.clear()
for source_id in self._pending_idle_ids:
GLib.source_remove(source_id)
@@ -112,13 +112,33 @@ class SearchPipeline:
results = self._safe_search(instance, query, token)
self._stamp_results(results, entry)
if not token.is_cancelled:
source_id = GLib.idle_add(lambda: callback(results))
def _fire():
with self._lock:
try:
self._pending_idle_ids.remove(source_id)
except ValueError:
pass
callback(results)
return False
source_id = GLib.idle_add(_fire)
with self._lock:
self._pending_idle_ids.append(source_id)
except Exception as e:
logger.error(f"[SearchPipeline] Error searching {entry.id}: {e}")
if not token.is_cancelled:
source_id = GLib.idle_add(lambda: callback([]))
def _fire_err():
with self._lock:
try:
self._pending_idle_ids.remove(source_id)
except ValueError:
pass
callback([])
return False
source_id = GLib.idle_add(_fire_err)
with self._lock:
self._pending_idle_ids.append(source_id)
@@ -143,6 +163,8 @@ class SearchPipeline:
remaining = len(searchable)
def _on_all_done():
if token.is_cancelled:
return
merged = self._merge_results(collected)
callback(merged)
@@ -151,7 +173,17 @@ class SearchPipeline:
with results_lock:
remaining -= 1
if remaining <= 0:
source_id = GLib.idle_add(_on_all_done)
def _fire_all_done():
with self._lock:
try:
self._pending_idle_ids.remove(source_id)
except ValueError:
pass
_on_all_done()
return False
source_id = GLib.idle_add(_fire_all_done)
with self._lock:
self._pending_idle_ids.append(source_id)
+58 -35
View File
@@ -1,4 +1,3 @@
import gc
import os
from fabric.core import Signal
@@ -98,7 +97,7 @@ class Spotlight(Box):
self.children = self.header, self.scrolled_clip
# Search
@debounce(200)
@debounce(300)
def on_entry_changed(self, entry: Entry, *_):
if getattr(entry, "_ignore_change", False):
entry._ignore_change = False
@@ -128,7 +127,11 @@ class Spotlight(Box):
self.header_icon.set_from_icon_name(
getattr(inst, "icon", "") or "system-search-symbolic"
)
self.plugin_service.pipeline.search(text, self._on_search_results)
# Scope to the detected plugin only — a global search would
# mix in unrelated plugins' results.
self.plugin_service.pipeline.search_single(
pe, text, self._on_search_results
)
return
self.header_icon.set_from_icon_name("system-search-symbolic")
@@ -225,7 +228,27 @@ class Spotlight(Box):
self._deactivate_keyword_plugin()
def _on_search_results(self, results: list[SearchResult]):
self._clear_viewport()
new_ids = [r.id for r in results]
old_ids = list(self._result_widgets.keys())
if new_ids == old_ids:
self._current_results = results
return
for rid in set(old_ids) - set(new_ids):
self._remove_result_widget(rid)
for child in list(self.viewport.children):
self.viewport.remove(child)
for result in results:
widget = self._result_widgets.get(result.id)
if not widget:
widget = self._render_result(result)
self._result_widgets[result.id] = widget
widget.connect("enter-notify-event", self.on_slot_enter)
self.viewport.add(widget)
self._current_results = results
if not results:
@@ -234,15 +257,19 @@ class Spotlight(Box):
return
self._auto_start_refresh_timer(results)
for result in results:
widget = self._render_result(result)
self._result_widgets[result.id] = widget
self.viewport.add(widget)
widget.connect("enter-notify-event", self.on_slot_enter)
self._post_arrange()
def _remove_result_widget(self, result_id: str):
widget = self._result_widgets.pop(result_id, None)
if not widget:
return
try:
widget.disconnect_by_func(self.on_slot_enter)
except (TypeError, ValueError):
pass
self.viewport.remove(widget)
widget.destroy()
# Rendering
def _render_result(self, result: SearchResult) -> Button:
rt = result.render_type
@@ -298,7 +325,7 @@ class Spotlight(Box):
style_classes="app-slot",
child=Box(orientation="h", spacing=12, children=children),
tooltip_text=r.subtitle,
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_calc(self, r: SearchResult) -> Button:
@@ -330,7 +357,7 @@ class Spotlight(Box):
),
],
),
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_emoji(self, r: SearchResult) -> Button:
@@ -368,7 +395,7 @@ class Spotlight(Box):
],
),
tooltip_text=f"Click to copy {emoji_char}",
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_power(self, r: SearchResult) -> Button:
@@ -399,7 +426,7 @@ class Spotlight(Box):
],
),
tooltip_text=f"Click to {r.title.lower()} the system",
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_wallpaper(self, r: SearchResult) -> Button:
@@ -415,7 +442,7 @@ class Spotlight(Box):
return Button(
style_classes="app-slot wallpaper",
child=child_widget,
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_clipboard_text(self, r: SearchResult) -> Button:
@@ -436,7 +463,7 @@ class Spotlight(Box):
],
),
tooltip_text=r.subtitle,
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_clipboard_image(self, r: SearchResult) -> Button:
@@ -465,7 +492,7 @@ class Spotlight(Box):
return Button(
style_classes="app-slot wallpaper clipboard-image",
child=child_widget,
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _render_default(self, r: SearchResult) -> Button:
@@ -497,7 +524,7 @@ class Spotlight(Box):
return Button(
style_classes="app-slot",
child=Box(orientation="h", spacing=12, children=children),
on_clicked=lambda *_: self._activate_result(r),
on_clicked=lambda *_: self._activate_result_by_id(r.id),
)
def _activate_result(self, result: SearchResult):
@@ -505,19 +532,23 @@ class Spotlight(Box):
result.action()
self.launched()
def _activate_result_by_id(self, result_id: str):
for r in self._current_results:
if r.id == result_id:
self._activate_result(r)
return
# Viewport management
def _clear_viewport(self):
old_results = self._current_results
self._current_results = []
self._result_widgets.clear()
for child in self.viewport.children:
for child in list(self.viewport.children):
try:
child.disconnect_by_func(self.on_slot_enter)
except (TypeError, ValueError):
pass
self.viewport.remove(child)
child.destroy()
del old_results
self._result_widgets.clear()
def update_result_subtitle(self, result_id: str, text: str) -> None:
"""Update subtitle label of a rendered result in-place (no re-render)."""
@@ -528,9 +559,6 @@ class Spotlight(Box):
if not child:
return
# Walk: Button → main Box → title_box (last Box) → subtitle Label (2nd)
from fabric.widgets.box import Box
from fabric.widgets.label import Label
boxes = [c for c in child.get_children() if isinstance(c, Box)]
if not boxes:
return
@@ -551,12 +579,6 @@ class Spotlight(Box):
self.scrolled_window.show()
self.scrolled_window.animate_size(new_height)
for i, slot in enumerate(self.viewport.children, start=1):
if i > 8:
break
slot.set_style(f"animation-duration: {round(i * 300)}ms;")
slot.add_style_class("shine")
if self.viewport.children:
self.set_selected_index(0)
@@ -660,15 +682,17 @@ class SpotlightWindow(Window):
def close_spotlight(self):
self.spotlight_box.plugin_service.pipeline.cancel()
self.spotlight_box._stop_refresh_timer()
self.spotlight_box.header_entry.set_text("")
debounce_id = getattr(
self.spotlight_box, "_debounce_timer_on_entry_changed", None
)
if debounce_id:
GLib.source_remove(debounce_id)
self.spotlight_box._debounce_timer_on_entry_changed = 0
self.spotlight_box._stop_refresh_timer()
self.spotlight_box.header_entry.set_text("")
self.spotlight_box._clear_viewport()
self.spotlight_box.scrolled_window.animate_size(0)
self.spotlight_box.scrolled_window.hide()
@@ -676,7 +700,6 @@ class SpotlightWindow(Window):
self.spotlight_box.header_icon.set_from_icon_name("system-search-symbolic")
self.spotlight_box._deactivate_keyword_plugin()
self.spotlight_box.plugin_service.manager.release_memory_all()
gc.collect()
self.hide()
def toggle(
+16 -1
View File
@@ -1,3 +1,4 @@
import time
from typing import Any
from fabric.utils import DesktopApp, get_desktop_applications
@@ -17,6 +18,8 @@ class ApplicationPlugin(SpotlightPlugin):
def __init__(self, context):
super().__init__(context)
self._desktop_apps: list[DesktopApp] = []
self._apps_loaded_at: float = 0.0
self._apps_ttl: float = 1.0
def initialize(self) -> None:
self._desktop_apps = get_desktop_applications()
@@ -28,6 +31,16 @@ class ApplicationPlugin(SpotlightPlugin):
if not query:
return []
# Re-scan (throttled) so newly installed .desktop files appear
# without a restart, while avoiding a disk scan on every keystroke.
now = time.monotonic()
if now - self._apps_loaded_at > self._apps_ttl:
try:
self._desktop_apps = get_desktop_applications()
self._apps_loaded_at = now
except Exception:
pass
results: list[SearchResult] = []
for app in self._desktop_apps:
display_name = getattr(app, "display_name", "") or ""
@@ -60,7 +73,9 @@ class ApplicationPlugin(SpotlightPlugin):
@staticmethod
def _launch_app(app: DesktopApp) -> None:
app.launch()
from globalmenu.launch import launch_desktop_app
launch_desktop_app(app)
PLUGIN = ApplicationPlugin
+6 -1
View File
@@ -13,6 +13,7 @@ class ClipboardPlugin(SpotlightPlugin):
id = "clipboard"
name = "Clipboard"
icon = "edit-paste-symbolic"
keywords = ["clip", "clipboard"]
searchable = True
priority = 80
@@ -45,7 +46,11 @@ class ClipboardPlugin(SpotlightPlugin):
def search(self, query: str, token: Any) -> list[SearchResult]:
q = query.strip()
if q.lower().startswith("clip "):
if q.lower().startswith("clipboard "):
q = q[10:].strip()
elif q.lower() == "clipboard":
q = ""
elif q.lower().startswith("clip "):
q = q[5:].strip()
elif q.lower() == "clip":
q = ""
-1
View File
@@ -36,7 +36,6 @@ class EmojiPlugin(SpotlightPlugin):
return
cldr = self._load_cldr_annotations()
self._emoji_data = cldr or {}
logger.info(f"[Emoji] Loaded {len(self._emoji_data)} emojis")
def detect(self, text: str) -> bool:
s = text.strip()
+1 -3
View File
@@ -1,4 +1,3 @@
import gc
import os
import random
from typing import Any
@@ -8,7 +7,7 @@ from shared.data import WALLPAPERS_THUMBNAILS_SIZE
from utils.functions import fuzzy_filter
from window.spotlight.api import SearchResult, SpotlightPlugin
MAX_THUMBS_PER_SEARCH = 5
MAX_THUMBS_PER_SEARCH = 3
class WallpaperPlugin(SpotlightPlugin):
@@ -28,7 +27,6 @@ class WallpaperPlugin(SpotlightPlugin):
def cleanup(self) -> None:
self._wallpaper_service = None
gc.collect()
def release_memory(self) -> None:
if self._wallpaper_service:
-7
View File
@@ -612,13 +612,6 @@ class ApplicationSwitcher(Window):
if addr in self._addr_to_idx:
self._capture.capture_by_handle(addr, 300, 168, False)
def _refill_and_pump(self):
# Called when the switcher becomes visible or windows change
if not self.get_visible():
return False
self._pump_capture_queue()
return False
def grab_keyboard(self):
try:
display = Gdk.Display.get_default()