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:
43 files changed
+1227
-637
No files matched your search
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user