diff --git a/.gitignore b/.gitignore index 9d892f7835..3530a8e295 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ .vscode/ .idea/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/ui/opensnitch/plugins/list_subscriptions/__init__.py b/ui/opensnitch/plugins/list_subscriptions/__init__.py new file mode 100644 index 0000000000..85b3702a9f --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/__init__.py @@ -0,0 +1,15 @@ +# This file is part of OpenSnitch. +# +# OpenSnitch is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OpenSnitch is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OpenSnitch. If not, see . + diff --git a/ui/opensnitch/plugins/list_subscriptions/_annotations.py b/ui/opensnitch/plugins/list_subscriptions/_annotations.py new file mode 100644 index 0000000000..1402b7a049 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_annotations.py @@ -0,0 +1,57 @@ +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from PyQt6 import QtCore, QtGui, QtWidgets + + +class RuleOperatorLike(Protocol): + operand: str + data: str | None + list: list["RuleOperatorLike"] + + +class RuleLike(Protocol): + name: str | None + enabled: bool + operator: RuleOperatorLike + + +class StatsDialogProto(Protocol): + """Typed subset of StatsDialog used by the plugin. + + actionsButton is injected by uic from stats.ui and otherwise appears + as unknown to static analyzers. + """ + + actionsButton: "QtWidgets.QPushButton" + + def windowIcon(self) -> "QtGui.QIcon": ... + + +class RulesEditorDialogProto(Protocol): + """Typed subset of RulesEditorDialog used by the plugin controllers.""" + + _old_rule_name: str + buttonBox: "QtWidgets.QDialogButtonBox" + ruleNameEdit: "QtWidgets.QLineEdit" + ruleDescEdit: "QtWidgets.QPlainTextEdit" + nodesCombo: "QtWidgets.QComboBox" + nodeApplyAllCheck: "QtWidgets.QCheckBox" + uidCombo: "QtWidgets.QComboBox" + uidCheck: "QtWidgets.QCheckBox" + enableCheck: "QtWidgets.QCheckBox" + durationCombo: "QtWidgets.QComboBox" + dstListsCheck: "QtWidgets.QCheckBox" + dstListsLine: "QtWidgets.QLineEdit" + + def installEventFilter(self, filterObj: "QtCore.QObject") -> None: ... + + def hide(self) -> None: ... + + def raise_(self) -> None: ... + + def activateWindow(self) -> None: ... + + def new_rule(self) -> None: ... + + def edit_rule(self, records: Any, _addr: str | None = None) -> None: ... diff --git a/ui/opensnitch/plugins/list_subscriptions/_compat.py b/ui/opensnitch/plugins/list_subscriptions/_compat.py new file mode 100644 index 0000000000..785c2951a2 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_compat.py @@ -0,0 +1,10 @@ +"""Runtime compatibility shim for StatsDialog across OpenSnitch versions.""" + + +# Runtime class kept for isinstance checks. +try: + from opensnitch.dialogs.events import StatsDialog +except ImportError: + from opensnitch.dialogs.stats import StatsDialog # type: ignore[assignment] + +__all__ = ["StatsDialog"] diff --git a/ui/opensnitch/plugins/list_subscriptions/_utils.py b/ui/opensnitch/plugins/list_subscriptions/_utils.py new file mode 100644 index 0000000000..013b1c9084 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -0,0 +1,394 @@ +import os +import re +from datetime import datetime +from typing import Any, Final +from urllib.parse import urlparse, unquote + +from opensnitch.utils.xdg import xdg_config_home + +ACTION_FILE: Final[str] = os.path.join( + xdg_config_home, "opensnitch", "actions", "list_subscriptions.json" +) +DEFAULT_LISTS_DIR: Final[str] = os.path.join( + xdg_config_home, "opensnitch", "list_subscriptions" +) +PLUGIN_DIR: Final[str] = os.path.abspath(os.path.dirname(__file__)) +RES_DIR: Final[str] = os.path.join(PLUGIN_DIR, "res") + +INTERVAL_UNITS: Final[tuple[str, ...]] = ( + "seconds", + "minutes", + "hours", + "days", + "weeks", +) +TIMEOUT_UNITS: Final[tuple[str, ...]] = ("seconds", "minutes", "hours", "days", "weeks") +SIZE_UNITS: Final[tuple[str, ...]] = ("bytes", "KB", "MB", "GB") +TIME_MULT: Final[dict[str, int]] = { + "seconds": 1, + "minutes": 60, + "hours": 60 * 60, + "days": 24 * 60 * 60, + "weeks": 7 * 24 * 60 * 60, + "s": 1, + "m": 60, + "h": 60 * 60, + "d": 24 * 60 * 60, + "w": 7 * 24 * 60 * 60, +} +SHORT_TIME_MULT: Final[dict[str, int]] = { + "s": TIME_MULT["seconds"], + "m": TIME_MULT["minutes"], + "h": TIME_MULT["hours"], + "d": TIME_MULT["days"], + "w": TIME_MULT["weeks"], +} +SIZE_MULT: Final[dict[str, int]] = { + "bytes": 1, + "kb": 1024, + "mb": 1024 * 1024, + "gb": 1024 * 1024 * 1024, +} + +DEFAULT_UA: Final[str] = ( + "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" +) + +DEFAULT_NOTIFY_CONFIG: Final[dict[str, dict[str, str]]] = { + "success": {"desktop": "Lists subscriptions updated"}, + "error": {"desktop": "Error updating lists subscriptions"}, +} + + +def now_iso(): + return datetime.now().astimezone().isoformat() + + +def parse_iso(ts: str): + try: + return datetime.fromisoformat(ts) + except Exception: + return None + + +def normalize_iso_timestamp(value: Any, fallback: str | None = None): + text = str(value or "").strip() + if text != "" and parse_iso(text) is not None: + return text + if fallback: + return fallback + return now_iso() + + +def opt_int(value: Any): + try: + return int(value) if value is not None else None + except Exception: + return None + + +def opt_str(value: Any): + try: + if value is None: + return None + normalized = (str(value) or "").strip().lower() + return normalized if normalized != "" else None + except Exception: + return None + + +def display_str(value: Any): + if value is None: + return "" + return str(value) + + +def strip_or_none(value: Any): + text = str(value or "").strip() + return text or None + + +def normalize_lists_dir(path: str | None): + default_dir = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") + raw = (path or "").strip() + if raw == "": + raw = default_dir + expanded = os.path.expandvars(os.path.expanduser(raw)) + if not os.path.isabs(expanded): + return os.path.abspath(expanded) + return expanded + + +def is_valid_url(value: str | None): + parsed = urlparse(str(value or "").strip()) + return parsed.scheme in {"http", "https"} and parsed.netloc != "" + + +def safe_filename(value: Any): + return os.path.basename((str(value or "")).strip()) + + +def normalized_list_type(value: str | None): + return (value or "hosts").strip().lower() + + +def filename_from_url(url: str | None): + try: + parsed = urlparse((url or "").strip()) + return safe_filename(unquote(parsed.path or "")) + except Exception: + return "" + + +def slugify_name(name: str | None): + raw = (name or "").strip().lower() + if raw == "": + return "subscription.list" + slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._") + if slug == "": + slug = "subscription" + if "." not in slug: + slug += ".list" + return safe_filename(slug) + + +def deslugify_filename(filename: str | None, list_type: str | None): + safe = safe_filename(filename) + base, _ext = os.path.splitext(safe) + suffix = f"-{normalized_list_type(list_type)}" + if base.lower().endswith(suffix): + base = base[: -len(suffix)] + pretty = re.sub(r"[-_.]+", " ", base).strip() + pretty = re.sub(r"\s+", " ", pretty) + if pretty == "": + return safe + return pretty.title() + + +def filename_from_content_disposition(value: str | None): + cd = str(value or "").strip() + if cd == "": + return "" + filename = "" + m_star = re.search( + r'filename\*\s*=\s*[^\'";]+\'[^\'";]*\'([^;]+)', cd, re.IGNORECASE + ) + if m_star: + filename = unquote(m_star.group(1).strip().strip('"')) + if filename == "": + params = {} + try: + raw_params = ";".join(cd.split(";")[1:]) + for part in raw_params.split(";"): + if "=" not in part: + continue + key, raw_value = part.split("=", 1) + params[key.strip().lower()] = raw_value.strip().strip('"') + except Exception: + params = {} + raw = params.get("filename") + if raw: + filename = unquote(str(raw)).strip() + return safe_filename(filename) + + +def derive_filename( + name: str | None, + url: str | None, + filename: str | None, + header_filename: str | None = None, +): + fn = safe_filename(header_filename) + if fn != "": + return fn + fn = safe_filename(filename) + if fn != "": + return fn + fn = filename_from_url(url) + if fn != "": + return fn + return slugify_name(name) + + +def ensure_filename_type_suffix(filename: str, list_type: str): + fn = safe_filename(filename) + base, ext = os.path.splitext(fn) + ltype = normalized_list_type(list_type) + suffix = f"-{ltype}" + if not base.lower().endswith(suffix): + base = f"{base}{suffix}" if base else ltype + if ext == "": + ext = ".txt" + return safe_filename(f"{base}{ext}") + + +def normalized_subscription_filename(filename: str | None, list_type: str | None): + safe_name = safe_filename(filename) + if safe_name == "": + safe_name = "subscription.list" + return ensure_filename_type_suffix(safe_name, normalized_list_type(list_type)) + + +def subscription_dirname(filename: str | None, list_type: str | None): + safe_name = normalized_subscription_filename(filename, list_type) + base, _ext = os.path.splitext(safe_name) + normalized_type = normalized_list_type(list_type) + suffix = f"-{normalized_type}" + dirname = base if base else "subscription" + if not dirname.lower().endswith(suffix): + dirname = f"{dirname}{suffix}" + return dirname + + +def list_file_path(lists_dir: str, filename: str | None, list_type: str | None): + safe_name = normalized_subscription_filename(filename, list_type) + return os.path.join(lists_dir, "sources.list.d", safe_name) + + +def subscription_rule_dir(lists_dir: str, filename: str | None, list_type: str | None): + return os.path.join( + lists_dir, + "rules.list.d", + subscription_dirname(filename, list_type), + ) + + +def normalize_unit(value: str | None, allowed: tuple[str, ...], fallback: str): + normalized = (value or "").strip().lower() + for unit in allowed: + if unit.lower() == normalized: + return unit + return fallback + + +def timestamp_sort_key(value: str | None): + normalized = str(value or "").strip() + return (normalized == "", normalized) + + +def normalize_group(group: str | None): + raw = (group or "").strip().lower() + if raw == "": + return "" + raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._") + return raw + + +def normalize_groups(groups: Any): + out: list[str] = [] + if isinstance(groups, (list, tuple, set)): + raw_items = [str(x) for x in groups] + else: + raw_items = str(groups or "").split(",") + seen: set[str] = set() + for item in raw_items: + g = normalize_group(item) + if g == "" or g == "all" or g in seen: + continue + seen.add(g) + out.append(g) + return out + + +def dedupe_subscription_identity( + filename: str, + name: str, + url: str, + list_type: str, + seen_filenames: dict[str, str] | None, +): + if seen_filenames is None: + return filename, name, False + + key = filename + seen_url = seen_filenames.get(key) + if seen_url is None: + seen_filenames[key] = url + return filename, name, False + if seen_url == url: + return filename, name, True + + base, ext = os.path.splitext(filename) + if ext == "": + ext = ".txt" + suffix = f"-{normalized_list_type(list_type)}" + root = base + if root.lower().endswith(suffix): + root = root[: -len(suffix)] + root = root.rstrip("-") + n = 2 + candidate = filename + while candidate in seen_filenames: + candidate = f"{root}-{n}{suffix}{ext}" + n += 1 + display_name = (name or "").strip() + if display_name == "": + display_name = root or "subscription" + seen_filenames[candidate] = url + return candidate, f"{display_name} ({n - 1})", False + + +def to_seconds(value: Any, units: str | None, default_seconds: int): + try: + if value is None: + return default_seconds + u = (units or "seconds").lower() + mult = TIME_MULT.get(u) + if mult is None: + return default_seconds + sec = int(value) * mult + return sec if sec > 0 else default_seconds + except Exception: + return default_seconds + + +def parse_compact_duration(value: Any): + if not isinstance(value, str): + return None + s = value.strip().lower().replace(" ", "") + if not s: + return None + + total = 0 + pos = 0 + for m in re.finditer(r"(\d+)([smhdw])", s): + if m.start() != pos: + return None + total += int(m.group(1)) * SHORT_TIME_MULT[m.group(2)] + pos = m.end() + if pos != len(s): + return None + return total if total > 0 else None + + +def to_max_bytes(value: Any, units: str | None, default_bytes: int): + try: + if value is None: + return default_bytes + u = (units or "bytes").lower() + mult = SIZE_MULT.get(u) + if mult is None: + return default_bytes + out = int(value) * mult + return out if out > 0 else default_bytes + except Exception: + return default_bytes + + +def is_hosts_file_like(sample_lines: list[str]): + valid = 0 + total = 0 + for line in sample_lines: + s = line.strip() + if not s or s.startswith("#"): + continue + total += 1 + parts = s.split() + if len(parts) >= 2 and parts[0] in ("0.0.0.0", "127.0.0.1", "::"): + if "." in parts[1] and "/" not in parts[1]: + valid += 1 + elif len(parts) == 1 and "." in parts[0]: + valid += 1 + if total <= 10: + return True + return (valid / max(total, 1)) >= 0.60 diff --git a/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json b/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json new file mode 100644 index 0000000000..ad8dc5ffef --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt", + "format": "hosts", + + "etag": "\"abc123\"", + "last_modified": "Sun, 01 Mar 2026 08:40:00 GMT", + + "last_checked": "2026-03-01T12:00:00+01:00", + "last_updated": "2026-03-01T12:00:02+01:00", + + "last_result": "updated", + "last_error": "", + + "fail_count": 0, + "backoff_until": "", + + "bytes": 1234567 +} diff --git a/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json new file mode 100644 index 0000000000..7f07bebe8f --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json @@ -0,0 +1,46 @@ +{ + "name": "listSubscriptionsActions", + "created": "", + "updated": "", + "description": "Manage and auto-update blocklist subscriptions (hosts format)", + "type": ["global", "main-dialog"], + "actions": { + "list_subscriptions": { + "enabled": true, + "config": { + "lists_dir": "~/.config/opensnitch/list_subscriptions", + + "interval": 24, + "interval_units": "hours", + + "timeout": 20, + "timeout_units": "seconds", + + "max_size": 50, + "max_size_units": "MB", + + "subscriptions": [ + { + "name": "HaGeZi Light Hosts", + "enabled": true, + "url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt", + "format": "hosts", + "filename": "hagezi-light.txt", + + "interval": 12, + "interval_units": "hours" + } + ], + + "notify": { + "success": { + "desktop": "Lists subscriptions updated" + }, + "error": { + "desktop": "Error updating lists subscriptions" + } + } + } + } + } +} diff --git a/ui/opensnitch/plugins/list_subscriptions/io/__init__.py b/ui/opensnitch/plugins/list_subscriptions/io/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/__init__.py @@ -0,0 +1 @@ + diff --git a/ui/opensnitch/plugins/list_subscriptions/io/lock.py b/ui/opensnitch/plugins/list_subscriptions/io/lock.py new file mode 100644 index 0000000000..341df16fb3 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/lock.py @@ -0,0 +1,101 @@ +import errno +import os +import time + + +class FileLock: + def __init__(self, lock_path: str): + self.lock_path = lock_path + self.fd: int | None = None + + def _read_owner_pid(self): + try: + with open(self.lock_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + except FileNotFoundError: + return None + except Exception: + return -1 + + if raw == "": + return -1 + try: + return int(raw) + except Exception: + return -1 + + def _pid_is_alive(self, pid: int): + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return True + return True + + def is_stale(self, max_age: float = 30.0): + try: + st = os.stat(self.lock_path) + except FileNotFoundError: + return False + except Exception: + return False + + pid = self._read_owner_pid() + if pid is not None and pid > 0 and self._pid_is_alive(pid): + return False + + age = time.time() - st.st_mtime + return age > max_age + + def break_stale(self, max_age: float = 30.0): + if not self.is_stale(max_age=max_age): + return False + try: + os.unlink(self.lock_path) + return True + except FileNotFoundError: + return True + except Exception: + return False + + def acquire(self): + pid = os.getpid() + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY + try: + fd = os.open(self.lock_path, flags, 0o600) + try: + os.write(fd, f"{pid}\n".encode("utf-8")) + os.fsync(fd) + except Exception: + try: + os.close(fd) + finally: + try: + os.unlink(self.lock_path) + except Exception: + pass + raise + self.fd = fd + return True + except FileExistsError: + return False + except OSError as exc: + if exc.errno == errno.EEXIST: + return False + raise + + def release(self): + try: + if self.fd is not None: + os.close(self.fd) + finally: + self.fd = None + try: + os.unlink(self.lock_path) + except FileNotFoundError: + pass \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/io/storage.py b/ui/opensnitch/plugins/list_subscriptions/io/storage.py new file mode 100644 index 0000000000..38c65a9ee7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/storage.py @@ -0,0 +1,59 @@ +import json +import os +import time +from typing import Any + +from opensnitch.plugins.list_subscriptions.io.lock import FileLock + + +def read_json(path: str): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def write_json_atomic(path: str, obj: dict[str, Any]): + d = os.path.dirname(path) + if d: + os.makedirs(d, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(obj, f, indent=2, sort_keys=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +def json_lock_path(path: str): + return f"{path}.lock" + + +def read_json_locked(path: str, timeout: float = 5.0, poll_interval: float = 0.05): + lock_path = json_lock_path(path) + lock = FileLock(lock_path) + deadline = time.monotonic() + max(timeout, 0.0) + while os.path.exists(lock_path): + lock.break_stale() + if not os.path.exists(lock_path): + break + if time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for lock: {lock_path}") + time.sleep(poll_interval) + return read_json(path) + + +def write_json_atomic_locked( + path: str, + obj: dict[str, Any], + timeout: float = 5.0, + poll_interval: float = 0.05, +): + lock = FileLock(json_lock_path(path)) + deadline = time.monotonic() + max(timeout, 0.0) + while not lock.acquire(): + if time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for lock: {lock.lock_path}") + time.sleep(poll_interval) + try: + write_json_atomic(path, obj) + finally: + lock.release() diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py new file mode 100644 index 0000000000..f9fcf01be9 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -0,0 +1,1772 @@ +import os +import logging +import hashlib +import threading +import shutil +import sys +from typing import TYPE_CHECKING, Any, ClassVar, Final, cast +from abc import ABCMeta +from datetime import datetime, timedelta +from queue import Queue +import requests + +if TYPE_CHECKING: + from PyQt6 import QtCore, QtGui, QtWidgets +elif "PyQt6" in sys.modules: + from PyQt6 import QtCore, QtGui, QtWidgets +elif "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui, QtWidgets +else: + try: + from PyQt6 import QtCore, QtGui, QtWidgets + except Exception: + from PyQt5 import QtCore, QtGui, QtWidgets + +from opensnitch.plugins.list_subscriptions._compat import StatsDialog +from opensnitch.plugins.list_subscriptions._annotations import StatsDialogProto +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + +from opensnitch.plugins.list_subscriptions.io.lock import FileLock +from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked +from opensnitch.plugins.list_subscriptions.models.config import PluginConfig +from opensnitch.plugins.list_subscriptions.models.events import ( + RuntimeEventType, + SubscriptionEventItem, +) +from opensnitch.plugins.list_subscriptions.models.metadata import ListMetadata +from opensnitch.plugins.list_subscriptions.models.subscriptions import SubscriptionSpec +from opensnitch.config import Config +from opensnitch.nodes import Nodes +from opensnitch.notifications import DesktopNotifications +from opensnitch.plugins import PluginBase, PluginSignal +from opensnitch.rules import Rule +from opensnitch.database import Database +from opensnitch.utils import GenericTimer +from opensnitch.plugins.list_subscriptions._utils import ( + ACTION_FILE, + DEFAULT_LISTS_DIR, + DEFAULT_UA, + is_hosts_file_like, + list_file_path, + normalize_groups, + normalize_lists_dir, + now_iso, + parse_iso, + subscription_dirname, +) +from opensnitch.plugins.list_subscriptions.io.storage import ( + write_json_atomic_locked, +) +from opensnitch.proto import ui_pb2 as ui_pb2 + + +ch: Final[logging.StreamHandler] = logging.StreamHandler() +# ch.setLevel(logging.ERROR) +formatter: Final[logging.Formatter] = logging.Formatter( + "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" +) +ch.setFormatter(formatter) +logger: Final[logging.Logger] = logging.getLogger(__name__) +logger.addHandler(ch) +logger.setLevel(logging.WARNING) + + +# -------------------- plugin core -------------------- + + +class _LogSignalWrapper(QtCore.QObject): + """Thin QObject container for the (message, level) log signal. + Follows the same pattern as PluginSignal so the runtime can emit + structured log entries directly to the UI status controller. + """ + signal = QtCore.pyqtSignal(str, str, str) + + def emit(self, message: str, level: str = "INFO", origin: str = "backend") -> None: + self.signal.emit(message, level, origin) + + def connect(self, callback: Any) -> None: + self.signal.connect(callback) + + def disconnect(self, callback: Any) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + self.signal.disconnect(callback) + + +class _UiLogBridgeHandler(logging.Handler): + """Relays backend logger records into the UI live log signal.""" + + def __init__(self, sink: _LogSignalWrapper): + super().__init__(level=logging.DEBUG) + self._sink = sink + + @staticmethod + def _level_name(record: logging.LogRecord) -> str: + if record.levelno >= logging.ERROR: + return "ERROR" + if record.levelno >= logging.WARNING: + return "WARN" + if record.levelno >= logging.INFO: + return "INFO" + return "DEBUG" + + def emit(self, record: logging.LogRecord) -> None: + try: + if bool(getattr(record, "_skip_ui_bridge", False)): + return + message = record.getMessage().strip() + if message == "": + return + self._sink.emit(message, self._level_name(record), "backend") + except Exception: + pass + + +class SingletonABCMeta(ABCMeta): + _instances: dict[type, object] = {} + _lock = threading.Lock() + + def __call__(cls, *args: Any, **kwargs: Any): + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class ListSubscriptions(PluginBase, metaclass=SingletonABCMeta): + """A plugin to manage list subscriptions (e.g. blocklists). + + The plugin is configured via a JSON file specifying a list of subscriptions. + Each subscription has a URL and a local filename to save to. + The plugin periodically checks each URL for updates, using HTTP cache validators to avoid unnecessary downloads. + Metadata about each subscription is stored in a metadata JSON file (same name + .meta.json) to track last update time, errors, backoff, etc. + The plugin exposes a results queue for the UI to display subscription status and errors. + """ + + # fields overriden from parent class + name: ClassVar[str] = "List_subscriptions" + version: ClassVar[int] = 0 + author: ClassVar[str] = "opensnitch" + created: ClassVar[str] = "" + modified: ClassVar[str] = "" + enabled: bool = False + description: ClassVar[str] = ( + "Manage list subscriptions (e.g. blocklists) with periodic updates" + ) + + # default + TYPE: ClassVar[list[Any]] = [PluginBase.TYPE_GLOBAL] + + # UI log signal — connect to DialogStatusController.log to forward + # runtime messages to the main window's live log facility. + log_out: ClassVar[_LogSignalWrapper] = _LogSignalWrapper() + _ui_log_bridge_handler_installed: ClassVar[bool] = False + + # runtime state + scheduled_tasks: dict[str, GenericTimer] = {} + default_conf: ClassVar[str] = ACTION_FILE + default_lists_dir: ClassVar[str] = DEFAULT_LISTS_DIR + REFRESH_SUBSCRIPTIONS_SIGNAL: ClassVar[str] = "refresh_subscriptions" + + @classmethod + def get_instance(cls) -> "ListSubscriptions | None": + instance = SingletonABCMeta._instances.get(cls) + if isinstance(instance, cls): + return instance + return None + + def __init__(self, config: dict[str, Any] | None = None): + config = config or {} + if getattr(self, "_initialized", False): + self._load_action_config(config) + return + + self._initialized = True + self._ensure_ui_log_bridge_handler() + self.signal_in.connect(self.cb_signal) + self._desktop_notifications = DesktopNotifications() + self._db = Database.instance() + self._nodes = Nodes.instance() + self._ok_msg = "" + self._err_msg = "" + self._notify: dict[str, Any] | None = None + self._notify_title = "[OpenSnitch] List subscriptions downloader" + self._resultsQueue: Queue[tuple[str, bool, str]] = Queue() + self._app_icon = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg" + ) + self._cfg_dialog: ListSubscriptionsDialog | None = None + self._cfg_action: QtGui.QAction | None = None + self._cfg_toolbar_button: QtWidgets.QPushButton | None = None + self.scheduled_tasks = {} + self._startup_recheck_lock = threading.Lock() + self._startup_recheck_pending = False + self._startup_recheck_scheduled = False + self._nodes.nodesUpdated.connect(self._on_nodes_updated) + self._load_action_config(config) + + # Set up requests session with default UA + self._session: requests.Session = requests.Session() + if self._config.defaults.user_agent: + self._session.headers.update( + {"User-Agent": self._config.defaults.user_agent} + ) + else: + self._session.headers.update({"User-Agent": DEFAULT_UA}) + + @classmethod + def _ensure_ui_log_bridge_handler(cls) -> None: + if cls._ui_log_bridge_handler_installed: + return + logger.addHandler(_UiLogBridgeHandler(cls.log_out)) + cls._ui_log_bridge_handler_installed = True + + @staticmethod + def _backend_level(level: str) -> int: + normalized = (level or "INFO").strip().upper() + if normalized in ("TRACE", "DEBUG"): + return logging.DEBUG + if normalized in ("WARN", "WARNING"): + return logging.WARNING + if normalized == "ERROR": + return logging.ERROR + if normalized == "CRITICAL": + return logging.CRITICAL + return logging.INFO + + def _log_backend( + self, + message: str, + level: str = "INFO", + *, + suppress_ui_bridge: bool = False, + ) -> None: + full_text = (message or "").strip() + if full_text == "": + return + logger.log( + self._backend_level(level), + full_text, + extra={"_skip_ui_bridge": bool(suppress_ui_bridge)}, + ) + + def ingest_ui_log( + self, + message: str, + level: str = "INFO", + origin: str = "ui", + ) -> None: + """UI -> backend bridge: write UI logs to the backend true logger.""" + self._log_backend( + f"[{origin}] {message}", + level, + suppress_ui_bridge=True, + ) + + def log_debug(self, message: str) -> None: + self._log_backend(message, "DEBUG") + + def log_info(self, message: str) -> None: + self._log_backend(message, "INFO") + + def log_warn(self, message: str) -> None: + self._log_backend(message, "WARN") + + def log_error(self, message: str) -> None: + self._log_backend(message, "ERROR") + + def _emit_runtime_event( + self, + event: RuntimeEventType, + message: str, + *, + error: str | None = None, + action_path: str | None = None, + target: str | None = None, + path: str | None = None, + source: str | None = None, + state: str | None = None, + items: list[dict[str, Any]] | list[SubscriptionEventItem] | None = None, + ): + payload: dict[str, Any] = { + "plugin": self.get_name(), + "event": event, + "message": message, + } + if action_path: + payload["action_path"] = action_path + if error: + payload["error"] = error + if target: + payload["target"] = target + if path: + payload["path"] = path + if source: + payload["source"] = source + if state: + payload["state"] = state + if items: + payload["items"] = items + self.signal_out.emit(payload) + + def _load_action_config(self, action_cfg: dict[str, Any] | None = None): + action_cfg = action_cfg or {} + self.enabled = bool(action_cfg.get("enabled") is True) + + plugin_cfg: Any = action_cfg.get("config", {}) + if not isinstance(plugin_cfg, dict): + plugin_cfg = {} + self._config = PluginConfig.from_dict( + plugin_cfg, + lists_dir=self.default_lists_dir, + ) + self._notify = plugin_cfg.get("notify") + self._ok_msg = "" + self._err_msg = "" + if isinstance(self._notify, dict): + ok = self._notify.get("success") + err = self._notify.get("error") + if isinstance(ok, dict): + ok_msg = ok.get("desktop") + if ok_msg: + self._ok_msg = ok_msg + if isinstance(err, dict): + err_msg = err.get("desktop") + if err_msg: + self._err_msg = err_msg + else: + self._notify = None + + def _config_update_diff_targets( + self, + previous_subscriptions: list[SubscriptionSpec], + ): + previous_enabled_by_key = { + self._sub_key(sub): bool(sub.enabled) for sub in previous_subscriptions + } + targets: list[SubscriptionSpec] = [] + try: + current_subscriptions = list(self._config.subscriptions) + except Exception: + current_subscriptions = [] + for sub in current_subscriptions: + if not sub.enabled: + continue + old_enabled = previous_enabled_by_key.get(self._sub_key(sub)) + if old_enabled is None or old_enabled is False: + targets.append(sub) + return targets + + def _apply_config_update_diff( + self, + previous_subscriptions: list[SubscriptionSpec], + ): + refresh_targets = self._config_update_diff_targets(previous_subscriptions) + if not refresh_targets: + return + th = threading.Thread( + target=self.refresh_subscriptions, + args=(refresh_targets, "config_update"), + daemon=True, + ) + th.start() + + def _start_runtime(self, *, recheck: bool): + if not self.enabled: + return + + for t in self.scheduled_tasks.values(): + try: + t.start() + except Exception: + pass + + if recheck: + if self._has_ready_local_node(): + self._schedule_startup_recheck(delay=0.5) + else: + with self._startup_recheck_lock: + self._startup_recheck_pending = True + logger.info( + "deferring startup refresh until a local node is connected" + ) + + def disable_runtime(self): + self.enabled = False + with self._startup_recheck_lock: + self._startup_recheck_pending = False + self.stop() + + def _has_ready_local_node(self) -> bool: + for addr in self._nodes.get().keys(): + if not self._nodes.is_local(addr): + continue + if self._nodes.is_connected(addr): + return True + return False + + def _schedule_startup_recheck(self, *, delay: float): + with self._startup_recheck_lock: + if self._startup_recheck_scheduled: + return + self._startup_recheck_pending = False + self._startup_recheck_scheduled = True + + def _run(): + try: + self._startup_recheck_all() + finally: + with self._startup_recheck_lock: + self._startup_recheck_scheduled = False + + timer = threading.Timer(delay, _run) + timer.daemon = True + timer.start() + + def _on_nodes_updated(self, total: int): + if total <= 0 or not self.enabled: + return + with self._startup_recheck_lock: + pending = self._startup_recheck_pending + if pending and self._has_ready_local_node(): + logger.info("local node connected, running deferred startup refresh") + self._schedule_startup_recheck(delay=0.5) + + def _reload_from_action_file(self, action_path: str | None = None): + action_path = (action_path or self.default_conf).strip() or self.default_conf + try: + raw_action = read_json_locked(action_path) + self._emit_runtime_event( + RuntimeEventType.FILE_LOAD_FINISHED, + "Runtime configuration loaded.", + action_path=action_path, + target="action_config", + path=action_path, + ) + except Exception as exc: + logger.warning( + "failed to read action file %s: %r", + action_path, + exc, + ) + self._emit_runtime_event( + RuntimeEventType.FILE_LOAD_ERROR, + "Failed to load runtime configuration.", + error=str(exc), + action_path=action_path, + target="action_config", + path=action_path, + ) + return False, str(exc) + + if not isinstance(raw_action, dict): + logger.warning( + "invalid action payload in %s: %r", + action_path, + type(raw_action).__name__, # pyright: ignore[reportCallIssue] + ) + return False, f"invalid action payload type: {type(raw_action).__name__}" + + actions_obj = raw_action.get("actions", {}) + if not isinstance(actions_obj, dict): + actions_obj = {} + action_cfg = actions_obj.get("list_subscriptions", {}) + if not isinstance(action_cfg, dict): + action_cfg = {} + self._load_action_config(action_cfg) + + self._session.headers.update( + {"User-Agent": self._config.defaults.user_agent or DEFAULT_UA} + ) + self.compile() + return True, None + + # -------- metadata/files handling -------- + + def _paths(self, sub: SubscriptionSpec): + if self._config is None: + raise RuntimeError("PluginConfig is not loaded") + lists_dir = normalize_lists_dir(self._config.defaults.lists_dir) + os.makedirs(lists_dir, mode=0o700, exist_ok=True) + sources_dir = os.path.join(lists_dir, "sources.list.d") + os.makedirs(sources_dir, mode=0o700, exist_ok=True) + list_path = list_file_path(lists_dir, sub.filename, sub.format) + meta_path = list_path + ".meta.json" + return list_path, meta_path + + def _subscription_dirname(self, sub: SubscriptionSpec): + return subscription_dirname(sub.filename, sub.format) + + def _rules_root_dir(self): + if self._config is None: + return os.path.join(self.default_lists_dir, "rules.list.d") + return os.path.join( + normalize_lists_dir(self._config.defaults.lists_dir), "rules.list.d" + ) + + def _sources_root_dir(self): + if self._config is None: + return os.path.join(self.default_lists_dir, "sources.list.d") + return os.path.join( + normalize_lists_dir(self._config.defaults.lists_dir), "sources.list.d" + ) + + def _sync_sources_dirs(self): + if self._config is None: + return + sources_dir = self._sources_root_dir() + os.makedirs(sources_dir, mode=0o700, exist_ok=True) + + desired_paths: set[str] = set() + for sub in self._config.subscriptions: + list_path, meta_path = self._paths(sub) + desired_paths.add(list_path) + desired_paths.add(meta_path) + + for entry in os.listdir(sources_dir): + p = os.path.join(sources_dir, entry) + try: + if p not in desired_paths: + os.unlink(p) + except Exception: + pass + + def _sync_global_symlinks(self): + if self._config is None: + return + rules_dir = self._rules_root_dir() + os.makedirs(rules_dir, mode=0o700, exist_ok=True) + desired: dict[str, dict[str, str]] = {} + for idx, sub in enumerate(self._config.subscriptions): + if not getattr(sub, "enabled", True): + continue + list_path, _ = self._paths(sub) + if not os.path.exists(list_path): + continue + raw_groups: tuple[str, ...] = getattr(sub, "groups", tuple()) + groups = [ + self._subscription_dirname(sub), + "all", + *normalize_groups(raw_groups), + ] + link_name = f"{idx:02d}-{os.path.basename(list_path)}" + for group in groups: + desired.setdefault(group, {})[link_name] = list_path + + existing_groups: set[str] = set() + for name in os.listdir(rules_dir): + p = os.path.join(rules_dir, name) + if os.path.isdir(p) and not os.path.islink(p): + existing_groups.add(name) + if name not in desired: + try: + shutil.rmtree(p) + except Exception: + pass + else: + try: + os.unlink(p) + except Exception: + pass + + for group_name in existing_groups | set(desired.keys()): + group_dir = os.path.join(rules_dir, group_name) + desired_links = desired.get(group_name, {}) + if desired_links: + os.makedirs(group_dir, mode=0o700, exist_ok=True) + try: + existing_entries = os.listdir(group_dir) + except Exception: + existing_entries = [] + + for entry in existing_entries: + entry_path = os.path.join(group_dir, entry) + if entry not in desired_links: + try: + if os.path.isdir(entry_path) and not os.path.islink(entry_path): + shutil.rmtree(entry_path) + else: + os.unlink(entry_path) + except Exception: + pass + continue + + expected_target = desired_links[entry] + in_sync = False + try: + if os.path.islink(entry_path): + in_sync = os.path.realpath(entry_path) == os.path.realpath( + expected_target + ) + except Exception: + in_sync = False + + if not in_sync: + try: + if os.path.isdir(entry_path) and not os.path.islink(entry_path): + shutil.rmtree(entry_path) + else: + os.unlink(entry_path) + except Exception: + pass + + for link_name, target in desired_links.items(): + link_path = os.path.join(group_dir, link_name) + if os.path.lexists(link_path): + continue + try: + os.symlink(target, link_path) + except Exception: + try: + shutil.copy2(target, link_path) + except Exception: + pass + + def _load_meta(self, meta_path: str): + try: + meta = ListMetadata.from_dict(read_json_locked(meta_path)) + self._emit_runtime_event( + RuntimeEventType.FILE_LOAD_FINISHED, + "Subscription metadata loaded.", + target="subscription_meta", + path=meta_path, + ) + return meta + except Exception as exc: + self._emit_runtime_event( + RuntimeEventType.FILE_LOAD_ERROR, + "Failed to load subscription metadata.", + error=str(exc), + target="subscription_meta", + path=meta_path, + ) + return ListMetadata() + + def _save_meta(self, meta_path: str, meta: ListMetadata): + try: + write_json_atomic_locked(meta_path, meta.to_dict()) + self._emit_runtime_event( + RuntimeEventType.FILE_SAVE_FINISHED, + "Subscription metadata saved.", + target="subscription_meta", + path=meta_path, + state=meta.last_result or None, + ) + except Exception as exc: + self._emit_runtime_event( + RuntimeEventType.FILE_SAVE_ERROR, + "Failed to save subscription metadata.", + error=str(exc), + target="subscription_meta", + path=meta_path, + state=meta.last_result or None, + ) + raise + + def _fsync_parent_dir(self, path: str): + parent = os.path.dirname(path) + if parent == "": + return + try: + dir_fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + except Exception: + return + try: + os.fsync(dir_fd) + except Exception: + pass + finally: + os.close(dir_fd) + + def _affected_rule_dirs(self, sub: SubscriptionSpec): + affected_dirs = { + os.path.join(self._rules_root_dir(), self._subscription_dirname(sub)) + } + rules_root = self._rules_root_dir() + affected_dirs.add(os.path.join(rules_root, "all")) + for group in normalize_groups(sub.groups): + affected_dirs.add(os.path.join(rules_root, group)) + return {os.path.normpath(path) for path in affected_dirs if path.strip() != ""} + + def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): + try: + affected_dirs = self._affected_rule_dirs(sub) + found_match = False + for addr in self._nodes.get().keys(): + if not self._nodes.is_local(addr): + continue + records = self._db.get_rules(addr) + if records is None or records == -1: + continue + matched = False + while records.next(): + rule = cast(ui_pb2.Rule, Rule.new_from_records(records)) + if rule.operator.operand == Config.OPERAND_LIST_DOMAINS: + direct_dir = os.path.normpath( + str(rule.operator.data or "").strip() + ) + if direct_dir in affected_dirs: + matched = True + if not matched: + for operator in getattr(rule.operator, "list", []): + if operator.operand != Config.OPERAND_LIST_DOMAINS: + continue + nested_dir = os.path.normpath( + str(operator.data or "").strip() + ) + if nested_dir in affected_dirs: + matched = True + break + if not matched: + continue + + notification = ui_pb2.Notification( + type=ui_pb2.CHANGE_RULE, + rules=[rule], + ) + self._nodes.send_notification(addr, notification, None) + found_match = True + logger.info( + "signaling affected rule '%s' for updated subscription '%s'", + rule.name, + sub.name, + ) + break + if found_match is False: + logger.info( + "no matching rules found for updated subscription '%s'", + sub.name, + ) + except Exception as e: + logger.warning( + "reload rules after updating '%s' failed: %s", + sub.name, + repr(e), + ) + + # -------- timer lifecycle -------- + + def _sub_key(self, sub: SubscriptionSpec): + base = f"{sub.url}|{sub.filename}" + return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] + + def configure(self, parent: StatsDialogProto | None = None): + if isinstance(parent, StatsDialog): + if self._cfg_action is not None or self._cfg_toolbar_button is not None: + return + + icon_path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "res", "blocklist.svg" + ) + icon = ( + QtGui.QIcon(icon_path) if os.path.exists(icon_path) else QtGui.QIcon() + ) + if self._install_toolbar_button(parent, icon): + self._remove_menu_action(parent) + return + self._install_menu_action(parent, icon) + + def _install_toolbar_button(self, parent: StatsDialogProto, icon: QtGui.QIcon): + actions_button = getattr(parent, "actionsButton", None) + if not isinstance(actions_button, QtWidgets.QPushButton): + return False + button_parent = actions_button.parentWidget() + if button_parent is None: + return False + layout = self._find_layout_containing_widget( + button_parent.layout(), actions_button + ) + if not isinstance(layout, QtWidgets.QHBoxLayout): + return False + + insert_at = -1 + reference_button: QtWidgets.QPushButton | None = None + for idx in range(layout.count()): + item = layout.itemAt(idx) + if item is None: + continue + if item.spacerItem() is not None: + insert_at = idx + break + widget = item.widget() + if isinstance(widget, QtWidgets.QPushButton): + reference_button = widget + if insert_at < 0: + insert_at = layout.count() + if reference_button is None: + return False + + button = QtWidgets.QPushButton(button_parent) # pyright: ignore[reportCallIssue,reportArgumentType] + button.setObjectName("listSubscriptionsButton") + button.setText("") + button.setToolTip("List subscriptions") + button.setStatusTip("Open list subscriptions") + button.setFlat(True) + if not icon.isNull(): + button.setIcon(icon) + button.setCursor(reference_button.cursor()) # pyright: ignore[reportArgumentType] + button.setFocusPolicy(reference_button.focusPolicy()) # pyright: ignore[reportArgumentType] + button.setSizePolicy(reference_button.sizePolicy()) # pyright: ignore[reportArgumentType] + button.setMinimumSize(reference_button.minimumSize()) # pyright: ignore[reportArgumentType] + button.setMaximumHeight(reference_button.maximumHeight()) # pyright: ignore[reportArgumentType] + button.setIconSize(reference_button.iconSize()) # pyright: ignore[reportArgumentType] + + layout.insertWidget(insert_at, button) # pyright: ignore[reportArgumentType] + button.clicked.connect(lambda *_: self._open_config_dialog(parent)) + self._cfg_toolbar_button = button + return True + + def _find_layout_containing_widget( + self, + layout: QtWidgets.QLayout | None, + target: QtWidgets.QWidget, + ): + if layout is None: + return None + for idx in range(layout.count()): + item = layout.itemAt(idx) + if item is None: + continue + widget = item.widget() + if widget is target: + return layout + if isinstance(widget, QtWidgets.QWidget): + found = self._find_layout_containing_widget(widget.layout(), target) + if found is not None: + return found + child_layout = item.layout() + if child_layout is not None: + found = self._find_layout_containing_widget(child_layout, target) + if found is not None: + return found + return None + + def _install_menu_action(self, parent: StatsDialogProto, icon: QtGui.QIcon): + menu = parent.actionsButton.menu() + if menu is None: + return + + quit_action = self._find_quit_action(menu) + if quit_action is not None: + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") + else: + self._cfg_action = menu.addAction("List subscriptions") + menu.insertAction(quit_action, self._cfg_action) + else: + acts = menu.actions() + if acts and not acts[-1].isSeparator(): + menu.addSeparator() + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") + else: + self._cfg_action = menu.addAction("List subscriptions") + + if self._cfg_action is not None: + self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) + + def _remove_menu_action(self, parent: StatsDialogProto): + menu = parent.actionsButton.menu() + if menu is None: + return + text = "list subscriptions" + for action in list(menu.actions()): + if (action.text() or "").replace("&", "").strip().lower() == text: + menu.removeAction(action) + if action is self._cfg_action: + self._cfg_action = None + break + + def _find_quit_action(self, menu: QtWidgets.QMenu) -> QtGui.QAction | None: + qt_key = getattr(getattr(QtCore, "Qt", object()), "Key", None) + key_q = getattr(qt_key, "Key_Q", None) if qt_key is not None else None + for act in menu.actions(): + if act.isSeparator(): + continue + txt = (act.text() or "").replace("&", "").strip().lower() + if txt == "quit": + return act + shortcut = act.shortcut() + if ( + key_q is not None + and shortcut + and shortcut.matches(QtGui.QKeySequence(key_q)) + ): + return act + # In OpenSnitch main actions menu, Quit is typically the last entry. + acts = [a for a in menu.actions() if not a.isSeparator()] + if acts: + return acts[-1] + return None + + def _open_config_dialog(self, parent: StatsDialogProto): + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + appicon = None + try: + appicon = parent.windowIcon() + except Exception: + appicon = None + + if self._cfg_dialog is None: + # Some wrapped dialog types are not accepted as QWidget parents by + # PyQt6 constructors in plugin context. Use a top-level dialog. + self._cfg_dialog = ListSubscriptionsDialog( + parent=None, appicon=appicon + ) + self._cfg_dialog.show() + self._cfg_dialog.raise_() + self._cfg_dialog.activateWindow() + + def compile(self): + """ + Build one GenericTimer per subscription. + Stops timers removed from config. + """ + if not self._config: + return + + latest_keys: set[str] = set() + + for sub in self._config.subscriptions: + if not sub.enabled: + continue + + key = self._sub_key(sub) + latest_keys.add(key) + + if self._config is None: + continue + interval_s = sub.interval_seconds + + # recreate timer (simple, applies interval changes) + if key in self.scheduled_tasks: + try: + self.scheduled_tasks[key].stop() + except Exception: + pass + + self.scheduled_tasks[key] = GenericTimer( + interval_s, True, self.cb_run_tasks, (key, sub) + ) + + # stop removed timers + for key in list(self.scheduled_tasks.keys()): + if key not in latest_keys: + try: + self.scheduled_tasks[key].stop() + except Exception: + pass + self.scheduled_tasks.pop(key, None) + self._sync_sources_dirs() + self._sync_global_symlinks() + + def run(self, parent: StatsDialogProto | None = None, args: tuple[Any, ...] = ()): # type: ignore[override] + """ + Start timers. + """ + + if isinstance(parent, StatsDialog): + pass + self._start_runtime(recheck=True) + + def _startup_recheck_all(self): + if self._config is None or not self.enabled: + return + if not self._has_ready_local_node(): + with self._startup_recheck_lock: + self._startup_recheck_pending = True + logger.info("startup refresh skipped, no local node is ready yet") + return + for sub in self._config.subscriptions: + if not sub.enabled: + continue + try: + self.refresh_subscriptions(sub, source="startup_recheck") + except Exception as e: + logger.error( + "startup recheck error name='%s' err=%s", + sub.name, + repr(e), + ) + self._sync_global_symlinks() + + def stop(self): + """ + Stop timers and clear them from memory. + """ + for t in self.scheduled_tasks.values(): + try: + t.stop() + except Exception: + pass + self.scheduled_tasks.clear() + + # -------- scheduled execution -------- + + def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): + """ + Timer callback for one subscription. + Mirrors downloader behavior: start worker thread, join it, + then immediately evaluate queued result. + """ + key: str + sub: SubscriptionSpec + key, sub = args + + # due/backoff gate via metadata + _, meta_path = self._paths(sub) + meta = self._load_meta(meta_path) + + if self._in_backoff(meta): + logger.info("skip '%s' (in backoff)", sub.name) + return + if not self._is_due(meta, sub): + logger.info("skip '%s' (not due yet)", sub.name) + return + + th = threading.Thread(target=self.download, args=(sub,)) + th.start() + th.join() + + matched: list[tuple[str, bool, str]] = [] + unmatched: list[tuple[str, bool, str]] = [] + while not self._resultsQueue.empty(): + item = self._resultsQueue.get_nowait() + if len(item) >= 3 and item[0] == key: + matched.append(item) + else: + unmatched.append(item) + for item in unmatched: + self._resultsQueue.put(item) + + if not matched: + logger.debug("cb_run_tasks: no result for key=%s sub=%s", key, sub.name) + return + + updated: bool = False + statuses: list[str] = [] + for _, ok, status in matched: + updated = ok + statuses.append(status) + if updated: + result_msg = self._ok_msg or f"{sub.name}: {', '.join(statuses)}" + else: + result_msg = self._err_msg or f"{sub.name} failed: {', '.join(statuses)}" + + if ( + self._notify is not None + and self._desktop_notifications.is_available() + and self._desktop_notifications.are_enabled() + ): + self._desktop_notifications.show( + self._notify_title, result_msg, self._app_icon + ) + + def refresh_subscriptions( + self, + subscriptions: SubscriptionSpec | list[SubscriptionSpec], + source: str = "scheduled", + force: bool = False, + ): + ok = self.download( + subscriptions, + force=force, + source=source, + emit_download_events=True, + ) + self._sync_global_symlinks() + return ok + + def cb_signal(self, signal: dict[str, Any]): + try: + sig = signal.get("signal") + action_path = signal.get("action_path") + + if sig == PluginSignal.ENABLE: + logger.debug( + "cb_signal: ENABLE action_path=%r", + action_path, + ) + ok, err = self._reload_from_action_file(action_path) + if ok: + self.enabled = True + self.run() + self._emit_runtime_event( + RuntimeEventType.RUNTIME_ENABLED, + "Plugin runtime enabled.", + action_path=action_path, + ) + logger.info("plugin runtime enabled") + else: + self._emit_runtime_event( + RuntimeEventType.RUNTIME_ERROR, + "Failed to enable plugin runtime.", + error=err, + action_path=action_path, + ) + logger.error("Failed to enable plugin runtime: %s", repr(err)) + + return + + if sig == self.REFRESH_SUBSCRIPTIONS_SIGNAL: + raw_items = signal.get("items") + source = str(signal.get("source") or "manual_refresh") + subscriptions: list[SubscriptionSpec] = [] + if isinstance(raw_items, list): + for raw_item in raw_items: + if not isinstance(raw_item, dict): + continue + try: + sub = SubscriptionSpec.from_dict( + raw_item, + self._config.defaults, + ) + except Exception: + sub = None + if sub is not None: + subscriptions.append(sub) + if not subscriptions: + self._emit_runtime_event( + RuntimeEventType.RUNTIME_ERROR, + "No subscriptions were provided for refresh.", + action_path=action_path, + ) + return + th = threading.Thread( + target=self.refresh_subscriptions, + args=(subscriptions, source, True), + daemon=True, + ) + th.start() + return + + if sig == PluginSignal.CONFIG_UPDATE: + logger.debug( + "cb_signal: CONFIG_UPDATE action_path=%r", + action_path, + ) + previous_subscriptions: list[SubscriptionSpec] = [] + try: + previous_subscriptions = list(self._config.subscriptions) + except Exception: + previous_subscriptions = [] + self.stop() + ok, err = self._reload_from_action_file(action_path) + if ok: + if self.enabled: + self._start_runtime(recheck=False) + self._apply_config_update_diff(previous_subscriptions) + self._emit_runtime_event( + RuntimeEventType.CONFIG_RELOADED, + "Plugin runtime configuration reloaded.", + action_path=action_path, + ) + logger.info("plugin runtime configuration reloaded") + else: + self._emit_runtime_event( + RuntimeEventType.RUNTIME_ERROR, + "Failed to reload plugin runtime configuration.", + error=err, + action_path=action_path, + ) + logger.error("Failed to reload plugin runtime configuration: %s", repr(err)) + return + + if sig == PluginSignal.DISABLE or sig == PluginSignal.STOP: + logger.debug( + "cb_signal: %s action_path=%r", + "DISABLE" if sig == PluginSignal.DISABLE else "STOP", + action_path, + ) + self.enabled = False + self.stop() + self._emit_runtime_event( + ( + RuntimeEventType.RUNTIME_DISABLED + if sig == PluginSignal.DISABLE + else RuntimeEventType.RUNTIME_STOPPED + ), + ( + "Plugin runtime disabled." + if sig == PluginSignal.DISABLE + else "Plugin runtime stopped." + ), + action_path=action_path, + ) + logger.info( + "plugin runtime %s", + "disabled" if sig == PluginSignal.DISABLE else "stopped", + ) + return + + if sig == PluginSignal.ERROR: + err = str(signal.get("error") or signal.get("message") or "") + self._emit_runtime_event( + RuntimeEventType.RUNTIME_ERROR, + "Plugin runtime reported an error.", + error=err or None, + action_path=action_path, + ) + return + + raise ValueError(f"unrecognized signal: {sig}") + except Exception as e: + logger.error("cb_signal: exception: %s", repr(e)) + + def _in_backoff(self, meta: ListMetadata): + if not meta.backoff_until: + return False + dt = parse_iso(meta.backoff_until) + if not dt: + return False + return datetime.now().astimezone() < dt + + def _is_due(self, meta: ListMetadata, sub: SubscriptionSpec): + if not meta.last_checked: + return True + lc = parse_iso(meta.last_checked) + if not lc: + return True + return ( + datetime.now().astimezone() - lc + ).total_seconds() >= sub.interval_seconds + + # -------- worker: download + update metadata -------- + + def _mark_failure(self, meta: ListMetadata, err: str): + meta.fail_count = int(meta.fail_count or 0) + 1 + meta.last_error = err + meta.last_result = "error" + + seconds = min((2 ** max(0, meta.fail_count)) * 60, 6 * 3600) + meta.backoff_until = ( + datetime.now().astimezone() + timedelta(seconds=seconds) + ).isoformat() + + def _download_one( + self, + key: str, + sub: SubscriptionSpec, + force: bool = False, + source: str = "scheduled", + emit_download_events: bool = True, + ): + list_path, meta_path = self._paths(sub) + os.makedirs(os.path.dirname(list_path), exist_ok=True) + + meta = self._load_meta(meta_path) + + # keep meta aligned + meta.version = 1 + meta.url = sub.url + meta.format = sub.format + + meta.last_checked = now_iso() + meta.last_error = "" + event_item = SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + path=list_path, + ) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_STARTED, + f"Downloading subscription '{sub.name}'.", + target="subscription_list", + path=list_path, + source=source, + items=[event_item], + ) + + # conditional headers + headers: dict[str, str] = {} + if not force and meta.etag: + headers["If-None-Match"] = meta.etag + if not force and meta.last_modified: + headers["If-Modified-Since"] = meta.last_modified + + headers["User-Agent"] = self._config.defaults.user_agent or DEFAULT_UA + + lock = FileLock(list_path + ".lock") + if not lock.acquire(): + meta.last_result = "busy" + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription '{sub.name}' is busy.", + target="subscription_list", + path=list_path, + source=source, + state="busy", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="busy", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "busy")) + return False + + try: + # requests defaults except UA; timeout is used + try: + r: requests.Response = self._session.get( + sub.url, headers=headers, timeout=sub.timeout_seconds, stream=True + ) + except Exception as e: + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="request_error", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="request_error", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "request_error")) + return False + + response_closed = False + try: + if r.status_code == 304: + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "not_modified" + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FINISHED, + f"Subscription '{sub.name}' is up to date.", + target="subscription_list", + path=list_path, + source=source, + state="not_modified", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="not_modified", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, True, "not_modified")) + logger.info("subscription not-modified name='%s'", sub.name) + return True + + if r.status_code != 200: + self._mark_failure(meta, f"http_{r.status_code}") + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=f"http_{r.status_code}", + target="subscription_list", + path=list_path, + source=source, + state=f"http_{r.status_code}", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state=f"http_{r.status_code}", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, f"http_{r.status_code}")) + logger.error( + "subscription download http-error name='%s' code=%s", + sub.name, + r.status_code, + ) + return False + + cl: str | None = r.headers.get("Content-Length") + if cl: + try: + if int(cl) > sub.max_bytes: + self._mark_failure(meta, f"too_large:{cl}") + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription download exceeded max size for '{sub.name}'.", + error=f"too_large:{cl}", + target="subscription_list", + path=list_path, + source=source, + state="too_large", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="too_large", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "too_large")) + logger.error( + "subscription download too-large name='%s' len=%s", + sub.name, + cl, + ) + return False + except Exception: + pass + + tmp = list_path + ".tmp" + downloaded = 0 + sample_lines: list[str] = [] + + try: + with open(tmp, "wb") as f: + for chunk in r.iter_content(chunk_size=32 * 1024): + if not chunk: + continue + downloaded += len(chunk) + if downloaded > sub.max_bytes: + raise RuntimeError("too_large_streamed") + f.write(chunk) + + if ( + sub.format.lower() == "hosts" + and len(sample_lines) < 200 + ): + txt = chunk.decode("utf-8", errors="ignore") + for ln in txt.splitlines(): + if len(sample_lines) < 200: + sample_lines.append(ln) + else: + break + + f.flush() + os.fsync(f.fileno()) + + if sub.format.lower() == "hosts" and not is_hosts_file_like( + sample_lines + ): + try: + os.remove(tmp) + except Exception: + pass + self._mark_failure(meta, "bad_format_hosts") + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription file format is invalid for '{sub.name}'.", + error="bad_format_hosts", + target="subscription_list", + path=list_path, + source=source, + state="bad_format", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="bad_format", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "bad_format")) + logger.error( + "subscription file bad-format name='%s'", + sub.name, + ) + return False + + os.replace(tmp, list_path) + self._fsync_parent_dir(list_path) + self._emit_runtime_event( + RuntimeEventType.FILE_SAVE_FINISHED, + f"Subscription file saved for '{sub.name}'.", + target="subscription_list", + path=list_path, + source=source, + items=[event_item], + ) + + except Exception as e: + try: + if os.path.exists(tmp): + os.remove(tmp) + except Exception: + pass + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + self._emit_runtime_event( + RuntimeEventType.FILE_SAVE_ERROR, + f"Failed to save subscription file for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="write_error", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="write_error", + path=list_path, + ) + ], + ) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="write_error", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="write_error", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "write_error")) + logger.error( + "subscription file write-error name='%s' err=%s", + sub.name, + repr(e), + ) + return False + + # update cache validators + et = r.headers.get("ETag") + lm = r.headers.get("Last-Modified") + if et: + meta.etag = et + if lm: + meta.last_modified = lm + + meta.bytes = downloaded + meta.last_updated = now_iso() + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "updated" + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FINISHED, + f"Subscription '{sub.name}' updated.", + target="subscription_list", + path=list_path, + source=source, + state="updated", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="updated", + path=list_path, + ) + ], + ) + logger.info( + "subscription updated name='%s' bytes=%s", + sub.name, + downloaded, + ) + r.close() + response_closed = True + self._reload_rules_for_updated_subscription(sub) + self._resultsQueue.put((key, True, "updated")) + return True + finally: + if not response_closed: + r.close() + except Exception as e: + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="unexpected_error", + items=[ + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state="unexpected_error", + path=list_path, + ) + ], + ) + self._resultsQueue.put((key, False, "unexpected_error")) + logger.error( + "subscription download unexpected-error name='%s' err=%s", + sub.name, + repr(e), + ) + return False + + finally: + lock.release() + + def download( + self, + subscriptions: SubscriptionSpec | list[SubscriptionSpec], + force: bool = False, + source: str = "scheduled", + emit_download_events: bool = True, + ): + if isinstance(subscriptions, SubscriptionSpec): + return self._download_one( + self._sub_key(subscriptions), + subscriptions, + force=force, + source=source, + emit_download_events=emit_download_events, + ) + + if not subscriptions: + return True + + items: list[SubscriptionEventItem] = [] + if emit_download_events: + self._emit_runtime_event( + RuntimeEventType.DOWNLOAD_STARTED, + "Batch subscription refresh started.", + target="subscription_list", + source=source, + items=[ + SubscriptionEventItem( + key=self._sub_key(sub), + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + ) + for sub in subscriptions + ], + ) + had_errors = False + for sub in subscriptions: + key = self._sub_key(sub) + list_path, meta_path = self._paths(sub) + item_state = "unexpected_error" + try: + ok = self._download_one( + key, + sub, + force=force, + source=source, + emit_download_events=False, + ) + item_state = "updated" if ok else "error" + if not ok: + had_errors = True + except Exception as exc: + had_errors = True + logger.error( + "batch download failed for '%s': %r", + sub.name, + exc, + ) + try: + item_state = self._load_meta(meta_path).last_result or item_state + except Exception: + pass + items.append( + SubscriptionEventItem( + key=key, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + state=item_state, + path=list_path, + ) + ) + if emit_download_events: + self._emit_runtime_event( + ( + RuntimeEventType.DOWNLOAD_FAILED + if had_errors + else RuntimeEventType.DOWNLOAD_FINISHED + ), + ( + "Batch subscription refresh finished with errors." + if had_errors + else "Batch subscription refresh finished." + ), + target="subscription_list", + source=source, + state="batch_failed" if had_errors else "batch_finished", + items=items, + ) + logger.error( + "batch subscription refresh failed for %d/%d items", + sum(1 for i in items if i.get('state') != "updated"), + len(items), + ) if had_errors else logger.info( + "batch subscription refresh finished for %d items", + len(items), + ) + return not had_errors diff --git a/ui/opensnitch/plugins/list_subscriptions/models/__init__.py b/ui/opensnitch/plugins/list_subscriptions/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/models/action.py b/ui/opensnitch/plugins/list_subscriptions/models/action.py new file mode 100644 index 0000000000..2c1bfca4bb --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/action.py @@ -0,0 +1,92 @@ +from opensnitch.plugins.list_subscriptions._utils import normalize_iso_timestamp, now_iso +from opensnitch.plugins.list_subscriptions.models.config import MutablePluginConfig + + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class MutableActionConfig: + enabled: bool = False + plugin: MutablePluginConfig = field(default_factory=MutablePluginConfig.default) + action_name: str = "listSubscriptionsActions" + created: str = "" + updated: str = "" + description: str = "Manage and auto-update blocklist subscriptions (hosts format)" + types: list[str] = field(default_factory=lambda: ["global", "main-dialog"]) + + @staticmethod + def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None): + action_name = str(raw_action.get("name", "listSubscriptionsActions")) + created = normalize_iso_timestamp(raw_action.get("created")) + updated = normalize_iso_timestamp(raw_action.get("updated"), fallback=created) + description = str( + raw_action.get( + "description", + "Manage and auto-update blocklist subscriptions (hosts format)", + ) + ) + action_types_raw = raw_action.get("type", ["global", "main-dialog"]) + if isinstance(action_types_raw, list): + action_types = [str(t) for t in action_types_raw] + else: + action_types = ["global", "main-dialog"] + + actions_obj = raw_action.get("actions", {}) + action_cfg = ( + actions_obj.get("list_subscriptions", {}) + if isinstance(actions_obj, dict) + else {} + ) + plugin_cfg_raw = ( + action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {} + ) + plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {} + mutable_plugin = MutablePluginConfig.from_dict( + plugin_cfg, lists_dir=plugin_cfg.get("lists_dir") or lists_dir + ) + enabled = ( + bool(action_cfg.get("enabled", False)) + if isinstance(action_cfg, dict) + else False + ) + + return MutableActionConfig( + enabled=enabled, + plugin=mutable_plugin, + action_name=action_name, + created=created, + updated=updated, + description=description, + types=action_types, + ) + + @staticmethod + def default(lists_dir: str | None = None): + created = now_iso() + return MutableActionConfig( + enabled=True, + plugin=MutablePluginConfig.default(lists_dir), + created=created, + updated=created, + ) + + def to_action_dict(self): + created = normalize_iso_timestamp(self.created) + updated = now_iso() + self.created = created + self.updated = updated + return { + "name": self.action_name, + "created": created, + "updated": updated, + "description": self.description, + "type": list(self.types), + "actions": { + "list_subscriptions": { + "enabled": bool(self.enabled), + "config": self.plugin.to_dict(), + } + }, + } \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/config.py b/ui/opensnitch/plugins/list_subscriptions/models/config.py new file mode 100644 index 0000000000..2f96aa0656 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/config.py @@ -0,0 +1,132 @@ +from opensnitch.plugins.list_subscriptions._utils import DEFAULT_NOTIFY_CONFIG, DEFAULT_UA, normalize_lists_dir +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults +from opensnitch.plugins.list_subscriptions.models.subscriptions import MutableSubscriptionSpec, SubscriptionSpec, normalize_subscription_identities + + +from dataclasses import dataclass, field, replace +from typing import Any + + +@dataclass(frozen=True) +class PluginConfig: + defaults: GlobalDefaults = field( + default_factory=lambda: GlobalDefaults.from_dict({}) + ) + subscriptions: list[SubscriptionSpec] = field(default_factory=list) + notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG)) + + @staticmethod + def from_dict( + raw_cfg: dict[str, Any], + lists_dir: str | None = None, + invalidate_duplicates: bool = False, + ): + raw_cfg = raw_cfg or {} + if not isinstance(raw_cfg, dict): + raw_cfg = {} + defaults = GlobalDefaults.from_dict(raw_cfg, lists_dir) + + subs: list[SubscriptionSpec] = [] + for item in raw_cfg.get("subscriptions") or []: + sub = SubscriptionSpec.from_dict( + item, + defaults, + ) + if sub is not None: + subs.append(sub) + + normalized_subs = normalize_subscription_identities( + subs, + invalidate_duplicates=invalidate_duplicates, + clone=lambda sub, name, url, filename, list_type: replace( + sub, + name=name, + url=url, + filename=filename, + format=list_type, + ), + ) + if normalized_subs is None: + normalized_subs = [] + + notify = raw_cfg.get("notify") + if not isinstance(notify, dict): + notify = dict(DEFAULT_NOTIFY_CONFIG) + + return PluginConfig( + defaults=defaults, subscriptions=normalized_subs, notify=notify + ) + + +@dataclass +class MutablePluginConfig: + defaults: GlobalDefaults = field( + default_factory=lambda: GlobalDefaults.from_dict({}) + ) + subscriptions: list[MutableSubscriptionSpec] = field(default_factory=list) + notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG)) + + @staticmethod + def from_plugin_config(config: PluginConfig): + return MutablePluginConfig( + defaults=config.defaults, + subscriptions=[ + MutableSubscriptionSpec.from_spec(sub) for sub in config.subscriptions + ], + notify=dict(config.notify), + ) + + @staticmethod + def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None): + compiled_cfg = PluginConfig.from_dict(raw_cfg, lists_dir=lists_dir) + return MutablePluginConfig.from_plugin_config(compiled_cfg) + + @staticmethod + def default(lists_dir: str | None = None): + return MutablePluginConfig( + defaults=GlobalDefaults.from_dict({}, lists_dir=lists_dir), + subscriptions=[], + notify=dict(DEFAULT_NOTIFY_CONFIG), + ) + + def normalize_subscriptions(self, invalidate_duplicates: bool = False): + normalized = normalize_subscription_identities( + self.subscriptions, + invalidate_duplicates=invalidate_duplicates, + clone=lambda sub, name, url, filename, list_type: MutableSubscriptionSpec( + name=name, + url=url, + filename=filename, + groups=list(sub.groups), + enabled=sub.enabled, + format=list_type, + interval=sub.interval, + interval_units=sub.interval_units, + timeout=sub.timeout, + timeout_units=sub.timeout_units, + max_size=sub.max_size, + max_size_units=sub.max_size_units, + ), + ) + if normalized is None: + return None + self.subscriptions = normalized + return normalized + + def to_dict(self): + return { + "lists_dir": normalize_lists_dir(self.defaults.lists_dir), + "interval": int(self.defaults.interval), + "interval_units": self.defaults.interval_units, + "timeout": int(self.defaults.timeout), + "timeout_units": self.defaults.timeout_units, + "max_size": int(self.defaults.max_size), + "max_size_units": self.defaults.max_size_units, + "user_agent": ( + self.defaults.user_agent + if self.defaults.user_agent is not None + else DEFAULT_UA + ), + "subscriptions": [sub.to_dict() for sub in self.subscriptions], + "notify": self.notify, + } diff --git a/ui/opensnitch/plugins/list_subscriptions/models/events.py b/ui/opensnitch/plugins/list_subscriptions/models/events.py new file mode 100644 index 0000000000..efb572e265 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/events.py @@ -0,0 +1,41 @@ +from typing import TypedDict +from enum import IntEnum + +class SubscriptionEventItem(TypedDict, total=False): + key: str + name: str + url: str + filename: str + format: str + state: str | None + path: str | None + + +class SubscriptionEventPayload(TypedDict, total=False): + enabled: bool + name: str + url: str + filename: str + format: str + groups: list[str] + interval: int | None + interval_units: str | None + timeout: int | None + timeout_units: str | None + max_size: int | None + max_size_units: str | None + + +class RuntimeEventType(IntEnum): + RUNTIME_ENABLED = 1 + CONFIG_RELOADED = 2 + RUNTIME_DISABLED = 3 + RUNTIME_STOPPED = 4 + RUNTIME_ERROR = 5 + DOWNLOAD_STARTED = 6 + DOWNLOAD_FINISHED = 7 + DOWNLOAD_FAILED = 8 + FILE_SAVE_FINISHED = 9 + FILE_SAVE_ERROR = 10 + FILE_LOAD_FINISHED = 11 + FILE_LOAD_ERROR = 12 \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py b/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py new file mode 100644 index 0000000000..cbe6539cc7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py @@ -0,0 +1,42 @@ +from opensnitch.plugins.list_subscriptions._utils import DEFAULT_UA, normalize_lists_dir + + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class GlobalDefaults: + lists_dir: str + interval: int = 24 + interval_units: str = "hours" + timeout: int = 60 + timeout_units: str = "seconds" + max_size: int = 20 + max_size_units: str = "MB" + user_agent: str | None = DEFAULT_UA + + @staticmethod + def from_dict(d: dict[str, Any], lists_dir: str | None = None): + lists_dir = normalize_lists_dir(str(d.get("lists_dir") or lists_dir or "")) + + def _int(v: int | float | str | None, default: int): + try: + return int(v) if v is not None else default + except Exception: + return default + + def _str(v: str | None, default: str): + v = (v or "").strip() + return v if v else default + + return GlobalDefaults( + lists_dir=lists_dir, + interval=_int(d.get("interval"), 24), + interval_units=_str(d.get("interval_units"), "hours"), + timeout=_int(d.get("timeout"), 60), + timeout_units=_str(d.get("timeout_units"), "seconds"), + max_size=_int(d.get("max_size"), 20), + max_size_units=_str(d.get("max_size_units"), "MB"), + user_agent=(d.get("user_agent") or DEFAULT_UA), + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/metadata.py b/ui/opensnitch/plugins/list_subscriptions/models/metadata.py new file mode 100644 index 0000000000..9b5305b84b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/metadata.py @@ -0,0 +1,51 @@ +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass +class ListMetadata: + version: int = 1 + url: str = "" + format: str = "hosts" + etag: str = "" + last_modified: str = "" + last_checked: str = "" + last_updated: str = "" + backoff_until: str = "" + last_result: str = "never" + last_error: str = "" + fail_count: int = 0 + bytes: int = 0 + + @staticmethod + def from_dict(d: dict[str, Any]): + m = ListMetadata() + if not isinstance(d, dict): + return m + + def _int(v: Any, default: int): + try: + return int(v) if v is not None else default + except Exception: + return default + + def _str(v: Any, default: str = ""): + return str(v or default) + + m.version = _int(d.get("version", 1), 1) + m.url = _str(d.get("url", "")) + m.format = _str(d.get("format", "hosts")) or "hosts" + m.etag = _str(d.get("etag", "")) + m.last_modified = _str(d.get("last_modified", "")) + m.last_checked = _str(d.get("last_checked", "")) + m.last_updated = _str(d.get("last_updated", "")) + m.backoff_until = _str(d.get("backoff_until", "")) + m.last_result = _str(d.get("last_result", "never")) or "never" + m.last_error = _str(d.get("last_error", "")) + m.fail_count = _int(d.get("fail_count", 0), 0) + m.bytes = _int(d.get("bytes", 0), 0) + + return m + + def to_dict(self): + return asdict(self) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py new file mode 100644 index 0000000000..aaba1540ac --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py @@ -0,0 +1,284 @@ +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from opensnitch.plugins.list_subscriptions._utils import dedupe_subscription_identity, derive_filename, ensure_filename_type_suffix, normalize_groups, opt_int, opt_str, parse_compact_duration, safe_filename, to_max_bytes, to_seconds +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults + +SubscriptionLike = TypeVar( + "SubscriptionLike", "SubscriptionSpec", "MutableSubscriptionSpec" +) + + +@dataclass(frozen=True) +class SubscriptionSpec: + name: str + url: str + filename: str + groups: tuple[str, ...] = () + enabled: bool = True + format: str = "hosts" + interval: int | None = None + interval_units: str | None = None + timeout: int | None = None + timeout_units: str | None = None + max_size: int | None = None + max_size_units: str | None = None + interval_seconds: int = 24 * 3600 + timeout_seconds: int = 60 + max_bytes: int = 20 * 1024 * 1024 + + @staticmethod + def from_dict( + d: dict[str, Any] | None, + defaults: GlobalDefaults | None = None, + require_url: bool = True, + ensure_suffix: bool = True, + ): + d = d or {} + defaults = defaults or GlobalDefaults.from_dict({}) + + name = (d.get("name") or "").strip() + url = (d.get("url") or "").strip() + list_type = str(d.get("format", "hosts") or "hosts").strip().lower() + filename = derive_filename(name, url, d.get("filename")) + if ensure_suffix and filename != "": + filename = ensure_filename_type_suffix(filename, list_type) + elif not ensure_suffix and filename == "": + filename = "" + groups_raw = d.get("groups") + if "group" in d: + legacy_group = d.get("group") + if isinstance(groups_raw, (list, tuple, set)): + groups_raw = list(groups_raw) + [legacy_group] + elif groups_raw is None: + groups_raw = [legacy_group] + else: + groups_raw = [groups_raw, legacy_group] + groups = normalize_groups(groups_raw) + if require_url and not url: + return None + if require_url and not name: + name = filename + + interval_raw: Any = d.get("interval") + timeout_raw: Any = d.get("timeout") + interval_units_raw: Any = d.get("interval_units") + timeout_units_raw: Any = d.get("timeout_units") + + interval = opt_int(interval_raw) + interval_units_opt = opt_str(interval_units_raw) + interval_units = interval_units_opt + timeout = opt_int(timeout_raw) + timeout_units_opt = opt_str(timeout_units_raw) + timeout_units = timeout_units_opt + max_size = opt_int(d.get("max_size")) + max_size_units = opt_str(d.get("max_size_units")) + + default_interval_seconds = to_seconds( + defaults.interval, defaults.interval_units, 24 * 3600 + ) + default_timeout_seconds = to_seconds( + defaults.timeout, defaults.timeout_units, 60 + ) + default_max_bytes = to_max_bytes( + defaults.max_size, defaults.max_size_units, 20 * 1024 * 1024 + ) + + effective_interval = interval if interval is not None else defaults.interval + effective_interval_units = interval_units or defaults.interval_units + effective_timeout = timeout if timeout is not None else defaults.timeout + effective_timeout_units = timeout_units or defaults.timeout_units + effective_max_size = max_size if max_size is not None else defaults.max_size + effective_max_size_units = max_size_units or defaults.max_size_units + + interval_seconds: int | None = None + interval_is_composite = False + if interval_units_opt is None: + interval_seconds = parse_compact_duration(interval_raw) + interval_is_composite = interval_seconds is not None + if interval_seconds is None: + interval_seconds = to_seconds( + effective_interval, effective_interval_units, default_interval_seconds + ) + elif interval_is_composite: + interval = interval_seconds + interval_units = "composite" + + timeout_seconds: int | None = None + timeout_is_composite = False + if timeout_units_opt is None: + timeout_seconds = parse_compact_duration(timeout_raw) + timeout_is_composite = timeout_seconds is not None + if timeout_seconds is None: + timeout_seconds = to_seconds( + effective_timeout, effective_timeout_units, default_timeout_seconds + ) + elif timeout_is_composite: + timeout = timeout_seconds + timeout_units = "composite" + + max_bytes = to_max_bytes( + effective_max_size, effective_max_size_units, default_max_bytes + ) + + return SubscriptionSpec( + name=name, + url=url, + filename=filename, + groups=tuple(groups), + enabled=bool(d.get("enabled", True)), + format=list_type, + interval=interval, + interval_units=interval_units, + timeout=timeout, + timeout_units=timeout_units, + max_size=max_size, + max_size_units=max_size_units, + interval_seconds=interval_seconds, + timeout_seconds=timeout_seconds, + max_bytes=max_bytes, + ) + + +@dataclass +class MutableSubscriptionSpec: + name: str = "" + url: str = "" + filename: str = "" + groups: list[str] = field(default_factory=list) + enabled: bool = True + format: str = "hosts" + interval: int | None = None + interval_units: str | None = None + timeout: int | None = None + timeout_units: str | None = None + max_size: int | None = None + max_size_units: str | None = None + + @staticmethod + def from_spec(spec: SubscriptionSpec): + return MutableSubscriptionSpec( + name=spec.name, + url=spec.url, + filename=spec.filename, + groups=list(spec.groups), + enabled=spec.enabled, + format=spec.format, + interval=spec.interval, + interval_units=spec.interval_units, + timeout=spec.timeout, + timeout_units=spec.timeout_units, + max_size=spec.max_size, + max_size_units=spec.max_size_units, + ) + + @staticmethod + def from_dict( + d: dict[str, Any] | None, + defaults: GlobalDefaults | None = None, + require_url: bool = True, + ensure_suffix: bool = True, + ): + spec = SubscriptionSpec.from_dict( + d, + defaults, + require_url=require_url, + ensure_suffix=ensure_suffix, + ) + if spec is None: + return None + + d = d or {} + + def _has_value(value: Any): + return value is not None and str(value).strip() != "" + + return MutableSubscriptionSpec( + name=spec.name, + url=spec.url, + filename=spec.filename, + groups=list(spec.groups), + enabled=spec.enabled, + format=spec.format, + interval=( + spec.interval + if _has_value(d.get("interval")) or spec.interval_units == "composite" + else None + ), + interval_units=( + spec.interval_units + if _has_value(d.get("interval_units")) + or spec.interval_units == "composite" + else None + ), + timeout=( + spec.timeout + if _has_value(d.get("timeout")) or spec.timeout_units == "composite" + else None + ), + timeout_units=( + spec.timeout_units + if _has_value(d.get("timeout_units")) + or spec.timeout_units == "composite" + else None + ), + max_size=spec.max_size if _has_value(d.get("max_size")) else None, + max_size_units=( + spec.max_size_units if _has_value(d.get("max_size_units")) else None + ), + ) + + def to_dict(self): + data: dict[str, Any] = { + "enabled": bool(self.enabled), + "name": (self.name or "").strip(), + "url": (self.url or "").strip(), + "filename": safe_filename(self.filename), + "format": (self.format or "hosts").strip().lower(), + "groups": normalize_groups(self.groups), + } + if self.interval is not None: + data["interval"] = int(self.interval) + data["interval_units"] = (self.interval_units or "hours").strip().lower() + if self.timeout is not None: + data["timeout"] = int(self.timeout) + data["timeout_units"] = (self.timeout_units or "seconds").strip().lower() + if self.max_size is not None: + data["max_size"] = int(self.max_size) + data["max_size_units"] = (self.max_size_units or "MB").strip() + return data + + +def normalize_subscription_identities( + subscriptions: list[SubscriptionLike], + invalidate_duplicates: bool = False, + clone: ( + Callable[[SubscriptionLike, str, str, str, str], SubscriptionLike] | None + ) = None, +): + normalized: list[SubscriptionLike] = [] + seen_filenames: dict[str, str] = {} + for sub in subscriptions: + url = (sub.url or "").strip() + if url == "": + return None + list_type = (sub.format or "hosts").strip().lower() + filename = ensure_filename_type_suffix( + derive_filename(sub.name, url, sub.filename), list_type + ) + name = (sub.name or "").strip() or filename + filename, name, duplicate_same_url = dedupe_subscription_identity( + filename, + name, + url, + list_type, + seen_filenames, + ) + if duplicate_same_url and invalidate_duplicates: + return None + if clone is None: + normalized.append(sub) + else: + normalized.append(clone(sub, name, url, filename, list_type)) + return normalized diff --git a/ui/opensnitch/plugins/list_subscriptions/res/__init__.py b/ui/opensnitch/plugins/list_subscriptions/res/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui new file mode 100644 index 0000000000..19bc2c6d4b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui @@ -0,0 +1,91 @@ + + + AttachedRulesDialog + + + + 0 + 0 + 760 + 420 + + + + Attached rules + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Create rule + + + + + + + Edit selected + + + + + + + Disable + + + + + + + Remove + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/blocklist.svg b/ui/opensnitch/plugins/list_subscriptions/res/blocklist.svg new file mode 100644 index 0000000000..9e3419f670 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/blocklist.svg @@ -0,0 +1,37 @@ + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui new file mode 100644 index 0000000000..2e3163ec13 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui @@ -0,0 +1,115 @@ + + + BulkEditDialog + + + + 0 + 0 + 640 + 360 + + + + Edit selected subscriptions + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Selected changes + + + + + + + + + + Choose which changes to apply to the selected subscriptions. + + + true + + + + + + + + Property + + + + + New value + + + + + + + + Qt::Horizontal + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Apply + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui new file mode 100644 index 0000000000..beaf354b22 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui @@ -0,0 +1,408 @@ + + + ListSubscriptionsDialog + + + + 0 + 0 + 1180 + 680 + + + + List subscriptions + + + + + + + + Enable list subscriptions plugin + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Create action file + + + + + + + Save + + + + + + + Reload + + + + + + + Start + + + + + + + Stop + + + + + + + Status + + + + + + + Runtime: inactive + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Defaults + + + + + + + + + + + + Lists directory + + + + + + + + + + Default interval + + + + + + + + + + + + + Default timeout + + + + + + + + + + + + + Default max size + + + + + + + + + + + + + Default User-Agent + + + + + + + + + + Node + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + List subscriptions + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Global actions + + + + + + + + + + + + Add subscription + + + + + + + Refresh all + + + + + + + Create global rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + QFrame::VLine + + + QFrame::Plain + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Selected subscriptions + + + + + + + + + + + + Edit + + + + + + + Delete + + + + + + + Refresh + + + + + + + Create rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + QFrame::HLine + + + QFrame::Plain + + + + + + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui new file mode 100644 index 0000000000..a9f028dc7c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui @@ -0,0 +1,70 @@ + + + StatusLogDialog + + + + 0 + 0 + 760 + 420 + + + + Status log + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui new file mode 100644 index 0000000000..54bc143ebf --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui @@ -0,0 +1,440 @@ + + + SubscriptionDialog + + + + 0 + 0 + 920 + 420 + + + + Subscription + + + + + + + + + + + true + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Settings + + + + + + + + + + + + Enabled + + + + + + + Name + + + + + + + + + + + + + + + + + URL + + + + + + + + + + + + + + + + + Filename + + + + + + + + + + + + + + + + + Format + + + + + + + + + + Groups + + + + + + + + + + + + + + + + + Interval + + + + + + + + + + + + + + + + + Timeout + + + + + + + + + + + + + + + + + Max size + + + + + + + + + + + + + + + + + + + + + + + + + true + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Metadata + + + + + + + + + + 12 + + + + + State + + + + + + + QFrame::VLine + + + QFrame::Plain + + + + + + + + + + + + + + Last checked + + + + + + + + + + + + + + Last updated + + + + + + + + + + + + + + Failures + + + + + + + + + + + + + + Error + + + + + + + + + + + + + + List file present + + + + + + + + + + + + + + List meta present + + + + + + + + + + + + + + List path + + + + + + + + + + true + + + + + + + Meta path + + + + + + + + + + true + + + + + + + + + + + + + + Qt::Horizontal + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Test URL + + + + + + + Cancel + + + + + + + Save + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui new file mode 100644 index 0000000000..91767cf75a --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui @@ -0,0 +1,95 @@ + + + SubscriptionStatusDialog + + + + 0 + 0 + 700 + 440 + + + + Subscription status + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + true + + + + + + + QFrame::NoFrame + + + true + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh + + + + + + + Edit + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui new file mode 100644 index 0000000000..f0427e52b0 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui @@ -0,0 +1,70 @@ + + + TextInspectDialog + + + + 0 + 0 + 760 + 420 + + + + Inspect + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py new file mode 100644 index 0000000000..7e1f069ef2 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py @@ -0,0 +1,42 @@ +import sys +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + # Keep static typing deterministic for linters/IDEs. + # Runtime still supports both PyQt6/PyQt5 below. + from PyQt6 import QtCore, QtGui, QtWidgets, uic + from PyQt6.QtCore import QCoreApplication as QC + from PyQt6.uic.load_ui import loadUiType as load_ui_type +else: + if "PyQt6" in sys.modules: + from PyQt6 import QtCore, QtGui, QtWidgets, uic + from PyQt6.QtCore import QCoreApplication as QC + from PyQt6.uic.load_ui import loadUiType as load_ui_type + elif "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui, QtWidgets, uic + from PyQt5.QtCore import QCoreApplication as QC + + load_ui_type = uic.loadUiType + else: + try: + from PyQt6 import QtCore, QtGui, QtWidgets, uic + from PyQt6.QtCore import QCoreApplication as QC + from PyQt6.uic.load_ui import loadUiType as load_ui_type + except Exception: + from PyQt5 import QtCore, QtGui, QtWidgets, uic + from PyQt5.QtCore import QCoreApplication as QC + + load_ui_type = uic.loadUiType + + +__all__ = [ + "QtCore", + "QtGui", + "QtWidgets", + "uic", + "QC", + "load_ui_type", + "Any", + "TYPE_CHECKING", +] + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py new file mode 100644 index 0000000000..a8bf5376b1 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py @@ -0,0 +1,279 @@ +import os +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.io.storage import ( + read_json_locked, + write_json_atomic_locked, +) +from opensnitch.plugins.list_subscriptions.models.action import MutableActionConfig +from opensnitch.plugins.list_subscriptions.models.config import PluginConfig +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults +from opensnitch.plugins.list_subscriptions.ui import QC +from opensnitch.plugins.list_subscriptions._utils import ( + DEFAULT_LISTS_DIR, + normalize_lists_dir, + safe_filename, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class ActionFileController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def load_action_file(self): + with self._dialog._table_view_controller.sorting_suspended(): + self._dialog._loading = True + self._dialog._status_controller.set_status("") + self._dialog._defaults_ui_controller.reload_nodes() + self._dialog.table.setRowCount(0) + self._dialog.create_file_button.setVisible(True) + self._dialog.lists_dir_edit.setText(DEFAULT_LISTS_DIR) + self._dialog.enable_plugin_check.setChecked(True) + self._dialog._runtime_controller.set_runtime_state(active=False) + self._dialog._global_defaults = GlobalDefaults.from_dict( + {}, lists_dir=DEFAULT_LISTS_DIR + ) + self._dialog._defaults_ui_controller.apply_defaults_to_widgets() + + if not os.path.exists(self._dialog._action_path): + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Action file not found. Click 'Create action file'." + ), + error=False, + ) + self._dialog._loading = False + return + + try: + data = read_json_locked(self._dialog._action_path) + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate("stats", "Error reading action file: {0}").format( + str(e) + ), + error=True, + ) + self._dialog._loading = False + return + + action_model = MutableActionConfig.from_action_dict( + data, lists_dir=DEFAULT_LISTS_DIR + ) + self._dialog._global_defaults = action_model.plugin.defaults + self._dialog.enable_plugin_check.setChecked(True) + self._dialog._runtime_controller.sync_runtime_binding_state() + self._dialog.lists_dir_edit.setText( + normalize_lists_dir(self._dialog._global_defaults.lists_dir) + ) + self._dialog._defaults_ui_controller.apply_defaults_to_widgets() + + normalized_subs = action_model.plugin.subscriptions + actions_obj = data.get("actions", {}) + action_cfg = ( + actions_obj.get("list_subscriptions", {}) + if isinstance(actions_obj, dict) + else {} + ) + plugin_cfg_raw = ( + action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {} + ) + plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {} + raw_subs = plugin_cfg.get("subscriptions") + migrated_legacy_group = False + if isinstance(raw_subs, list): + for item in raw_subs: + if ( + isinstance(item, dict) + and ("group" in item) + and ("groups" not in item) + ): + migrated_legacy_group = True + break + normalized_subs_dicts = [s.to_dict() for s in normalized_subs] + fixed_count = ( + 1 + if (isinstance(raw_subs, list) and raw_subs != normalized_subs_dicts) + else 0 + ) + + for sub in normalized_subs: + self._dialog._table_data_controller.append_row(sub) + + self._dialog._loading = False + self._dialog._table_data_controller.refresh_states() + self._dialog._selection_controller.update_selected_actions_state() + self._dialog.create_file_button.setVisible(False) + if migrated_legacy_group: + self._dialog._status_controller.append_log( + QC.translate( + "stats", + "Detected legacy 'group' fields and migrated them to 'groups'.", + ), + level="WARN", + ) + self.save_action_file() + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Migrated legacy 'group' entries to 'groups' and auto-saved configuration.", + ), + error=False, + ) + return + if fixed_count > 0: + self._dialog._status_controller.append_log( + QC.translate( + "stats", + "Normalized subscription fields while loading configuration.", + ), + level="WARN", + ) + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Loaded configuration with normalized subscription fields.", + ), + error=False, + ) + else: + self._dialog._status_controller.set_status( + QC.translate("stats", "List subscriptions configuration loaded."), + error=False, + ) + + def create_action_file(self): + try: + os.makedirs( + os.path.dirname(self._dialog._action_path), mode=0o700, exist_ok=True + ) + if not os.path.exists(self._dialog._action_path): + action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR) + write_json_atomic_locked( + self._dialog._action_path, + action_model.to_action_dict(), + ) + self.load_action_file() + self._dialog._status_controller.set_status( + QC.translate("stats", "Action file created."), + error=False, + ) + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate("stats", "Error creating action file: {0}").format(str(e)), + error=True, + ) + + def save_action_file(self): + if self._dialog._loading: + return + + if not os.path.exists(self._dialog._action_path): + self.create_action_file() + if not os.path.exists(self._dialog._action_path): + return + + subscriptions = self._dialog._table_data_controller.collect_subscriptions() + if subscriptions is None: + return + + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + try: + os.makedirs(lists_dir, mode=0o700, exist_ok=True) + except Exception: + pass + defaults = GlobalDefaults( + lists_dir=lists_dir, + interval=max(1, int(self._dialog.default_interval_spin.value())), + interval_units=self._dialog.default_interval_units.currentText(), + timeout=max(1, int(self._dialog.default_timeout_spin.value())), + timeout_units=self._dialog.default_timeout_units.currentText(), + max_size=max(1, int(self._dialog.default_max_size_spin.value())), + max_size_units=self._dialog.default_max_size_units.currentText(), + user_agent=(self._dialog.default_user_agent.text() or "").strip(), + ) + action_model = MutableActionConfig.default(lists_dir) + action_model.enabled = True + action_model.plugin.defaults = defaults + action_model.plugin.subscriptions = subscriptions + normalized_subscriptions = action_model.plugin.normalize_subscriptions( + invalidate_duplicates=True + ) + if normalized_subscriptions is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Invalid subscriptions: duplicate filename for the same URL.", + ), + error=True, + ) + return + action = action_model.to_action_dict() + + compiled_cfg = PluginConfig.from_dict( + action_model.plugin.to_dict(), + lists_dir=lists_dir, + invalidate_duplicates=True, + ) + if len(compiled_cfg.subscriptions) != len(normalized_subscriptions): + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Invalid subscriptions: URL and filename are mandatory." + ), + error=True, + ) + return + + for row, sub in enumerate(normalized_subscriptions): + self._dialog._table_data_controller.set_text_item( + row, + self._col("name"), + sub.name, + ) + self._dialog._table_data_controller.set_text_item( + row, + self._col("filename"), + safe_filename(sub.filename), + ) + + try: + write_json_atomic_locked(self._dialog._action_path, action) + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate("stats", "Error saving action file: {0}").format(str(e)), + error=True, + ) + return + + self._dialog._status_controller.append_log( + QC.translate( + "stats", + "Saving configuration: {0} subscriptions, runtime {1}.", + ).format( + len(normalized_subscriptions), + QC.translate("stats", "enabled") + if action_model.enabled + else QC.translate("stats", "disabled"), + ), + ) + self._dialog._runtime_controller.apply_runtime_state( + action_model.enabled + ) + self._dialog._table_data_controller.refresh_states() + self._dialog._status_controller.set_status( + QC.translate("stats", "List subscriptions configuration saved."), + error=False, + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py new file mode 100644 index 0000000000..81f6039724 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py @@ -0,0 +1,118 @@ +import os +from typing import Any + + +class AttachedRulesIndex: + def __init__(self) -> None: + self._snapshot: dict[str, list[dict[str, Any]]] = {} + + def snapshot(self) -> dict[str, list[dict[str, Any]]]: + return dict(self._snapshot) + + def set_from_snapshot_obj( + self, + snapshot: object, + ) -> dict[str, list[dict[str, Any]]]: + data: dict[str, list[dict[str, Any]]] = {} + if isinstance(snapshot, dict): + for key, value in snapshot.items(): + if not isinstance(key, str) or not isinstance(value, list): + continue + normalized_key = os.path.normpath(key) + items: list[dict[str, Any]] = [] + for entry in value: + if isinstance(entry, dict): + items.append(entry) + data[normalized_key] = items + self._snapshot = data + return data + + def apply_rule_editor_change(self, change: dict[str, Any]) -> bool: + addr = str(change.get("addr", "") or "").strip() + if addr == "": + return False + + old_name = str(change.get("old_name", "") or "").strip() + new_name = str(change.get("new_name", "") or "").strip() + enabled = bool(change.get("enabled", True)) + directories = [ + str(path).strip() + for path in list(change.get("directories", [])) + if str(path).strip() != "" + ] + + names_to_remove = {name for name in (old_name, new_name) if name != ""} + self._remove_rule_from_snapshot(addr, names_to_remove) + if new_name != "" and directories: + self._upsert_rule_in_snapshot( + addr=addr, + rule_name=new_name, + enabled=enabled, + directories=directories, + ) + return True + + def update_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> bool: + changed = False + for entries in self._snapshot.values(): + for entry in entries: + if ( + str(entry.get("addr", "")).strip() == addr + and str(entry.get("name", "")).strip() == rule_name + ): + entry["enabled"] = bool(enabled) + changed = True + return changed + + def remove_rule(self, addr: str, rule_name: str) -> None: + self._remove_rule_from_snapshot(addr, {rule_name}) + + def _remove_rule_from_snapshot(self, addr: str, rule_names: set[str]) -> None: + if not rule_names: + return + for directory, entries in list(self._snapshot.items()): + filtered = [ + entry + for entry in entries + if not ( + str(entry.get("addr", "")).strip() == addr + and str(entry.get("name", "")).strip() in rule_names + ) + ] + if filtered: + self._snapshot[directory] = filtered + else: + del self._snapshot[directory] + + def _upsert_rule_in_snapshot( + self, + *, + addr: str, + rule_name: str, + enabled: bool, + directories: list[str], + ) -> None: + normalized_dirs = [ + os.path.normpath(path) + for path in directories + if path.strip() + ] + if not normalized_dirs: + return + for directory in normalized_dirs: + entries = self._snapshot.setdefault(directory, []) + for entry in entries: + if ( + str(entry.get("addr", "")).strip() == addr + and str(entry.get("name", "")).strip() == rule_name + ): + entry["enabled"] = bool(enabled) + break + else: + entries.append( + { + "addr": addr, + "name": rule_name, + "enabled": bool(enabled), + } + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py new file mode 100644 index 0000000000..84e0a9215b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py @@ -0,0 +1,134 @@ +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.bulk_edit_dialog import ( + BulkEditDialog, +) +from opensnitch.plugins.list_subscriptions._utils import ( + INTERVAL_UNITS, + TIMEOUT_UNITS, + SIZE_UNITS, + display_str, + normalize_groups, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class BulkEditController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def bulk_edit(self, rows: list[int]): + if not rows: + return + dlg = BulkEditDialog( + self._dialog, + self._dialog._global_defaults, + groups=self._dialog._selection_controller.known_groups(), + selected_count=len(rows), + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + values = dlg.values() + with self._dialog._table_view_controller.sorting_suspended(): + for row in rows: + if values.get("enabled") is not None: + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is None: + enabled_item = self._dialog._table_data_controller.new_enabled_item(False) + self._dialog.table.setItem( + row, + self._col("enabled"), + enabled_item, + ) + enabled_item.setCheckState( + QtCore.Qt.CheckState.Checked + if bool(values["enabled"]) + else QtCore.Qt.CheckState.Unchecked + ) + if values.get("groups") is not None: + self._dialog._table_data_controller.set_text_item( + row, + self._col("group"), + ", ".join(normalize_groups(values["groups"])), + ) + if values.get("format") is not None: + self._dialog._table_data_controller.set_text_item( + row, + self._col("format"), + str(values["format"]), + ) + if values.get("apply_interval"): + self._dialog._table_data_controller.set_text_item( + row, + self._col("interval"), + display_str(values.get("interval")), + ) + interval_units = display_str(values.get("interval_units")) + self._dialog._table_data_controller.set_text_item( + row, + self._col("interval_units"), + interval_units, + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("interval_units"), + INTERVAL_UNITS, + interval_units, + ) + if values.get("apply_timeout"): + self._dialog._table_data_controller.set_text_item( + row, + self._col("timeout"), + display_str(values.get("timeout")), + ) + timeout_units = display_str(values.get("timeout_units")) + self._dialog._table_data_controller.set_text_item( + row, + self._col("timeout_units"), + timeout_units, + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("timeout_units"), + TIMEOUT_UNITS, + timeout_units, + ) + if values.get("apply_max_size"): + self._dialog._table_data_controller.set_text_item( + row, + self._col("max_size"), + display_str(values.get("max_size")), + ) + max_size_units = display_str(values.get("max_size_units")) + self._dialog._table_data_controller.set_text_item( + row, + self._col("max_size_units"), + max_size_units, + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("max_size_units"), + SIZE_UNITS, + max_size_units, + ) + self._dialog._table_data_controller.ensure_row_final_filename(row) + self._dialog._table_data_controller.update_row_sort_keys(row) + self._dialog._action_file_controller.save_action_file() + self._dialog._table_data_controller.refresh_states() + self._dialog._status_controller.set_status( + QC.translate("stats", "Updated {0} selected subscriptions.").format( + len(rows) + ), + error=False, + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py new file mode 100644 index 0000000000..f4286c0371 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING, Any + +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class ContextMenuController: + def __init__(self, *, dialog: "ListSubscriptionsDialog"): + self._dialog = dialog + + def open_table_context_menu(self, pos: Any): + rows = self._dialog._selection_controller.selected_rows() + if not rows: + row = self._dialog.table.rowAt(pos.y()) + if row >= 0: + self._dialog.table.selectRow(row) + rows = [row] + + menu = QtWidgets.QMenu(self._dialog.table) + viewport = self._dialog.table.viewport() + if viewport is None: + return + + if not rows: + act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting")) + act_reset_widths = menu.addAction( + QC.translate("stats", "Reset column widths") + ) + chosen = QtWidgets.QMenu.exec( + menu.actions(), + viewport.mapToGlobal(pos), + None, + menu, + ) + if chosen is act_reset_sort: + self._dialog._table_view_controller.reset_table_sort_for_current_tab() + elif chosen is act_reset_widths: + self._dialog._table_view_controller.reset_table_column_widths_for_current_tab() + return + + if len(rows) == 1: + act_inspect = menu.addAction(QC.translate("stats", "Inspect")) + act_edit = menu.addAction(QC.translate("stats", "Edit")) + act_remove = menu.addAction(QC.translate("stats", "Delete")) + act_refresh = menu.addAction(QC.translate("stats", "Refresh")) + act_rule = menu.addAction(QC.translate("stats", "Rules")) + menu.addSeparator() + act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting")) + act_reset_widths = menu.addAction( + QC.translate("stats", "Reset column widths") + ) + chosen = QtWidgets.QMenu.exec( + menu.actions(), + viewport.mapToGlobal(pos), + None, + menu, + ) + if chosen is act_inspect: + self._dialog._selection_controller.open_selected_inspector() + elif chosen is act_edit: + self._dialog._subscription_edit_controller.edit_selected_subscription() + elif chosen is act_remove: + self._dialog.remove_selected_subscription() + elif chosen is act_refresh: + self._dialog._table_data_controller.refresh_selected_now() + elif chosen is act_rule: + self._dialog._rules_attachment_controller.show_attached_rules_dialog() + elif chosen is act_reset_sort: + self._dialog._table_view_controller.reset_table_sort_for_current_tab() + elif chosen is act_reset_widths: + self._dialog._table_view_controller.reset_table_column_widths_for_current_tab() + return + + act_inspect = menu.addAction(QC.translate("stats", "Inspect")) + act_edit = menu.addAction(QC.translate("stats", "Edit")) + act_remove = menu.addAction(QC.translate("stats", "Delete")) + act_refresh = menu.addAction(QC.translate("stats", "Refresh")) + act_rule = menu.addAction(QC.translate("stats", "Create rule")) + menu.addSeparator() + act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting")) + act_reset_widths = menu.addAction(QC.translate("stats", "Reset column widths")) + chosen = QtWidgets.QMenu.exec( + menu.actions(), + viewport.mapToGlobal(pos), + None, + menu, + ) + if chosen is act_inspect: + self._dialog._selection_controller.open_selected_inspector() + elif chosen is act_edit: + self._dialog._bulk_edit_controller.bulk_edit(rows) + elif chosen is act_remove: + self._dialog.remove_selected_subscription() + elif chosen is act_refresh: + self._dialog._table_data_controller.refresh_selected_now() + elif chosen is act_rule: + self._dialog._rules_editor_controller.create_rule_from_selected() + elif chosen is act_reset_sort: + self._dialog._table_view_controller.reset_table_sort_for_current_tab() + elif chosen is act_reset_widths: + self._dialog._table_view_controller.reset_table_column_widths_for_current_tab() \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py new file mode 100644 index 0000000000..c5f8e68316 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py @@ -0,0 +1,77 @@ +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import _configure_spin_and_units +from opensnitch.plugins.list_subscriptions._utils import ( + INTERVAL_UNITS, + SIZE_UNITS, + TIMEOUT_UNITS, + normalize_unit, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class DefaultsUiController: + def __init__(self, *, dialog: "ListSubscriptionsDialog"): + self._dialog = dialog + + def reload_nodes(self): + self._dialog.nodes_combo.blockSignals(True) + self._dialog.nodes_combo.clear() + for addr in self._dialog._nodes.get_nodes(): + self._dialog.nodes_combo.addItem(addr, addr) + self._dialog.nodes_combo.blockSignals(False) + + def apply_defaults_to_widgets(self): + _configure_spin_and_units( + self._dialog.default_interval_spin, + self._dialog.default_interval_units, + value=int(self._dialog._global_defaults.interval), + unit_value=self._dialog._global_defaults.interval_units, + allowed_units=INTERVAL_UNITS, + fallback_unit="hours", + min_value=1, + ) + _configure_spin_and_units( + self._dialog.default_timeout_spin, + self._dialog.default_timeout_units, + value=int(self._dialog._global_defaults.timeout), + unit_value=self._dialog._global_defaults.timeout_units, + allowed_units=TIMEOUT_UNITS, + fallback_unit="seconds", + min_value=1, + ) + _configure_spin_and_units( + self._dialog.default_max_size_spin, + self._dialog.default_max_size_units, + value=int(self._dialog._global_defaults.max_size), + unit_value=self._dialog._global_defaults.max_size_units, + allowed_units=SIZE_UNITS, + fallback_unit="MB", + min_value=1, + ) + self._dialog.default_user_agent.setText( + (self._dialog._global_defaults.user_agent or "").strip() + ) + + def set_units_combo( + self, row: int, col: int, allowed: tuple[str, ...], value: str | None + ): + combo = QtWidgets.QComboBox() + combo.addItem("") + combo.addItems(allowed) + combo.setToolTip( + QC.translate( + "stats", + "Leave blank to inherit the global default for this subscription.", + ) + ) + if value is None or value.strip() == "": + combo.setCurrentIndex(0) + else: + combo.setCurrentText(normalize_unit(value, allowed, allowed[0])) + self._dialog.table.setCellWidget(row, col, combo) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py new file mode 100644 index 0000000000..c341dc5135 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py @@ -0,0 +1,378 @@ +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.text_inspect_dialog import ( + TextInspectDialog, +) +from opensnitch.plugins.list_subscriptions._utils import safe_filename, timestamp_sort_key + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class InspectorController: + def __init__( + self, + *, + dialog: "ListSubscriptionsDialog", + columns: dict[str, int], + error_preview_limit: int, + ): + self._dialog = dialog + self._cols = columns + self._error_preview_limit = error_preview_limit + + def _col(self, key: str): + return self._cols[key] + + def show_error_inspect_dialog(self): + text = (self._dialog._inspect_error_full_text or "").strip() + dlg = TextInspectDialog( + self._dialog, + title=QC.translate("stats", "Error details"), + text=text, + ) + dlg.exec() + + def set_inspector_toggle_icon(self): + style = self._dialog.style() + if style is None: + return + if not self._dialog._inspect_has_selection: + icon = style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft + ) + tip = QC.translate("stats", "Select a subscription to inspect") + self._dialog._inspect_toggle_button.setIcon(icon) + self._dialog._inspect_toggle_button.setToolTip(tip) + self._dialog._inspect_toggle_button.setEnabled(False) + return + if self._dialog._inspect_collapsed: + icon = style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft + ) + tip = QC.translate("stats", "Expand inspector") + else: + icon = style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_ArrowRight + ) + tip = QC.translate("stats", "Collapse inspector") + self._dialog._inspect_toggle_button.setEnabled(True) + self._dialog._inspect_toggle_button.setIcon(icon) + self._dialog._inspect_toggle_button.setToolTip(tip) + + def toggle_inspector_collapsed(self): + if not self._dialog._inspect_has_selection: + return + self._dialog._inspect_collapsed = not self._dialog._inspect_collapsed + if not self._dialog._inspect_panel.isVisible(): + return + if self._dialog._inspect_collapsed: + self._dialog._inspect_scroll.setVisible(False) + self._dialog._inspect_title_label.setVisible(False) + self._dialog._inspect_header_separator.setVisible(False) + self._dialog._inspect_panel.setMinimumWidth(36) + self._dialog._inspect_panel.setMaximumWidth(36) + total = max(36, self._dialog._table_inspect_splitter.width()) + self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36]) + else: + self._dialog._inspect_scroll.setVisible(True) + self._dialog._inspect_title_label.setVisible(True) + self._dialog._inspect_header_separator.setVisible(True) + self._dialog._inspect_panel.setMinimumWidth(240) + self._dialog._inspect_panel.setMaximumWidth(16777215) + total = max(300, self._dialog._table_inspect_splitter.width()) + width = min(self._dialog._inspect_default_width, max(280, total // 2)) + self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width]) + self.set_inspector_toggle_icon() + + def set_inspector_visible(self, visible: bool): + if not hasattr(self._dialog, "_inspect_panel"): + return + self._dialog._inspect_has_selection = bool(visible) + self._dialog._inspect_panel.setVisible(True) + if not self._dialog._inspect_has_selection: + self._dialog._inspect_collapsed = True + self._dialog._inspect_scroll.setVisible(False) + self._dialog._inspect_title_label.setVisible(False) + self._dialog._inspect_header_separator.setVisible(False) + self._dialog._inspect_panel.setMinimumWidth(36) + self._dialog._inspect_panel.setMaximumWidth(36) + total = max(36, self._dialog._table_inspect_splitter.width()) + self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36]) + self.set_inspector_toggle_icon() + return + if self._dialog._inspect_collapsed: + self._dialog._inspect_scroll.setVisible(False) + self._dialog._inspect_title_label.setVisible(False) + self._dialog._inspect_header_separator.setVisible(False) + self._dialog._inspect_panel.setMinimumWidth(36) + self._dialog._inspect_panel.setMaximumWidth(36) + total = max(36, self._dialog._table_inspect_splitter.width()) + self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36]) + else: + self._dialog._inspect_scroll.setVisible(True) + self._dialog._inspect_title_label.setVisible(True) + self._dialog._inspect_header_separator.setVisible(True) + self._dialog._inspect_panel.setMinimumWidth(240) + self._dialog._inspect_panel.setMaximumWidth(16777215) + total = max(300, self._dialog._table_inspect_splitter.width()) + width = min(self._dialog._inspect_default_width, max(240, total // 3)) + self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width]) + self.set_inspector_toggle_icon() + + def set_inspector_values( + self, + *, + row: int, + name: str, + url: str, + filename: str, + meta: dict[str, str], + ): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + enabled = enabled_item is not None and ( + enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ) + interval_value = self._dialog._table_data_controller.cell_text( + row, self._col("interval") + ) + interval_units = self._dialog._table_data_controller.cell_text( + row, self._col("interval_units") + ) + timeout_value = self._dialog._table_data_controller.cell_text( + row, self._col("timeout") + ) + timeout_units = self._dialog._table_data_controller.cell_text( + row, self._col("timeout_units") + ) + max_size_value = self._dialog._table_data_controller.cell_text( + row, self._col("max_size") + ) + max_size_units = self._dialog._table_data_controller.cell_text( + row, self._col("max_size_units") + ) + values = { + "enabled": QC.translate("stats", "Yes") if enabled else QC.translate("stats", "No"), + "name": name, + "url": url, + "filename": filename, + "format": self._dialog._table_data_controller.cell_text( + row, self._col("format") + ), + "groups": self._dialog._table_data_controller.cell_text( + row, self._col("group") + ), + "interval": " ".join( + part for part in (interval_value, interval_units) if (part or "").strip() != "" + ), + "timeout": " ".join( + part for part in (timeout_value, timeout_units) if (part or "").strip() != "" + ), + "max_size": " ".join( + part for part in (max_size_value, max_size_units) if (part or "").strip() != "" + ), + "state": meta.get("state", ""), + "last_checked": meta.get("last_checked", ""), + "last_updated": meta.get("last_updated", ""), + "failures": meta.get("failures", ""), + "error": meta.get("error", ""), + "list_path": meta.get("list_path", ""), + "meta_path": meta.get("meta_path", ""), + } + for key, value in values.items(): + label = self._dialog._inspect_value_labels.get(key) + if label is None: + continue + text = (str(value or "")).strip() or "-" + if key == "error": + self.set_error_preview(text) + continue + if key == "state": + label.setText(text) + label.setStyleSheet( + f"color: {self.state_bucket_color(text).name()};" + ) + continue + label.setText(text) + + def state_bucket_color(self, state: str): + normalized = (state or "").strip().lower() + if normalized in ("updated", "not_modified"): + return self._dialog._table_data_controller.state_text_color("updated") + if normalized == "pending": + return self._dialog._table_data_controller.state_text_color("pending") + return self._dialog._table_data_controller.state_text_color("error") + + def set_error_preview(self, text: str): + error_label = self._dialog._inspect_value_labels.get("error") + if error_label is None: + return + + normalized = (text or "").strip() + if normalized in ("", "-"): + self._dialog._inspect_error_full_text = "" + error_label.setText("-") + error_label.setToolTip("") + if self._dialog._inspect_error_button is not None: + self._dialog._inspect_error_button.setVisible(False) + return + + self._dialog._inspect_error_full_text = normalized + if len(normalized) <= self._error_preview_limit: + error_label.setText(normalized) + error_label.setToolTip(normalized) + if self._dialog._inspect_error_button is not None: + self._dialog._inspect_error_button.setVisible(False) + return + + preview = normalized[: self._error_preview_limit - 1].rstrip() + "..." + error_label.setText(preview) + error_label.setToolTip(normalized) + if self._dialog._inspect_error_button is not None: + self._dialog._inspect_error_button.setVisible(True) + self._dialog._inspect_error_button.setEnabled(True) + + def set_inspector_multi_selection_mode(self, enabled: bool): + if enabled: + self._dialog._inspect_details_widget.setVisible(False) + self._dialog._inspect_summary_widget.setVisible(True) + return + self._dialog._inspect_details_widget.setVisible(True) + self._dialog._inspect_summary_widget.setVisible(False) + + def set_inspector_summary_values(self, rows: list[int]): + selected_count = len(rows) + enabled_count = 0 + healthy_count = 0 + pending_count = 0 + problematic_count = 0 + total_failures = 0 + with_errors = 0 + newest_checked = "" + oldest_checked = "" + newest_key = None + oldest_key = None + + for row in rows: + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is not None and ( + enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ): + enabled_count += 1 + + meta = self._dialog._table_data_controller.row_meta_snapshot(row) + state = (meta.get("state", "") or "").strip().lower() + if state in ("updated", "not_modified"): + healthy_count += 1 + elif state == "pending": + pending_count += 1 + else: + problematic_count += 1 + + failures_text = (meta.get("failures", "") or "").strip() + try: + total_failures += int(failures_text or "0") + except Exception: + pass + + if (meta.get("error", "") or "").strip() != "": + with_errors += 1 + + checked = (meta.get("last_checked", "") or "").strip() + if checked == "": + continue + checked_key = timestamp_sort_key(checked) + if newest_key is None or checked_key > newest_key: + newest_key = checked_key + newest_checked = checked + if oldest_key is None or checked_key < oldest_key: + oldest_key = checked_key + oldest_checked = checked + + values = { + "selected": str(selected_count), + "enabled": f"{enabled_count}/{selected_count}", + "healthy": str(healthy_count), + "pending": str(pending_count), + "problematic": str(problematic_count), + "failures": str(total_failures), + "with_errors": str(with_errors), + "newest_checked": newest_checked, + "oldest_checked": oldest_checked, + } + for key, value in values.items(): + label = self._dialog._inspect_summary_labels.get(key) + if label is None: + continue + label.setText((value or "").strip() or "-") + + def update_inspector_panel(self): + if not hasattr(self._dialog, "_inspect_panel"): + return + + rows = self._dialog._selection_controller.selected_rows() + if not rows: + self.set_inspector_visible(False) + return + + self.set_inspector_multi_selection_mode(len(rows) > 1) + if len(rows) > 1: + self.set_inspector_summary_values(rows) + self.set_inspector_visible(True) + return + + row = rows[0] + name = self._dialog._table_data_controller.cell_text(row, self._col("name")) + url = self._dialog._table_data_controller.cell_text(row, self._col("url")) + filename = safe_filename( + self._dialog._table_data_controller.cell_text(row, self._col("filename")) + ) + meta = self._dialog._table_data_controller.row_meta_snapshot(row) + self.set_inspector_values( + row=row, + name=name, + url=url, + filename=filename, + meta=meta, + ) + self.set_inspector_visible(True) + + def handle_table_selection_changed(self, *_): + self._dialog._selection_controller.update_selected_actions_state() + self.update_inspector_panel() + + def on_subscription_state_refreshed(self, url: str, filename: str, meta: dict[str, str]): + if not hasattr(self._dialog, "_inspect_panel"): + return + if not self._dialog._inspect_panel.isVisible(): + return + + rows = self._dialog._selection_controller.selected_rows() + if not rows: + self.set_inspector_visible(False) + return + if len(rows) > 1: + changed_row = self._dialog._subscription_status_controller.find_row_by_identity( + url, + filename, + ) + if changed_row in rows: + self.set_inspector_summary_values(rows) + return + row = rows[0] + row_url = self._dialog._table_data_controller.cell_text(row, self._col("url")) + row_filename = safe_filename( + self._dialog._table_data_controller.cell_text(row, self._col("filename")) + ) + if row_url != url or row_filename != filename: + return + self.set_inspector_values( + row=row, + name=self._dialog._table_data_controller.cell_text(row, self._col("name")), + url=row_url, + filename=row_filename, + meta=meta, + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py new file mode 100644 index 0000000000..473592731c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py @@ -0,0 +1,927 @@ +import os +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.attached_rules_dialog import ( + AttachedRulesDialog, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.attached_rules_index import ( + AttachedRulesIndex, +) +from opensnitch.plugins.list_subscriptions.ui.workers import attached_rules_snapshot_worker as attached_rules_workers +from opensnitch.database import Database +from opensnitch.config import Config +from opensnitch.rules import Rule, Rules +from opensnitch.proto import ui_pb2 as ui_pb2 + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +def _is_memory_db_file(db_file: str) -> bool: + value = str(db_file or "").strip().lower() + if value in ("", ":memory:", "file::memory:"): + return True + if value.startswith("file::memory"): + return True + return "mode=memory" in value + + +def _is_shared_memory_db_file(db_file: str) -> bool: + value = str(db_file or "").strip().lower() + # OpenSnitch's default in-memory URI uses file::memory: and enables + # shared-cache via connection options at DB initialization time. + if value.startswith("file::memory:"): + return True + if "cache=shared" not in value: + return False + return "mode=memory" in value or value.startswith("file::memory:") + + +def _is_sqlite_uri(db_file: str) -> bool: + return str(db_file or "").strip().lower().startswith("file:") + + +ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS = 2000 +ATTACHED_RULES_SNAPSHOT_WARN_RULES = 100 +ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS = 6 * 60 * 60 +ATTACHED_RULES_COUNT_BASELINE_MS = 8 +ATTACHED_RULES_TIMEOUT_MAX_MS = 8000 +ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW = 0.1 +ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW = 0.05 +ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR = 3.0 +ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS = 2000 + + +class RulesAttachmentController: + def __init__(self, *, dialog: "ListSubscriptionsDialog"): + self._dialog = dialog + self._rules = Rules.instance() + self._rules_index = AttachedRulesIndex() + self._snapshot_cache_dirty = False + self._snapshot_worker: Any = None + self._snapshot_thread: QtCore.QThread | None = None + self._snapshot_phase = "idle" + self._snapshot_callbacks: list[ + Callable[[dict[str, list[dict[str, Any]]]], None] + ] = [] + self._rules_count_cache: dict[ + tuple[str, tuple[str, ...]], + tuple[int | None, bool, float], + ] = {} + self._pending_snapshot_db_file = "" + self._pending_snapshot_local_nodes: list[str] = [] + self._pending_snapshot_in_memory_db = False + self._pending_snapshot_shared_memory_db = False + self._pending_snapshot_count_over_limit = False + self._pending_snapshot_row_count = 0 + self._count_query_delay_factor = 1.0 + self._fetch_ms_per_row = ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW + self._process_ms_per_row = ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW + self._snapshot_timeout_timer = QtCore.QTimer(dialog) + self._snapshot_timeout_timer.setSingleShot(True) + self._snapshot_timeout_timer.setInterval(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS) + self._snapshot_timeout_timer.timeout.connect(self._on_snapshot_worker_timeout) + self._snapshot_timed_out = False + try: + self._dialog._nodes.nodesUpdated.connect(self._on_nodes_updated) + except Exception: + pass + try: + self._rules.updated.connect(self._on_rules_updated) + except Exception: + pass + dialog.destroyed.connect(self._on_dialog_destroyed) + + def _on_dialog_destroyed(self, *_args): + worker = self._snapshot_worker + thread = self._snapshot_thread + if worker is None or thread is None: + return + try: + worker.stop() + except Exception: + pass + self._snapshot_timeout_timer.stop() + if thread.isRunning(): + thread.quit() + thread.wait(300) + + def attached_rules_snapshot(self): + return self._rules_index.snapshot() + + def has_active_snapshot(self) -> bool: + thread = self._snapshot_thread + return bool(thread is not None and thread.isRunning()) + + def cancel_active_snapshot(self) -> None: + worker = self._snapshot_worker + thread = self._snapshot_thread + self._snapshot_timeout_timer.stop() + if worker is None or thread is None or not thread.isRunning(): + return + try: + stop = getattr(worker, "stop", None) + if callable(stop): + stop() + except Exception: + pass + thread.quit() + self._snapshot_phase = "idle" + if self._snapshot_callbacks: + snapshot = self._rules_index.snapshot() + callbacks = self._snapshot_callbacks[:] + self._snapshot_callbacks.clear() + for callback in callbacks: + callback(snapshot) + + def apply_rule_editor_change(self, change: dict[str, Any]) -> None: + if not self._rules_index.apply_rule_editor_change(change): + self._mark_snapshot_cache_dirty("rule-editor-missing-addr") + return + self._snapshot_cache_dirty = False + + def update_cached_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> None: + if not self._rules_index.update_rule_enabled(addr, rule_name, enabled): + self._mark_snapshot_cache_dirty("rule-enabled-miss") + return + self._snapshot_cache_dirty = False + + def remove_cached_rule(self, addr: str, rule_name: str) -> None: + self._rules_index.remove_rule(addr, rule_name) + self._snapshot_cache_dirty = False + + def _mark_snapshot_cache_dirty(self, reason: str) -> None: + self._snapshot_cache_dirty = True + self._rules_count_cache.clear() + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules cache invalidated: {0}", + ).format(reason), + origin="ui:attached-rules", + ) + + def invalidate_snapshot_cache(self, reason: str) -> None: + self._mark_snapshot_cache_dirty(reason) + + def _on_nodes_updated(self, _count: int) -> None: + self._mark_snapshot_cache_dirty("nodes-updated") + + def _on_rules_updated(self, _value: int) -> None: + self._mark_snapshot_cache_dirty("rules-updated") + + def _attached_rules_snapshot_sync(self): + attached_rules_by_dir: dict[str, list[dict[str, Any]]] = {} + seen_entries: set[tuple[str, str, str]] = set() + for addr in self._dialog._nodes.get().keys(): + try: + if not self._dialog._nodes.is_local(addr): + continue + except Exception: + continue + + records = self._dialog._nodes.get_rules(addr) + if records is None or records == -1: + continue + + while records.next(): + try: + rule = cast(ui_pb2.Rule, Rule.new_from_records(records)) + except Exception: + continue + + rule_name = str(getattr(rule, "name", "") or "").strip() + if rule_name == "": + continue + rule_enabled = bool(getattr(rule, "enabled", True)) + + if rule.operator.operand == Config.OPERAND_LIST_DOMAINS: + direct = os.path.normpath(str(rule.operator.data or "").strip()) + if direct != "": + entry_key = (direct, addr, rule_name) + if entry_key not in seen_entries: + seen_entries.add(entry_key) + attached_rules_by_dir.setdefault(direct, []).append( + { + "addr": addr, + "name": rule_name, + "enabled": rule_enabled, + } + ) + + for operator in getattr(rule.operator, "list", []): + if operator.operand != Config.OPERAND_LIST_DOMAINS: + continue + nested = os.path.normpath(str(operator.data or "").strip()) + if nested != "": + entry_key = (nested, addr, rule_name) + if entry_key not in seen_entries: + seen_entries.add(entry_key) + attached_rules_by_dir.setdefault(nested, []).append( + { + "addr": addr, + "name": rule_name, + "enabled": rule_enabled, + } + ) + + return attached_rules_by_dir + + def _on_snapshot_worker_done(self, snapshot: object) -> None: + self._snapshot_timeout_timer.stop() + self._snapshot_phase = "idle" + actual_snapshot: object = snapshot + if isinstance(snapshot, dict) and "snapshot" in snapshot and "elapsed_ms" in snapshot: + raw_elapsed = snapshot.get("elapsed_ms") + raw_row_count = snapshot.get("row_count") + if isinstance(raw_elapsed, int): + row_count = raw_row_count if isinstance(raw_row_count, int) else 0 + self._update_process_ms_per_row(raw_elapsed, row_count) + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules process: {0} ms for {1} rows ({2:.3f} ms/row EMA)", + ).format(raw_elapsed, row_count, self._process_ms_per_row), + origin="ui:attached-rules", + ) + actual_snapshot = snapshot.get("snapshot") or {} + data = self._rules_index.set_from_snapshot_obj(actual_snapshot) + self._snapshot_cache_dirty = False + if not self._snapshot_callbacks: + return + callbacks = self._snapshot_callbacks[:] + self._snapshot_callbacks.clear() + for callback in callbacks: + callback(data) + + def _on_snapshot_worker_stopped( + self, + worker: Any, + thread: QtCore.QThread, + ) -> None: + if self._snapshot_worker is worker: + self._snapshot_worker = None + if self._snapshot_thread is thread: + self._snapshot_thread = None + + def _on_snapshot_worker_timeout(self) -> None: + worker = self._snapshot_worker + thread = self._snapshot_thread + if worker is None or thread is None or not thread.isRunning(): + return + self._snapshot_timed_out = True + self._snapshot_phase = "timeout" + try: + stop = getattr(worker, "stop", None) + if callable(stop): + stop() + except Exception: + pass + thread.quit() + fallback_snapshot = self._rules_index.snapshot() + if fallback_snapshot: + self._dialog._status_controller.warn( + QC.translate( + "stats", + "Attached-rules lookup timed out; showing cached results.", + ), + origin="ui:attached-rules", + ) + else: + self._dialog._status_controller.warn( + QC.translate( + "stats", + "Attached-rules lookup timed out; showing no results.", + ), + origin="ui:attached-rules", + ) + callbacks = self._snapshot_callbacks[:] + self._snapshot_callbacks.clear() + for callback in callbacks: + callback(fallback_snapshot) + + def _snapshot_db_mode(self, db_file: str) -> tuple[str, str]: + if _is_shared_memory_db_file(db_file): + return "memory-shared", "async" + if _is_memory_db_file(db_file): + return "memory-private", "sync" + if _is_sqlite_uri(db_file): + return "file-uri", "async" + return "file", "async" + + def _count_cache_key(self, db_file: str, local_nodes: list[str]) -> tuple[str, tuple[str, ...]]: + return db_file, tuple(sorted(local_nodes)) + + def _get_cached_rules_count( + self, + db_file: str, + local_nodes: list[str], + ) -> tuple[int | None, bool] | None: + key = self._count_cache_key(db_file, local_nodes) + cached = self._rules_count_cache.get(key) + if cached is None: + return None + count, over_limit, ts = cached + if (time.monotonic() - ts) > ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS: + self._rules_count_cache.pop(key, None) + return None + return count, over_limit + + def _set_cached_rules_count( + self, + db_file: str, + local_nodes: list[str], + count: int | None, + over_limit: bool, + ) -> None: + key = self._count_cache_key(db_file, local_nodes) + self._rules_count_cache[key] = (count, over_limit, time.monotonic()) + + def _start_stage_worker( + self, + *, + worker: Any, + done_signal_name: str, + done_handler: Callable[[object], None], + thread_name: str, + ) -> None: + worker_thread = QtCore.QThread(self._dialog) + worker_thread.setObjectName(thread_name) + worker.moveToThread(worker_thread) + self._snapshot_worker = worker + self._snapshot_thread = worker_thread + + worker_thread.started.connect(worker.run) + getattr(worker, done_signal_name).connect(done_handler) + worker.finished.connect(worker_thread.quit) + worker.finished.connect(worker.deleteLater) + worker_thread.finished.connect( + lambda w=worker, t=worker_thread: self._on_snapshot_worker_stopped(w, t) + ) + worker_thread.finished.connect(worker_thread.deleteLater) + worker_thread.start() + + def _confirm_potential_snapshot_delay( + self, + *, + estimated_rules: int | None, + count_over_limit: bool, + in_memory_db: bool, + shared_memory_db: bool, + ) -> bool: + may_freeze = in_memory_db and not shared_memory_db + estimated_delay_ms: int | None = None + if estimated_rules is not None and estimated_rules > 0: + estimated_delay_ms = int( + estimated_rules * (self._fetch_ms_per_row + self._process_ms_per_row) + ) + may_delay = count_over_limit or ( + estimated_delay_ms is not None + and estimated_delay_ms >= ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS + ) + + if not may_freeze and not may_delay: + return True + + if may_freeze and count_over_limit and estimated_rules is not None: + message = QC.translate( + "stats", + "Loading attached rules may freeze the UI briefly (at least {0} rules on local nodes). Continue?", + ).format(estimated_rules) + elif may_freeze and estimated_rules is not None: + message = QC.translate( + "stats", + "Loading attached rules may freeze the UI briefly (about {0} rules on local nodes). Continue?", + ).format(estimated_rules) + elif may_freeze: + message = QC.translate( + "stats", + "Loading attached rules may freeze the UI briefly on this setup. Continue?", + ) + elif count_over_limit and estimated_rules is not None: + message = QC.translate( + "stats", + "Loading attached rules may take longer than usual (at least {0} rules on local nodes). Continue?", + ).format(estimated_rules) + elif estimated_rules is not None and estimated_delay_ms is not None: + message = QC.translate( + "stats", + "Loading attached rules may take longer than usual (~{0:.1f}s, {1} rules on local nodes). Continue?", + ).format(max(0.1, estimated_delay_ms / 1000.0), estimated_rules) + else: + message = QC.translate( + "stats", + "Loading attached rules may take longer than usual. Continue?", + ) + + reply = QtWidgets.QMessageBox.question( + self._dialog, + QC.translate("stats", "Attached rules lookup"), + message, + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No, + ) + return reply == QtWidgets.QMessageBox.StandardButton.Yes + + def _update_count_query_delay_factor(self, elapsed_ms: int) -> None: + sample = max(0.25, min(4.0, elapsed_ms / ATTACHED_RULES_COUNT_BASELINE_MS)) + self._count_query_delay_factor = (0.7 * self._count_query_delay_factor) + (0.3 * sample) + + def _update_fetch_ms_per_row(self, elapsed_ms: int, row_count: int) -> None: + if row_count < 1: + return + sample = max(0.001, min(50.0, elapsed_ms / row_count)) + self._fetch_ms_per_row = 0.7 * self._fetch_ms_per_row + 0.3 * sample + + def _update_process_ms_per_row(self, elapsed_ms: int, row_count: int) -> None: + if row_count < 1: + return + sample = max(0.001, min(50.0, elapsed_ms / row_count)) + self._process_ms_per_row = 0.7 * self._process_ms_per_row + 0.3 * sample + + def _snapshot_timeout_interval_ms(self) -> int: + count = self._pending_snapshot_row_count + if count > 0: + estimated = int( + count + * (self._fetch_ms_per_row + self._process_ms_per_row) + * ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR + ) + return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, estimated)) + # fallback: scale base timeout by count query delay factor + scaled = int(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS * max(self._count_query_delay_factor, 0.75)) + return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, scaled)) + + def refresh_attached_rules_snapshot_async( + self, + callback: Callable[[dict[str, list[dict[str, Any]]]], None], + ) -> None: + self._snapshot_callbacks.append(callback) + self._snapshot_timed_out = False + thread = self._snapshot_thread + if thread is not None and thread.isRunning(): + return + + local_nodes: list[str] = [] + for addr in self._dialog._nodes.get().keys(): + try: + if self._dialog._nodes.is_local(addr): + local_nodes.append(addr) + except Exception: + continue + + db_file = str(Database.instance().get_db_file() or "").strip() + in_memory_db = _is_memory_db_file(db_file) + shared_memory_db = _is_shared_memory_db_file(db_file) + self._pending_snapshot_db_file = db_file + self._pending_snapshot_local_nodes = local_nodes + self._pending_snapshot_in_memory_db = in_memory_db + self._pending_snapshot_shared_memory_db = shared_memory_db + db_mode, snapshot_mode = self._snapshot_db_mode(db_file) + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules snapshot DB mode: {0} ({1})", + ).format(db_mode, snapshot_mode), + origin="ui:attached-rules", + ) + if self._snapshot_cache_dirty: + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules cache is dirty; forcing fresh snapshot.", + ), + origin="ui:attached-rules", + ) + # Three DB modes: + # 1) classic private in-memory -> no detached worker; use sync snapshot + # 2) shared in-memory URI -> detached worker is safe + # 3) file DB (path or file: URI) -> detached worker is safe + if in_memory_db and not shared_memory_db: + if not self._confirm_potential_snapshot_delay( + estimated_rules=None, + count_over_limit=False, + in_memory_db=in_memory_db, + shared_memory_db=shared_memory_db, + ): + snapshot = self._rules_index.snapshot() + callbacks = self._snapshot_callbacks[:] + self._snapshot_callbacks.clear() + for pending_callback in callbacks: + pending_callback(snapshot) + return + snapshot = self._attached_rules_snapshot_sync() + self._on_snapshot_worker_done(snapshot) + return + + cached_rules_count = self._get_cached_rules_count(db_file, local_nodes) + if cached_rules_count is None: + self._snapshot_phase = "count" + count_worker_cls = getattr(attached_rules_workers, "AttachedRulesCountWorker") + count_worker = count_worker_cls( + db_file=db_file, + local_nodes=local_nodes, + ) + self._start_stage_worker( + worker=count_worker, + done_signal_name="count_done", + done_handler=self._on_rules_count_done, + thread_name="AttachedRulesCountWorkerThread", + ) + return + + cached_count, cached_over_limit = cached_rules_count + self._pending_snapshot_count_over_limit = cached_over_limit + self._continue_snapshot_after_count(cached_count) + + def _continue_snapshot_after_count(self, estimated_rules: int | None) -> None: + if not self._confirm_potential_snapshot_delay( + estimated_rules=estimated_rules, + count_over_limit=self._pending_snapshot_count_over_limit, + in_memory_db=self._pending_snapshot_in_memory_db, + shared_memory_db=self._pending_snapshot_shared_memory_db, + ): + snapshot = self._rules_index.snapshot() + callbacks = self._snapshot_callbacks[:] + self._snapshot_callbacks.clear() + for pending_callback in callbacks: + pending_callback(snapshot) + return + + self._pending_snapshot_row_count = estimated_rules if estimated_rules is not None else 0 + self._snapshot_timeout_timer.setInterval(self._snapshot_timeout_interval_ms()) + self._snapshot_timeout_timer.start() + self._snapshot_phase = "fetch" + fetch_worker_cls = getattr(attached_rules_workers, "AttachedRulesFetchWorker") + fetch_worker = fetch_worker_cls( + db_file=self._pending_snapshot_db_file, + local_nodes=self._pending_snapshot_local_nodes, + ) + self._start_stage_worker( + worker=fetch_worker, + done_signal_name="rows_done", + done_handler=self._on_snapshot_rows_fetched, + thread_name="AttachedRulesFetchWorkerThread", + ) + + def _on_rules_count_done(self, count_obj: object) -> None: + db_file = self._pending_snapshot_db_file + local_nodes = self._pending_snapshot_local_nodes + count: int | None = None + elapsed_ms: int | None = None + over_limit = False + if isinstance(count_obj, dict): + raw_count = count_obj.get("count") + raw_elapsed = count_obj.get("elapsed_ms") + raw_over_limit = count_obj.get("over_limit") + count = raw_count if isinstance(raw_count, int) else None + elapsed_ms = raw_elapsed if isinstance(raw_elapsed, int) else None + over_limit = bool(raw_over_limit) + elif isinstance(count_obj, int): + count = count_obj + + if elapsed_ms is not None: + self._update_count_query_delay_factor(elapsed_ms) + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules count query: {0} ms (delay factor {1:.2f})", + ).format(elapsed_ms, self._count_query_delay_factor), + origin="ui:attached-rules", + ) + self._pending_snapshot_count_over_limit = over_limit + self._set_cached_rules_count(db_file, local_nodes, count, over_limit) + self._continue_snapshot_after_count(count) + + def _on_snapshot_rows_fetched(self, rows_obj: object) -> None: + rows: list = [] + if isinstance(rows_obj, dict): + raw_rows = rows_obj.get("rows") + raw_elapsed = rows_obj.get("elapsed_ms") + raw_row_count = rows_obj.get("row_count") + rows = raw_rows if isinstance(raw_rows, list) else [] + if isinstance(raw_elapsed, int): + actual_count = raw_row_count if isinstance(raw_row_count, int) else len(rows) + self._update_fetch_ms_per_row(raw_elapsed, actual_count) + self._dialog._status_controller.debug( + QC.translate( + "stats", + "Attached-rules fetch: {0} ms for {1} rows ({2:.3f} ms/row EMA)", + ).format(raw_elapsed, actual_count, self._fetch_ms_per_row), + origin="ui:attached-rules", + ) + elif isinstance(rows_obj, list): + rows = rows_obj + self._snapshot_phase = "process" + process_worker_cls = getattr(attached_rules_workers, "AttachedRulesProcessWorker") + process_worker = process_worker_cls(rows=rows) + self._start_stage_worker( + worker=process_worker, + done_signal_name="snapshot_done", + done_handler=self._on_snapshot_worker_done, + thread_name="AttachedRulesSnapshotWorkerThread", + ) + + def show_attached_rules_dialog(self): + rows = self._dialog._selection_controller.selected_rows() + if len(rows) != 1: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select a single subscription row first."), + error=True, + ) + return + + row = rows[0] + + def _open_dialog(snapshot: dict[str, list[dict[str, Any]]]) -> None: + if not self._dialog.isVisible(): + return + + def _get_attached_rules() -> list[dict[str, Any]]: + from opensnitch.plugins.list_subscriptions._utils import ( + DEFAULT_LISTS_DIR, + normalize_groups, + normalize_lists_dir, + safe_filename, + ) + + tdc = self._dialog._table_data_controller + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + filename = safe_filename(tdc.cell_text(row, tdc._col("filename"))) + list_type = (tdc.cell_text(row, tdc._col("format")) or "hosts").strip().lower() + groups = normalize_groups(tdc.cell_text(row, tdc._col("group"))) + return self.aggregate_attached_rules( + tdc.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + attached_rules_by_dir=snapshot, + include_disabled=True, + ) + ) + + dlg = AttachedRulesDialog( + self._dialog, + get_attached_rules=_get_attached_rules, + on_create_rule=self._dialog._rules_editor_controller.create_rule_from_selected, + on_edit_rule=self.edit_attached_rule_entry, + on_toggle_rule=self.toggle_attached_rule_entry, + on_remove_rule=self.remove_attached_rule_entry, + ) + dlg.exec() + + self._dialog._status_controller.set_status( + QC.translate("stats", "Loading attached rules..."), + error=False, + log=False, + ) + def _on_snapshot(snapshot: dict[str, list[dict[str, Any]]]) -> None: + timed_out = self._snapshot_timed_out + self._snapshot_timed_out = False + if timed_out: + if snapshot: + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Attached-rules lookup timed out; showing cached results.", + ), + error=True, + origin="ui:attached-rules", + ) + else: + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Attached-rules lookup timed out; showing no results.", + ), + error=True, + origin="ui:attached-rules", + ) + else: + self._dialog._status_controller.set_status("", error=False, log=False) + _open_dialog(snapshot) + + self.refresh_attached_rules_snapshot_async(_on_snapshot) + + def attached_rule_scope_parts(self, source: str): + normalized = (source or "").strip() + if normalized == "subscription": + return "single", "" + if normalized == "all": + return "all", "" + if normalized.startswith("group:"): + return "group", normalized.split(":", 1)[1].strip() + return normalized or "other", "" + + def aggregate_attached_rules( + self, + attached_rules: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + aggregated: dict[tuple[str, str], dict[str, Any]] = {} + for entry in attached_rules: + addr = str(entry.get("addr", "")).strip() + name = str(entry.get("name", "")).strip() + if addr == "" or name == "": + continue + key = (addr, name) + current = aggregated.get(key) + if current is None: + current = { + "addr": addr, + "name": name, + "enabled": bool(entry.get("enabled", True)), + "single": False, + "all": False, + "groups": set(), + } + aggregated[key] = current + else: + current["enabled"] = bool(entry.get("enabled", True)) + scope_kind, scope_value = self.attached_rule_scope_parts( + str(entry.get("source", "")) + ) + if scope_kind == "single": + current["single"] = True + elif scope_kind == "all": + current["all"] = True + elif scope_kind == "group" and scope_value != "": + from typing import cast + cast(set[str], current["groups"]).add(scope_value) + aggregated_rows = list(aggregated.values()) + for entry in aggregated_rows: + entry["groups"] = sorted(entry["groups"]) + aggregated_rows.sort(key=lambda item: (item["name"].lower(), item["addr"])) + return aggregated_rows + + def rule_attachment_scope_summary(self, matches: list[dict[str, Any]]): + has_single = False + has_all = False + groups: set[str] = set() + other_sources: set[str] = set() + + for entry in matches: + scope_kind, scope_value = self.attached_rule_scope_parts( + str(entry.get("source", "")) + ) + if scope_kind == "single": + has_single = True + elif scope_kind == "all": + has_all = True + elif scope_kind == "group" and scope_value != "": + groups.add(scope_value) + elif scope_kind != "": + other_sources.add(scope_kind) + + parts: list[str] = [] + if has_single: + parts.append(QC.translate("stats", "single")) + if has_all: + parts.append(QC.translate("stats", "all")) + if groups: + parts.append( + QC.translate("stats", "groups: {0}").format( + ", ".join(sorted(groups)) + ) + ) + if other_sources: + parts.extend(sorted(other_sources)) + return ", ".join(parts) + + def rule_entry_identity(self, entry: dict[str, Any]): + addr = str(entry.get("addr", "")).strip() + name = str(entry.get("name", "")).strip() + if addr == "" or name == "": + return None + return addr, name + + def find_rule_record(self, addr: str, rule_name: str): + records = self._dialog._nodes.get_rules(addr) + if records is None or records == -1: + return None + + while records.next(): + try: + rule = cast(ui_pb2.Rule, Rule.new_from_records(records)) + except Exception: + continue + if str(rule.name or "").strip() == rule_name: + return records + return None + + def edit_attached_rule_entry(self, entry: dict[str, Any]): + identity = self.rule_entry_identity(entry) + if identity is None: + return + addr, name = identity + self.open_attached_rule_in_editor(addr, name) + + def toggle_attached_rule_entry(self, entry: dict[str, Any]): + identity = self.rule_entry_identity(entry) + if identity is None: + return + addr, name = identity + records = self.find_rule_record(addr, name) + if records is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Rule not found: {0}").format(name), + error=True, + ) + return + + try: + rule = cast(ui_pb2.Rule, Rule.new_from_records(records)) + except Exception: + self._dialog._status_controller.set_status( + QC.translate("stats", "Failed to load rule: {0}").format(name), + error=True, + ) + return + + if bool(getattr(rule, "enabled", True)): + self._dialog._nodes.disable_rule(addr, name) + self.update_cached_rule_enabled(addr, name, False) + self._dialog._status_controller.set_status( + QC.translate("stats", "Rule updated: {0} disabled").format(name), + error=False, + ) + else: + rule.enabled = True + self._dialog._nodes.add_rules(addr, [rule]) + self._dialog._nodes.send_notification( + addr, + ui_pb2.Notification( + type=ui_pb2.CHANGE_RULE, + rules=[rule], + ), + None, + ) + self.update_cached_rule_enabled(addr, name, True) + self._dialog._status_controller.set_status( + QC.translate("stats", "Rule updated: {0} enabled").format(name), + error=False, + ) + + self._dialog._table_data_controller.refresh_states() + + def remove_attached_rule_entry(self, entry: dict[str, Any]): + identity = self.rule_entry_identity(entry) + if identity is None: + return + addr, name = identity + confirmed = QtWidgets.QMessageBox.question( + self._dialog, + QC.translate("stats", "Remove rule"), + QC.translate( + "stats", + "Remove rule '{0}' on node {1}? This action cannot be undone.", + ).format(name, addr), + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No, + ) + if confirmed != QtWidgets.QMessageBox.StandardButton.Yes: + return + + nid, _noti = self._dialog._nodes.delete_rule(name, addr, None) + if nid is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Failed to remove rule: {0}").format(name), + error=True, + ) + return + + self.remove_cached_rule(addr, name) + self._dialog._table_data_controller.refresh_states() + self._dialog._status_controller.set_status( + QC.translate("stats", "Rule deleted: {0}").format(name), + error=False, + ) + + def open_attached_rule_in_editor(self, addr: str, rule_name: str): + records = self.find_rule_record(addr, rule_name) + if records is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Rule not found: {0}").format(rule_name), + error=True, + ) + return + + rules_dialog = self._dialog._rules_editor_controller.ensure_rules_dialog() + if rules_dialog is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Rules editor is not available."), + error=True, + ) + return + + rules_dialog.edit_rule(records, _addr=addr) + self._dialog._table_data_controller.refresh_states() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py new file mode 100644 index 0000000000..a51d0bc1b4 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py @@ -0,0 +1,462 @@ +import os +from typing import TYPE_CHECKING, Any + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions._utils import ( + DEFAULT_LISTS_DIR, + list_file_path, + normalize_group, + normalize_groups, + normalize_lists_dir, + safe_filename, + subscription_rule_dir, +) +from opensnitch.config import Config +from opensnitch.dialogs.ruleseditor import RulesEditorDialog +from opensnitch.dialogs.ruleseditor import constants as ruleseditor_constants +from opensnitch.rules import Rules + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class _RulesDialogEventFilter(QtCore.QObject): + def __init__(self, controller: "RulesEditorController"): + super().__init__(controller._dialog) + self._controller = controller + + def eventFilter(self, a0, a1): + event_type = a1.type() if a1 is not None else None + if event_type == QtCore.QEvent.Type.Show: + self._controller._on_rules_dialog_shown() + elif event_type in ( + QtCore.QEvent.Type.Hide, + QtCore.QEvent.Type.Close, + ): + self._controller._on_rules_dialog_hidden() + return super().eventFilter(a0, a1) + + +class RulesEditorController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + self._rules = Rules.instance() + self._pending_rule_change: dict[str, Any] | None = None + self._rules_dialog_event_filter: _RulesDialogEventFilter | None = None + self._rules.updated.connect(self._handle_rules_updated) + + def _col(self, key: str): + return self._cols[key] + + def ensure_rules_dialog(self): + if self._dialog._rules_dialog is None: + appicon = ( + self._dialog.windowIcon() + if self._dialog.windowIcon() is not None + else None + ) + try: + self._dialog._rules_dialog = RulesEditorDialog( + parent=None, + appicon=appicon, + ) + except TypeError: + self._dialog._rules_dialog = RulesEditorDialog() + self._install_rules_dialog_event_filter() + self._connect_rules_dialog_signals() + return self._dialog._rules_dialog + + def _install_rules_dialog_event_filter(self): + rules_dialog = self._dialog._rules_dialog + if rules_dialog is None: + return + if self._rules_dialog_event_filter is None: + self._rules_dialog_event_filter = _RulesDialogEventFilter(self) + rules_dialog.installEventFilter(self._rules_dialog_event_filter) + + def _connect_rules_dialog_signals(self): + rules_dialog = self._dialog._rules_dialog + if rules_dialog is None: + return + save_button = rules_dialog.buttonBox.button( + QtWidgets.QDialogButtonBox.StandardButton.Save + ) + if save_button is None: + return + try: + save_button.pressed.disconnect(self._capture_rule_save_context) + except Exception: + pass + save_button.pressed.connect(self._capture_rule_save_context) + + def _capture_rule_save_context(self): + rules_dialog = self._dialog._rules_dialog + if rules_dialog is None: + return + node_index = rules_dialog.nodesCombo.currentIndex() + node_addr = str(rules_dialog.nodesCombo.itemData(node_index) or "").strip() + rule_dirs: list[str] = [] + if rules_dialog.dstListsCheck.isChecked(): + rule_dir = str(rules_dialog.dstListsLine.text() or "").strip() + if rule_dir != "": + rule_dirs.append(rule_dir) + self._pending_rule_change = { + "mode": int(getattr(ruleseditor_constants, "WORK_MODE", 0)), + "old_name": str(getattr(rules_dialog, "_old_rule_name", "") or "").strip(), + "new_name": str(rules_dialog.ruleNameEdit.text() or "").strip(), + "addr": node_addr, + "enabled": bool(rules_dialog.enableCheck.isChecked()), + "directories": rule_dirs, + "apply_all": bool(rules_dialog.nodeApplyAllCheck.isChecked()), + } + + def _handle_rules_updated(self, _value: int): + if self._pending_rule_change is None: + return + # Defer so we exit cb_save_clicked's call chain before touching the DB. + # Running a DB scan synchronously inside Rules.updated (which is emitted + # from cb_save_clicked) blocks the main thread while the gRPC reply for + # the CHANGE_RULE notification is still pending, causing the daemon to + # appear locked. + QtCore.QTimer.singleShot(0, self._finalize_pending_rule_change) + + def _finalize_pending_rule_change(self): + pending = self._pending_rule_change + self._pending_rule_change = None + if pending is None: + return + + mode = int(pending.get("mode") or 0) + old_name = str(pending.get("old_name") or "").strip() + new_name = str(pending.get("new_name") or "").strip() + + if mode == ruleseditor_constants.ADD_RULE: + message = QC.translate("stats", "Rule created: {0}").format( + new_name or old_name + ) + elif old_name != "" and new_name != "" and old_name != new_name: + message = QC.translate( + "stats", "Rule updated: {0} (renamed from {1})" + ).format(new_name, old_name) + else: + message = QC.translate("stats", "Rule updated: {0}").format( + new_name or old_name + ) + + if bool(pending.get("apply_all", False)): + self._dialog._rules_attachment_controller.invalidate_snapshot_cache( + "rule-editor-apply-all" + ) + else: + self._dialog._rules_attachment_controller.apply_rule_editor_change(pending) + + self._dialog._status_controller.set_status(message, error=False) + self._dialog._table_data_controller.refresh_states() + self._dialog._selection_controller.update_selected_actions_state() + + def _on_rules_dialog_shown(self): + self._dialog._table_data_controller.stop_poll() + + def _on_rules_dialog_hidden(self): + self._pending_rule_change = None + # Restart polling right away, but defer the DB-heavy refresh so it + # runs after the dialog teardown event chain has fully unwound. + self._dialog._table_data_controller.start_poll() + if self._dialog.isVisible() and not self._dialog._loading: + QtCore.QTimer.singleShot(0, self._deferred_post_editor_refresh) + + def _deferred_post_editor_refresh(self): + if not self._dialog.isVisible() or self._dialog._loading: + return + self._dialog._table_data_controller.refresh_states() + self._dialog._selection_controller.update_selected_actions_state() + + def configure_rules_dialog_for_local_user(self): + rules_dialog = self._dialog._rules_dialog + if rules_dialog is None: + return False + + local_addr = None + for addr in self._dialog._nodes.get().keys(): + try: + if self._dialog._nodes.is_local(addr): + local_addr = addr + break + except Exception: + continue + + if local_addr is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "No local OpenSnitch node is connected. Rules can only be created for the local user.", + ), + error=True, + ) + rules_dialog.hide() + return False + + nodes_combo = rules_dialog.nodesCombo + node_idx = nodes_combo.findData(local_addr) + if node_idx != -1: + nodes_combo.setCurrentIndex(node_idx) + nodes_combo.setEnabled(False) + rules_dialog.nodeApplyAllCheck.setChecked(False) + rules_dialog.nodeApplyAllCheck.setEnabled(False) + rules_dialog.nodeApplyAllCheck.setVisible(False) + + uid_text = str(os.getuid()) + uid_combo = rules_dialog.uidCombo + uid_idx = uid_combo.findData(int(uid_text)) + rules_dialog.uidCheck.setChecked(True) + uid_combo.setEnabled(True) + if uid_idx != -1: + uid_combo.setCurrentIndex(uid_idx) + else: + uid_combo.setCurrentText(uid_text) + return True + + def apply_rule_editor_defaults(self): + rules_dialog = self._dialog._rules_dialog + if rules_dialog is None: + return + rules_dialog.enableCheck.setChecked(True) + duration_idx = rules_dialog.durationCombo.findData(Config.DURATION_ALWAYS) + if duration_idx < 0: + duration_idx = rules_dialog.durationCombo.findText( + Config.DURATION_ALWAYS, + QtCore.Qt.MatchFlag.MatchFixedString, + ) + if duration_idx < 0: + duration_idx = 8 + rules_dialog.durationCombo.setCurrentIndex(duration_idx) + + def choose_group_for_selected(self, rows: list[int]): + if not rows: + return None + selected_group_sets = [ + set( + normalize_groups( + self._dialog._table_data_controller.cell_text( + r, + self._col("group"), + ) + ) + ) + for r in rows + ] + common = ( + set.intersection(*selected_group_sets) if selected_group_sets else set() + ) + known = self._dialog._selection_controller.known_groups() + default_group = "" + if common: + default_group = sorted(common)[0] + if default_group != "" and default_group not in known: + known.append(default_group) + known = sorted(set(known)) or [""] + try: + default_idx = known.index(default_group) + except ValueError: + default_idx = 0 + value, ok = QtWidgets.QInputDialog.getItem( + self._dialog, + QC.translate("stats", "Create rule from multiple subscriptions"), + QC.translate( + "stats", "Select or enter a group to aggregate selected subscriptions:" + ), + known, + default_idx, + True, + ) + if not ok: + return None + group = normalize_group(value) + if group in ("", "all"): + self._dialog._status_controller.set_status( + QC.translate("stats", "Group cannot be empty."), error=True + ) + return None + return group + + def assign_group_to_rows(self, rows: list[int], group: str): + if not rows: + return False + target_group = normalize_group(group) + for row in rows: + groups = normalize_groups( + self._dialog._table_data_controller.cell_text(row, self._col("group")) + ) + groups.append(target_group) + groups = normalize_groups(groups) + self._dialog._table_data_controller.set_text_item( + row, + self._col("group"), + ", ".join(groups), + ) + return True + + def prepare_rule_dir( + self, + url: str, + filename: str, + list_path: str, + lists_dir: str, + list_type: str, + ): + _ = (url, list_path) + rule_dir = subscription_rule_dir(lists_dir, filename, list_type) + try: + os.makedirs(rule_dir, mode=0o700, exist_ok=True) + return rule_dir + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Error preparing list rule directory: {0}" + ).format(str(e)), + error=True, + ) + return None + + def create_rule_from_selected(self): + rows = self._dialog._selection_controller.selected_rows() + if not rows: + row = self._dialog.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select one or more subscriptions first."), + error=True, + ) + return + + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + if len(rows) == 1: + row = rows[0] + url = self._dialog._table_data_controller.cell_text(row, self._col("url")) + filename, filename_changed = ( + self._dialog._table_data_controller.ensure_row_final_filename(row) + ) + if url == "" or filename == "": + self._dialog._status_controller.set_status( + QC.translate("stats", "URL and filename cannot be empty."), + error=True, + ) + return + if filename_changed: + self._dialog._action_file_controller.save_action_file() + + list_type = ( + self._dialog._table_data_controller.cell_text(row, self._col("format")) + ) or "hosts" + list_type = list_type.strip().lower() + list_path = list_file_path(lists_dir, filename, list_type) + rule_dir = self.prepare_rule_dir( + url, + filename, + list_path, + lists_dir, + list_type, + ) + if rule_dir is None: + return + rule_token = os.path.splitext(safe_filename(filename))[0] + rule_name = f"00-blocklist-{rule_token}" + desc = f"From list subscription : {filename}" + else: + rule_group = self.choose_group_for_selected(rows) + if rule_group is None: + return + if not self.assign_group_to_rows(rows, rule_group): + return + self._dialog._action_file_controller.save_action_file() + rule_dir = os.path.join(lists_dir, "rules.list.d", rule_group) + try: + os.makedirs(rule_dir, mode=0o700, exist_ok=True) + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Error preparing grouped rule directory: {0}" + ).format(str(e)), + error=True, + ) + return + rule_name = f"00-blocklist-{rule_group}" + desc = f"From list subscription : {rule_group}" + + rules_dialog = self.ensure_rules_dialog() + if rules_dialog is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Rules editor is not available."), + error=True, + ) + return + rules_dialog.new_rule() + if not self.configure_rules_dialog_for_local_user(): + return + self.apply_rule_editor_defaults() + rules_dialog.dstListsCheck.setChecked(True) + rules_dialog.dstListsLine.setText(rule_dir) + if rules_dialog.ruleNameEdit.text().strip() == "": + rules_dialog.ruleNameEdit.setText(rule_name) + if rules_dialog.ruleDescEdit.toPlainText().strip() == "": + rules_dialog.ruleDescEdit.setPlainText(desc) + rules_dialog.raise_() + rules_dialog.activateWindow() + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Rules Editor opened with prefilled list directory path." + ), + error=False, + ) + + def create_global_rule(self): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + rule_dir = os.path.join(lists_dir, "rules.list.d", "all") + try: + os.makedirs(rule_dir, mode=0o700, exist_ok=True) + except Exception as e: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Error preparing global rule directory: {0}" + ).format(str(e)), + error=True, + ) + return + + rules_dialog = self.ensure_rules_dialog() + if rules_dialog is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Rules editor is not available."), + error=True, + ) + return + rules_dialog.new_rule() + if not self.configure_rules_dialog_for_local_user(): + return + self.apply_rule_editor_defaults() + rule_name = "00-blocklist-all" + rules_dialog.dstListsCheck.setChecked(True) + rules_dialog.dstListsLine.setText(rule_dir) + if rules_dialog.ruleNameEdit.text().strip() == "": + rules_dialog.ruleNameEdit.setText(rule_name) + if rules_dialog.ruleDescEdit.toPlainText().strip() == "": + rules_dialog.ruleDescEdit.setPlainText("From list subscription : all") + rules_dialog.raise_() + rules_dialog.activateWindow() + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Rules Editor opened with global list directory path." + ), + error=False, + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py new file mode 100644 index 0000000000..0fd11013e2 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py @@ -0,0 +1,439 @@ +import os +from typing import TYPE_CHECKING, Any, cast + +from opensnitch.plugins import PluginSignal +from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC +from opensnitch.plugins.list_subscriptions.models.events import RuntimeEventType + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class RuntimeController: + def __init__( + self, + *, + dialog: "ListSubscriptionsDialog", + status_label: QtWidgets.QLabel, + start_button: QtWidgets.QPushButton, + stop_button: QtWidgets.QPushButton, + ): + self._dialog = dialog + self._status_label = status_label + self._start_button = start_button + self._stop_button = stop_button + + # -- UI state ----------------------------------------------------------- + + def set_runtime_state(self, active: bool | None, text: str | None = None): + if text is None: + if active is True: + text = QC.translate("stats", "Runtime: active") + elif active is False: + text = QC.translate("stats", "Runtime: inactive") + else: + text = QC.translate("stats", "Runtime: pending") + + if active is True: + style = "color: green;" + elif active is False: + style = "color: #666666;" + else: + style = "color: #b36b00;" + + self._status_label.setStyleSheet(style) + self._status_label.setText(text) + self._start_button.setEnabled(active is not True) + self._stop_button.setEnabled(active is not False) + + # -- Refresh busy state ------------------------------------------------- + + def set_refresh_busy(self, busy: bool): + self._dialog.refresh_state_button.setEnabled(not busy) + self._dialog.refresh_now_button.setEnabled( + not busy and len(self._dialog._selection_controller.selected_rows()) > 0 + ) + + def track_refresh_keys(self, keys: set[str]): + if not keys: + return + self._dialog._pending_refresh_keys.update(keys) + self.set_refresh_busy(True) + + def clear_refresh_key(self, key: str): + self._dialog._pending_refresh_keys.discard(key) + self._dialog._active_refresh_keys.discard(key) + if not self._dialog._pending_refresh_keys and not self._dialog._active_refresh_keys: + self.set_refresh_busy(False) + + def refresh_keys_from_payload(self, payload: dict[str, Any]): + items_raw = payload.get("items") + keys: set[str] = set() + if isinstance(items_raw, list): + for item in items_raw: + if not isinstance(item, dict): + continue + key = str(item.get("key") or "").strip() + if key != "": + keys.add(key) + return keys + + def runtime_event_items(self, payload: dict[str, Any]): + items_raw = payload.get("items") + if not isinstance(items_raw, list): + return [] + return [item for item in items_raw if isinstance(item, dict)] + + def runtime_download_message( + self, + event_name: RuntimeEventType | None, + payload: dict[str, Any], + fallback: str, + ): + items = self.runtime_event_items(payload) + if not items: + return fallback + count = len(items) + first_name = str(items[0].get("name") or "").strip() + if event_name == RuntimeEventType.DOWNLOAD_STARTED: + if count == 1 and first_name != "": + return QC.translate("stats", "Refreshing subscription '{0}'.").format( + first_name + ) + return QC.translate("stats", "Refreshing {0} subscriptions.").format(count) + if event_name == RuntimeEventType.DOWNLOAD_FINISHED: + if count == 1 and first_name != "": + return QC.translate("stats", "Subscription '{0}' refreshed.").format( + first_name + ) + return QC.translate("stats", "Refreshed {0} subscriptions.").format(count) + if event_name == RuntimeEventType.DOWNLOAD_FAILED: + if count == 1 and first_name != "": + return QC.translate( + "stats", "Subscription '{0}' refresh failed." + ).format(first_name) + return QC.translate( + "stats", "Refresh failed for {0} subscriptions." + ).format(count) + return fallback + + def handle_runtime_event(self, event: dict[str, Any]): + payload = event if isinstance(event, dict) else {} + message = str(payload.get("message") or "").strip() + error_detail = str(payload.get("error") or "").strip() + event_keys = self.refresh_keys_from_payload(payload) + event_value = payload.get("event") + if isinstance(event_value, int): + try: + event_name = RuntimeEventType(event_value) + except Exception: + event_name = None + else: + event_name = None + is_error = event_name in ( + RuntimeEventType.RUNTIME_ERROR, + RuntimeEventType.DOWNLOAD_FAILED, + RuntimeEventType.FILE_SAVE_ERROR, + RuntimeEventType.FILE_LOAD_ERROR, + ) + if event_name == RuntimeEventType.DOWNLOAD_STARTED: + for key in event_keys: + if key in self._dialog._pending_refresh_keys: + self._dialog._pending_refresh_keys.discard(key) + self._dialog._active_refresh_keys.add(key) + self.set_refresh_busy(True) + elif event_name in ( + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, + ): + for key in event_keys: + self.clear_refresh_key(key) + if event_name in ( + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, + ): + self._dialog._table_data_controller.refresh_states() + self._dialog._selection_controller.update_selected_actions_state() + if event_name == RuntimeEventType.RUNTIME_ENABLED: + self.set_runtime_state(active=True) + elif event_name in ( + RuntimeEventType.RUNTIME_DISABLED, + RuntimeEventType.RUNTIME_STOPPED, + ): + self.set_runtime_state(active=False) + elif self._dialog._pending_runtime_reload is not None: + self.set_runtime_state( + active=None, + text=QC.translate("stats", "Runtime: reloading"), + ) + elif is_error: + self.set_runtime_state( + active=None, text=QC.translate("stats", "Runtime: error") + ) + if self._dialog._pending_runtime_reload == "waiting_config_reload": + if event_name == RuntimeEventType.CONFIG_RELOADED: + self._dialog._pending_runtime_reload = None + self._dialog._action_file_controller.load_action_file() + return + if is_error: + self._dialog._pending_runtime_reload = None + if message == "": + message = QC.translate("stats", "Plugin runtime event: {0}").format( + str(event_value or "unknown") + ) + if event_name in ( + RuntimeEventType.DOWNLOAD_STARTED, + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, + ): + message = self.runtime_download_message(event_name, payload, message) + if is_error and error_detail != "": + message = f"{message} {error_detail}".strip() + self._dialog._status_controller.set_status( + message, + error=is_error, + origin="backend:event", + ) + + # -- Lifecycle ---------------------------------------------------------- + + def _runtime_reload_failed_message(self): + return QC.translate( + "stats", "Config saved but runtime reload failed. Restart UI." + ) + + def start_runtime_clicked(self): + runtime_plugin = self.sync_runtime_binding_state() + if runtime_plugin is not None and bool(getattr(runtime_plugin, "enabled", False)): + self.bind_runtime_plugin(runtime_plugin) + self.set_runtime_state(active=True) + self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already active.")) + return + + if not os.path.exists(self._dialog._action_path): + self._dialog._status_controller.set_status( + QC.translate( + "stats", + "Action file not found. Create and save the configuration first.", + ), + error=True, + ) + return + + if runtime_plugin is None: + runtime_plugin = ListSubscriptions({}) + + self.bind_runtime_plugin(runtime_plugin) + self.set_runtime_state( + active=None, + text=QC.translate("stats", "Runtime: starting"), + ) + self._dialog._status_controller.append_log( + QC.translate("stats", "Runtime start requested."), + ) + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.ENABLE, + "action_path": self._dialog._action_path, + } + ) + except Exception: + self.set_runtime_state(active=False) + self._dialog._status_controller.set_status( + QC.translate("stats", "Failed to start runtime."), + error=True, + ) + + def stop_runtime_clicked(self): + runtime_plugin = self.sync_runtime_binding_state() + if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)): + self.set_runtime_state(active=False) + self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already inactive.")) + return + + self.bind_runtime_plugin(runtime_plugin) + self.set_runtime_state( + active=None, + text=QC.translate("stats", "Runtime: stopping"), + ) + self._dialog._status_controller.append_log( + QC.translate("stats", "Runtime stop requested."), + ) + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.DISABLE, + "action_path": self._dialog._action_path, + } + ) + except Exception: + self._dialog._status_controller.set_status( + QC.translate("stats", "Failed to stop runtime."), + error=True, + ) + + def reload_runtime_and_config(self): + runtime_plugin = self.sync_runtime_binding_state() + if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)): + self._dialog._action_file_controller.load_action_file() + return + + self.bind_runtime_plugin(runtime_plugin) + self._dialog._pending_runtime_reload = "waiting_config_reload" + self._dialog._status_controller.append_log( + QC.translate("stats", "Runtime config reload requested."), + ) + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.CONFIG_UPDATE, + "action_path": self._dialog._action_path, + } + ) + except Exception: + self._dialog._pending_runtime_reload = None + self._dialog._status_controller.set_status( + QC.translate("stats", "Runtime reload failed to start. Restart UI."), + error=True, + ) + + def apply_runtime_state(self, enabled: bool): + loaded = self.find_loaded_action() + old_key, _old_action, old_plugin = loaded if loaded is not None else (None, None, None) + runtime_plugin = ListSubscriptions.get_instance() + target_plugin = runtime_plugin if runtime_plugin is not None else old_plugin + was_enabled = bool(getattr(target_plugin, "enabled", False)) + + if target_plugin is not None: + self.bind_runtime_plugin(target_plugin) + try: + signal = None + if enabled: + signal = ( + PluginSignal.CONFIG_UPDATE + if was_enabled + else PluginSignal.ENABLE + ) + elif was_enabled: + signal = PluginSignal.DISABLE + + if signal is not None: + target_plugin.signal_in.emit( + { + "plugin": target_plugin.get_name(), + "signal": signal, + "action_path": self._dialog._action_path, + } + ) + except Exception: + self._dialog._status_controller.set_status( + self._runtime_reload_failed_message(), + error=True, + ) + return + if not enabled and old_key is not None: + self._dialog._actions.delete(old_key) + return + + if not enabled: + if old_key is not None: + self._dialog._actions.delete(old_key) + return + + obj, compiled = self._dialog._actions.load(self._dialog._action_path) + if obj is None or compiled is None: + self._dialog._status_controller.set_status( + self._runtime_reload_failed_message(), + error=True, + ) + return + + obj = cast(dict[str, Any], obj) + compiled = cast(dict[str, Any], compiled) + action_name = obj.get("name") + if old_key is not None and old_key != action_name: + self._dialog._actions.delete(old_key) + if isinstance(action_name, str) and action_name != "": + self._dialog._actions._actions_list[action_name] = compiled + + compiled_actions = cast(dict[str, Any], compiled.get("actions", {})) + plug = cast( + ListSubscriptions | None, compiled_actions.get("list_subscriptions") + ) + if plug is None: + self._dialog._status_controller.set_status( + self._runtime_reload_failed_message(), + error=True, + ) + return + self.bind_runtime_plugin(plug) + try: + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": PluginSignal.ENABLE, + "action_path": self._dialog._action_path, + } + ) + except Exception: + self._dialog._status_controller.set_status( + self._runtime_reload_failed_message(), + error=True, + ) + + def sync_runtime_binding_state(self): + runtime_plugin = ListSubscriptions.get_instance() + if runtime_plugin is None: + loaded = self.find_loaded_action() + runtime_plugin = loaded[2] if loaded is not None else None + + if runtime_plugin is not None: + self.bind_runtime_plugin(runtime_plugin) + self.set_runtime_state( + active=bool(getattr(runtime_plugin, "enabled", False)), + ) + return runtime_plugin + + self._dialog._runtime_plugin = None + self._dialog._status_controller.set_backend_log_sink(None) + self.set_runtime_state(active=False) + self.set_refresh_busy(False) + return None + + def bind_runtime_plugin(self, plug: ListSubscriptions | None): + if plug is None: + self._dialog._status_controller.set_backend_log_sink(None) + return + try: + plug.signal_out.disconnect(self.handle_runtime_event) + except Exception: + pass + try: + plug.log_out.disconnect(self._dialog._status_controller.ingest_backend_log) + except Exception: + pass + try: + plug.signal_out.connect(self.handle_runtime_event) + plug.log_out.connect(self._dialog._status_controller.ingest_backend_log) + self._dialog._status_controller.set_backend_log_sink(plug.ingest_ui_log) + self._dialog._runtime_plugin = plug + except Exception: + self._dialog._status_controller.set_backend_log_sink(None) + self._dialog._runtime_plugin = None + + def find_loaded_action(self): + for action_key, action_obj in self._dialog._actions.getAll().items(): + if action_obj is None: + continue + action_obj_dict = cast(dict[str, Any], action_obj) + action_cfg = cast(dict[str, Any], action_obj_dict.get("actions", {})) + plug = cast(ListSubscriptions | None, action_cfg.get("list_subscriptions")) + if plug is not None: + return str(action_key), action_obj_dict, plug diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py new file mode 100644 index 0000000000..61bc3abc61 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QC +from opensnitch.plugins.list_subscriptions._utils import normalize_groups + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class SelectionController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def selected_rows(self): + idx = self._dialog.table.selectionModel() + if idx is None: + return [] + return sorted({i.row() for i in idx.selectedRows()}) + + def update_selected_actions_state(self): + count = len(self.selected_rows()) + has_selection = count > 0 + self._dialog.edit_sub_button.setEnabled(has_selection) + self._dialog.remove_sub_button.setEnabled(has_selection) + self._dialog.refresh_now_button.setEnabled( + has_selection + and not self._dialog._pending_refresh_keys + and not self._dialog._active_refresh_keys + ) + self._dialog.create_rule_button.setEnabled(has_selection) + if count == 1: + self._dialog.create_rule_button.setText(QC.translate("stats", "Rules")) + else: + self._dialog.create_rule_button.setText( + QC.translate("stats", "Create rule") + ) + + def open_rules_action(self): + rows = self.selected_rows() + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select a subscription row first."), + error=True, + ) + return + if len(rows) == 1: + self._dialog._rules_attachment_controller.show_attached_rules_dialog() + return + self._dialog._rules_editor_controller.create_rule_from_selected() + + def open_selected_inspector(self): + rows = self.selected_rows() + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select a subscription row first."), + error=True, + ) + return + if hasattr(self._dialog, "_inspect_collapsed"): + self._dialog._inspect_collapsed = False + self._dialog._inspector_controller.update_inspector_panel() + + def known_groups(self): + groups: set[str] = set() + for row in range(self._dialog.table.rowCount()): + for g in normalize_groups( + self._dialog._table_data_controller.cell_text(row, self._col("group")) + ): + if g not in ("", "all"): + groups.add(g) + return sorted(groups) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py new file mode 100644 index 0000000000..b59850947c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py @@ -0,0 +1,344 @@ +from collections.abc import Callable +import queue +import threading +from typing import TYPE_CHECKING, Literal + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import ( + StatusLogDialog, + ) + +EmptyButtonBehavior = Literal["hide", "show-if-logs"] +MAX_BACKEND_LOGS_PER_UI_TICK = 100 + + +def _set_preview_label_text( + label: QtWidgets.QLabel, + *, + text: str, + preview_limit: int, +): + full_text = (text or "").strip() + if full_text == "": + label.setText("") + label.setToolTip("") + return + + if len(full_text) <= preview_limit: + label.setText(full_text) + label.setToolTip(full_text) + return + + preview = full_text[: preview_limit - 1].rstrip() + "..." + label.setText(preview) + label.setToolTip(full_text) + + +def append_log_entry( + entries: list[str], + *, + message: str, + error: bool = False, + level: str | None = None, + origin: str | None = None, + dedupe: bool = False, + last_signature: tuple[str, bool] | None = None, + timestamp_format: str = "HH:mm:ss", + limit: int = 200, +): + full_text = (message or "").strip() + if full_text == "": + return + + signature = (full_text, bool(error)) + if dedupe and signature == last_signature: + return + + timestamp = QtCore.QDateTime.currentDateTime().toString(timestamp_format) + log_level = level or ("ERROR" if error else "INFO") + log_origin = (origin or "ui").strip() + entries.append(f"[{timestamp}] [{log_level}] [{log_origin}] {full_text}") + if len(entries) > limit: + del entries[:-limit] + + +def apply_status_label( + label: QtWidgets.QLabel, + *, + message: str, + error: bool, + preview_limit: int, + inspect_button: QtWidgets.QPushButton | None = None, + empty_button_behavior: Literal["hide", "show-if-logs"] = "hide", + log_entry_count: int = 0, + ok_color: str = "green", + error_color: str = "red", +): + label.setStyleSheet(f"color: {error_color if error else ok_color};") + full_text = (message or "").strip() + if full_text == "": + label.setText("") + label.setToolTip("") + if inspect_button is not None: + if empty_button_behavior == "show-if-logs": + has_logs = log_entry_count > 0 + inspect_button.setVisible(has_logs) + inspect_button.setEnabled(has_logs) + else: + inspect_button.setVisible(False) + inspect_button.setEnabled(False) + return full_text + + _set_preview_label_text( + label, + text=full_text, + preview_limit=preview_limit, + ) + if inspect_button is not None: + inspect_button.setVisible(True) + inspect_button.setEnabled(True) + return full_text + + +class DialogStatusController: + def __init__( + self, + *, + label: QtWidgets.QLabel, + inspect_button: QtWidgets.QPushButton | None, + preview_limit: int, + log_limit: int, + timestamp_format: str, + ok_color: str, + error_color: str, + empty_button_behavior: EmptyButtonBehavior, + ): + self._label = label + self._inspect_button = inspect_button + self._preview_limit = preview_limit + self._log_limit = log_limit + self._timestamp_format = timestamp_format + self._ok_color = ok_color + self._error_color = error_color + self._empty_button_behavior: EmptyButtonBehavior = empty_button_behavior + self._full_text = "" + self._log_entries: list[str] = [] + self._last_signature: tuple[str, bool] | None = None + self._backend_log_sink: Callable[[str, str, str], None] | None = None + self._backend_sink_queue: queue.SimpleQueue[tuple[str, str, str]] = ( + queue.SimpleQueue() + ) + self._backend_sink_worker_started = False + self._backend_to_ui_queue: queue.SimpleQueue[tuple[str, str, str]] = ( + queue.SimpleQueue() + ) + self._backend_to_ui_timer = QtCore.QTimer(self._label) + self._backend_to_ui_timer.setInterval(100) + self._backend_to_ui_timer.timeout.connect(self._drain_backend_to_ui_queue) + self._backend_to_ui_timer.start() + self._log_dialog: "StatusLogDialog | None" = None + self._log_dialog_level_color: Callable[[str], str] | None = None + self._log_dialog_timestamp_color = "" + + def _start_backend_sink_worker(self) -> None: + if self._backend_sink_worker_started: + return + self._backend_sink_worker_started = True + + def _run() -> None: + while True: + message, level, origin = self._backend_sink_queue.get() + sink = self._backend_log_sink + if sink is None: + continue + try: + sink(message, level, origin) + except Exception: + pass + + th = threading.Thread( + target=_run, + name="DialogStatusBackendSink", + daemon=True, + ) + th.start() + + def _drain_backend_to_ui_queue(self) -> None: + for _ in range(MAX_BACKEND_LOGS_PER_UI_TICK): + try: + message, level, origin = self._backend_to_ui_queue.get_nowait() + except queue.Empty: + return + self.append_log( + message, + level=level, + origin=origin, + forward_backend=False, + ) + + @property + def full_text(self): + return self._full_text + + @property + def log_entries(self): + return self._log_entries + + def log(self, message: str, level: str = "INFO", origin: str = "ui") -> None: + """Generic slot: connect any pyqtSignal(str, str) directly to this.""" + self.append_log(message, level=level, origin=origin) + + def ingest_backend_log( + self, + message: str, + level: str = "INFO", + origin: str = "backend", + ) -> None: + """Backend -> UI path; enqueue to avoid blocking backend threads.""" + full_text = (message or "").strip() + if full_text == "": + return + self._backend_to_ui_queue.put((full_text, level, origin)) + + def set_backend_log_sink( + self, + sink: Callable[[str, str, str], None] | None, + ) -> None: + self._backend_log_sink = sink + if sink is not None: + self._start_backend_sink_worker() + + def debug(self, message: str, origin: str = "ui") -> None: + self.append_log(message, level="DEBUG", origin=origin) + + def info(self, message: str, origin: str = "ui") -> None: + self.append_log(message, level="INFO", origin=origin) + + def warn(self, message: str, origin: str = "ui") -> None: + self.append_log(message, level="WARN", origin=origin) + + def error(self, message: str, origin: str = "ui") -> None: + self.append_log(message, level="ERROR", origin=origin) + + def trace(self, message: str, origin: str = "ui") -> None: + self.append_log(message, level="TRACE", origin=origin) + + def append_log( + self, + message: str, + *, + error: bool = False, + level: str | None = None, + origin: str = "ui", + dedupe: bool = False, + forward_backend: bool = True, + ): + full_text = (message or "").strip() + if full_text == "": + return + resolved_level = (level or ("ERROR" if error else "INFO")).upper() + append_log_entry( + self._log_entries, + message=full_text, + error=error, + level=resolved_level, + origin=origin, + dedupe=dedupe, + last_signature=self._last_signature, + timestamp_format=self._timestamp_format, + limit=self._log_limit, + ) + if ( + forward_backend + and self._backend_log_sink is not None + and not origin.lower().startswith("backend") + ): + self._backend_sink_queue.put((full_text, resolved_level, origin)) + self._refresh_log_dialog_if_open() + + def _refresh_log_dialog_if_open(self) -> None: + dlg = self._log_dialog + if dlg is None or not dlg.isVisible(): + return + if self._log_dialog_level_color is None: + return + try: + dlg.update_entries( + lines=self._log_entries[:], + fallback_text=self._full_text, + level_color=self._log_dialog_level_color, + timestamp_color=self._log_dialog_timestamp_color, + ) + except Exception: + pass + + def set_status( + self, + message: str, + *, + error: bool = False, + log: bool = True, + origin: str = "ui", + ): + full_text = apply_status_label( + self._label, + message=message, + error=error, + preview_limit=self._preview_limit, + inspect_button=self._inspect_button, + empty_button_behavior=self._empty_button_behavior, + log_entry_count=len(self._log_entries), + ok_color=self._ok_color, + error_color=self._error_color, + ) + self._full_text = full_text + if full_text == "": + return + + if not log: + return + + signature = (full_text, bool(error)) + if signature != self._last_signature: + self.append_log(full_text, error=error, origin=origin) + self._last_signature = signature + + def show_log_dialog( + self, + parent: "QtWidgets.QWidget", + *, + title: str, + level_color: Callable[[str], str], + timestamp_color: str, + ) -> None: + from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import ( + StatusLogDialog, + ) + self._log_dialog_level_color = level_color + self._log_dialog_timestamp_color = timestamp_color + dlg = self._log_dialog + if dlg is None: + dlg = StatusLogDialog( + parent, + title=title, + lines=self._log_entries[:], + fallback_text=self._full_text, + level_color=level_color, + timestamp_color=timestamp_color, + ) + dlg.setWindowModality(QtCore.Qt.WindowModality.NonModal) + dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + dlg.destroyed.connect(lambda *_: setattr(self, "_log_dialog", None)) + self._log_dialog = dlg + else: + dlg.update_entries( + lines=self._log_entries[:], + fallback_text=self._full_text, + level_color=level_color, + timestamp_color=timestamp_color, + ) + dlg.show() + dlg.raise_() + dlg.activateWindow() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py new file mode 100644 index 0000000000..a59933711a --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py @@ -0,0 +1,297 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QC +from opensnitch.plugins.list_subscriptions._utils import ( + deslugify_filename, + derive_filename, + ensure_filename_type_suffix, + is_valid_url, + safe_filename, +) +from opensnitch.plugins.list_subscriptions.ui.workers import UrlTestWorker + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import ( + SubscriptionDialog, + ) + + +class SubscriptionDialogController: + def __init__(self, *, dialog: "SubscriptionDialog"): + self._dialog = dialog + self._refresh_signal: Any = None + self._url_worker: UrlTestWorker | None = None + self._url_thread: QtCore.QThread | None = None + self._url_worker_stopped_callbacks: list[Callable[[], None]] = [] + self._shutting_down = False + dialog.destroyed.connect(self._on_dialog_destroyed) + app = QtGui.QGuiApplication.instance() + if app is not None: + app.aboutToQuit.connect(self._on_app_about_to_quit) + + def _on_dialog_destroyed(self, *_args): + self.shutdown_url_worker(wait_ms=3000) + + def _on_app_about_to_quit(self): + self.shutdown_url_worker(wait_ms=3000) + + def has_active_url_test(self) -> bool: + thread = self._url_thread + return bool(thread is not None and thread.isRunning()) + + def on_url_test_stopped(self, callback: Callable[[], None]) -> None: + if not self.has_active_url_test(): + callback() + return + self._url_worker_stopped_callbacks.append(callback) + + def cancel_active_url_test(self): + worker = self._url_worker + thread = self._url_thread + if worker is None or thread is None or not thread.isRunning(): + return + worker.stop() + thread.quit() + + def wait_for_active_url_test_stop(self, wait_ms: int = 1200) -> bool: + worker = self._url_worker + thread = self._url_thread + if worker is None or thread is None: + return True + if not thread.isRunning(): + self._url_worker = None + self._url_thread = None + return True + + worker.stop() + thread.quit() + if wait_ms <= 0: + stopped = thread.wait() + else: + stopped = thread.wait(wait_ms) + if stopped: + self._url_worker = None + self._url_thread = None + return bool(stopped) + + def shutdown_url_worker(self, wait_ms: int = 2000) -> bool: + self._shutting_down = True + worker = self._url_worker + thread = self._url_thread + if worker is None or thread is None: + self._url_worker = None + self._url_thread = None + return True + try: + worker.test_result.disconnect(self._dialog._url_test_finished.emit) + except Exception: + pass + if thread.isRunning(): + worker.stop() + thread.quit() + if wait_ms <= 0: + thread.wait() + else: + thread.wait(wait_ms) + if thread.isRunning(): + thread.terminate() + thread.wait(500) + if thread.isRunning(): + self._url_worker = worker + self._url_thread = thread + return False + self._url_worker = None + self._url_thread = None + return True + + # -- Meta refresh ------------------------------------------------------- + + def connect_to_refresh_signal(self, signal: Any) -> None: + self._refresh_signal = signal + signal.connect(self.on_state_refreshed) + + def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None: + if url != str(self._dialog._sub.url) or filename != str(self._dialog._sub.filename): + return + self.update_meta(meta) + + def update_meta(self, meta: dict[str, str]) -> None: + self._dialog.meta_file_present.setText(str(meta.get("file_present", ""))) + self._dialog.meta_meta_present.setText(str(meta.get("meta_present", ""))) + self._dialog.meta_state.setText(str(meta.get("state", ""))) + self.apply_meta_state_color(str(meta.get("state", ""))) + self._dialog.meta_last_checked.setText(str(meta.get("last_checked", ""))) + self._dialog.meta_last_updated.setText(str(meta.get("last_updated", ""))) + self._dialog.meta_failures.setText(str(meta.get("failures", ""))) + self._dialog.meta_error.setText(str(meta.get("error", ""))) + self._dialog.meta_list_path.setText(str(meta.get("list_path", ""))) + self._dialog.meta_meta_path.setText(str(meta.get("meta_path", ""))) + + def apply_meta_state_color(self, state: str) -> None: + normalized = (state or "").strip().lower() + dark_theme = ( + self._dialog.palette() + .color(QtGui.QPalette.ColorRole.Window) + .lightness() + < 128 + ) + if dark_theme: + healthy_color = "#7CE3A1" + pending_color = "#F5D76E" + problematic_color = "#FF8A80" + else: + healthy_color = "#0F8A4B" + pending_color = "#9A6700" + problematic_color = "#C62828" + if normalized in ("updated", "not_modified"): + color = healthy_color + elif normalized == "pending": + color = pending_color + else: + color = problematic_color + self._dialog.meta_state.setStyleSheet(f"color: {color};") + + def disconnect_signal(self) -> None: + self.cancel_active_url_test() + if self._refresh_signal is not None: + try: + self._refresh_signal.disconnect(self.on_state_refreshed) + except Exception: + pass + self._refresh_signal = None + + # -- Field helpers ------------------------------------------------------ + + def sync_optional_fields_state(self) -> None: + self._dialog.interval_units.setEnabled(self._dialog.interval_spin.value() > 0) + self._dialog.timeout_units.setEnabled(self._dialog.timeout_spin.value() > 0) + self._dialog.max_size_units.setEnabled(self._dialog.max_size_spin.value() > 0) + + def clear_field_errors(self) -> None: + self.set_dialog_message("", error=False) + self._dialog.name_error_label.setText("") + self._dialog.url_error_label.setText("") + self._dialog.filename_error_label.setText("") + + def set_dialog_message(self, message: str, error: bool) -> None: + self._dialog._dialog_message_controller.set_status(message, error=error) + if (message or "").strip(): + self._dialog.log_message.emit( + message, + "ERROR" if error else "INFO", + str(getattr(self._dialog, "_log_origin", "ui:subscription")), + ) + + # -- URL test ----------------------------------------------------------- + + def test_url(self) -> None: + if self._shutting_down: + return + self._dialog.url_error_label.setText("") + self.set_dialog_message("", error=False) + url = (self._dialog.url_edit.text() or "").strip() + if url == "": + self._dialog.url_error_label.setText(QC.translate("stats", "URL is required.")) + self.set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) + return + if not is_valid_url(url): + self._dialog.url_error_label.setText( + QC.translate("stats", "Enter a valid http:// or https:// URL.") + ) + self.set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) + return + self._dialog.test_url_button.setEnabled(False) + self.set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) + self.shutdown_url_worker(wait_ms=100) + self._shutting_down = False + list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower() + thread = QtCore.QThread(self._dialog) + thread.setObjectName("UrlTestWorkerThread") + worker = UrlTestWorker(url, list_type) + worker.setObjectName("UrlTestWorker") + worker.moveToThread(thread) + self._url_worker = worker + self._url_thread = thread + thread.started.connect(worker.run) + worker.test_result.connect(self._dialog._url_test_finished.emit) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_url_test_worker_stopped) + thread.finished.connect(thread.deleteLater) + thread.start() + + def _on_url_test_worker_stopped(self) -> None: + self._url_worker = None + self._url_thread = None + callbacks = self._url_worker_stopped_callbacks[:] + self._url_worker_stopped_callbacks.clear() + for callback in callbacks: + callback() + + def handle_url_test_finished(self, success: bool, message: str) -> None: + if self._shutting_down: + return + self._dialog.test_url_button.setEnabled(True) + if success: + self._dialog.url_error_label.setText("") + self.set_dialog_message(message, error=False) + return + self._dialog.url_error_label.setText(QC.translate("stats", "URL check failed.")) + self.set_dialog_message(message, error=True) + + # -- Validation --------------------------------------------------------- + + def validate_then_accept(self) -> None: + self.clear_field_errors() + raw_url = (self._dialog.url_edit.text() or "").strip() + raw_name = (self._dialog.name_edit.text() or "").strip() + raw_filename = (self._dialog.filename_edit.text() or "").strip() + list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower() + name = raw_name + filename = safe_filename(raw_filename) + has_error = False + + if raw_url == "": + self._dialog.url_error_label.setText(QC.translate("stats", "URL is required.")) + has_error = True + elif not is_valid_url(raw_url): + self._dialog.url_error_label.setText( + QC.translate("stats", "Enter a valid http:// or https:// URL.") + ) + has_error = True + + if raw_name == "" and raw_filename == "": + self._dialog.name_error_label.setText( + QC.translate("stats", "Provide a name or filename.") + ) + self._dialog.filename_error_label.setText( + QC.translate("stats", "Provide a filename or name.") + ) + has_error = True + elif raw_filename != "" and filename != raw_filename: + self._dialog.filename_error_label.setText( + QC.translate("stats", "Filename must not include directory components.") + ) + has_error = True + + if has_error: + self.set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) + return + + if filename == "" and name != "": + filename = safe_filename(derive_filename(name, None, "")) + filename = ensure_filename_type_suffix(filename, list_type) + + if name == "" and filename != "": + name = deslugify_filename(filename, list_type) + + self._dialog.name_edit.setText(name) + self._dialog.filename_edit.setText(filename) + self._dialog.accept() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py new file mode 100644 index 0000000000..8850b012dc --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py @@ -0,0 +1,269 @@ +import os +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.models.subscriptions import ( + MutableSubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import ( + SubscriptionDialog, +) +from opensnitch.plugins.list_subscriptions._utils import ( + INTERVAL_UNITS, + SIZE_UNITS, + TIMEOUT_UNITS, + display_str, + normalize_groups, + safe_filename, + strip_or_none, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class SubscriptionEditController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def add_subscription_row(self): + dlg = SubscriptionDialog( + self._dialog, + self._dialog._global_defaults, + groups=self._dialog._selection_controller.known_groups(), + sub=MutableSubscriptionSpec.from_dict( + {"enabled": True}, + defaults=self._dialog._global_defaults, + require_url=False, + ensure_suffix=False, + ), + title="New subscription", + ) + dlg.log_message.connect(self._dialog._status_controller.log) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + + sub = dlg.subscription_spec() + with self._dialog._table_view_controller.sorting_suspended(): + self._dialog._table_data_controller.append_row(sub) + row = self._dialog.table.rowCount() - 1 + _, changed = self._dialog._table_data_controller.ensure_row_final_filename(row) + if changed: + self._dialog._table_data_controller.refresh_states() + + if not os.path.exists(self._dialog._action_path): + self._dialog._action_file_controller.create_action_file() + self._dialog._action_file_controller.save_action_file() + self._dialog._selection_controller.update_selected_actions_state() + + def edit_selected_subscription(self): + row = self._dialog.table.currentRow() + if row < 0: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select a subscription row first."), error=True + ) + return + with self._dialog._table_view_controller.sorting_suspended(): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is None: + enabled_item = self._dialog._table_data_controller.new_enabled_item(False) + self._dialog.table.setItem(row, self._col("enabled"), enabled_item) + + interval_ok, interval_val = self._dialog._table_data_controller.optional_int_from_text( + self._dialog._table_data_controller.cell_text(row, self._col("interval")), + "Interval", + row=row, + ) + timeout_ok, timeout_val = self._dialog._table_data_controller.optional_int_from_text( + self._dialog._table_data_controller.cell_text(row, self._col("timeout")), + "Timeout", + row=row, + ) + max_size_ok, max_size_val = self._dialog._table_data_controller.optional_int_from_text( + self._dialog._table_data_controller.cell_text(row, self._col("max_size")), + "Max size", + row=row, + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return + sub = MutableSubscriptionSpec( + enabled=enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + name=self._dialog._table_data_controller.cell_text(row, self._col("name")), + url=self._dialog._table_data_controller.cell_text(row, self._col("url")), + filename=self._dialog._table_data_controller.cell_text( + row, self._col("filename") + ), + format=self._dialog._table_data_controller.cell_text( + row, self._col("format") + ) or "hosts", + groups=normalize_groups( + self._dialog._table_data_controller.cell_text(row, self._col("group")) + ), + interval=interval_val, + interval_units=strip_or_none( + self._dialog._table_data_controller.cell_text( + row, self._col("interval_units") + ) + ), + timeout=timeout_val, + timeout_units=strip_or_none( + self._dialog._table_data_controller.cell_text( + row, self._col("timeout_units") + ) + ), + max_size=max_size_val, + max_size_units=strip_or_none( + self._dialog._table_data_controller.cell_text( + row, self._col("max_size_units") + ) + ), + ) + meta = self._dialog._table_data_controller.row_meta_snapshot(row) + dlg = SubscriptionDialog( + self._dialog, + self._dialog._global_defaults, + groups=self._dialog._selection_controller.known_groups(), + sub=sub, + meta=meta, + title="Edit subscription", + ) + dlg.log_message.connect(self._dialog._status_controller.log) + dlg._subscription_dialog_controller.connect_to_refresh_signal( + self._dialog.subscription_state_refreshed + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + updated = dlg.subscription_spec() + + with self._dialog._table_view_controller.sorting_suspended(): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is None: + enabled_item = self._dialog._table_data_controller.new_enabled_item(False) + self._dialog.table.setItem(row, self._col("enabled"), enabled_item) + enabled_item.setCheckState( + QtCore.Qt.CheckState.Checked + if bool(updated.enabled) + else QtCore.Qt.CheckState.Unchecked + ) + enabled_item.setData( + QtCore.Qt.ItemDataRole.UserRole, 1 if bool(updated.enabled) else 0 + ) + self._dialog._table_data_controller.set_text_item( + row, self._col("name"), updated.name + ) + self._dialog._table_data_controller.set_text_item( + row, self._col("url"), updated.url + ) + self._dialog._table_data_controller.set_text_item( + row, + self._col("filename"), + safe_filename(updated.filename), + ) + self._dialog._table_data_controller.set_text_item( + row, self._col("format"), updated.format + ) + self._dialog._table_data_controller.set_text_item( + row, self._col("group"), ", ".join(normalize_groups(updated.groups)) + ) + self._dialog._table_data_controller.set_text_item( + row, + self._col("interval"), + display_str(updated.interval), + ) + interval_units_val = display_str(updated.interval_units) + self._dialog._table_data_controller.set_text_item( + row, + self._col("interval_units"), + interval_units_val, + ) + self._dialog._table_data_controller.set_text_item( + row, + self._col("timeout"), + display_str(updated.timeout), + ) + timeout_units_val = display_str(updated.timeout_units) + self._dialog._table_data_controller.set_text_item( + row, + self._col("timeout_units"), + timeout_units_val, + ) + self._dialog._table_data_controller.set_text_item( + row, + self._col("max_size"), + display_str(updated.max_size), + ) + max_size_units_val = display_str(updated.max_size_units) + self._dialog._table_data_controller.set_text_item( + row, + self._col("max_size_units"), + max_size_units_val, + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, self._col("interval_units"), INTERVAL_UNITS, interval_units_val + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, self._col("timeout_units"), TIMEOUT_UNITS, timeout_units_val + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, self._col("max_size_units"), SIZE_UNITS, max_size_units_val + ) + _, changed = self._dialog._table_data_controller.ensure_row_final_filename(row) + self._dialog._table_data_controller.update_row_sort_keys(row) + self._dialog._action_file_controller.save_action_file() + self._dialog._table_data_controller.refresh_states() + if changed: + self._dialog._status_controller.set_status( + QC.translate("stats", "Subscription updated and filename normalized."), + error=False, + ) + else: + self._dialog._status_controller.set_status( + QC.translate("stats", "Subscription updated."), error=False + ) + + def edit_action_clicked(self): + rows = self._dialog._selection_controller.selected_rows() + if len(rows) == 0: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select one or more subscriptions first."), + error=True, + ) + return + if len(rows) == 1: + self.edit_selected_subscription() + return + self._dialog._bulk_edit_controller.bulk_edit(rows) + + def remove_selected_subscription(self): + rows = self._dialog._selection_controller.selected_rows() + if not rows: + row = self._dialog.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select one or more subscription rows first."), + error=True, + ) + return + for row in sorted(rows, reverse=True): + self._dialog.table.removeRow(row) + self._dialog._action_file_controller.save_action_file() + self._dialog._table_data_controller.refresh_states() + self._dialog._selection_controller.update_selected_actions_state() + self._dialog._status_controller.set_status( + QC.translate("stats", "Selected subscriptions removed."), error=False + ) + + def handle_table_item_double_clicked(self, item: QtWidgets.QTableWidgetItem): + if item is not None: + self._dialog.table.selectRow(item.row()) + self.edit_selected_subscription() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py new file mode 100644 index 0000000000..f76a8c0ce6 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py @@ -0,0 +1,86 @@ +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.subscription_status_dialog import ( + SubscriptionStatusDialog, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class SubscriptionStatusController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def show_selected_subscription_status(self): + rows = self._dialog._selection_controller.selected_rows() + if not rows: + row = self._dialog.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select a subscription row first."), + error=True, + ) + return + + row = rows[0] + name = self._dialog._table_data_controller.cell_text(row, self._col("name")) + url = self._dialog._table_data_controller.cell_text(row, self._col("url")) + filename = self._dialog._table_data_controller.cell_text( + row, + self._col("filename"), + ) + meta = self.meta_snapshot_by_identity(url, filename) + if meta is None: + meta = self._dialog._table_data_controller.row_meta_snapshot(row) + + dlg = SubscriptionStatusDialog( + self._dialog, + name=name, + url=url, + filename=filename, + meta=meta, + ) + dlg.connect_to_refresh_signal( + self._dialog.subscription_state_refreshed + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + + action = dlg.action() + if action == SubscriptionStatusDialog.ACTION_EDIT: + self._dialog._subscription_edit_controller.edit_selected_subscription() + elif action == SubscriptionStatusDialog.ACTION_REFRESH: + self._dialog._table_data_controller.refresh_selected_now() + + def find_row_by_identity(self, url: str, filename: str): + for row in range(self._dialog.table.rowCount()): + if self._dialog._table_data_controller.cell_text(row, self._col("url")) != url: + continue + if ( + self._dialog._table_data_controller.cell_text( + row, + self._col("filename"), + ) + != filename + ): + continue + return row + return -1 + + def meta_snapshot_by_identity(self, url: str, filename: str): + row = self.find_row_by_identity(url, filename) + if row < 0: + return None + return self._dialog._table_data_controller.row_meta_snapshot(row) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py new file mode 100644 index 0000000000..191ab8879e --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py @@ -0,0 +1,1265 @@ +import json +import os +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import requests + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.models.subscriptions import ( + MutableSubscriptionSpec, + SubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions.models.events import ( + SubscriptionEventPayload, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import ( + SortableTableWidgetItem, +) +from opensnitch.plugins.list_subscriptions.ui.workers import ( + SubscriptionStateRefreshWorker, +) +from opensnitch.plugins.list_subscriptions._utils import ( + DEFAULT_LISTS_DIR, + INTERVAL_UNITS, + SIZE_UNITS, + TIMEOUT_UNITS, + derive_filename, + display_str, + ensure_filename_type_suffix, + filename_from_content_disposition, + list_file_path, + normalize_groups, + normalize_lists_dir, + safe_filename, + strip_or_none, + subscription_rule_dir, + timestamp_sort_key, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +ATTACHED_RULES_REFRESH_INTERVAL_MS = 2_000 + + +class TableDataController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + self._poll_timer = QtCore.QTimer(dialog) + self._poll_timer.setInterval(2000) + self._poll_timer.timeout.connect( + lambda: dialog.isVisible() + and dialog.isActiveWindow() + and (not dialog._loading) + and self.refresh_attached_rules_only() + ) + self._refresh_generation = 0 + self._refresh_worker: SubscriptionStateRefreshWorker | None = None + self._refresh_thread: QtCore.QThread | None = None + self._refresh_stopped_callbacks: list[Callable[[], None]] = [] + self._pending_refresh_job: dict[str, Any] | None = None + self._pending_attached_rules_refresh = False + self._last_attached_rules_refresh_ms = 0 + self._shutting_down = False + dialog.destroyed.connect(self._on_dialog_destroyed) + app = QtCore.QCoreApplication.instance() + if app is not None: + app.aboutToQuit.connect(self._on_app_about_to_quit) + + def start_poll(self): + if self._shutting_down: + return + if not self._poll_timer.isActive(): + self._poll_timer.start() + + def stop_poll(self): + if self._poll_timer.isActive(): + self._poll_timer.stop() + + def pause_for_focus_loss(self): + self.stop_poll() + self.cancel_active_refresh() + + def resume_for_focus_gain(self): + if self._dialog.isVisible() and not self._dialog._loading: + self.start_poll() + + def _on_dialog_destroyed(self, *_args): + self.shutdown_refresh_worker(wait_ms=3000) + + def _on_app_about_to_quit(self): + self.shutdown_refresh_worker(wait_ms=3000) + + def has_active_refresh(self) -> bool: + thread = self._refresh_thread + return bool(thread is not None and thread.isRunning()) + + def on_refresh_stopped(self, callback: Callable[[], None]) -> None: + if not self.has_active_refresh(): + callback() + return + self._refresh_stopped_callbacks.append(callback) + + def cancel_active_refresh(self): + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None or not thread.isRunning(): + return + worker.stop() + thread.quit() + + def wait_for_active_refresh_stop(self, wait_ms: int = 1200) -> bool: + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None: + return True + if not thread.isRunning(): + self._refresh_worker = None + self._refresh_thread = None + return True + + worker.stop() + thread.quit() + if wait_ms <= 0: + stopped = thread.wait() + else: + stopped = thread.wait(wait_ms) + if stopped: + self._refresh_worker = None + self._refresh_thread = None + return bool(stopped) + + def shutdown_refresh_worker(self, wait_ms: int = 2000) -> bool: + self._shutting_down = True + self._pending_refresh_job = None + self._refresh_generation += 1 + + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None: + self._refresh_worker = None + self._refresh_thread = None + return True + + try: + worker.refresh_done.disconnect(self._on_state_refresh_worker_finished) + except Exception: + pass + + if thread.isRunning(): + worker.stop() + thread.quit() + if wait_ms <= 0: + thread.wait() + else: + thread.wait(wait_ms) + if thread.isRunning(): + thread.terminate() + thread.wait(500) + + if thread.isRunning(): + self._refresh_worker = worker + self._refresh_thread = thread + return False + + self._refresh_worker = None + self._refresh_thread = None + return True + + # -- Shared primitives ------------------------------------------------- + def _col(self, key: str): + return self._cols[key] + + def new_enabled_item(self, enabled: bool) -> SortableTableWidgetItem: + item = SortableTableWidgetItem("") + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + QtCore.Qt.CheckState.Checked if enabled else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, 1 if enabled else 0) + return item + + def update_row_sort_keys(self, row: int): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is None: + return + enabled_rank = ( + 0 if enabled_item.checkState() == QtCore.Qt.CheckState.Checked else 1 + ) + enabled_item.setData(QtCore.Qt.ItemDataRole.UserRole, enabled_rank) + + def sort_key_for_column(self, col: int, text: str): + value = (text or "").strip() + if col in ( + self._col("interval"), + self._col("timeout"), + self._col("max_size"), + ): + if value == "": + return -1 + try: + return int(value) + except Exception: + return value.lower() + if col in (self._col("last_checked"), self._col("last_updated")): + return timestamp_sort_key(value) + if col == self._col("state"): + normalized = (value or "").strip().lower() + if normalized in ("updated", "not_modified"): + return 0, normalized + if normalized == "pending": + return 1, normalized + return 2, normalized + return value.lower() + + def state_text_color(self, state: str): + palette = self._dialog.table.palette() + dark_theme = palette.base().color().lightness() < 128 + + if dark_theme: + colors = { + "disabled": "#B8C0CC", + "pending": "#F5D76E", + "busy": "#F5D76E", + "missing": "#FF8A80", + "updated": "#7CE3A1", + "not_modified": "#86C5FF", + "error": "#FF8A80", + "write_error": "#FF8A80", + "request_error": "#FF8A80", + "unexpected_error": "#FF8A80", + "bad_format": "#FF8A80", + "too_large": "#FF8A80", + "other": "#F7E37A", + } + else: + colors = { + "disabled": "#6B7280", + "pending": "#9A6700", + "busy": "#9A6700", + "missing": "#C62828", + "updated": "#0F8A4B", + "not_modified": "#1565C0", + "error": "#C62828", + "write_error": "#C62828", + "request_error": "#C62828", + "unexpected_error": "#C62828", + "bad_format": "#C62828", + "too_large": "#C62828", + "other": "#8D6E00", + } + + return QtGui.QColor(colors.get(state, colors["other"])) + + def status_log_level_color(self, level: str) -> str: + normalized = (level or "").strip().upper() + if normalized in ("TRACE",): + return self.state_text_color("disabled").name() + if normalized in ("DEBUG",): + return self.state_text_color("other").name() + if normalized in ("INFO",): + return self.state_text_color("not_modified").name() + if normalized in ("SUCCESS",): + return self.state_text_color("updated").name() + if normalized == "ERROR": + return self.state_text_color("error").name() + if normalized in ("WARN", "WARNING"): + return self.state_text_color("pending").name() + return self.state_text_color("not_modified").name() + + # -- Table interaction ------------------------------------------------- + def handle_table_clicked(self, index: QtCore.QModelIndex): + if not index.isValid() or index.column() != self._col("enabled"): + return + item = self._dialog.table.item(index.row(), self._col("enabled")) + if item is None: + return + checked = item.checkState() != QtCore.Qt.CheckState.Checked + item.setCheckState( + QtCore.Qt.CheckState.Checked if checked else QtCore.Qt.CheckState.Unchecked + ) + self.update_row_sort_keys(index.row()) + self._dialog._table_view_controller.apply_table_view_mode() + + # -- Runtime refresh helpers ------------------------------------------ + def _find_runtime_subscription(self, plug: Any, url: str, filename: str): + try: + for sub in plug._config.subscriptions: + if sub.url == url and sub.filename == filename: + return sub + except Exception: + return None + return None + + def _build_subscription_from_row( + self, + *, + plug: Any, + row: int, + enabled_from_row: bool, + ): + try: + interval_ok, interval_val = self.optional_int_from_text( + self.cell_text(row, self._col("interval")), + "Interval", + row=row, + ) + timeout_ok, timeout_val = self.optional_int_from_text( + self.cell_text(row, self._col("timeout")), + "Timeout", + row=row, + ) + max_size_ok, max_size_val = self.optional_int_from_text( + self.cell_text(row, self._col("max_size")), + "Max size", + row=row, + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return None + + enabled = True + if enabled_from_row: + enabled_item = self._dialog.table.item(row, self._col("enabled")) + enabled = ( + enabled_item is None + or enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ) + + row_sub_edit = MutableSubscriptionSpec( + enabled=enabled, + name=self.cell_text(row, self._col("name")), + url=self.cell_text(row, self._col("url")), + filename=self.cell_text(row, self._col("filename")), + format=self.cell_text(row, self._col("format")) or "hosts", + groups=normalize_groups(self.cell_text(row, self._col("group"))), + interval=interval_val, + interval_units=strip_or_none( + self.cell_text(row, self._col("interval_units")) + ), + timeout=timeout_val, + timeout_units=strip_or_none( + self.cell_text(row, self._col("timeout_units")) + ), + max_size=max_size_val, + max_size_units=strip_or_none( + self.cell_text(row, self._col("max_size_units")) + ), + ) + return SubscriptionSpec.from_dict( + row_sub_edit.to_dict(), + plug._config.defaults, + ) + except Exception: + return None + + def _resolve_target_subscription( + self, + *, + plug: Any, + row: int, + enabled_from_row: bool, + ): + url = self.cell_text(row, self._col("url")) + filename = self.cell_text(row, self._col("filename")) + target_sub = self._find_runtime_subscription(plug, url, filename) + if target_sub is not None: + return target_sub + return self._build_subscription_from_row( + plug=plug, + row=row, + enabled_from_row=enabled_from_row, + ) + + # -- Runtime refresh actions ------------------------------------------ + def refresh_selected_now(self): + rows = self._dialog._selection_controller.selected_rows() + if not rows: + row = self._dialog.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "Select one or more subscription rows first."), + error=True, + ) + return + + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + refresh_targets: list[tuple[SubscriptionSpec, str]] = [] + filename_changed = False + for row in rows: + url = self.cell_text(row, self._col("url")) + filename, row_filename_changed = self.ensure_row_final_filename(row) + if url == "" or filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return + filename_changed = filename_changed or row_filename_changed + + if filename_changed: + self._dialog._action_file_controller.save_action_file() + + for row in rows: + target_sub = self._resolve_target_subscription( + plug=plug, + row=row, + enabled_from_row=False, + ) + if target_sub is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Internal error: target_sub is None."), + error=True, + ) + return + list_path, _ = plug._paths(target_sub) + refresh_targets.append((target_sub, list_path)) + + refresh_keys = {plug._sub_key(target_sub) for target_sub, _ in refresh_targets} + self._dialog._runtime_controller.track_refresh_keys(refresh_keys) + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Manual refresh requested for {0} selected subscription(s)." + ).format(len(refresh_targets)), + ) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._dialog._action_path, + "source": "manual_refresh", + "items": [ + SubscriptionEventPayload( + enabled=target_sub.enabled, + name=target_sub.name, + url=target_sub.url, + filename=target_sub.filename, + format=target_sub.format, + groups=list(target_sub.groups), + interval=target_sub.interval, + interval_units=target_sub.interval_units, + timeout=target_sub.timeout, + timeout_units=target_sub.timeout_units, + max_size=target_sub.max_size, + max_size_units=target_sub.max_size_units, + ) + for target_sub, _ in refresh_targets + ], + } + ) + if len(refresh_targets) == 1: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Subscription refresh triggered. Destination: {0}" + ).format(refresh_targets[0][1]), + error=False, + ) + return + + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Bulk refresh triggered for {0} selected subscriptions." + ).format(len(refresh_targets)), + error=False, + ) + + def refresh_all_now(self): + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + rows = list(range(self._dialog.table.rowCount())) + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return + + filename_changed = False + for row in rows: + url = self.cell_text(row, self._col("url")) + filename, row_filename_changed = self.ensure_row_final_filename(row) + if url == "" or filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return + filename_changed = filename_changed or row_filename_changed + + if filename_changed: + self._dialog._action_file_controller.save_action_file() + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + refresh_targets: list[SubscriptionSpec] = [] + for row in rows: + target_sub = self._resolve_target_subscription( + plug=plug, + row=row, + enabled_from_row=True, + ) + if target_sub is not None: + refresh_targets.append(target_sub) + + if not refresh_targets: + self._dialog._status_controller.set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return + + refresh_keys = {plug._sub_key(sub) for sub in refresh_targets} + self._dialog._runtime_controller.track_refresh_keys(refresh_keys) + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Manual refresh requested for all listed subscriptions ({0})." + ).format(len(refresh_targets)), + ) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._dialog._action_path, + "source": "manual_refresh", + "items": [ + SubscriptionEventPayload( + enabled=sub.enabled, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + groups=list(sub.groups), + interval=sub.interval, + interval_units=sub.interval_units, + timeout=sub.timeout, + timeout_units=sub.timeout_units, + max_size=sub.max_size, + max_size_units=sub.max_size_units, + ) + for sub in refresh_targets + ], + } + ) + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Bulk refresh triggered for all listed subscriptions." + ), + error=False, + ) + + # -- Subscription data collection ------------------------------------- + def collect_subscriptions(self): + out: list[MutableSubscriptionSpec] = [] + auto_filled = 0 + for row in range(self._dialog.table.rowCount()): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + interval = self.cell_text(row, self._col("interval")) + interval_units = self.cell_text(row, self._col("interval_units")) + timeout = self.cell_text(row, self._col("timeout")) + timeout_units = self.cell_text(row, self._col("timeout_units")) + max_size = self.cell_text(row, self._col("max_size")) + max_size_units = self.cell_text(row, self._col("max_size_units")) + name = self.cell_text(row, self._col("name")) + url = self.cell_text(row, self._col("url")) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + groups = normalize_groups(self.cell_text(row, self._col("group"))) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + if filename == "": + filename = self.guess_filename(name, url) + if filename != "": + auto_filled += 1 + filename = ensure_filename_type_suffix(filename, list_type) + self.set_text_item(row, self._col("filename"), filename) + interval_ok, interval_val = self.optional_int_from_text( + interval, "Interval", row=row + ) + timeout_ok, timeout_val = self.optional_int_from_text( + timeout, "Timeout", row=row + ) + max_size_ok, max_size_val = self.optional_int_from_text( + max_size, "Max size", row=row + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return None + sub = MutableSubscriptionSpec( + enabled=enabled_item is not None + and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + name=name, + url=url, + filename=filename, + format=list_type, + groups=groups, + interval=interval_val, + interval_units=strip_or_none(interval_units), + timeout=timeout_val, + timeout_units=strip_or_none(timeout_units), + max_size=max_size_val, + max_size_units=strip_or_none(max_size_units), + ) + if sub.url == "" or sub.filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return None + out.append(sub) + + if auto_filled > 0: + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Auto-filled filename for {0} subscription(s)." + ).format(auto_filled), + level="WARN", + ) + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Auto-filled filename for {0} subscription(s)." + ).format(auto_filled), + error=False, + ) + return out + + # -- Filename derivation and normalization ---------------------------- + def guess_filename(self, name: str, url: str): + from_header = self.filename_from_headers(url) + return safe_filename(derive_filename(name, url, "", from_header)) + + def filename_from_headers(self, url: str): + if (url or "").strip() == "": + return "" + try: + r = requests.head(url, allow_redirects=True, timeout=5) + cd = r.headers.get("Content-Disposition", "") + if cd: + return filename_from_content_disposition(cd) + except Exception: + return "" + return "" + + def ensure_row_final_filename(self, row: int): + name = self.cell_text(row, self._col("name")) + url = self.cell_text(row, self._col("url")) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + original = safe_filename(self.cell_text(row, self._col("filename"))) + final_name = original + changed = False + + if final_name == "": + final_name = self.guess_filename(name, url) + changed = final_name != "" + final_name = ensure_filename_type_suffix(final_name, list_type) + if final_name != original: + changed = True + + if final_name != "": + key = final_name + existing: set[str] = set() + for i in range(self._dialog.table.rowCount()): + if i == row: + continue + other = safe_filename(self.cell_text(i, self._col("filename"))) + if other != "": + existing.add(other) + if key in existing: + base, ext = os.path.splitext(final_name) + n = 2 + candidate = final_name + while candidate in existing: + suffix = f"-{n}" + candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" + n += 1 + final_name = candidate + changed = True + + if changed: + self.set_text_item(row, self._col("filename"), final_name) + return final_name, changed + + # -- Row state and metadata ------------------------------------------- + def row_meta_snapshot(self, row: int): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + current_rule_attached = ( + self.cell_text(row, self._col("rule_attached")) or "no" + ).strip().lower() + if current_rule_attached not in ("yes", "no"): + current_rule_attached = "no" + list_path = list_file_path(lists_dir, filename, list_type) + meta_path = list_path + ".meta.json" + + file_exists = os.path.exists(list_path) + meta_exists = os.path.exists(meta_path) + meta: dict[str, Any] = {} + if meta_exists: + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception: + meta = {} + + return { + "file_present": "yes" if file_exists else "no", + "meta_present": "yes" if meta_exists else "no", + "state": str( + meta.get( + "last_result", + self.cell_text(row, self._col("state")) or "never", + ) + ), + "rule_attached": current_rule_attached, + "rule_attached_detail": current_rule_attached, + "last_checked": str( + meta.get( + "last_checked", + self.cell_text(row, self._col("last_checked")) or "", + ) + ), + "last_updated": str( + meta.get( + "last_updated", + self.cell_text(row, self._col("last_updated")) or "", + ) + ), + "failures": str(meta.get("fail_count", "0")), + "error": str(meta.get("last_error", "")), + "list_path": list_path, + "meta_path": meta_path, + } + + def apply_url_error_indicator( + self, + row: int, + *, + enabled: bool, + state: str, + last_error: str, + ): + url_item = self._dialog.table.item(row, self._col("url")) + if url_item is None: + return + + normalized_state = (state or "").strip().lower() + has_error = enabled and ( + normalized_state + in { + "missing", + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + } + or (last_error or "").strip() != "" + ) + + if not has_error: + url_item.setIcon(QtGui.QIcon()) + url_item.setToolTip(url_item.text()) + url_item.setForeground( + self._dialog.table.palette().brush(QtGui.QPalette.ColorRole.Text) + ) + return + + style = self._dialog.table.style() + if style is not None: + url_item.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + ) + if (last_error or "").strip() != "": + url_item.setToolTip( + QC.translate("stats", "Subscription error: {0}").format(last_error) + ) + else: + url_item.setToolTip( + QC.translate("stats", "Subscription error state: {0}").format( + normalized_state + ) + ) + url_item.setForeground(QtGui.QBrush(self.state_text_color("other"))) + + def refresh_states(self): + if self._shutting_down: + return + if self._refresh_thread is not None and self._refresh_thread.isRunning(): + # Worker already running: defer the next refresh request without + # recomputing expensive snapshots on the UI thread. + self._pending_refresh_job = {"deferred": True} + return + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + rows: list[dict[str, Any]] = [] + for row in range(self._dialog.table.rowCount()): + filename_item = self._dialog.table.item(row, self._col("filename")) + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if filename_item is None or enabled_item is None: + continue + rows.append( + { + "row": row, + "url": self.cell_text(row, self._col("url")), + "filename": safe_filename(filename_item.text()), + "list_type": (self.cell_text(row, self._col("format")) or "hosts") + .strip() + .lower(), + "enabled": enabled_item.checkState() + == QtCore.Qt.CheckState.Checked, + "groups": normalize_groups(self.cell_text(row, self._col("group"))), + } + ) + + self._refresh_generation += 1 + job = { + "generation": self._refresh_generation, + "lists_dir": lists_dir, + "rows": rows, + # Attached-rules snapshot is intentionally omitted in background + # refresh jobs to avoid automatic DB scans on the UI path. + "attached_rules_by_dir": {}, + } + + self._start_state_refresh_worker(job) + + def refresh_attached_rules_only(self): + # Attached-rules DB scan is on-demand only (explicit user action) + # to avoid blocking UI/daemon during background polling. + return + + def _start_state_refresh_worker(self, job: dict[str, Any]): + if self._shutting_down: + return + self._pending_refresh_job = None + thread = QtCore.QThread(self._dialog) + thread.setObjectName("SubscriptionStateRefreshWorkerThread") + worker = SubscriptionStateRefreshWorker( + generation=int(job["generation"]), + lists_dir=str(job["lists_dir"]), + rows=list(job["rows"]), + attached_rules_by_dir=dict(job["attached_rules_by_dir"]), + ) + worker.setObjectName("SubscriptionStateRefreshWorker") + worker.moveToThread(thread) + self._refresh_worker = worker + self._refresh_thread = thread + thread.started.connect(worker.run) + worker.refresh_done.connect(self._on_state_refresh_worker_finished) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_state_refresh_worker_stopped) + thread.finished.connect(thread.deleteLater) + thread.start() + + def _on_state_refresh_worker_stopped(self) -> None: + self._refresh_worker = None + self._refresh_thread = None + callbacks = self._refresh_stopped_callbacks[:] + self._refresh_stopped_callbacks.clear() + for callback in callbacks: + callback() + if not self._shutting_down and self._pending_refresh_job is not None: + job = self._pending_refresh_job + self._pending_refresh_job = None + if bool(job.get("deferred")): + self.refresh_states() + return + self._start_state_refresh_worker(job) + return + if not self._shutting_down and self._pending_attached_rules_refresh: + self._pending_attached_rules_refresh = False + self.refresh_attached_rules_only() + + def _on_state_refresh_worker_finished( + self, + generation: int, + results: list[dict[str, Any]], + ): + if self._shutting_down: + return + + if generation != self._refresh_generation: + return + + result_by_row: dict[int, dict[str, Any]] = { + int(item.get("row", -1)): item for item in results + } + + with self._dialog._table_view_controller.sorting_suspended(): + for row in range(self._dialog.table.rowCount()): + result = result_by_row.get(row) + if result is None: + continue + + enabled = bool(result.get("enabled", True)) + state = str(result.get("state", "")) + last_error = str(result.get("error", "")) + rule_attached = str(result.get("rule_attached", "no")) + last_checked = str(result.get("last_checked", "")) + last_updated = str(result.get("last_updated", "")) + attachment_matches = list(result.get("attachment_matches", [])) + rule_attached_detail = self.rule_attachment_detail(attachment_matches) + + fg_color = self.state_text_color(state if state != "" else "other") + + self.set_text_item( + row, + self._col("file"), + str(result.get("file_present", "no")), + editable=False, + ) + self.set_text_item( + row, + self._col("meta"), + str(result.get("meta_present", "no")), + editable=False, + ) + self.set_text_item(row, self._col("state"), state, editable=False) + self.set_text_item( + row, + self._col("rule_attached"), + rule_attached, + editable=False, + ) + self.set_text_item( + row, + self._col("last_checked"), + last_checked, + editable=False, + ) + self.set_text_item( + row, + self._col("last_updated"), + last_updated, + editable=False, + ) + self.apply_url_error_indicator( + row, + enabled=enabled, + state=state, + last_error=last_error, + ) + + for col in ( + self._col("file"), + self._col("meta"), + self._col("state"), + self._col("rule_attached"), + self._col("last_checked"), + self._col("last_updated"), + ): + item = self._dialog.table.item(row, col) + if item is not None: + item.setForeground(fg_color) + + self.update_row_sort_keys(row) + self._dialog.subscription_state_refreshed.emit( + str(result.get("url", "")), + str(result.get("filename", "")), + { + "file_present": str(result.get("file_present", "no")), + "meta_present": str(result.get("meta_present", "no")), + "state": state, + "rule_attached": rule_attached, + "rule_attached_detail": rule_attached_detail, + "last_checked": last_checked, + "last_updated": last_updated, + "failures": str(result.get("failures", "0")), + "error": last_error, + "list_path": str(result.get("list_path", "")), + "meta_path": str(result.get("meta_path", "")), + }, + ) + + def rule_attachment_matches( + self, + lists_dir: str, + filename: str, + list_type: str, + groups: list[str], + *, + attached_rules_by_dir: dict[str, list[dict[str, Any]]] | None = None, + include_disabled: bool = False, + ): + snapshot = ( + attached_rules_by_dir + if attached_rules_by_dir is not None + else self._dialog._rules_attachment_controller.attached_rules_snapshot() + ) + rules_root = os.path.join(lists_dir, "rules.list.d") + candidate_dirs = [ + ( + "subscription", + os.path.normpath(subscription_rule_dir(lists_dir, filename, list_type)), + ), + ("all", os.path.normpath(os.path.join(rules_root, "all"))), + ] + candidate_dirs.extend( + (f"group:{group}", os.path.normpath(os.path.join(rules_root, group))) + for group in groups + ) + + matches: list[dict[str, Any]] = [] + seen_match: set[tuple[str, str, str]] = set() + for source, directory in candidate_dirs: + for rule_entry in snapshot.get(directory, []): + addr = str(rule_entry.get("addr", "")).strip() + name = str(rule_entry.get("name", "")).strip() + enabled = bool(rule_entry.get("enabled", True)) + if addr == "" or name == "": + continue + if not include_disabled and not enabled: + continue + key = (addr, name, source) + if key in seen_match: + continue + seen_match.add(key) + matches.append( + { + "addr": addr, + "name": name, + "enabled": enabled, + "source": source, + "directory": directory, + } + ) + + matches.sort( + key=lambda item: (item["name"].lower(), item["addr"], item["source"]) + ) + return matches + + def rule_attachment_detail(self, matches: list[dict[str, Any]]): + if not matches: + return "no" + + unique_rules = {(entry["addr"], entry["name"]) for entry in matches} + sources_text = ( + self._dialog._rules_attachment_controller.rule_attachment_scope_summary( + matches + ) + ) + return QC.translate("stats", "yes ({0} rules via {1})").format( + len(unique_rules), + sources_text, + ) + + def rule_attached_value( + self, + lists_dir: str, + filename: str, + list_type: str, + groups: list[str], + *, + attached_rules_by_dir: dict[str, list[dict[str, Any]]] | None = None, + ): + matches = self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + attached_rules_by_dir=attached_rules_by_dir, + ) + return "yes" if matches else "no" + + def attached_rules_for_row( + self, + row: int, + *, + include_disabled: bool = False, + ): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + groups = normalize_groups(self.cell_text(row, self._col("group"))) + return self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + include_disabled=include_disabled, + ) + + # -- Row creation and cell access ------------------------------------- + def append_row(self, sub: MutableSubscriptionSpec): + row = self._dialog.table.rowCount() + self._dialog.table.insertRow(row) + enabled_item = self.new_enabled_item(bool(sub.enabled)) + self._dialog.table.setItem(row, self._col("enabled"), enabled_item) + + self.set_text_item(row, self._col("name"), str(sub.name)) + self.set_text_item(row, self._col("url"), str(sub.url)) + self.set_text_item(row, self._col("filename"), safe_filename(sub.filename)) + self.set_text_item(row, self._col("format"), str(sub.format)) + groups = normalize_groups(sub.groups) + self.set_text_item(row, self._col("group"), ", ".join(groups)) + interval = sub.interval + timeout = sub.timeout + max_size = sub.max_size + interval_units = sub.interval_units + timeout_units = sub.timeout_units + max_size_units = sub.max_size_units + self.set_text_item(row, self._col("interval"), display_str(interval)) + self.set_text_item( + row, + self._col("interval_units"), + display_str(interval_units), + ) + self.set_text_item(row, self._col("timeout"), display_str(timeout)) + self.set_text_item(row, self._col("timeout_units"), display_str(timeout_units)) + self.set_text_item(row, self._col("max_size"), display_str(max_size)) + self.set_text_item( + row, + self._col("max_size_units"), + display_str(max_size_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("interval_units"), + INTERVAL_UNITS, + display_str(interval_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("timeout_units"), + TIMEOUT_UNITS, + display_str(timeout_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("max_size_units"), + SIZE_UNITS, + display_str(max_size_units), + ) + + self.set_text_item(row, self._col("file"), "", editable=False) + self.set_text_item(row, self._col("meta"), "", editable=False) + self.set_text_item(row, self._col("state"), "", editable=False) + self.set_text_item(row, self._col("rule_attached"), "", editable=False) + self.set_text_item(row, self._col("last_checked"), "", editable=False) + self.set_text_item(row, self._col("last_updated"), "", editable=False) + self.apply_url_error_indicator( + row, + enabled=bool(sub.enabled), + state="", + last_error="", + ) + self.update_row_sort_keys(row) + + def set_text_item(self, row: int, col: int, text: str, editable: bool = True): + item = self._dialog.table.item(row, col) + if item is None: + item = SortableTableWidgetItem() + self._dialog.table.setItem(row, col, item) + item.setText(text) + item.setData( + QtCore.Qt.ItemDataRole.UserRole, + self.sort_key_for_column(col, text), + ) + if editable: + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) + else: + item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + + def cell_text(self, row: int, col: int): + widget = self._dialog.table.cellWidget(row, col) + if isinstance(widget, QtWidgets.QComboBox): + return (widget.currentText() or "").strip() + item = self._dialog.table.item(row, col) + if item is None: + return "" + return (item.text() or "").strip() + + def optional_int_from_text( + self, value: Any, field_name: str, row: int | None = None + ): + if value == "": + return True, None + parsed = self.to_int_or_keep(value, field_name, row=row) + if parsed is None: + return False, None + return True, parsed + + def to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): + try: + parsed = int(value) + except Exception: + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._dialog._status_controller.set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + if parsed < 1: + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._dialog._status_controller.set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + return parsed diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py new file mode 100644 index 0000000000..aa94a73251 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py @@ -0,0 +1,226 @@ +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class TableViewController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + + def _col(self, key: str): + return self._cols[key] + + def _visible_columns(self) -> list[int]: + return [ + col + for col in range(self._dialog.table.columnCount()) + if not self._dialog.table.isColumnHidden(col) + ] + + def _expand_visible_columns_to_viewport(self, base_widths: dict[int, int]) -> None: + visible_cols = [col for col in self._visible_columns() if col in base_widths] + if not visible_cols: + return + + viewport_width = self._dialog.table.viewport().width() + if viewport_width <= 0: + return + + min_total = sum(max(1, int(base_widths.get(col, 1))) for col in visible_cols) + if viewport_width <= min_total: + return + + extra = viewport_width - min_total + add_each = extra // len(visible_cols) + remainder = extra % len(visible_cols) + + for idx, col in enumerate(visible_cols): + target = max(1, int(base_widths.get(col, 1))) + add_each + if idx < remainder: + target += 1 + self._dialog.table.setColumnWidth(col, target) + + @contextmanager + def sorting_suspended(self): + header = self._dialog.table.horizontalHeader() + sorting_enabled = self._dialog.table.isSortingEnabled() + sort_section = header.sortIndicatorSection() if header is not None else -1 + sort_order = ( + header.sortIndicatorOrder() + if header is not None + else QtCore.Qt.SortOrder.AscendingOrder + ) + self._dialog.table.setSortingEnabled(False) + try: + yield + finally: + self._dialog.table.setSortingEnabled(sorting_enabled) + if sorting_enabled and header is not None and sort_section >= 0: + self._dialog.table.sortItems(sort_section, sort_order) + self.apply_table_view_mode() + + def on_table_view_tab_changed(self, index: int): + monitoring = index == 0 + always_hidden = { + self._col("interval"), + self._col("interval_units"), + self._col("timeout"), + self._col("timeout_units"), + self._col("max_size"), + self._col("max_size_units"), + self._col("file"), + self._col("meta"), + self._col("rule_attached"), + } + monitoring_only = { + self._col("state"), + self._col("last_checked"), + self._col("last_updated"), + } + config_only = { + self._col("enabled"), + self._col("url"), + self._col("filename"), + self._col("format"), + self._col("group"), + } + for col in range(self._dialog.table.columnCount()): + if col in always_hidden: + self._dialog.table.setColumnHidden(col, True) + elif col in monitoring_only: + self._dialog.table.setColumnHidden(col, not monitoring) + elif col in config_only: + self._dialog.table.setColumnHidden(col, monitoring) + self.apply_table_column_sizing(index) + self.apply_table_view_mode(index, set_sort=True) + self._dialog._inspector_controller.update_inspector_panel() + + def apply_table_column_sizing(self, index: int | None = None): + header = self._dialog.table.horizontalHeader() + if header is None: + return + + monitoring = ( + index == 0 + if index is not None + else self._dialog._table_tab_bar.currentIndex() == 0 + ) + tab_index = 0 if monitoring else 1 + resized_columns = self._dialog._user_resized_columns_by_tab.get(tab_index, set()) + + header.setStretchLastSection(False) + self._dialog._applying_table_column_sizing = True + base_widths: dict[int, int] = {} + + try: + if monitoring: + # Monitoring: all visible data columns are user-resizable. + header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(self._col("state"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode( + self._col("last_checked"), QtWidgets.QHeaderView.ResizeMode.Interactive + ) + header.setSectionResizeMode( + self._col("last_updated"), QtWidgets.QHeaderView.ResizeMode.Interactive + ) + if self._col("name") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("name"), 260) + if self._col("state") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("state"), 140) + if self._col("last_checked") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("last_checked"), 260) + if self._col("last_updated") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("last_updated"), 260) + for col in self._visible_columns(): + base_widths[col] = self._dialog.table.columnWidth(col) + self._expand_visible_columns_to_viewport(base_widths) + return + + # Config: keep URL flexible, reserve enough space for frequently edited fields. + header.setSectionResizeMode(self._col("enabled"), QtWidgets.QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(self._col("url"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(self._col("filename"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(self._col("format"), QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSectionResizeMode(self._col("group"), QtWidgets.QHeaderView.ResizeMode.Interactive) + + if self._col("name") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("name"), 220) + if self._col("url") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("url"), 380) + if self._col("filename") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("filename"), 220) + if self._col("format") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("format"), 120) + if self._col("group") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("group"), 180) + + for col in self._visible_columns(): + base_widths[col] = self._dialog.table.columnWidth(col) + self._expand_visible_columns_to_viewport(base_widths) + finally: + self._dialog._applying_table_column_sizing = False + + def on_table_section_resized(self, logical_index: int, _old_size: int, _new_size: int): + if self._dialog._applying_table_column_sizing: + return + if logical_index < 0 or logical_index >= self._dialog.table.columnCount(): + return + if self._dialog.table.isColumnHidden(logical_index): + return + if not hasattr(self._dialog, "_table_tab_bar"): + return + tab_index = self._dialog._table_tab_bar.currentIndex() + self._dialog._user_resized_columns_by_tab.setdefault(tab_index, set()).add(logical_index) + + def reset_table_column_widths_for_current_tab(self): + if not hasattr(self._dialog, "_table_tab_bar"): + return + tab_index = self._dialog._table_tab_bar.currentIndex() + self._dialog._user_resized_columns_by_tab.pop(tab_index, None) + self.apply_table_column_sizing(tab_index) + + def reset_table_sort_for_current_tab(self): + if not hasattr(self._dialog, "_table_tab_bar"): + return + tab_index = self._dialog._table_tab_bar.currentIndex() + self.apply_table_view_mode(tab_index, set_sort=True) + + def apply_table_view_mode(self, index: int | None = None, *, set_sort: bool = False): + if not hasattr(self._dialog, "_table_tab_bar"): + return + monitoring = ( + index == 0 + if index is not None + else self._dialog._table_tab_bar.currentIndex() == 0 + ) + for row in range(self._dialog.table.rowCount()): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + enabled = enabled_item is not None and ( + enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ) + self._dialog.table.setRowHidden(row, monitoring and not enabled) + + if not set_sort: + return + header = self._dialog.table.horizontalHeader() + if header is None: + return + if monitoring: + sort_col = self._col("state") + sort_order = QtCore.Qt.SortOrder.AscendingOrder + else: + sort_col = self._col("enabled") + sort_order = QtCore.Qt.SortOrder.AscendingOrder + header.setSortIndicator(sort_col, sort_order) + if self._dialog.table.isSortingEnabled(): + self._dialog.table.sortItems(sort_col, sort_order) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py new file mode 100644 index 0000000000..bb0dec493b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py @@ -0,0 +1,274 @@ +import os +from collections.abc import Callable +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtWidgets, + QC, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions._utils import RES_DIR +from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog +from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import ( + SortableTableWidgetItem, +) + +ATTACHED_RULES_DIALOG_UI_PATH: Final[str] = os.path.join( + RES_DIR, "attached_rules_dialog.ui" +) + +AttachedRulesDialogUI: Final[Any] = load_ui_type(ATTACHED_RULES_DIALOG_UI_PATH)[0] + +ATTACHED_RULE_ENTRY_ROLE = int(QtCore.Qt.ItemDataRole.UserRole) + 1 + + +class AttachedRulesDialog(QtWidgets.QDialog, AttachedRulesDialogUI): + if TYPE_CHECKING: + rules_table: QtWidgets.QTableWidget + create_button: QtWidgets.QPushButton + edit_button: QtWidgets.QPushButton + toggle_button: QtWidgets.QPushButton + remove_button: QtWidgets.QPushButton + close_button: QtWidgets.QPushButton + + def __init__( + self, + parent: QtWidgets.QWidget, + *, + get_attached_rules: Callable[[], list[dict[str, Any]]], + on_create_rule: Callable[[], None], + on_edit_rule: Callable[[dict[str, Any]], None], + on_toggle_rule: Callable[[dict[str, Any]], None], + on_remove_rule: Callable[[dict[str, Any]], None], + ): + super().__init__(parent) + self._get_attached_rules = get_attached_rules + self._on_create_rule = on_create_rule + self._on_edit_rule = on_edit_rule + self._on_toggle_rule = on_toggle_rule + self._on_remove_rule = on_remove_rule + + self.setupUi(self) + self._build_ui() + self._refresh_table() + + def _configure_rules_table(self) -> None: + self.rules_table.setColumnCount(6) + self.rules_table.setHorizontalHeaderLabels( + [ + QC.translate("stats", "Rule"), + QC.translate("stats", "Node"), + QC.translate("stats", "Status"), + QC.translate("stats", "Single sub"), + QC.translate("stats", "All"), + QC.translate("stats", "Groups"), + ] + ) + self.rules_table.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.rules_table.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self.rules_table.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.SingleSelection + ) + self.rules_table.setAlternatingRowColors(True) + self.rules_table.setSortingEnabled(True) + + vertical_header = self.rules_table.verticalHeader() + if vertical_header is not None: + vertical_header.setVisible(False) + + header = self.rules_table.horizontalHeader() + if header is not None: + header.setStretchLastSection(False) + header.setSortIndicatorShown(True) + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column in (1, 2, 3, 4): + header.setSectionResizeMode( + column, + QtWidgets.QHeaderView.ResizeMode.ResizeToContents, + ) + header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.setSortIndicator(0, QtCore.Qt.SortOrder.AscendingOrder) + + self.rules_table.setColumnWidth(5, 180) + + def _build_ui(self): + _configure_modal_dialog( + self, + title=QC.translate("stats", "Attached rules"), + size=(760, 420), + ) + self._configure_rules_table() + self.rules_table.itemDoubleClicked.connect(lambda _item: self._edit_selected()) + self.rules_table.itemSelectionChanged.connect(self._update_action_buttons) + + self.create_button.setText(QC.translate("stats", "Create rule")) + self.edit_button.setText(QC.translate("stats", "Edit selected")) + self.toggle_button.setText(QC.translate("stats", "Disable")) + self.remove_button.setText(QC.translate("stats", "Remove")) + self.close_button.setText(QC.translate("stats", "Close")) + + self.create_button.clicked.connect(self._create_rule) + self.edit_button.clicked.connect(self._edit_selected) + self.toggle_button.clicked.connect(self._toggle_selected) + self.remove_button.clicked.connect(self._remove_selected) + self.close_button.clicked.connect(self.accept) + + def _create_rule(self): + self.accept() + self._on_create_rule() + + def _selected_entry(self): + row = self.rules_table.currentRow() + if row < 0: + return None + item = self.rules_table.item(row, 0) + if item is None: + return None + entry = item.data(ATTACHED_RULE_ENTRY_ROLE) + if not isinstance(entry, dict): + return None + addr = str(entry.get("addr", "")).strip() + name = str(entry.get("name", "")).strip() + if addr == "" or name == "": + return None + return { + "addr": addr, + "name": name, + "enabled": bool(entry.get("enabled", True)), + } + + def _populate_table(self, aggregated_rules: list[dict[str, Any]]): + header = self.rules_table.horizontalHeader() + sort_column = 0 + sort_order = QtCore.Qt.SortOrder.AscendingOrder + if header is not None: + current_sort_column = header.sortIndicatorSection() + if 0 <= current_sort_column < self.rules_table.columnCount(): + sort_column = current_sort_column + sort_order = header.sortIndicatorOrder() + selected_entry = self._selected_entry() + + self.rules_table.setSortingEnabled(False) + self.rules_table.clearContents() + self.rules_table.setRowCount(len(aggregated_rules)) + + for row, entry in enumerate(aggregated_rules): + state_text = ( + QC.translate("stats", "enabled") + if bool(entry.get("enabled", True)) + else QC.translate("stats", "disabled") + ) + groups_text = ", ".join(entry.get("groups", [])) or "-" + row_values = [ + ( + QtWidgets.QTableWidgetItem(str(entry.get("name", "")).strip()), + None, + ), + ( + SortableTableWidgetItem(str(entry.get("addr", "")).strip()), + str(entry.get("addr", "")).strip().lower(), + ), + ( + SortableTableWidgetItem(state_text), + 0 if bool(entry.get("enabled", True)) else 1, + ), + ( + SortableTableWidgetItem( + QC.translate("stats", "yes") if bool(entry.get("single")) else "-" + ), + 0 if bool(entry.get("single")) else 1, + ), + ( + SortableTableWidgetItem( + QC.translate("stats", "yes") if bool(entry.get("all")) else "-" + ), + 0 if bool(entry.get("all")) else 1, + ), + ( + SortableTableWidgetItem(groups_text), + [group.lower() for group in entry.get("groups", [])], + ), + ] + for column, (item, sort_key) in enumerate(row_values): + if column == 0: + item.setData(ATTACHED_RULE_ENTRY_ROLE, entry) + if sort_key is not None: + item.setData(QtCore.Qt.ItemDataRole.UserRole, sort_key) + item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + self.rules_table.setItem(row, column, item) + + self.rules_table.setSortingEnabled(True) + self.rules_table.sortItems(sort_column, sort_order) + + if self.rules_table.rowCount() <= 0: + self._update_action_buttons() + return + + if selected_entry is not None: + selected_addr = str(selected_entry.get("addr", "")).strip() + selected_name = str(selected_entry.get("name", "")).strip() + for row in range(self.rules_table.rowCount()): + item = self.rules_table.item(row, 0) + if item is None: + continue + entry = item.data(ATTACHED_RULE_ENTRY_ROLE) + if not isinstance(entry, dict): + continue + row_addr = str(entry.get("addr", "")).strip() + row_name = str(entry.get("name", "")).strip() + if row_addr == selected_addr and row_name == selected_name: + self.rules_table.selectRow(row) + self._update_action_buttons() + return + + self.rules_table.selectRow(0) + self._update_action_buttons() + + def _refresh_table(self): + self._populate_table(self._get_attached_rules()) + + def _update_toggle_button(self): + entry = self._selected_entry() + if entry is None: + self.toggle_button.setEnabled(False) + self.toggle_button.setText(QC.translate("stats", "Disable")) + return + + self.toggle_button.setEnabled(True) + if bool(entry.get("enabled", True)): + self.toggle_button.setText(QC.translate("stats", "Disable")) + else: + self.toggle_button.setText(QC.translate("stats", "Enable")) + + def _update_action_buttons(self): + entry = self._selected_entry() + has_selection = entry is not None + self.edit_button.setEnabled(has_selection) + self.remove_button.setEnabled(has_selection) + self._update_toggle_button() + + def _edit_selected(self): + entry = self._selected_entry() + if entry is None: + return + self._on_edit_rule(entry) + + def _toggle_selected(self): + entry = self._selected_entry() + if entry is None: + return + self._on_toggle_rule(entry) + self._refresh_table() + + def _remove_selected(self): + entry = self._selected_entry() + if entry is None: + return + self._on_remove_rule(entry) + self._refresh_table() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py new file mode 100644 index 0000000000..72730ca3b0 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py @@ -0,0 +1,361 @@ +import logging +import os +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtWidgets, + QC, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _apply_section_bar_style, + _apply_footer_separator_style, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import ( + _configure_spin_and_units, + _set_optional_field_tooltips, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import ( + ToggleSwitch, +) +from opensnitch.plugins.list_subscriptions.models.global_defaults import ( + GlobalDefaults, +) +from opensnitch.plugins.list_subscriptions._utils import ( + RES_DIR, + INTERVAL_UNITS, + TIMEOUT_UNITS, + SIZE_UNITS, + normalize_group, + normalize_groups, +) + + +BULK_EDIT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "bulk_edit_dialog.ui") + +BulkEditDialogUI: Final[Any] = load_ui_type(BULK_EDIT_DIALOG_UI_PATH)[0] + +logger: Final[logging.Logger] = logging.getLogger(__name__) + + +class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): + if TYPE_CHECKING: + rootLayout: QtWidgets.QVBoxLayout + buttons_layout: QtWidgets.QHBoxLayout + changes_section_bar: QtWidgets.QFrame + changes_section_label: QtWidgets.QLabel + selection_hint_label: QtWidgets.QLabel + changes_tree: QtWidgets.QTreeWidget + enabled_value: QtWidgets.QCheckBox + group_value: QtWidgets.QComboBox + format_value: QtWidgets.QComboBox + interval_spin: QtWidgets.QSpinBox + interval_units: QtWidgets.QComboBox + timeout_spin: QtWidgets.QSpinBox + timeout_units: QtWidgets.QComboBox + max_size_spin: QtWidgets.QSpinBox + max_size_units: QtWidgets.QComboBox + error_label: QtWidgets.QLabel + footer_separator_line: QtWidgets.QFrame + cancel_button: QtWidgets.QPushButton + save_button: QtWidgets.QPushButton + _defaults: GlobalDefaults + _groups: list[str] + + def __init__( + self, + parent: QtWidgets.QWidget | None, + defaults: GlobalDefaults, + groups: list[str] | None = None, + selected_count: int | None = None, + ): + super().__init__(parent) + self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions")) + self._defaults = defaults + self._groups = groups or [] + self._selected_count = selected_count + self._field_items: dict[str, QtWidgets.QTreeWidgetItem] = {} + self._build_ui() + + def _build_ui(self): + self.setupUi(self) + self.error_label.setStyleSheet("color: red;") + self.rootLayout.setContentsMargins(0, 0, 0, 0) + self.rootLayout.setSpacing(0) + self.selection_hint_label.setContentsMargins(12, 10, 12, 8) + self.changes_tree.setContentsMargins(0, 0, 0, 0) + self.buttons_layout.setContentsMargins(12, 10, 12, 12) + self.buttons_layout.setSpacing(8) + _apply_section_bar_style( + self, + self.changes_section_bar, + self.changes_section_label, + ) + _apply_footer_separator_style(self, self.footer_separator_line) + self.error_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + self.error_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + if self._selected_count is not None: + self.selection_hint_label.setText( + QC.translate( + "stats", + "Choose which changes to apply to {0} selected subscriptions.", + ).format(self._selected_count) + ) + self.changes_tree.setRootIsDecorated(False) + self.changes_tree.setUniformRowHeights(False) + self.changes_tree.setItemsExpandable(False) + self.changes_tree.setAllColumnsShowFocus(False) + self.changes_tree.setIndentation(0) + self.changes_tree.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.NoSelection + ) + header = self.changes_tree.header() + if header is not None: + header.setStretchLastSection(True) + header.setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) + expanding = QtWidgets.QSizePolicy.Policy.Expanding + fixed = QtWidgets.QSizePolicy.Policy.Fixed + + self.enabled_value = ToggleSwitch(QC.translate("stats", "Enabled")) + self.enabled_value.setSizePolicy(expanding, fixed) + + self.group_value = QtWidgets.QComboBox() + self.group_value.setEditable(True) + self.group_value.setSizePolicy(expanding, fixed) + self.group_value.setEditable(True) + self.group_value.setToolTip( + QC.translate( + "stats", + "Optional explicit groups. Every subscription is always included in the global 'all' rules directory.", + ) + ) + self.format_value = QtWidgets.QComboBox() + self.format_value.setSizePolicy(expanding, fixed) + self.interval_spin = QtWidgets.QSpinBox() + self.interval_spin.setSizePolicy(expanding, fixed) + self.interval_units = QtWidgets.QComboBox() + self.interval_units.setSizePolicy(fixed, fixed) + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setSizePolicy(expanding, fixed) + self.timeout_units = QtWidgets.QComboBox() + self.timeout_units.setSizePolicy(fixed, fixed) + self.max_size_spin = QtWidgets.QSpinBox() + self.max_size_spin.setSizePolicy(expanding, fixed) + self.max_size_units = QtWidgets.QComboBox() + self.max_size_units.setSizePolicy(fixed, fixed) + unit_combo_width = 132 + self.interval_units.setMinimumWidth(unit_combo_width) + self.timeout_units.setMinimumWidth(unit_combo_width) + self.max_size_units.setMinimumWidth(unit_combo_width) + + self.cancel_button.clicked.connect(self.reject) + self.save_button.clicked.connect(self.validate_then_accept) + + self.enabled_value.setChecked(True) + self.group_value.clear() + for g in self._groups: + ng = normalize_group(g) + if ng not in ("", "all"): + self.group_value.addItem(ng) + self.group_value.setCurrentText("") + self.format_value.clear() + self.format_value.addItems(("hosts",)) + _configure_spin_and_units( + self.interval_spin, + self.interval_units, + value=0, + unit_value=self._defaults.interval_units, + allowed_units=INTERVAL_UNITS, + fallback_unit="hours", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.interval, + self._defaults.interval_units, + ), + ) + _configure_spin_and_units( + self.timeout_spin, + self.timeout_units, + value=0, + unit_value=self._defaults.timeout_units, + allowed_units=TIMEOUT_UNITS, + fallback_unit="seconds", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.timeout, + self._defaults.timeout_units, + ), + ) + _configure_spin_and_units( + self.max_size_spin, + self.max_size_units, + value=0, + unit_value=self._defaults.max_size_units, + allowed_units=SIZE_UNITS, + fallback_unit="MB", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.max_size, + self._defaults.max_size_units, + ), + ) + + self._add_change_row( + "enabled", QC.translate("stats", "Enabled"), self.enabled_value + ) + self._add_change_row( + "groups", QC.translate("stats", "Groups"), self.group_value + ) + self._add_change_row( + "format", QC.translate("stats", "Format"), self.format_value + ) + self._add_change_row( + "interval", + QC.translate("stats", "Interval"), + self._build_compound_editor(self.interval_spin, self.interval_units), + ) + self._add_change_row( + "timeout", + QC.translate("stats", "Timeout"), + self._build_compound_editor(self.timeout_spin, self.timeout_units), + ) + self._add_change_row( + "max_size", + QC.translate("stats", "Max size"), + self._build_compound_editor(self.max_size_spin, self.max_size_units), + ) + self.changes_tree.itemChanged.connect(self.handle_item_changed) + + self.interval_spin.valueChanged.connect(self.sync_optional_fields_state) + self.timeout_spin.valueChanged.connect(self.sync_optional_fields_state) + self.max_size_spin.valueChanged.connect(self.sync_optional_fields_state) + _set_optional_field_tooltips( + self.interval_spin, + self.interval_units, + self.timeout_spin, + self.timeout_units, + self.max_size_spin, + self.max_size_units, + inherit_wording=False, + ) + self.sync_apply_fields_state() + self.sync_optional_fields_state() + self.resize(760, 420) + + def _build_compound_editor( + self, primary: QtWidgets.QWidget, secondary: QtWidgets.QWidget + ): + container = QtWidgets.QWidget(self.changes_tree) + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(primary, 1) + layout.addWidget(secondary, 0) + return container + + def _add_change_row(self, key: str, label: str, editor: QtWidgets.QWidget): + item = QtWidgets.QTreeWidgetItem(self.changes_tree) + item.setText(0, label) + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, QtCore.Qt.CheckState.Unchecked) + self.changes_tree.setItemWidget(item, 1, editor) + self._field_items[key] = item + + # -- Field apply state -------------------------------------------------- + + def is_field_applied(self, key: str) -> bool: + item = self._field_items.get(key) + if item is None: + return False + return item.checkState(0) == QtCore.Qt.CheckState.Checked + + def handle_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: + if column != 0: + return + self.sync_apply_fields_state() + + def sync_optional_fields_state(self) -> None: + self.interval_units.setEnabled( + self.is_field_applied("interval") and self.interval_spin.value() > 0 + ) + self.timeout_units.setEnabled( + self.is_field_applied("timeout") and self.timeout_spin.value() > 0 + ) + self.max_size_units.setEnabled( + self.is_field_applied("max_size") and self.max_size_spin.value() > 0 + ) + + def sync_apply_fields_state(self) -> None: + self.enabled_value.setEnabled(self.is_field_applied("enabled")) + self.group_value.setEnabled(self.is_field_applied("groups")) + self.format_value.setEnabled(self.is_field_applied("format")) + self.interval_spin.setEnabled(self.is_field_applied("interval")) + self.timeout_spin.setEnabled(self.is_field_applied("timeout")) + self.max_size_spin.setEnabled(self.is_field_applied("max_size")) + self.sync_optional_fields_state() + + # -- Validation --------------------------------------------------------- + + def validate_then_accept(self) -> None: + if not any(self.is_field_applied(key) for key in self._field_items): + self.error_label.setText( + QC.translate("stats", "Select at least one field to apply.") + ) + return + self.error_label.setText("") + self.accept() + + # -- Result extraction -------------------------------------------------- + + def values(self) -> dict[str, Any]: + return { + "enabled": ( + self.enabled_value.isChecked() + if self.is_field_applied("enabled") + else None + ), + "groups": ( + normalize_groups(self.group_value.currentText()) + if self.is_field_applied("groups") + else None + ), + "format": ( + (self.format_value.currentText() or "hosts").strip().lower() + if self.is_field_applied("format") + else None + ), + "apply_interval": self.is_field_applied("interval"), + "interval": int(self.interval_spin.value()) or None, + "interval_units": ( + self.interval_units.currentText() + if self.interval_spin.value() > 0 + else None + ), + "apply_timeout": self.is_field_applied("timeout"), + "timeout": int(self.timeout_spin.value()) or None, + "timeout_units": ( + self.timeout_units.currentText() + if self.timeout_spin.value() > 0 + else None + ), + "apply_max_size": self.is_field_applied("max_size"), + "max_size": int(self.max_size_spin.value()) or None, + "max_size_units": ( + self.max_size_units.currentText() + if self.max_size_spin.value() > 0 + else None + ), + } diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py new file mode 100644 index 0000000000..300807ff59 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py @@ -0,0 +1,116 @@ +from typing import Any + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtGui, + QtWidgets, + QC, +) + + +def _section_border_color_name(widget: QtWidgets.QWidget): + dark_palette = ( + widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + ) + border_role = ( + QtGui.QPalette.ColorRole.Midlight + if dark_palette + else QtGui.QPalette.ColorRole.Mid + ) + return widget.palette().color(border_role).name() + + +def _apply_section_bar_style( + widget: QtWidgets.QWidget, + container: QtWidgets.QFrame, + label: QtWidgets.QLabel, + *, + right_border: bool = False, + expanding_label: bool = False, +): + dark_palette = ( + widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + ) + bg_role = ( + QtGui.QPalette.ColorRole.AlternateBase + if dark_palette + else QtGui.QPalette.ColorRole.Button + ) + bg = widget.palette().color(bg_role).name() + border = _section_border_color_name(widget) + text = widget.palette().color(QtGui.QPalette.ColorRole.WindowText).name() + font = label.font() + font.setPointSizeF(font.pointSizeF() + 1.0) + label.setFont(font) + container.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding + if expanding_label + else QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + border_right = f"border-right: 1px solid {border};" if right_border else "" + container.setStyleSheet( + "QFrame {" + f"background-color: {bg};" + f"border-top: 1px solid {border};" + f"border-bottom: 1px solid {border};" + f"{border_right}" + "}" + ) + label.setStyleSheet( + "QLabel {" + f"color: {text};" + "background: transparent;" + "padding: 3px 10px;" + "border: 0;" + "}" + ) + + +def _apply_footer_separator_style(widget: QtWidgets.QWidget, separator: QtWidgets.QFrame): + dark_palette = ( + widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + ) + footer_role = ( + QtGui.QPalette.ColorRole.Midlight + if dark_palette + else QtGui.QPalette.ColorRole.Dark + ) + footer_color = widget.palette().color(footer_role).name() + separator.setFixedHeight(1) + separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + separator.setStyleSheet( + f"QFrame {{ color: {footer_color}; background-color: {footer_color}; }}" + ) + + +def _configure_modal_dialog( + dialog: QtWidgets.QDialog, + *, + title: str | None = None, + size: tuple[int, int] | None = None, +): + if title is not None: + dialog.setWindowTitle(title) + dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + if size is not None: + dialog.resize(size[0], size[1]) + + +def _wire_copy_close_buttons( + dialog: QtWidgets.QDialog, + copy_button: QtWidgets.QPushButton, + close_button: QtWidgets.QPushButton, + text_view: Any, +): + copy_button.setText(QC.translate("stats", "Copy")) + close_button.setText(QC.translate("stats", "Close")) + copy_button.clicked.connect(lambda: text_view.selectAll()) + copy_button.clicked.connect(lambda: text_view.copy()) + close_button.clicked.connect(dialog.accept) + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py new file mode 100644 index 0000000000..a7992790e8 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py @@ -0,0 +1,247 @@ +from typing import TYPE_CHECKING, cast + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _apply_section_bar_style, + _section_border_color_name, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class InspectorPanel(QtWidgets.QFrame): + def __init__(self, *, dialog: "ListSubscriptionsDialog") -> None: + super().__init__(dialog) + self._dialog: "ListSubscriptionsDialog" = dialog + + def build(self) -> None: + dialog: "ListSubscriptionsDialog" = self._dialog + dialog._inspect_panel = cast("InspectorPanel", self) + dialog._inspect_collapsed = False + dialog._inspect_default_width = 420 + dialog._inspect_has_selection = False + + dialog.tableContentLayout.removeWidget(dialog._table_tab_bar) + dialog.tableContentLayout.removeWidget(dialog.table) + + dialog._table_inspect_splitter = QtWidgets.QSplitter( + QtCore.Qt.Orientation.Horizontal, dialog + ) + dialog._table_inspect_splitter.setChildrenCollapsible(False) + + left_container: QtWidgets.QWidget = QtWidgets.QWidget( + dialog._table_inspect_splitter + ) + left_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(left_container) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(0) + dialog._table_tab_bar.setParent(left_container) + dialog.table.setParent(left_container) + left_layout.addWidget(dialog._table_tab_bar) + left_layout.addWidget(dialog.table, 1) + + self.setParent(dialog._table_inspect_splitter) + self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + + inspect_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(self) + inspect_layout.setContentsMargins(0, 0, 0, 0) + inspect_layout.setSpacing(0) + + dialog._inspect_header = QtWidgets.QFrame(self) + tab_row_height = max(28, dialog._table_tab_bar.sizeHint().height()) + dialog._inspect_header.setMinimumHeight(tab_row_height) + dialog._inspect_header.setMaximumHeight(tab_row_height) + + header: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout(dialog._inspect_header) + header.setContentsMargins(12, 0, 12, 0) + header.setSpacing(4) + dialog._inspect_title_label = QtWidgets.QLabel(QC.translate("stats", "Inspect")) + dialog._inspect_title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter) + _apply_section_bar_style( + dialog, + dialog._inspect_header, + dialog._inspect_title_label, + expanding_label=True, + ) + header.addWidget(dialog._inspect_title_label) + header.addStretch(1) + dialog._inspect_toggle_button = QtWidgets.QToolButton(self) + dialog._inspect_toggle_button.setAutoRaise(True) + dialog._inspect_toggle_button.clicked.connect( + dialog._inspector_controller.toggle_inspector_collapsed + ) + header.setAlignment(dialog._inspect_toggle_button, QtCore.Qt.AlignmentFlag.AlignVCenter) + header.addWidget(dialog._inspect_toggle_button) + inspect_layout.addWidget(dialog._inspect_header) + + dialog._inspect_header_separator = QtWidgets.QFrame(self) + dialog._inspect_header_separator.setFrameShape(QtWidgets.QFrame.Shape.HLine) + dialog._inspect_header_separator.setFixedHeight(1) + dialog._inspect_header_separator.setStyleSheet( + f"background-color: {_section_border_color_name(dialog)}; border: 0;" + ) + inspect_layout.addWidget(dialog._inspect_header_separator) + + dialog._inspect_scroll = QtWidgets.QScrollArea(self) + dialog._inspect_scroll.setWidgetResizable(True) + dialog._inspect_scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + dialog._inspect_scroll.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + dialog._inspect_scroll.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + + dialog._inspect_body = QtWidgets.QWidget(dialog._inspect_scroll) + body_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout( + dialog._inspect_body + ) + body_layout.setContentsMargins(8, 6, 8, 8) + body_layout.setSpacing(0) + body_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + + dialog._inspect_details_widget = QtWidgets.QWidget(dialog._inspect_body) + details_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout( + dialog._inspect_details_widget + ) + details_layout.setContentsMargins(0, 0, 0, 0) + details_layout.setSpacing(0) + + form: QtWidgets.QFormLayout = QtWidgets.QFormLayout() + form.setLabelAlignment( + QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop + ) + form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + form.setHorizontalSpacing(10) + form.setVerticalSpacing(4) + form.setFieldGrowthPolicy( + QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + dialog._inspect_value_labels = {} + dialog._inspect_error_button = None + dialog._inspect_error_full_text = "" + for key, label in ( + ("name", QC.translate("stats", "Name")), + ("enabled", QC.translate("stats", "Enabled")), + ("state", QC.translate("stats", "State")), + ("last_checked", QC.translate("stats", "Last checked")), + ("last_updated", QC.translate("stats", "Last updated")), + ("failures", QC.translate("stats", "Failures")), + ("error", QC.translate("stats", "Error")), + ("url", QC.translate("stats", "URL")), + ("filename", QC.translate("stats", "Filename")), + ("format", QC.translate("stats", "Format")), + ("groups", QC.translate("stats", "Groups")), + ("interval", QC.translate("stats", "Interval")), + ("timeout", QC.translate("stats", "Timeout")), + ("max_size", QC.translate("stats", "Max size")), + ("list_path", QC.translate("stats", "List path")), + ("meta_path", QC.translate("stats", "Meta path")), + ): + key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":") + key_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop + ) + key_label.setMinimumWidth(112) + value_label: QtWidgets.QLabel = QtWidgets.QLabel("-") + value_label.setWordWrap(True) + value_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop + ) + value_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + value_label.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextSelectableByMouse + ) + dialog._inspect_value_labels[key] = value_label + if key == "error": + field_widget: QtWidgets.QWidget = QtWidgets.QWidget( + dialog._inspect_details_widget + ) + field_layout: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout( + field_widget + ) + field_layout.setContentsMargins(0, 0, 0, 0) + field_layout.setSpacing(6) + field_layout.addWidget(value_label, 1) + if key == "error": + inspect_button: QtWidgets.QPushButton = QtWidgets.QPushButton( + QC.translate("stats", "Inspect"), field_widget + ) + inspect_button.setVisible(False) + inspect_button.clicked.connect( + dialog._inspector_controller.show_error_inspect_dialog + ) + dialog._inspect_error_button = inspect_button + field_layout.addWidget(inspect_button, 0) + form.addRow(key_label, field_widget) + else: + form.addRow(key_label, value_label) + details_layout.addLayout(form) + + dialog._inspect_summary_widget = QtWidgets.QWidget(dialog._inspect_body) + summary_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout( + dialog._inspect_summary_widget + ) + summary_layout.setContentsMargins(0, 0, 0, 0) + summary_layout.setSpacing(6) + summary_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + + summary_form: QtWidgets.QFormLayout = QtWidgets.QFormLayout() + summary_form.setLabelAlignment( + QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop + ) + summary_form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + summary_form.setHorizontalSpacing(10) + summary_form.setVerticalSpacing(4) + summary_form.setFieldGrowthPolicy( + QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + dialog._inspect_summary_labels = {} + for key, label in ( + ("selected", QC.translate("stats", "Selected")), + ("enabled", QC.translate("stats", "Enabled")), + ("healthy", QC.translate("stats", "Healthy")), + ("pending", QC.translate("stats", "Pending")), + ("problematic", QC.translate("stats", "Problematic")), + ("failures", QC.translate("stats", "Total failures")), + ("with_errors", QC.translate("stats", "With errors")), + ("newest_checked", QC.translate("stats", "Newest checked")), + ("oldest_checked", QC.translate("stats", "Oldest checked")), + ): + key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":") + key_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop + ) + key_label.setMinimumWidth(112) + value_label: QtWidgets.QLabel = QtWidgets.QLabel("-") + value_label.setWordWrap(True) + value_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop + ) + value_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + value_label.setTextInteractionFlags( + QtCore.Qt.TextInteractionFlag.TextSelectableByMouse + ) + dialog._inspect_summary_labels[key] = value_label + summary_form.addRow(key_label, value_label) + summary_layout.addLayout(summary_form) + + body_layout.addWidget(dialog._inspect_details_widget) + body_layout.addWidget(dialog._inspect_summary_widget) + dialog._inspector_controller.set_inspector_multi_selection_mode(False) + dialog._inspect_scroll.setWidget(dialog._inspect_body) + inspect_layout.addWidget(dialog._inspect_scroll, 1) + + dialog.tableContentLayout.addWidget(dialog._table_inspect_splitter) + dialog._table_inspect_splitter.setStretchFactor(0, 1) + dialog._table_inspect_splitter.setStretchFactor(1, 0) + dialog._inspector_controller.set_inspector_visible(False) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py new file mode 100644 index 0000000000..fec29f6926 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py @@ -0,0 +1,850 @@ +import logging +import os +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtGui, + QtWidgets, + QC, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults +from opensnitch.actions import Actions +from opensnitch.nodes import Nodes +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _section_border_color_name, + _apply_section_bar_style, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import ( + _configure_spin_and_units, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import ( + _replace_checkbox_with_toggle, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import ( + CenteredCheckDelegate, + KeepForegroundOnSelectionDelegate, +) +from opensnitch.plugins.list_subscriptions.ui.views.inspector_panel import ( + InspectorPanel, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import ( + DialogStatusController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.runtime_controller import ( + RuntimeController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.inspector_controller import ( + InspectorController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.defaults_ui_controller import ( + DefaultsUiController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.selection_controller import ( + SelectionController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.context_menu_controller import ( + ContextMenuController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.bulk_edit_controller import ( + BulkEditController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_status_controller import ( + SubscriptionStatusController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.action_file_controller import ( + ActionFileController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.table_data_controller import ( + TableDataController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.table_view_controller import ( + TableViewController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.rules_attachment_controller import ( + RulesAttachmentController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.rules_editor_controller import ( + RulesEditorController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_edit_controller import ( + SubscriptionEditController, +) +from opensnitch.plugins.list_subscriptions._utils import ( + ACTION_FILE, + DEFAULT_LISTS_DIR, + RES_DIR, + INTERVAL_UNITS, + TIMEOUT_UNITS, + SIZE_UNITS, +) +from opensnitch.plugins.list_subscriptions._annotations import RulesEditorDialogProto +from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions + +LIST_SUBSCRIPTIONS_DIALOG_UI_PATH: Final[str] = os.path.join( + RES_DIR, "list_subscriptions_dialog.ui" +) + +ListSubscriptionsDialogUI: Final[Any] = load_ui_type(LIST_SUBSCRIPTIONS_DIALOG_UI_PATH)[ + 0 +] + +COL_ENABLED: Final[int] = 0 +COL_NAME: Final[int] = 1 +COL_URL: Final[int] = 2 +COL_FILENAME: Final[int] = 3 +COL_FORMAT: Final[int] = 4 +COL_GROUP: Final[int] = 5 +COL_INTERVAL: Final[int] = 6 +COL_INTERVAL_UNITS: Final[int] = 7 +COL_TIMEOUT: Final[int] = 8 +COL_TIMEOUT_UNITS: Final[int] = 9 +COL_MAX_SIZE: Final[int] = 10 +COL_MAX_SIZE_UNITS: Final[int] = 11 +COL_FILE: Final[int] = 12 +COL_META: Final[int] = 13 +COL_STATE: Final[int] = 14 +COL_RULE_ATTACHED: Final[int] = 15 +COL_LAST_CHECKED: Final[int] = 16 +COL_LAST_UPDATED: Final[int] = 17 +INSPECT_ERROR_PREVIEW_LIMIT: Final[int] = 48 +STATUS_MESSAGE_PREVIEW_LIMIT: Final[int] = 48 +STATUS_LOG_LIMIT: Final[int] = 200 + +logger: Final[logging.Logger] = logging.getLogger(__name__) + + +class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): + subscription_state_refreshed = QtCore.pyqtSignal(str, str, dict) + + if TYPE_CHECKING: + rootLayout: QtWidgets.QVBoxLayout + topRowLayout: QtWidgets.QHBoxLayout + defaultsSectionLayout: QtWidgets.QVBoxLayout + defaultsGridLayout: QtWidgets.QGridLayout + tableSectionLayout: QtWidgets.QVBoxLayout + table_section_bar: QtWidgets.QFrame + table_section_label: QtWidgets.QLabel + tableContentLayout: QtWidgets.QVBoxLayout + actionsRowLayout: QtWidgets.QHBoxLayout + actionsSeparatorLayout: QtWidgets.QVBoxLayout + globalActionsLayout: QtWidgets.QHBoxLayout + ruleActionsLayout: QtWidgets.QHBoxLayout + enable_plugin_check: QtWidgets.QCheckBox + create_file_button: QtWidgets.QPushButton + save_button: QtWidgets.QPushButton + reload_button: QtWidgets.QPushButton + start_runtime_button: QtWidgets.QPushButton + stop_runtime_button: QtWidgets.QPushButton + runtime_status_title_label: QtWidgets.QLabel + runtime_status_label: QtWidgets.QLabel + defaults_section_bar: QtWidgets.QFrame + defaults_section_label: QtWidgets.QLabel + lists_dir_label: QtWidgets.QLabel + lists_dir_edit: QtWidgets.QLineEdit + default_interval_label: QtWidgets.QLabel + default_interval_spin: QtWidgets.QSpinBox + default_interval_units: QtWidgets.QComboBox + default_timeout_label: QtWidgets.QLabel + default_timeout_spin: QtWidgets.QSpinBox + default_timeout_units: QtWidgets.QComboBox + default_max_size_label: QtWidgets.QLabel + default_max_size_spin: QtWidgets.QSpinBox + default_max_size_units: QtWidgets.QComboBox + default_user_agent_label: QtWidgets.QLabel + default_user_agent: QtWidgets.QLineEdit + node_label: QtWidgets.QLabel + nodes_combo: QtWidgets.QComboBox + table: QtWidgets.QTableWidget + global_actions_bar: QtWidgets.QFrame + global_actions_label: QtWidgets.QLabel + actions_vertical_separator: QtWidgets.QFrame + add_sub_button: QtWidgets.QPushButton + refresh_state_button: QtWidgets.QPushButton + create_global_rule_button: QtWidgets.QPushButton + selected_actions_bar: QtWidgets.QFrame + selected_actions_label: QtWidgets.QLabel + edit_sub_button: QtWidgets.QPushButton + remove_sub_button: QtWidgets.QPushButton + refresh_now_button: QtWidgets.QPushButton + create_rule_button: QtWidgets.QPushButton + status_separator_line: QtWidgets.QFrame + status_label: QtWidgets.QLabel + _status_inspect_button: QtWidgets.QPushButton + _nodes: Nodes + _actions: Actions + _action_path: str + _loading: bool + _global_defaults: GlobalDefaults + _rules_dialog: RulesEditorDialogProto | None + _runtime_plugin: ListSubscriptions | None + _pending_runtime_reload: str | None + _pending_refresh_keys: set[str] + _active_refresh_keys: set[str] + _status_controller: DialogStatusController + _runtime_controller: RuntimeController + _defaults_ui_controller: DefaultsUiController + _selection_controller: SelectionController + _context_menu_controller: ContextMenuController + _bulk_edit_controller: BulkEditController + _subscription_status_controller: SubscriptionStatusController + _action_file_controller: ActionFileController + _inspector_controller: InspectorController + _table_data_controller: TableDataController + _rules_attachment_controller: RulesAttachmentController + _rules_editor_controller: RulesEditorController + _table_tab_bar: QtWidgets.QTabBar + _table_inspect_splitter: QtWidgets.QSplitter + _inspect_panel: QtWidgets.QFrame + _inspect_header: QtWidgets.QFrame + _inspect_title_label: QtWidgets.QLabel + _inspect_toggle_button: QtWidgets.QToolButton + _inspect_header_separator: QtWidgets.QFrame + _inspect_scroll: QtWidgets.QScrollArea + _inspect_body: QtWidgets.QWidget + _inspect_details_widget: QtWidgets.QWidget + _inspect_summary_widget: QtWidgets.QWidget + _inspect_value_labels: dict[str, QtWidgets.QLabel] + _inspect_summary_labels: dict[str, QtWidgets.QLabel] + _inspect_error_button: QtWidgets.QPushButton | None + _inspect_error_full_text: str + _inspect_collapsed: bool + _inspect_default_width: int + _inspect_has_selection: bool + _user_resized_columns_by_tab: dict[int, set[int]] + _applying_table_column_sizing: bool + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + appicon: QtGui.QIcon | None = None, + ): + dlg_parent = parent if isinstance(parent, QtWidgets.QWidget) else None + super().__init__(dlg_parent) + self.setWindowTitle(QC.translate("stats", "List subscriptions")) + if appicon is not None: + self.setWindowIcon(appicon) + + self._nodes = Nodes.instance() + self._actions = Actions.instance() + self._action_path = ACTION_FILE + self._loading = False + self._global_defaults: GlobalDefaults = GlobalDefaults.from_dict( + {}, lists_dir=DEFAULT_LISTS_DIR + ) + self._rules_dialog: RulesEditorDialogProto | None = None + self._runtime_plugin: ListSubscriptions | None = None + self._pending_runtime_reload: str | None = None + self._pending_refresh_keys: set[str] = set() + self._active_refresh_keys: set[str] = set() + self._deferred_close_pending = False + self._user_resized_columns_by_tab: dict[int, set[int]] = {} + self._applying_table_column_sizing = False + self._resize_fill_timer = QtCore.QTimer(self) + self._resize_fill_timer.setSingleShot(True) + self._resize_fill_timer.setInterval(140) + self._resize_fill_timer.timeout.connect(self._apply_table_fill_after_resize) + self._build_ui() + + def showEvent(self, event: QtGui.QShowEvent | None): # type: ignore[override] + super().showEvent(event) + self._action_file_controller.load_action_file() + self._table_data_controller.start_poll() + # Ensure equal-fill sizing runs after the first layout pass when viewport width is valid. + QtCore.QTimer.singleShot( + 0, + lambda: self._table_view_controller.apply_table_column_sizing( + self._table_tab_bar.currentIndex() + ), + ) + + def _pause_background_workers_for_focus_loss(self) -> None: + pause_poll = getattr(self._table_data_controller, "pause_for_focus_loss", None) + if callable(pause_poll): + pause_poll() + else: + self._table_data_controller.stop_poll() + self._table_data_controller.cancel_active_refresh() + + cancel_snapshot = getattr( + self._rules_attachment_controller, + "cancel_active_snapshot", + None, + ) + if callable(cancel_snapshot): + cancel_snapshot() + + def _resume_background_workers_for_focus_gain(self) -> None: + resume_poll = getattr(self._table_data_controller, "resume_for_focus_gain", None) + if callable(resume_poll): + resume_poll() + elif self.isVisible() and not self._loading: + self._table_data_controller.start_poll() + + def changeEvent(self, event: QtCore.QEvent | None): # type: ignore[override] + if ( + event is not None + and event.type() == QtCore.QEvent.Type.ActivationChange + ): + if self.isVisible() and self.isActiveWindow(): + self._resume_background_workers_for_focus_gain() + else: + self._pause_background_workers_for_focus_loss() + super().changeEvent(event) + + def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override] + self._pause_background_workers_for_focus_loss() + super().hideEvent(event) + + def resizeEvent(self, event: QtGui.QResizeEvent | None): # type: ignore[override] + super().resizeEvent(event) + if hasattr(self, "_resize_fill_timer"): + self._resize_fill_timer.start() + + def _apply_table_fill_after_resize(self) -> None: + if not self.isVisible(): + return + if hasattr(self, "_table_view_controller") and hasattr(self, "_table_tab_bar"): + self._table_view_controller.apply_table_column_sizing( + self._table_tab_bar.currentIndex() + ) + + def _complete_deferred_close(self) -> None: + self._deferred_close_pending = False + self.setEnabled(True) + self._status_controller.set_status("", error=False, log=False) + self.close() + + def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override] + self._table_data_controller.stop_poll() + self._pause_background_workers_for_focus_loss() + if self._table_data_controller.has_active_refresh(): + if not self._deferred_close_pending: + self._deferred_close_pending = True + self.setEnabled(False) + self._status_controller.set_status( + QC.translate("stats", "Stopping background tasks..."), + error=False, + log=False, + ) + self._table_data_controller.cancel_active_refresh() + self._table_data_controller.on_refresh_stopped( + self._complete_deferred_close + ) + if event is not None: + event.ignore() + return + self._status_controller.set_status("", error=False, log=False) + super().closeEvent(event) + + def _show_log_dialog(self) -> None: + color = self.palette().color(QtGui.QPalette.ColorRole.WindowText) + self._status_controller.show_log_dialog( + self, + title=QC.translate("stats", "Status log"), + level_color=self._table_data_controller.status_log_level_color, + timestamp_color=f"rgba({color.red()}, {color.green()}, {color.blue()}, 0.55)", + ) + + def _build_ui(self): + self.setupUi(self) + self.enable_plugin_check = _replace_checkbox_with_toggle( + self.enable_plugin_check + ) + self.enable_plugin_check.setChecked(True) + self.enable_plugin_check.setEnabled(False) + self.enable_plugin_check.setToolTip( + QC.translate( + "stats", + "The plugin action must remain enabled. OpenSnitch only loads enabled action files on startup.", + ) + ) + self.setWindowTitle(QC.translate("stats", "List subscriptions")) + self.resize(1180, 680) + self.rootLayout.setContentsMargins(0, 0, 0, 0) + self.rootLayout.setSpacing(0) + self.rootLayout.setStretch(0, 0) + self.rootLayout.setStretch(1, 0) + self.rootLayout.setStretch(2, 1) + self.rootLayout.setStretch(3, 0) + self.rootLayout.setStretch(4, 0) + self.topRowLayout.setContentsMargins(12, 10, 12, 4) + self.topRowLayout.setSpacing(8) + self.topRowLayout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter) + self.defaultsSectionLayout.setContentsMargins(0, 8, 0, 0) + self.defaultsGridLayout.setContentsMargins(12, 10, 12, 10) + self.tableSectionLayout.setContentsMargins(0, 8, 0, 0) + self.tableContentLayout.setContentsMargins(0, 0, 0, 0) + self.actionsRowLayout.setContentsMargins(0, 0, 0, 0) + self.actionsRowLayout.setSpacing(0) + self.globalActionsLayout.setContentsMargins(12, 10, 12, 10) + self.ruleActionsLayout.setContentsMargins(12, 10, 12, 10) + self.status_label.setContentsMargins(12, 8, 12, 8) + self.status_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + _apply_section_bar_style( + self, + self.defaults_section_bar, + self.defaults_section_label, + expanding_label=True, + ) + _apply_section_bar_style( + self, + self.table_section_bar, + self.table_section_label, + expanding_label=True, + ) + _apply_section_bar_style( + self, + self.global_actions_bar, + self.global_actions_label, + expanding_label=True, + ) + _apply_section_bar_style( + self, + self.selected_actions_bar, + self.selected_actions_label, + expanding_label=True, + ) + self.actionsRowLayout.setStretch(0, 1) + self.actionsRowLayout.setStretch(2, 1) + self.actions_vertical_separator.hide() + background_role = ( + QtGui.QPalette.ColorRole.AlternateBase + if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + else QtGui.QPalette.ColorRole.Button + ) + section_border_color = _section_border_color_name(self) + self.global_actions_bar.setStyleSheet( + "QFrame {" + f"background-color: {self.palette().color(background_role).name()};" + f"border-top: 1px solid {section_border_color};" + f"border-bottom: 1px solid {section_border_color};" + f"border-right: 1px solid {section_border_color};" + "}" + ) + self.runtime_status_label.setContentsMargins(12, 0, 0, 0) + self.runtime_status_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + self.runtime_status_title_label.setContentsMargins(12, 0, 0, 0) + self.runtime_status_title_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + runtime_status_title_font = self.runtime_status_title_label.font() + runtime_status_title_font.setBold(True) + self.runtime_status_title_label.setFont(runtime_status_title_font) + footer_role = ( + QtGui.QPalette.ColorRole.Midlight + if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + else QtGui.QPalette.ColorRole.Dark + ) + footer_border = self.palette().color(footer_role).name() + self.status_separator_line.setStyleSheet(f"color: {footer_border};") + self.status_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.status_label.setStyleSheet( + f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}" + ) + self._status_inspect_button = QtWidgets.QPushButton( + QC.translate("stats", "Log"), + self, + ) + self._status_inspect_button.setVisible(False) + self._status_controller = DialogStatusController( + label=self.status_label, + inspect_button=self._status_inspect_button, + preview_limit=STATUS_MESSAGE_PREVIEW_LIMIT, + log_limit=STATUS_LOG_LIMIT, + timestamp_format="HH:mm:ss", + ok_color="green", + error_color="red", + empty_button_behavior="show-if-logs", + ) + + self._status_inspect_button.clicked.connect(self._show_log_dialog) + self._runtime_controller = RuntimeController( + dialog=self, + status_label=self.runtime_status_label, + start_button=self.start_runtime_button, + stop_button=self.stop_runtime_button, + ) + self._defaults_ui_controller = DefaultsUiController(dialog=self) + self._selection_controller = SelectionController( + dialog=self, + columns={ + "group": COL_GROUP, + }, + ) + self._context_menu_controller = ContextMenuController(dialog=self) + self._bulk_edit_controller = BulkEditController( + dialog=self, + columns={ + "enabled": COL_ENABLED, + "group": COL_GROUP, + "format": COL_FORMAT, + "interval": COL_INTERVAL, + "interval_units": COL_INTERVAL_UNITS, + "timeout": COL_TIMEOUT, + "timeout_units": COL_TIMEOUT_UNITS, + "max_size": COL_MAX_SIZE, + "max_size_units": COL_MAX_SIZE_UNITS, + }, + ) + self._subscription_status_controller = SubscriptionStatusController( + dialog=self, + columns={ + "name": COL_NAME, + "url": COL_URL, + "filename": COL_FILENAME, + }, + ) + self._action_file_controller = ActionFileController( + dialog=self, + columns={ + "name": COL_NAME, + "filename": COL_FILENAME, + }, + ) + self._rules_attachment_controller = RulesAttachmentController(dialog=self) + self._rules_editor_controller = RulesEditorController( + dialog=self, + columns={ + "url": COL_URL, + "format": COL_FORMAT, + "group": COL_GROUP, + }, + ) + self._table_data_controller = TableDataController( + dialog=self, + columns={ + "enabled": COL_ENABLED, + "name": COL_NAME, + "url": COL_URL, + "filename": COL_FILENAME, + "format": COL_FORMAT, + "group": COL_GROUP, + "interval": COL_INTERVAL, + "interval_units": COL_INTERVAL_UNITS, + "timeout": COL_TIMEOUT, + "timeout_units": COL_TIMEOUT_UNITS, + "max_size": COL_MAX_SIZE, + "max_size_units": COL_MAX_SIZE_UNITS, + "file": COL_FILE, + "meta": COL_META, + "state": COL_STATE, + "rule_attached": COL_RULE_ATTACHED, + "last_checked": COL_LAST_CHECKED, + "last_updated": COL_LAST_UPDATED, + }, + ) + status_row = QtWidgets.QWidget(self) + status_row.setStyleSheet( + "QWidget {" + f"border-top: 1px solid {footer_border};" + f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};" + "}" + ) + status_row_layout = QtWidgets.QHBoxLayout(status_row) + status_row_layout.setContentsMargins(12, 0, 12, 0) + status_row_layout.setSpacing(8) + self.rootLayout.removeWidget(self.status_label) + self.status_label.setParent(status_row) + status_row_layout.addWidget( + self._status_inspect_button, + 0, + QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + status_row_layout.addWidget(self.status_label, 1) + self.rootLayout.insertWidget(4, status_row) + for widget in ( + self.enable_plugin_check, + self.create_file_button, + self.save_button, + self.reload_button, + self.start_runtime_button, + self.stop_runtime_button, + self.runtime_status_title_label, + self.runtime_status_label, + ): + self.topRowLayout.setAlignment(widget, QtCore.Qt.AlignmentFlag.AlignVCenter) + self.table.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.table.setLineWidth(0) + self.table.setContentsMargins(0, 0, 0, 0) + self.table.setMinimumHeight(0) + self.table.setMaximumHeight(16777215) + defaults_label_width = 120 + for label in ( + self.lists_dir_label, + self.default_interval_label, + self.default_timeout_label, + self.default_max_size_label, + self.default_user_agent_label, + self.node_label, + ): + label.setMinimumWidth(defaults_label_width) + self.node_label.hide() + self.nodes_combo.hide() + + _configure_spin_and_units( + self.default_interval_spin, + self.default_interval_units, + value=1, + unit_value="hours", + allowed_units=INTERVAL_UNITS, + fallback_unit="hours", + min_value=1, + ) + _configure_spin_and_units( + self.default_timeout_spin, + self.default_timeout_units, + value=1, + unit_value="seconds", + allowed_units=TIMEOUT_UNITS, + fallback_unit="seconds", + min_value=1, + ) + _configure_spin_and_units( + self.default_max_size_spin, + self.default_max_size_units, + value=1, + unit_value="MB", + allowed_units=SIZE_UNITS, + fallback_unit="MB", + min_value=1, + ) + + self.table.setColumnCount(18) + self.table.setHorizontalHeaderLabels( + [ + "☑", + QC.translate("stats", "Name"), + QC.translate("stats", "URL"), + QC.translate("stats", "Filename"), + QC.translate("stats", "Format"), + QC.translate("stats", "Groups"), + QC.translate("stats", "Interval"), + QC.translate("stats", "Interval units"), + QC.translate("stats", "Timeout"), + QC.translate("stats", "Timeout units"), + QC.translate("stats", "Max size"), + QC.translate("stats", "Max size units"), + QC.translate("stats", "List file present"), + QC.translate("stats", "List meta present"), + QC.translate("stats", "State"), + QC.translate("stats", "Rule attached"), + QC.translate("stats", "Last checked"), + QC.translate("stats", "Last updated"), + ] + ) + for col in ( + COL_INTERVAL, + COL_INTERVAL_UNITS, + COL_TIMEOUT, + COL_TIMEOUT_UNITS, + COL_MAX_SIZE, + COL_MAX_SIZE_UNITS, + ): + header_item = self.table.horizontalHeaderItem(col) + if header_item is not None: + header_item.setToolTip( + QC.translate( + "stats", + "Leave blank to inherit the global default for this subscription.", + ) + ) + self.table.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.table.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self.table.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.table.setItemDelegateForColumn( + COL_ENABLED, CenteredCheckDelegate(self.table) + ) + state_delegate = KeepForegroundOnSelectionDelegate(self.table) + for col in ( + COL_STATE, + COL_RULE_ATTACHED, + COL_LAST_CHECKED, + COL_LAST_UPDATED, + ): + self.table.setItemDelegateForColumn(col, state_delegate) + self._table_view_controller = TableViewController( + dialog=self, + columns={ + "enabled": COL_ENABLED, + "name": COL_NAME, + "url": COL_URL, + "filename": COL_FILENAME, + "format": COL_FORMAT, + "group": COL_GROUP, + "interval": COL_INTERVAL, + "interval_units": COL_INTERVAL_UNITS, + "timeout": COL_TIMEOUT, + "timeout_units": COL_TIMEOUT_UNITS, + "max_size": COL_MAX_SIZE, + "max_size_units": COL_MAX_SIZE_UNITS, + "file": COL_FILE, + "meta": COL_META, + "state": COL_STATE, + "rule_attached": COL_RULE_ATTACHED, + "last_checked": COL_LAST_CHECKED, + "last_updated": COL_LAST_UPDATED, + }, + ) + header = self.table.horizontalHeader() + if header is not None: + header.setStretchLastSection(False) + header.setSortIndicatorShown(True) + header.setSortIndicator(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder) + header.setSectionResizeMode( + COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + style = self.table.style() + if style is not None: + indicator_w = style.pixelMetric( + QtWidgets.QStyle.PixelMetric.PM_IndicatorWidth, + None, + self.table, + ) + indicator_h = style.pixelMetric( + QtWidgets.QStyle.PixelMetric.PM_IndicatorHeight, + None, + self.table, + ) + self.table.setColumnWidth( + COL_ENABLED, max(indicator_w, indicator_h) + 18 + ) + header.setSectionResizeMode(COL_URL, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.sectionResized.connect( + self._table_view_controller.on_table_section_resized + ) + self.table.setSortingEnabled(True) + self.table.sortItems(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder) + # Keep advanced tuning + verbose metadata available internally but + # reduce visible table complexity; edit dialog exposes full details. + # Initial column visibility is controlled by TableViewController. + for col in ( + COL_INTERVAL, + COL_INTERVAL_UNITS, + COL_TIMEOUT, + COL_TIMEOUT_UNITS, + COL_MAX_SIZE, + COL_MAX_SIZE_UNITS, + COL_FILE, + COL_META, + ): + self.table.setColumnHidden(col, True) + + # Switch between Config and Monitoring table views. + self._table_tab_bar = QtWidgets.QTabBar(self) + self._table_tab_bar.addTab(QC.translate("stats", "Monitoring")) + self._table_tab_bar.addTab(QC.translate("stats", "Config")) + self._table_tab_bar.setContentsMargins(12, 4, 12, 0) + self._table_tab_bar.currentChanged.connect( + self._table_view_controller.on_table_view_tab_changed + ) + self.tableContentLayout.insertWidget(0, self._table_tab_bar) + self._inspector_controller = InspectorController( + dialog=self, + columns={ + "enabled": COL_ENABLED, + "name": COL_NAME, + "url": COL_URL, + "filename": COL_FILENAME, + "format": COL_FORMAT, + "group": COL_GROUP, + "interval": COL_INTERVAL, + "interval_units": COL_INTERVAL_UNITS, + "timeout": COL_TIMEOUT, + "timeout_units": COL_TIMEOUT_UNITS, + "max_size": COL_MAX_SIZE, + "max_size_units": COL_MAX_SIZE_UNITS, + }, + error_preview_limit=INSPECT_ERROR_PREVIEW_LIMIT, + ) + self._table_view_controller.on_table_view_tab_changed(0) + InspectorPanel(dialog=self).build() + self._subscription_edit_controller = SubscriptionEditController( + dialog=self, + columns={ + "enabled": COL_ENABLED, + "name": COL_NAME, + "url": COL_URL, + "filename": COL_FILENAME, + "format": COL_FORMAT, + "group": COL_GROUP, + "interval": COL_INTERVAL, + "interval_units": COL_INTERVAL_UNITS, + "timeout": COL_TIMEOUT, + "timeout_units": COL_TIMEOUT_UNITS, + "max_size": COL_MAX_SIZE, + "max_size_units": COL_MAX_SIZE_UNITS, + }, + ) + + self.create_file_button.clicked.connect( + self._action_file_controller.create_action_file + ) + self.save_button.clicked.connect(self._action_file_controller.save_action_file) + self.reload_button.clicked.connect( + self._runtime_controller.reload_runtime_and_config + ) + self.start_runtime_button.clicked.connect( + self._runtime_controller.start_runtime_clicked + ) + self.stop_runtime_button.clicked.connect( + self._runtime_controller.stop_runtime_clicked + ) + self.add_sub_button.clicked.connect( + self._subscription_edit_controller.add_subscription_row + ) + self.create_global_rule_button.clicked.connect( + self._rules_editor_controller.create_global_rule + ) + self.edit_sub_button.clicked.connect( + self._subscription_edit_controller.edit_action_clicked + ) + self.remove_sub_button.clicked.connect( + self._subscription_edit_controller.remove_selected_subscription + ) + self.refresh_state_button.clicked.connect( + self._table_data_controller.refresh_all_now + ) + self.refresh_now_button.clicked.connect( + self._table_data_controller.refresh_selected_now + ) + self.create_rule_button.clicked.connect( + self._selection_controller.open_rules_action + ) + self.table.itemDoubleClicked.connect( + self._subscription_edit_controller.handle_table_item_double_clicked + ) + self.table.clicked.connect(self._table_data_controller.handle_table_clicked) + self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect( + self._context_menu_controller.open_table_context_menu + ) + sel_model = self.table.selectionModel() + if sel_model is not None: + sel_model.selectionChanged.connect( + self._inspector_controller.handle_table_selection_changed + ) + self.subscription_state_refreshed.connect( + self._inspector_controller.on_subscription_state_refreshed + ) + self._runtime_controller.set_runtime_state(active=False) + self._selection_controller.update_selected_actions_state() + self._inspector_controller.update_inspector_panel() + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py new file mode 100644 index 0000000000..d5eb4e45b3 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py @@ -0,0 +1,158 @@ +import html +import os +from collections.abc import Callable +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtWidgets, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions._utils import RES_DIR +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _configure_modal_dialog, + _wire_copy_close_buttons, +) + +STATUS_LOG_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "status_log_dialog.ui") +StatusLogDialogUI: Final[Any] = load_ui_type(STATUS_LOG_DIALOG_UI_PATH)[0] + + +class StatusLogDialog(QtWidgets.QDialog, StatusLogDialogUI): + if TYPE_CHECKING: + text_view: QtWidgets.QTextEdit + copy_button: QtWidgets.QPushButton + close_button: QtWidgets.QPushButton + + def __init__( + self, + parent: QtWidgets.QWidget, + *, + title: str, + lines: list[str], + fallback_text: str, + level_color: Callable[[str], str], + timestamp_color: str, + ): + super().__init__(parent) + self.setupUi(self) + _configure_modal_dialog(self, title=title) + + self.text_view.setReadOnly(True) + self.text_view.setLineWrapMode(QtWidgets.QTextEdit.LineWrapMode.NoWrap) + self.text_view.setFontFamily("monospace") + self.update_entries( + lines=lines, + fallback_text=fallback_text, + level_color=level_color, + timestamp_color=timestamp_color, + ) + # Scroll to the last entry when dialog is shown + scrollbar = self.text_view.verticalScrollBar() + if scrollbar is not None: + scrollbar.setValue(scrollbar.maximum()) + + _wire_copy_close_buttons( + self, + self.copy_button, + self.close_button, + self.text_view, + ) + + @staticmethod + def _is_near_bottom(scrollbar: QtWidgets.QScrollBar) -> bool: + return scrollbar.value() >= (scrollbar.maximum() - 2) + + def update_entries( + self, + *, + lines: list[str], + fallback_text: str, + level_color: Callable[[str], str], + timestamp_color: str, + ) -> None: + scrollbar = self.text_view.verticalScrollBar() + if scrollbar is None: + display_lines = lines[:] + if not display_lines and (fallback_text or "").strip() != "": + display_lines = [fallback_text] + html_text = self._entries_html(display_lines, level_color, timestamp_color) + self.text_view.setHtml(html_text) + return + + prev_value = scrollbar.value() + follow_tail = self._is_near_bottom(scrollbar) + + display_lines = lines[:] + if not display_lines and (fallback_text or "").strip() != "": + display_lines = [fallback_text] + html_text = self._entries_html(display_lines, level_color, timestamp_color) + self.text_view.setHtml(html_text) + + if follow_tail: + scrollbar.setValue(scrollbar.maximum()) + return + + scrollbar.setValue(min(prev_value, scrollbar.maximum())) + + @staticmethod + def _entries_html( + lines: list[str], + level_color: Callable[[str], str], + timestamp_color: str, + ) -> str: + html_lines: list[str] = [] + for line in lines: + text = str(line or "").rstrip("\n") + if text == "": + html_lines.append(" ") + continue + + level = "INFO" + timestamp = "" + remainder = text + if text.startswith("["): + timestamp_end = text.find("]") + if timestamp_end > 0: + timestamp = text[1:timestamp_end].strip() + remainder = text[timestamp_end + 1 :].lstrip() + if remainder.startswith("["): + level_end = remainder.find("]") + if level_end > 0: + level = remainder[1:level_end].strip() or "INFO" + remainder = remainder[level_end + 1 :].lstrip() + + level_html = html.escape(level) + timestamp_html = html.escape(timestamp) + message_html = html.escape(remainder.lstrip()) + color = level_color(level) + timestamp_prefix = "" + if timestamp_html != "": + timestamp_prefix = ( + f"[{timestamp_html}] " + ) + html_lines.append( + "" + f"{timestamp_prefix}" + f"[{level_html}] " + f"{message_html}" + "" + ) + + body = "
".join(html_lines) + return ( + "' + f"{body}" + "" + ) + + def exec(self) -> int: + return int(super().exec()) + + def show(self) -> None: + super().show() + # Scroll to the last entry when dialog is shown + scrollbar = self.text_view.verticalScrollBar() + if scrollbar is not None: + scrollbar.setValue(scrollbar.maximum()) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py new file mode 100644 index 0000000000..9a479dd34e --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py @@ -0,0 +1,535 @@ +import logging +import os +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtGui, + QtWidgets, + QC, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults +from opensnitch.plugins.list_subscriptions.models.subscriptions import ( + MutableSubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions._utils import ( + RES_DIR, + INTERVAL_UNITS, + TIMEOUT_UNITS, + SIZE_UNITS, + normalize_group, + normalize_groups, +) +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _apply_footer_separator_style, + _apply_section_bar_style, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import ( + _configure_spin_and_units, + _set_optional_field_tooltips, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import ( + _replace_checkbox_with_toggle, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import ( + DialogStatusController, +) +from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_dialog_controller import ( + SubscriptionDialogController, +) + +SUBSCRIPTION_DIALOG_UI_PATH: Final[str] = os.path.join( + RES_DIR, "subscription_dialog.ui" +) + +SubscriptionDialogUI: Final[Any] = load_ui_type(SUBSCRIPTION_DIALOG_UI_PATH)[0] +DIALOG_MESSAGE_PREVIEW_LIMIT: Final[int] = 48 +DIALOG_MESSAGE_LOG_LIMIT: Final[int] = 200 + +logger: Final[logging.Logger] = logging.getLogger(__name__) + + +def _origin_slug_from_title(title: str) -> str: + slug = "-".join((title or "subscription").strip().lower().split()) + return slug or "subscription" + + +class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): + _url_test_finished = QtCore.pyqtSignal(bool, str) + log_message = QtCore.pyqtSignal(str, str, str) # (message, level, origin) + + if TYPE_CHECKING: + rootLayout: QtWidgets.QVBoxLayout + bodyLayout: QtWidgets.QHBoxLayout + settings_group_layout: QtWidgets.QVBoxLayout + settings_section_bar: QtWidgets.QFrame + settings_section_label: QtWidgets.QLabel + settings_group: QtWidgets.QGroupBox + meta_grid: QtWidgets.QGridLayout + enabled_check: QtWidgets.QCheckBox + buttons_layout: QtWidgets.QHBoxLayout + settings_form: QtWidgets.QFormLayout + name_label: QtWidgets.QLabel + name_edit: QtWidgets.QLineEdit + name_error_label: QtWidgets.QLabel + url_label: QtWidgets.QLabel + url_edit: QtWidgets.QLineEdit + url_error_label: QtWidgets.QLabel + filename_label: QtWidgets.QLabel + filename_edit: QtWidgets.QLineEdit + filename_error_label: QtWidgets.QLabel + format_label: QtWidgets.QLabel + format_combo: QtWidgets.QComboBox + groups_label: QtWidgets.QLabel + group_combo: QtWidgets.QComboBox + interval_label: QtWidgets.QLabel + interval_layout: QtWidgets.QHBoxLayout + interval_spin: QtWidgets.QSpinBox + interval_units: QtWidgets.QComboBox + timeout_label: QtWidgets.QLabel + timeout_layout: QtWidgets.QHBoxLayout + timeout_spin: QtWidgets.QSpinBox + timeout_units: QtWidgets.QComboBox + max_size_label: QtWidgets.QLabel + max_size_layout: QtWidgets.QHBoxLayout + max_size_spin: QtWidgets.QSpinBox + max_size_units: QtWidgets.QComboBox + meta_group_layout: QtWidgets.QVBoxLayout + meta_section_bar: QtWidgets.QFrame + meta_section_label: QtWidgets.QLabel + meta_group: QtWidgets.QGroupBox + meta_separator: QtWidgets.QFrame + meta_file_present_label: QtWidgets.QLabel + meta_file_present: QtWidgets.QLabel + meta_meta_present_label: QtWidgets.QLabel + meta_meta_present: QtWidgets.QLabel + meta_state_label: QtWidgets.QLabel + meta_state: QtWidgets.QLabel + meta_last_checked_label: QtWidgets.QLabel + meta_last_checked: QtWidgets.QLabel + meta_last_updated_label: QtWidgets.QLabel + meta_last_updated: QtWidgets.QLabel + meta_failures_label: QtWidgets.QLabel + meta_failures: QtWidgets.QLabel + meta_error_label: QtWidgets.QLabel + meta_error: QtWidgets.QLabel + meta_list_path_label: QtWidgets.QLabel + meta_list_path: QtWidgets.QLabel + meta_meta_path_label: QtWidgets.QLabel + meta_meta_path: QtWidgets.QLabel + error_label: QtWidgets.QLabel + footer_separator_line: QtWidgets.QFrame + test_url_button: QtWidgets.QPushButton + cancel_button: QtWidgets.QPushButton + add_button: QtWidgets.QPushButton + _title: str + _defaults: GlobalDefaults + _groups: list[str] + _sub: MutableSubscriptionSpec + _meta: dict[str, str] + + def __init__( + self, + parent: QtWidgets.QWidget | None, + defaults: GlobalDefaults, + groups: list[str] | None = None, + sub: MutableSubscriptionSpec | dict[str, Any] | None = None, + meta: dict[str, str] | None = None, + title: str = "Subscription", + ): + super().__init__(parent) + self.setWindowTitle(QC.translate("stats", title)) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + self._title = title + self._log_origin = f"ui:{_origin_slug_from_title(title)}" + self._defaults = defaults + self._groups = groups or [] + try: + if isinstance(sub, MutableSubscriptionSpec): + self._sub = sub + elif sub is None: + parsed_sub = MutableSubscriptionSpec.from_dict( + {"enabled": True}, + defaults=self._defaults, + require_url=False, + ensure_suffix=False, + ) + if parsed_sub is None: + raise ValueError( + "default subscription state could not be initialized" + ) + self._sub = parsed_sub + else: + parsed_sub = MutableSubscriptionSpec.from_dict( + sub, + defaults=self._defaults, + require_url=False, + ensure_suffix=False, + ) + if parsed_sub is None: + raise ValueError("subscription data could not be initialized") + self._sub = parsed_sub + except Exception as exc: + QtWidgets.QMessageBox.critical( + parent, + QC.translate("stats", "Subscription Error"), + QC.translate( + "stats", "Failed to initialize subscription data: {0}" + ).format(str(exc)), + ) + raise + self._meta = meta or {} + self._dialog_message_inspect_button: QtWidgets.QPushButton | None = None + self._deferred_dialog_result: int | None = None + self._build_ui() + self.finished.connect(lambda _: self._subscription_dialog_controller.disconnect_signal()) + + def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override] + self._subscription_dialog_controller.cancel_active_url_test() + super().hideEvent(event) + + def _defer_dialog_close(self, result: int) -> bool: + if not self._subscription_dialog_controller.has_active_url_test(): + return False + if self._deferred_dialog_result is None: + self._deferred_dialog_result = result + self.setEnabled(False) + self._dialog_message_controller.set_status( + QC.translate("stats", "Stopping background tasks..."), + error=False, + log=False, + ) + self._subscription_dialog_controller.cancel_active_url_test() + self._subscription_dialog_controller.on_url_test_stopped( + self._complete_deferred_dialog_close + ) + return True + + def _complete_deferred_dialog_close(self) -> None: + result = self._deferred_dialog_result + if result is None: + return + self._deferred_dialog_result = None + self.setEnabled(True) + self._dialog_message_controller.set_status("", error=False, log=False) + if result == int(QtWidgets.QDialog.DialogCode.Accepted): + super().accept() + return + super().reject() + + def accept(self) -> None: + if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Accepted)): + return + super().accept() + + def reject(self) -> None: + if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)): + return + super().reject() + + def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override] + if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)): + if event is not None: + event.ignore() + return + self._dialog_message_controller.set_status("", error=False, log=False) + super().closeEvent(event) + + def _build_ui(self): + self.setupUi(self) + self.enabled_check = _replace_checkbox_with_toggle(self.enabled_check) + self.rootLayout.setContentsMargins(0, 0, 0, 0) + self.rootLayout.setSpacing(0) + self.bodyLayout.setContentsMargins(0, 0, 0, 0) + self.bodyLayout.setSpacing(0) + self.settings_group.setStyleSheet( + "QGroupBox { border: 0; margin: 0; padding: 0; }" + ) + self.meta_group.setStyleSheet("QGroupBox { border: 0; margin: 0; padding: 0; }") + self.settings_group_layout.setContentsMargins(0, 0, 0, 0) + self.settings_group_layout.setSpacing(0) + self.settings_form.setContentsMargins(12, 10, 12, 10) + self.settings_form.setVerticalSpacing(14) + self.meta_group_layout.setContentsMargins(0, 0, 0, 0) + self.meta_group_layout.setSpacing(0) + self.meta_grid.setContentsMargins(12, 10, 12, 10) + self.buttons_layout.setContentsMargins(12, 10, 12, 12) + self.buttons_layout.setSpacing(8) + self.bodyLayout.setStretch(0, 1) + self.bodyLayout.setStretch(1, 1) + _apply_section_bar_style( + self, self.settings_section_bar, self.settings_section_label + ) + _apply_section_bar_style( + self, self.meta_section_bar, self.meta_section_label + ) + _apply_section_bar_style( + self, + self.settings_section_bar, + self.settings_section_label, + right_border=True, + ) + _apply_footer_separator_style(self, self.footer_separator_line) + footer_role = ( + QtGui.QPalette.ColorRole.Midlight + if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + else QtGui.QPalette.ColorRole.Dark + ) + footer_border = self.palette().color(footer_role).name() + self.footer_separator_line.setStyleSheet(f"color: {footer_border};") + self.error_label.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.error_label.setStyleSheet( + f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}" + ) + self.error_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + self.error_label.setWordWrap(False) + self.error_label.setTextFormat(QtCore.Qt.TextFormat.PlainText) + self._dialog_message_controller = DialogStatusController( + label=self.error_label, + inspect_button=None, + preview_limit=DIALOG_MESSAGE_PREVIEW_LIMIT, + log_limit=DIALOG_MESSAGE_LOG_LIMIT, + timestamp_format="yyyy-MM-ddTHH:mm:ss.zzz", + ok_color="#2e7d32", + error_color="red", + empty_button_behavior="hide", + ) + self._subscription_dialog_controller = SubscriptionDialogController(dialog=self) + self._dialog_message_controller.set_status("", error=False) + footer_index = self.rootLayout.indexOf(self.footer_separator_line) + error_index = self.rootLayout.indexOf(self.error_label) + if error_index >= 0: + status_row = QtWidgets.QWidget(self) + status_row.setStyleSheet( + "QWidget {" + f"border-top: 1px solid {footer_border};" + f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};" + "}" + ) + status_row_layout = QtWidgets.QHBoxLayout(status_row) + status_row_layout.setContentsMargins(12, 0, 12, 0) + status_row_layout.setSpacing(8) + self.buttons_layout.removeWidget(self.error_label) + self.error_label.setParent(status_row) + status_row_layout.addWidget(self.error_label, 1) + insert_index = footer_index + 1 if footer_index >= 0 else error_index + self.rootLayout.insertWidget(insert_index, status_row) + self.settings_form.setFieldGrowthPolicy( + QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + ) + settings_label_width = 96 + for label in ( + self.name_label, + self.url_label, + self.filename_label, + self.format_label, + self.groups_label, + self.interval_label, + self.timeout_label, + self.max_size_label, + ): + label.setMinimumWidth(settings_label_width) + self.enabled_check.setContentsMargins(0, 0, 0, 8) + + unit_combo_width = 132 + expanding = QtWidgets.QSizePolicy.Policy.Expanding + fixed = QtWidgets.QSizePolicy.Policy.Fixed + self.format_combo.setSizePolicy(expanding, fixed) + self.group_combo.setSizePolicy(expanding, fixed) + self.interval_spin.setSizePolicy(expanding, fixed) + self.timeout_spin.setSizePolicy(expanding, fixed) + self.max_size_spin.setSizePolicy(expanding, fixed) + self.interval_units.setSizePolicy(fixed, fixed) + self.timeout_units.setSizePolicy(fixed, fixed) + self.max_size_units.setSizePolicy(fixed, fixed) + self.format_combo.setMinimumWidth(0) + self.group_combo.setMinimumWidth(0) + self.interval_units.setMinimumWidth(unit_combo_width) + self.timeout_units.setMinimumWidth(unit_combo_width) + section_font = self.settings_section_label.font() + section_font.setPointSize(13) + section_font.setBold(True) + self.settings_section_label.setFont(section_font) + self.meta_section_label.setFont(section_font) + self.settings_section_label.setMinimumHeight(32) + self.meta_section_label.setMinimumHeight(32) + self.max_size_units.setMinimumWidth(unit_combo_width) + self.interval_layout.setStretch(0, 1) + self.interval_layout.setStretch(1, 0) + self.timeout_layout.setStretch(0, 1) + self.timeout_layout.setStretch(1, 0) + self.max_size_layout.setStretch(0, 1) + self.max_size_layout.setStretch(1, 0) + + meta_label_width = 150 + window_color = self.palette().color(QtGui.QPalette.ColorRole.Window) + is_dark_theme = window_color.lightness() < 128 + separator_role = ( + QtGui.QPalette.ColorRole.Midlight + if is_dark_theme + else QtGui.QPalette.ColorRole.Dark + ) + separator_color = self.palette().color(separator_role) + separator_css = separator_color.name() + self.meta_separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.meta_separator.setFixedWidth(1) + self.meta_separator.setStyleSheet( + f"background-color: {separator_css}; border: 0; margin-left: 4px; margin-right: 10px;" + ) + meta_label_style = "padding-right: 6px;" + for label in ( + self.meta_file_present_label, + self.meta_meta_present_label, + self.meta_state_label, + self.meta_last_checked_label, + self.meta_last_updated_label, + self.meta_failures_label, + self.meta_error_label, + self.meta_list_path_label, + self.meta_meta_path_label, + ): + label.setMinimumWidth(meta_label_width) + label.setStyleSheet(meta_label_style) + + for label in ( + self.name_error_label, + self.url_error_label, + self.filename_error_label, + ): + label.setStyleSheet("color: red;") + label.setText("") + self.group_combo.setEditable(True) + self.group_combo.setToolTip( + QC.translate( + "stats", + "Optional explicit groups. Every subscription is always included in the global 'all' rules directory.", + ) + ) + self._url_test_finished.connect(self._subscription_dialog_controller.handle_url_test_finished) + self.add_button.clicked.connect(self._subscription_dialog_controller.validate_then_accept) + self.test_url_button.clicked.connect(self._subscription_dialog_controller.test_url) + self.cancel_button.clicked.connect(self.reject) + + self.enabled_check.setChecked(bool(self._sub.enabled)) + self.name_edit.setText(str(self._sub.name)) + self.url_edit.setText(str(self._sub.url)) + self.filename_edit.setText(str(self._sub.filename)) + self.format_combo.clear() + self.format_combo.addItems(("hosts",)) + self.format_combo.setCurrentText(str(self._sub.format or "hosts")) + for g in self._groups: + ng = normalize_group(g) + if ng not in ("", "all"): + self.group_combo.addItem(ng) + current_groups = normalize_groups(self._sub.groups) + current_group_text = ", ".join(current_groups) + if ( + current_group_text != "" + and self.group_combo.findText(current_group_text) < 0 + ): + self.group_combo.addItem(current_group_text) + self.group_combo.setCurrentText(current_group_text) + _configure_spin_and_units( + self.interval_spin, + self.interval_units, + value=int(self._sub.interval or 0), + unit_value=str(self._sub.interval_units or self._defaults.interval_units), + allowed_units=INTERVAL_UNITS, + fallback_unit="hours", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.interval, + self._defaults.interval_units, + ), + ) + _configure_spin_and_units( + self.timeout_spin, + self.timeout_units, + value=int(self._sub.timeout or 0), + unit_value=str(self._sub.timeout_units or self._defaults.timeout_units), + allowed_units=TIMEOUT_UNITS, + fallback_unit="seconds", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.timeout, + self._defaults.timeout_units, + ), + ) + _configure_spin_and_units( + self.max_size_spin, + self.max_size_units, + value=int(self._sub.max_size or 0), + unit_value=str(self._sub.max_size_units or self._defaults.max_size_units), + allowed_units=SIZE_UNITS, + fallback_unit="MB", + special_value_text=QC.translate( + "stats", "Use global default ({0} {1})" + ).format( + self._defaults.max_size, + self._defaults.max_size_units, + ), + ) + self.interval_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state) + self.timeout_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state) + self.max_size_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state) + _set_optional_field_tooltips( + self.interval_spin, + self.interval_units, + self.timeout_spin, + self.timeout_units, + self.max_size_spin, + self.max_size_units, + inherit_wording=True, + ) + self._subscription_dialog_controller.sync_optional_fields_state() + self.meta_file_present.setText(str(self._meta.get("file_present", ""))) + self.meta_meta_present.setText(str(self._meta.get("meta_present", ""))) + self.meta_state.setText(str(self._meta.get("state", ""))) + self._subscription_dialog_controller.apply_meta_state_color(str(self._meta.get("state", ""))) + self.meta_last_checked.setText(str(self._meta.get("last_checked", ""))) + self.meta_last_updated.setText(str(self._meta.get("last_updated", ""))) + self.meta_failures.setText(str(self._meta.get("failures", ""))) + self.meta_error.setText(str(self._meta.get("error", ""))) + self.meta_list_path.setText(str(self._meta.get("list_path", ""))) + self.meta_meta_path.setText(str(self._meta.get("meta_path", ""))) + if "new" in (self._title or "").strip().lower(): + self.meta_group.setVisible(False) + self.resize(920, 420) + + def subscription_spec(self): + groups = normalize_groups((self.group_combo.currentText() or "").strip()) + return MutableSubscriptionSpec( + enabled=self.enabled_check.isChecked(), + name=(self.name_edit.text() or "").strip(), + url=(self.url_edit.text() or "").strip(), + filename=(self.filename_edit.text() or "").strip(), + format=(self.format_combo.currentText() or "hosts").strip().lower(), + groups=groups, + interval=int(self.interval_spin.value()) or None, + interval_units=( + self.interval_units.currentText() + if self.interval_spin.value() > 0 + else None + ), + timeout=int(self.timeout_spin.value()) or None, + timeout_units=( + self.timeout_units.currentText() + if self.timeout_spin.value() > 0 + else None + ), + max_size=int(self.max_size_spin.value()) or None, + max_size_units=( + self.max_size_units.currentText() + if self.max_size_spin.value() > 0 + else None + ), + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py new file mode 100644 index 0000000000..4b48c9e38c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py @@ -0,0 +1,192 @@ +import os +from typing import Any, Final, TYPE_CHECKING + +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtWidgets, + QC, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions._utils import RES_DIR +from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog + +SUBSCRIPTION_STATUS_DIALOG_UI_PATH: Final[str] = os.path.join( + RES_DIR, "subscription_status_dialog.ui" +) + +SubscriptionStatusDialogUI: Final[Any] = load_ui_type( + SUBSCRIPTION_STATUS_DIALOG_UI_PATH +)[0] + + +class SubscriptionStatusDialog(QtWidgets.QDialog, SubscriptionStatusDialogUI): + ACTION_NONE: Final[str] = "none" + ACTION_EDIT: Final[str] = "edit" + ACTION_REFRESH: Final[str] = "refresh" + + if TYPE_CHECKING: + title_label: QtWidgets.QLabel + details_scroll: QtWidgets.QScrollArea + details_container: QtWidgets.QWidget + buttons_layout: QtWidgets.QHBoxLayout + refresh_button: QtWidgets.QPushButton + edit_button: QtWidgets.QPushButton + close_button: QtWidgets.QPushButton + + def __init__( + self, + parent: QtWidgets.QWidget | None, + name: str, + url: str, + filename: str, + meta: dict[str, str], + ): + super().__init__(parent) + self.setupUi(self) + self._action = self.ACTION_NONE + self._url = url + self._filename = filename + self._value_labels: dict[str, QtWidgets.QLabel] = {} + self._refresh_signal: Any = None + _configure_modal_dialog( + self, + title=QC.translate("stats", "Subscription status"), + size=(700, 440), + ) + + self.title_label.setText(name or filename or url) + title_font = self.title_label.font() + title_font.setBold(True) + self.title_label.setFont(title_font) + + details = QtWidgets.QFormLayout(self.details_container) + details.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + details.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + details.setHorizontalSpacing(10) + details.setVerticalSpacing(6) + + self._add_value_row(details, "url", QC.translate("stats", "URL"), url) + self._add_value_row( + details, + "filename", + QC.translate("stats", "Filename"), + filename, + ) + self._add_value_row( + details, + "file_present", + QC.translate("stats", "List file present"), + meta.get("file_present", ""), + ) + self._add_value_row( + details, + "meta_present", + QC.translate("stats", "List meta present"), + meta.get("meta_present", ""), + ) + self._add_value_row( + details, + "state", + QC.translate("stats", "State"), + meta.get("state", ""), + ) + self._add_value_row( + details, + "last_checked", + QC.translate("stats", "Last checked"), + meta.get("last_checked", ""), + ) + self._add_value_row( + details, + "last_updated", + QC.translate("stats", "Last updated"), + meta.get("last_updated", ""), + ) + self._add_value_row( + details, + "failures", + QC.translate("stats", "Failures"), + meta.get("failures", ""), + ) + self._add_value_row( + details, + "error", + QC.translate("stats", "Error"), + meta.get("error", ""), + ) + self._add_value_row( + details, + "list_path", + QC.translate("stats", "List path"), + meta.get("list_path", ""), + ) + self._add_value_row( + details, + "meta_path", + QC.translate("stats", "Meta path"), + meta.get("meta_path", ""), + ) + + self.refresh_button.setText(QC.translate("stats", "Refresh")) + self.edit_button.setText(QC.translate("stats", "Edit")) + self.close_button.setText(QC.translate("stats", "Close")) + self.refresh_button.clicked.connect(self.on_refresh) + self.edit_button.clicked.connect(self.on_edit) + self.close_button.clicked.connect(self.reject) + + def reject(self): + self.disconnect_signal() + super().reject() + + def action(self): + return self._action + + # -- Meta refresh ------------------------------------------------------- + + def connect_to_refresh_signal(self, signal: Any) -> None: + self._refresh_signal = signal + signal.connect(self.on_state_refreshed) + + def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None: + if url != self._url or filename != self._filename: + return + self.update_meta(meta) + + def update_meta(self, meta: dict[str, str]) -> None: + fields = ( + "file_present", + "meta_present", + "state", + "last_checked", + "last_updated", + "failures", + "error", + "list_path", + "meta_path", + ) + for key in fields: + label = self._value_labels.get(key) + if label is None: + continue + label.setText((meta.get(key, "") or "-").strip() or "-") + + def disconnect_signal(self) -> None: + if self._refresh_signal is not None: + try: + self._refresh_signal.disconnect(self.on_state_refreshed) + except Exception: + pass + self._refresh_signal = None + + # -- Actions ------------------------------------------------------------ + + def on_refresh(self) -> None: + self.disconnect_signal() + self._action = self.ACTION_REFRESH + self.accept() + + def on_edit(self) -> None: + self.disconnect_signal() + self._action = self.ACTION_EDIT + self.accept() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py new file mode 100644 index 0000000000..77144097a9 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py @@ -0,0 +1,54 @@ +import os +from typing import Any, TYPE_CHECKING, Final + +from opensnitch.plugins.list_subscriptions.ui import ( + QtWidgets, + load_ui_type, +) + +from opensnitch.plugins.list_subscriptions._utils import RES_DIR +from opensnitch.plugins.list_subscriptions.ui.views.helpers import ( + _configure_modal_dialog, + _wire_copy_close_buttons, +) + +TEXT_INSPECT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "text_inspect_dialog.ui") +TextInspectDialogUI: Final[Any] = load_ui_type(TEXT_INSPECT_DIALOG_UI_PATH)[0] + + +class TextInspectDialog(QtWidgets.QDialog, TextInspectDialogUI): + if TYPE_CHECKING: + text_view: QtWidgets.QPlainTextEdit + copy_button: QtWidgets.QPushButton + close_button: QtWidgets.QPushButton + + def __init__( + self, + parent: QtWidgets.QWidget, + *, + title: str, + text: str, + ): + super().__init__(parent) + self._has_content = (text or "").strip() != "" + if not self._has_content: + return + + self.setupUi(self) + _configure_modal_dialog(self, title=title) + + self.text_view.setReadOnly(True) + self.text_view.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap) + self.text_view.setPlainText(text) + + _wire_copy_close_buttons( + self, + self.copy_button, + self.close_button, + self.text_view, + ) + + def exec(self) -> int: + if not self._has_content: + return int(QtWidgets.QDialog.DialogCode.Rejected) + return int(super().exec()) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py new file mode 100644 index 0000000000..28cf441344 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py @@ -0,0 +1,91 @@ +from collections.abc import Sequence + +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC + + +def _set_optional_field_tooltips( + interval_spin: QtWidgets.QWidget, + interval_units: QtWidgets.QWidget, + timeout_spin: QtWidgets.QWidget, + timeout_units: QtWidgets.QWidget, + max_size_spin: QtWidgets.QWidget, + max_size_units: QtWidgets.QWidget, + *, + inherit_wording: bool, +): + if inherit_wording: + interval_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global interval.") + ) + interval_units.setToolTip( + QC.translate("stats", "Used only when the interval override is set.") + ) + timeout_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global timeout.") + ) + timeout_units.setToolTip( + QC.translate("stats", "Used only when the timeout override is set.") + ) + max_size_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global max size.") + ) + max_size_units.setToolTip( + QC.translate("stats", "Used only when the max size override is set.") + ) + return + interval_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the interval override and use the global default.", + ) + ) + interval_units.setToolTip( + QC.translate("stats", "Used only when an interval override is applied.") + ) + timeout_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the timeout override and use the global default.", + ) + ) + timeout_units.setToolTip( + QC.translate("stats", "Used only when a timeout override is applied.") + ) + max_size_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the max size override and use the global default.", + ) + ) + max_size_units.setToolTip( + QC.translate("stats", "Used only when a max size override is applied.") + ) + + +def _configure_spin_and_units( + spin: QtWidgets.QSpinBox, + units_combo: QtWidgets.QComboBox, + *, + value: int, + unit_value: str | None, + allowed_units: Sequence[str], + fallback_unit: str, + min_value: int = 0, + max_value: int = 999999, + special_value_text: str | None = None, +): + spin.setRange(min_value, max_value) + if special_value_text is not None: + spin.setSpecialValueText(special_value_text) + spin.setValue(max(min_value, int(value))) + units_combo.clear() + units_combo.addItems(tuple(allowed_units)) + normalized = (unit_value or "").strip().lower() + current_unit = fallback_unit + for unit in allowed_units: + if unit.lower() == normalized: + current_unit = unit + break + units_combo.setCurrentText( + current_unit + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py new file mode 100644 index 0000000000..eb4e3c0683 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py @@ -0,0 +1,139 @@ +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QtWidgets + + +class KeepForegroundOnSelectionDelegate(QtWidgets.QStyledItemDelegate): + def initStyleOption( + self, + option: QtWidgets.QStyleOptionViewItem | None, + index: QtCore.QModelIndex, + ): + super().initStyleOption(option, index) + if option is None or index is None: + return + foreground = index.data(QtCore.Qt.ItemDataRole.ForegroundRole) + if foreground is None: + return + brush = ( + foreground + if isinstance(foreground, QtGui.QBrush) + else QtGui.QBrush(foreground) + ) + option.palette.setBrush( + QtGui.QPalette.ColorRole.Text, + brush, + ) + option.palette.setBrush( + QtGui.QPalette.ColorRole.HighlightedText, + brush, + ) + + +class CenteredCheckDelegate(QtWidgets.QStyledItemDelegate): + def _indicator_rect( + self, + option: QtWidgets.QStyleOptionViewItem, + ) -> QtCore.QRect: + style = ( + option.widget.style() + if option.widget is not None + else QtWidgets.QApplication.style() + ) + if style is None: + return option.rect + indicator_rect = style.subElementRect( + QtWidgets.QStyle.SubElement.SE_ItemViewItemCheckIndicator, + option, + option.widget, + ) + return QtCore.QRect( + option.rect.x() + (option.rect.width() - indicator_rect.width()) // 2, + option.rect.y() + (option.rect.height() - indicator_rect.height()) // 2, + indicator_rect.width(), + indicator_rect.height(), + ) + + def initStyleOption( + self, + option: QtWidgets.QStyleOptionViewItem | None, + index: QtCore.QModelIndex, + ) -> None: + super().initStyleOption(option, index) + if option is None: + return + option.displayAlignment = QtCore.Qt.AlignmentFlag.AlignCenter + + def paint( + self, + painter: QtGui.QPainter | None, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + if painter is None: + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + if not ( + opt.features + & QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator + ): + super().paint(painter, option, index) + return + + style = ( + opt.widget.style() + if opt.widget is not None + else QtWidgets.QApplication.style() + ) + if style is None: + return + + draw_opt = QtWidgets.QStyleOptionViewItem(opt) + draw_opt.features &= ( + ~QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator + ) + draw_opt.text = "" + draw_opt.checkState = QtCore.Qt.CheckState.Unchecked + style.drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, + draw_opt, + painter, + draw_opt.widget, + ) + + indicator_opt = QtWidgets.QStyleOptionViewItem(opt) + indicator_opt.rect = self._indicator_rect(opt) + indicator_opt.state &= ~( + QtWidgets.QStyle.StateFlag.State_On + | QtWidgets.QStyle.StateFlag.State_Off + | QtWidgets.QStyle.StateFlag.State_NoChange + ) + check_state_raw = index.data(QtCore.Qt.ItemDataRole.CheckStateRole) + check_state = ( + int(check_state_raw.value) + if isinstance(check_state_raw, QtCore.Qt.CheckState) + else int(check_state_raw or 0) + ) + if check_state == int(QtCore.Qt.CheckState.Checked.value): + indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_On + indicator_opt.checkState = QtCore.Qt.CheckState.Checked + elif check_state == int(QtCore.Qt.CheckState.PartiallyChecked.value): + indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_NoChange + indicator_opt.checkState = QtCore.Qt.CheckState.PartiallyChecked + else: + indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_Off + indicator_opt.checkState = QtCore.Qt.CheckState.Unchecked + style.drawPrimitive( + QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck, + indicator_opt, + painter, + opt.widget, + ) + + +class SortableTableWidgetItem(QtWidgets.QTableWidgetItem): + def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool: + left = self.data(QtCore.Qt.ItemDataRole.UserRole) + right = other.data(QtCore.Qt.ItemDataRole.UserRole) + if left is not None or right is not None: + return (left, self.text().lower()) < (right, other.text().lower()) + return super().__lt__(other) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py new file mode 100644 index 0000000000..dd546112cb --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py @@ -0,0 +1,249 @@ +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtGui, + QtWidgets, + QC, +) + + +class ToggleSwitch(QtWidgets.QCheckBox): + def __init__( + self, + text: str = "", + parent: QtWidgets.QWidget | None = None, + ): + super().__init__(text, parent) + self._base_text = text + self._track_width = 38 + self._track_height = 22 + self._thumb_diameter = 16 + self._label_gap = 8 + self._outer_padding = 4 + self._paint_margin = 1.5 + self._focus_margin = 2.0 + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.setContentsMargins( + self._outer_padding, + self._outer_padding, + self._outer_padding, + self._outer_padding, + ) + self.toggled.connect(self._refresh_geometry) + font = self.font() + font.setBold(True) + self.setFont(font) + self._refresh_geometry(self.isChecked()) + + def sizeHint(self) -> QtCore.QSize: + metrics = self.fontMetrics() + label_text = self._display_text() + text_width = metrics.horizontalAdvance(label_text) if label_text else 0 + width = self._track_width + int(self._focus_margin * 2) + text_width + if text_width: + width += self._label_gap + margins = self.contentsMargins() + width += margins.left() + margins.right() + height = max( + metrics.height(), + self._track_height + int(self._focus_margin * 2), + ) + height += margins.top() + margins.bottom() + return QtCore.QSize(width, height) + + def minimumSizeHint(self) -> QtCore.QSize: + return self.sizeHint() + + def hitButton(self, pos: QtCore.QPoint) -> bool: + return self.rect().contains(pos) + + def _refresh_geometry(self, _checked: bool): + self.setMinimumHeight(self.sizeHint().height()) + self.updateGeometry() + self.update() + + def _display_text(self) -> str: + base = (self._base_text or "").strip() + if base.lower() == "enable list subscriptions plugin": + state = QC.translate( + "stats", + "enabled" if self.isChecked() else "disabled", + ) + return QC.translate( + "stats", + "List subscriptions plugin {0}", + ).format(state) + if base.lower() in {"enabled", "disabled"}: + return QC.translate( + "stats", + "Enabled" if self.isChecked() else "Disabled", + ) + return base + + def paintEvent(self, event: QtGui.QPaintEvent): # type: ignore[override] + del event + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) + + margins = self.contentsMargins() + content_rect = self.rect().adjusted( + margins.left(), + margins.top(), + -margins.right(), + -margins.bottom(), + ) + _draw_toggle_switch( + painter, + self.palette(), + QtCore.QRectF(content_rect), + checked=self.isChecked(), + enabled=self.isEnabled(), + text=self._display_text(), + bold_text=True, + focused=self.hasFocus(), + track_width=float(self._track_width), + track_height=float(self._track_height), + thumb_diameter=float(self._thumb_diameter), + label_gap=float(self._label_gap), + paint_margin=float(self._paint_margin), + focus_margin=float(self._focus_margin), + ) + + +def _draw_toggle_switch( + painter: QtGui.QPainter, + palette: QtGui.QPalette, + rect: QtCore.QRectF, + *, + checked: bool, + enabled: bool, + text: str = "", + bold_text: bool = False, + focused: bool = False, + track_width: float = 38.0, + track_height: float = 22.0, + thumb_diameter: float = 16.0, + label_gap: float = 8.0, + paint_margin: float = 1.5, + focus_margin: float = 2.0, +): + is_dark = palette.color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + if enabled: + off_role = ( + QtGui.QPalette.ColorRole.Midlight + if is_dark + else QtGui.QPalette.ColorRole.Mid + ) + border_role = ( + QtGui.QPalette.ColorRole.Light if is_dark else QtGui.QPalette.ColorRole.Dark + ) + track_color = ( + palette.color(QtGui.QPalette.ColorRole.Highlight) + if checked + else palette.color(off_role) + ) + thumb_color = palette.color(QtGui.QPalette.ColorRole.Base) + text_color = palette.color(QtGui.QPalette.ColorRole.WindowText) + border_color = palette.color(border_role) + else: + track_color = palette.color(QtGui.QPalette.ColorRole.Dark) + thumb_color = palette.color(QtGui.QPalette.ColorRole.Mid) + text_color = palette.color(QtGui.QPalette.ColorRole.Mid) + border_color = palette.color(QtGui.QPalette.ColorRole.Mid) + + track_x = rect.left() + paint_margin + focus_margin + track_y = rect.top() + (rect.height() - track_height) / 2.0 + paint_margin + track_rect = QtCore.QRectF( + track_x, + track_y, + track_width - (paint_margin * 2.0), + track_height - (paint_margin * 2.0), + ) + thumb_margin = (track_rect.height() - thumb_diameter) / 2.0 + thumb_left_off = track_rect.left() + thumb_margin + thumb_left_on = track_rect.right() - thumb_margin - thumb_diameter + thumb_rect = QtCore.QRectF( + thumb_left_on if checked else thumb_left_off, + track_rect.top() + thumb_margin, + thumb_diameter, + thumb_diameter, + ) + radius = track_rect.height() / 2.0 + + border_pen = QtGui.QPen(border_color) + border_pen.setWidth(1) + painter.setPen(border_pen) + painter.setBrush(track_color) + painter.drawRoundedRect(track_rect, radius, radius) + painter.setBrush(thumb_color) + painter.drawEllipse(thumb_rect) + + if text: + text_rect = QtCore.QRectF( + track_rect.right() + label_gap, + rect.top(), + max(0.0, rect.right() - track_rect.right() - label_gap), + rect.height(), + ) + painter.setPen(text_color) + font = painter.font() + font.setBold(bold_text) + painter.setFont(font) + painter.drawText( + text_rect, + int( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter + ), + text, + ) + + if focused: + focus_pen = QtGui.QPen(palette.color(QtGui.QPalette.ColorRole.Highlight)) + focus_pen.setWidth(1) + painter.setPen(focus_pen) + painter.setBrush(QtCore.Qt.BrushStyle.NoBrush) + painter.drawRoundedRect( + track_rect.adjusted( + -focus_margin, + -focus_margin, + focus_margin, + focus_margin, + ), + radius + focus_margin, + radius + focus_margin, + ) + + +def _replace_checkbox_with_toggle( + checkbox: QtWidgets.QCheckBox, +) -> ToggleSwitch: + toggle = ToggleSwitch(checkbox.text(), checkbox.parentWidget()) + toggle.setObjectName(checkbox.objectName()) + toggle.setChecked(checkbox.isChecked()) + toggle.setEnabled(checkbox.isEnabled()) + toggle.setToolTip(checkbox.toolTip()) + toggle.setStatusTip(checkbox.statusTip()) + toggle.setWhatsThis(checkbox.whatsThis()) + toggle.setAccessibleName(checkbox.accessibleName()) + toggle.setAccessibleDescription(checkbox.accessibleDescription()) + toggle.setSizePolicy(checkbox.sizePolicy()) + toggle.setMinimumSize(checkbox.minimumSize()) + toggle.setMaximumSize(checkbox.maximumSize()) + margins = checkbox.contentsMargins() + toggle.setContentsMargins( + max(toggle.contentsMargins().left(), margins.left()), + max(toggle.contentsMargins().top(), margins.top()), + max(toggle.contentsMargins().right(), margins.right()), + max(toggle.contentsMargins().bottom(), margins.bottom()), + ) + parent = checkbox.parentWidget() + layout = parent.layout() if parent is not None else None + if layout is not None: + layout.replaceWidget(checkbox, toggle) + checkbox.hide() + checkbox.deleteLater() + return toggle diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py new file mode 100644 index 0000000000..06f3817ae9 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py @@ -0,0 +1,17 @@ +from .attached_rules_snapshot_worker import ( + AttachedRulesCountWorker, + AttachedRulesFetchWorker, + AttachedRulesProcessWorker, + AttachedRulesSnapshotWorker, +) +from .state_refresh_worker import SubscriptionStateRefreshWorker +from .url_test_worker import UrlTestWorker + +__all__ = [ + "AttachedRulesCountWorker", + "AttachedRulesFetchWorker", + "AttachedRulesProcessWorker", + "AttachedRulesSnapshotWorker", + "SubscriptionStateRefreshWorker", + "UrlTestWorker", +] diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py new file mode 100644 index 0000000000..93ad69e8d7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py @@ -0,0 +1,308 @@ +import json +import time +from typing import Any + +from PyQt6.QtSql import QSqlDatabase, QSqlQuery + +from opensnitch.plugins.list_subscriptions.ui import QtCore +from opensnitch.config import Config + + +def _is_sqlite_uri(db_file: str) -> bool: + return str(db_file or "").strip().lower().startswith("file:") + + +def _worker_connection_name(prefix: str) -> str: + thread_id = int(QtCore.QThread.currentThreadId()) + return f"{prefix}_{thread_id}_{time.time_ns()}" + + +def _open_worker_db(*, db_file: str, busy_timeout_ms: int, conn_name: str) -> QSqlDatabase | None: + db = QSqlDatabase.addDatabase("QSQLITE", conn_name) + db.setDatabaseName(db_file) + options: list[str] = [f"QSQLITE_BUSY_TIMEOUT={busy_timeout_ms}"] + if _is_sqlite_uri(db_file): + options.extend(["QSQLITE_OPEN_URI", "QSQLITE_ENABLE_SHARED_CACHE"]) + db.setConnectOptions(";".join(options)) + if not db.open(): + return None + return db + + +def _close_worker_db(*, conn_name: str, db: QSqlDatabase | None) -> None: + if db is not None: + try: + if db.isOpen(): + db.close() + except Exception: + pass + QSqlDatabase.removeDatabase(conn_name) + + +class AttachedRulesCountWorker(QtCore.QObject): + count_done = QtCore.pyqtSignal(object) + finished = QtCore.pyqtSignal() + + def __init__(self, *, db_file: str, local_nodes: list[str]): + super().__init__() + self._db_file = db_file + self._local_nodes = local_nodes + self._stop_requested = False + + def stop(self) -> None: + self._stop_requested = True + + def _should_stop(self) -> bool: + return self._stop_requested + + @QtCore.pyqtSlot() + def run(self): + count: int | None = None + started = time.monotonic() + conn_name = _worker_connection_name("attached_rules_count") + db: QSqlDatabase | None = None + query: QSqlQuery | None = None + try: + if self._should_stop() or not self._local_nodes: + count = 0 + return + + db = _open_worker_db( + db_file=self._db_file, + busy_timeout_ms=800, + conn_name=conn_name, + ) + if db is None: + count = None + return + + placeholders = ",".join("?" for _ in self._local_nodes) + query = QSqlQuery(db) + query.prepare(f"SELECT COUNT(*) FROM rules WHERE node IN ({placeholders})") + for node in self._local_nodes: + query.addBindValue(node) + if not query.exec(): + count = None + return + if query.next(): + count = int((query.value(0) or 0) or 0) + else: + count = 0 + except Exception: + count = None + finally: + if query is not None: + try: + query.finish() + except Exception: + pass + query = None + if db is not None: + try: + if db.isOpen(): + db.close() + except Exception: + pass + # Drop Python refs before removeDatabase to satisfy Qt lifetime rules. + db = QSqlDatabase() + _close_worker_db(conn_name=conn_name, db=None) + elapsed_ms = max(0, int((time.monotonic() - started) * 1000)) + if not self._should_stop(): + self.count_done.emit( + { + "count": count, + "over_limit": False, + "is_estimate": False, + "elapsed_ms": elapsed_ms, + } + ) + self.finished.emit() + + +class AttachedRulesFetchWorker(QtCore.QObject): + rows_done = QtCore.pyqtSignal(object) + finished = QtCore.pyqtSignal() + + def __init__(self, *, db_file: str, local_nodes: list[str]): + super().__init__() + self._db_file = db_file + self._local_nodes = local_nodes + self._stop_requested = False + + def stop(self) -> None: + self._stop_requested = True + + def _should_stop(self) -> bool: + return self._stop_requested + + @QtCore.pyqtSlot() + def run(self): + rows: list[tuple[str, str, bool, str, str, str]] = [] + started = time.monotonic() + conn_name = _worker_connection_name("attached_rules_fetch") + db: QSqlDatabase | None = None + query: QSqlQuery | None = None + try: + if self._should_stop() or not self._local_nodes: + return + + db = _open_worker_db( + db_file=self._db_file, + busy_timeout_ms=1000, + conn_name=conn_name, + ) + if db is None: + rows = [] + return + + placeholders = ",".join("?" for _ in self._local_nodes) + sql = ( + "SELECT node, name, enabled, operator_type, operator_operand, operator_data " + f"FROM rules WHERE node IN ({placeholders})" + ) + query = QSqlQuery(db) + query.prepare(sql) + for node in self._local_nodes: + query.addBindValue(node) + if not query.exec(): + rows = [] + return + + while query.next(): + if self._should_stop(): + break + addr = str(query.value(0) or "").strip() + rule_name = str(query.value(1) or "").strip() + enabled_raw = str(query.value(2) or "").strip().lower() + op_type = str(query.value(3) or "").strip() + op_operand = str(query.value(4) or "").strip() + op_data = str(query.value(5) or "").strip() + if addr == "" or rule_name == "": + continue + rows.append( + ( + addr, + rule_name, + enabled_raw == "true", + op_type, + op_operand, + op_data, + ) + ) + except Exception: + rows = [] + finally: + if query is not None: + try: + query.finish() + except Exception: + pass + query = None + if db is not None: + try: + if db.isOpen(): + db.close() + except Exception: + pass + # Drop Python refs before removeDatabase to satisfy Qt lifetime rules. + db = QSqlDatabase() + _close_worker_db(conn_name=conn_name, db=None) + elapsed_ms = max(0, int((time.monotonic() - started) * 1000)) + if not self._should_stop(): + self.rows_done.emit( + { + "rows": rows, + "elapsed_ms": elapsed_ms, + "row_count": len(rows), + } + ) + self.finished.emit() + + +class AttachedRulesProcessWorker(QtCore.QObject): + snapshot_done = QtCore.pyqtSignal(object) + finished = QtCore.pyqtSignal() + + def __init__(self, *, rows: list[tuple[str, str, bool, str, str, str]]): + super().__init__() + self._rows = rows + self._stop_requested = False + + def stop(self) -> None: + self._stop_requested = True + + def _should_stop(self) -> bool: + return self._stop_requested + + @QtCore.pyqtSlot() + def run(self): + snapshot: dict[str, list[dict[str, Any]]] = {} + started = time.monotonic() + try: + if self._should_stop() or not self._rows: + return + + seen_entries: set[tuple[str, str, str]] = set() + for row in self._rows: + if self._should_stop(): + break + addr, rule_name, rule_enabled, op_type, op_operand, op_data = row + + if op_operand == Config.OPERAND_LIST_DOMAINS and op_data != "": + direct = op_data.strip() + entry_key = (direct, addr, rule_name) + if entry_key not in seen_entries: + seen_entries.add(entry_key) + snapshot.setdefault(direct, []).append( + { + "addr": addr, + "name": rule_name, + "enabled": rule_enabled, + } + ) + + if op_type != Config.RULE_TYPE_LIST: + continue + try: + operators = json.loads(op_data) + except Exception: + continue + if not isinstance(operators, list): + continue + for op in operators: + if self._should_stop(): + break + if not isinstance(op, dict): + continue + operand = str(op.get("operand") or "").strip() + data = str(op.get("data") or "").strip() + if operand != Config.OPERAND_LIST_DOMAINS or data == "": + continue + entry_key = (data, addr, rule_name) + if entry_key in seen_entries: + continue + seen_entries.add(entry_key) + snapshot.setdefault(data, []).append( + { + "addr": addr, + "name": rule_name, + "enabled": rule_enabled, + } + ) + except Exception: + snapshot = {} + finally: + elapsed_ms = max(0, int((time.monotonic() - started) * 1000)) + if not self._should_stop(): + self.snapshot_done.emit( + { + "snapshot": snapshot, + "elapsed_ms": elapsed_ms, + "row_count": len(self._rows), + } + ) + self.finished.emit() + + +class AttachedRulesSnapshotWorker(AttachedRulesProcessWorker): + """Backward-compatible alias for snapshot processing worker.""" diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py new file mode 100644 index 0000000000..59e64ec1a7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py @@ -0,0 +1,177 @@ +import json +import os +from typing import Any + +from opensnitch.plugins.list_subscriptions.ui import QtCore +from opensnitch.plugins.list_subscriptions._utils import ( + list_file_path, + subscription_rule_dir, +) + + +class SubscriptionStateRefreshWorker(QtCore.QObject): + refresh_done = QtCore.pyqtSignal(int, object) + finished = QtCore.pyqtSignal() + + def __init__( + self, + *, + generation: int, + lists_dir: str, + rows: list[dict[str, Any]], + attached_rules_by_dir: dict[str, list[dict[str, Any]]], + ): + super().__init__() + self._generation = generation + self._lists_dir = lists_dir + self._rows = rows + self._attached_rules_by_dir = attached_rules_by_dir + self._stop_requested = False + + def stop(self) -> None: + self._stop_requested = True + + def _should_stop(self) -> bool: + return self._stop_requested + + @staticmethod + def _rule_attachment_matches( + *, + lists_dir: str, + filename: str, + list_type: str, + groups: list[str], + attached_rules_by_dir: dict[str, list[dict[str, Any]]], + ) -> list[dict[str, Any]]: + rules_root = os.path.join(lists_dir, "rules.list.d") + candidate_dirs = [ + ( + "subscription", + os.path.normpath(subscription_rule_dir(lists_dir, filename, list_type)), + ), + ("all", os.path.normpath(os.path.join(rules_root, "all"))), + ] + candidate_dirs.extend( + (f"group:{group}", os.path.normpath(os.path.join(rules_root, group))) + for group in groups + ) + + matches: list[dict[str, Any]] = [] + seen_match: set[tuple[str, str, str]] = set() + for source, directory in candidate_dirs: + for rule_entry in attached_rules_by_dir.get(directory, []): + addr = str(rule_entry.get("addr", "")).strip() + name = str(rule_entry.get("name", "")).strip() + enabled = bool(rule_entry.get("enabled", True)) + if addr == "" or name == "" or not enabled: + continue + key = (addr, name, source) + if key in seen_match: + continue + seen_match.add(key) + matches.append( + { + "addr": addr, + "name": name, + "enabled": enabled, + "source": source, + "directory": directory, + } + ) + + matches.sort( + key=lambda item: (item["name"].lower(), item["addr"], item["source"]) + ) + return matches + + @QtCore.pyqtSlot() + def run(self): + results: list[dict[str, Any]] = [] + try: + for row_data in self._rows: + if self._should_stop(): + return + + row = int(row_data.get("row", -1)) + url = str(row_data.get("url", "") or "") + filename = str(row_data.get("filename", "") or "") + list_type = str(row_data.get("list_type", "hosts") or "hosts") + enabled = bool(row_data.get("enabled", True)) + groups = list(row_data.get("groups", [])) + + list_path = list_file_path(self._lists_dir, filename, list_type) + meta_path = list_path + ".meta.json" + + file_exists = os.path.exists(list_path) + meta_exists = os.path.exists(meta_path) + + meta: dict[str, Any] = {} + if meta_exists: + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception: + meta = {} + + last_result = str(meta.get("last_result", "never")) if meta else "never" + last_checked = str(meta.get("last_checked", "")) if meta else "" + last_updated = str(meta.get("last_updated", "")) if meta else "" + fail_count = str(meta.get("fail_count", 0)) if meta else "0" + last_error = str(meta.get("last_error", "")) if meta else "" + + attachment_matches = self._rule_attachment_matches( + lists_dir=self._lists_dir, + filename=filename, + list_type=list_type, + groups=groups, + attached_rules_by_dir=self._attached_rules_by_dir, + ) + rule_attached = "yes" if attachment_matches else "no" + + if not enabled: + state = "disabled" + elif not file_exists: + if not meta_exists or last_result in ("never", "", "busy"): + state = "pending" + else: + state = "missing" + elif last_result in ("updated", "not_modified"): + state = last_result + elif last_result in ( + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + ): + state = last_result + elif last_result == "busy": + state = "busy" + else: + state = last_result + + results.append( + { + "row": row, + "url": url, + "filename": filename, + "enabled": enabled, + "file_present": "yes" if file_exists else "no", + "meta_present": "yes" if meta_exists else "no", + "state": state, + "rule_attached": rule_attached, + "attachment_matches": attachment_matches, + "last_checked": last_checked, + "last_updated": last_updated, + "failures": fail_count, + "error": last_error, + "list_path": list_path, + "meta_path": meta_path, + } + ) + + if not self._should_stop(): + self.refresh_done.emit(self._generation, results) + finally: + self.finished.emit() \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py new file mode 100644 index 0000000000..f7f7533b39 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py @@ -0,0 +1,108 @@ +from typing import Any + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QC +from opensnitch.plugins.list_subscriptions._utils import is_hosts_file_like + + +class UrlTestWorker(QtCore.QObject): + test_result = QtCore.pyqtSignal(bool, str) + finished = QtCore.pyqtSignal() + + def __init__(self, url: str, list_type: str = "hosts"): + super().__init__() + self.url = url + self.list_type = (list_type or "hosts").strip().lower() + self._stop_requested = False + self._active_response: Any = None + + def stop(self) -> None: + self._stop_requested = True + resp = self._active_response + if resp is not None: + try: + resp.close() + except Exception: + pass + + def _should_stop(self) -> bool: + return self._stop_requested + + @QtCore.pyqtSlot() + def run(self): + import requests + + try: + if self._should_stop(): + return + # HEAD has no interruptible response object; keep timeout short. + response = requests.head(self.url, allow_redirects=True, timeout=3) + if response.status_code >= 400 and response.status_code not in (403, 405): + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or self.url + response.close() + if self._should_stop(): + return + if response.status_code in (403, 405): + response = requests.get( + self.url, allow_redirects=True, timeout=3, stream=True + ) + self._active_response = response + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or final_url + response.close() + self._active_response = None + if self._should_stop(): + return + message = QC.translate("stats", "URL reachable.") + if self.list_type == "hosts": + sample_lines: list[str] = [] + response = requests.get( + self.url, + allow_redirects=True, + timeout=5, + stream=True, + ) + self._active_response = response + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + + for chunk in response.iter_content(chunk_size=32 * 1024): + if self._should_stop(): + return + if not chunk: + continue + txt = chunk.decode("utf-8", errors="ignore") + for line in txt.splitlines(): + if len(sample_lines) < 200: + sample_lines.append(line) + else: + break + if len(sample_lines) >= 200: + break + response.close() + self._active_response = None + + if not is_hosts_file_like(sample_lines): + self.test_result.emit( + False, + QC.translate( + "stats", + "URL is reachable but content is not valid hosts format.", + ), + ) + return + + if final_url != self.url: + message = QC.translate("stats", "URL reachable via redirect.") + if final_url: + message = QC.translate("stats", "URL reachable via redirect.") + self.test_result.emit(True, f"{message} {final_url}") + return + self.test_result.emit(True, message) + except requests.RequestException as exc: + if not self._should_stop(): + self.test_result.emit(False, str(exc)) + finally: + self._active_response = None + self.finished.emit() \ No newline at end of file