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