"""Application blocking by watching `/proc`. No root needed: these are your own processes. The scan reads `/proc/*/comm` and the cmdline, matches against the configured names, and either terminates the process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending on the setting. Matching is deliberately forgiving — `steam` catches `steamwebhelper`, and `telegram` catches `Telegram` — and an entry containing `*` or `?` is treated as a glob (`steam*`, `*discord*`, `telegram-?`), matched against both the process name and its command line. It never matches ourselves, PID 1, or anything outside the current user's processes. """ from __future__ import annotations import logging import os import signal import time from fnmatch import fnmatch from dataclasses import dataclass from pathlib import Path log = logging.getLogger(__name__) _SELF_MARKERS = ("kadence", "python3 -m kadence_desktop") def matches(pattern: str, name: str, cmdline: str) -> bool: """Whether one block-list entry matches a process. A mask is anchored to the process *name* (so `steam*` does not match every command that merely mentions steam) but also tried against the whole command line, which is how `*discord*` catches an Electron app launched by path. """ pattern = pattern.strip().lower() if not pattern: return False if any(char in pattern for char in "*?["): return fnmatch(name, pattern) or fnmatch(cmdline, pattern) return pattern in name or pattern in cmdline @dataclass(slots=True) class RunningApp: pid: int name: str cmdline: str matched: str def _read(path: Path) -> str: try: return path.read_text("utf-8", errors="replace") except OSError: return "" def scan(patterns: list[str]) -> list[RunningApp]: """Every live process of this user matching one of `patterns`.""" wanted = [pattern.strip().lower() for pattern in patterns if pattern.strip()] if not wanted: return [] uid = os.getuid() self_pid = os.getpid() found: list[RunningApp] = [] for entry in Path("/proc").iterdir(): if not entry.name.isdigit(): continue pid = int(entry.name) if pid in (1, self_pid): continue try: if entry.stat().st_uid != uid: continue except OSError: continue comm = _read(entry / "comm").strip() if not comm: continue cmdline = _read(entry / "cmdline").replace("\x00", " ").strip() name, command = comm.lower(), cmdline.lower() if any(marker in f"{name} {command}" for marker in _SELF_MARKERS): continue match = next((pattern for pattern in wanted if matches(pattern, name, command)), None) if match: found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match)) return found def terminate(app: RunningApp, *, grace_seconds: float = 2.0) -> bool: """Ask the process to quit, then insist. True when it is gone.""" try: os.kill(app.pid, signal.SIGTERM) except ProcessLookupError: return True except PermissionError: log.warning("not allowed to stop %s (pid %s)", app.name, app.pid) return False deadline = time.monotonic() + grace_seconds while time.monotonic() < deadline: try: os.kill(app.pid, 0) except ProcessLookupError: return True time.sleep(0.1) try: os.kill(app.pid, signal.SIGKILL) except ProcessLookupError: return True except PermissionError: return False return True